Improve Bitwarden compatibility across account, sync, attachment, and send flows

This commit is contained in:
shuaiplus
2026-06-21 15:02:41 +08:00
parent f1b716fb31
commit add921b3b3
12 changed files with 249 additions and 102 deletions
+93 -42
View File
@@ -1,4 +1,4 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { Env, User, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -9,6 +9,7 @@ import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
@@ -174,6 +175,24 @@ function readBodyString(body: Record<string, unknown>, names: string[]): string
return ''; return '';
} }
function readNestedString(source: unknown, path: string[]): string {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'string' ? current : '';
}
function readNestedNumber(source: unknown, path: string[]): number | undefined {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'number' ? current : undefined;
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> { async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -183,34 +202,32 @@ async function readRequestBody(request: Request): Promise<Record<string, unknown
return await request.json(); return await request.json();
} }
function toProfile(user: User, env: Env): ProfileResponse { function masterPasswordPolicyResponse(): Record<string, unknown> {
void env; return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
object: 'masterPasswordPolicy',
};
}
function keysResponse(user: User): Record<string, unknown> {
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
return { return {
id: user.id, Key: user.key,
name: user.name, PublicKey: user.publicKey ?? '',
email: user.email, PrivateKey: user.privateKey ?? '',
emailVerified: true, AccountKeys: accountKeys,
premium: true, Object: 'keys',
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, publicKey: user.publicKey ?? '',
privateKey: user.privateKey ?? '',
accountKeys, accountKeys,
securityStamp: user.securityStamp || user.id, object: 'keys',
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
}; };
} }
@@ -445,7 +462,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT /api/accounts/profile // PUT /api/accounts/profile
@@ -484,7 +501,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}, },
}); });
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
@@ -498,6 +515,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
secret?: string; secret?: string;
masterPasswordHash?: string; masterPasswordHash?: string;
verifyDevices?: boolean; verifyDevices?: boolean;
VerifyDevices?: boolean;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -505,7 +523,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (typeof body.verifyDevices !== 'boolean') { const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400); return errorResponse('verifyDevices must be true or false', 400);
} }
@@ -514,7 +533,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
user.verifyDevices = body.verifyDevices; user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -533,6 +552,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
// GET /api/accounts/keys
export async function handleGetKeys(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
return jsonResponse(keysResponse(user));
}
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -593,7 +625,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
}, },
}); });
return handleGetProfile(request, env, userId); return jsonResponse(keysResponse(user));
} }
// POST/PUT /api/accounts/password // POST/PUT /api/accounts/password
@@ -607,6 +639,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
masterPasswordHash?: string; masterPasswordHash?: string;
currentPasswordHash?: string; currentPasswordHash?: string;
newMasterPasswordHash?: string; newMasterPasswordHash?: string;
masterPasswordHint?: string | null;
key?: string; key?: string;
newKey?: string; newKey?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
@@ -617,6 +650,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
kdfIterations?: number; kdfIterations?: number;
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
authenticationData?: Record<string, unknown>;
unlockData?: Record<string, unknown>;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -629,10 +664,16 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) { const newMasterPasswordHash =
body.newMasterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400); return errorResponse('newMasterPasswordHash is required', 400);
} }
const nextKey = body.newKey || body.key; const nextKey =
body.newKey ||
body.key ||
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']);
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey; const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) { if (nextKey && !looksLikeEncString(nextKey)) {
@@ -642,17 +683,24 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
} }
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism); const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType;
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey; if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf; if (typeof nextKdf === 'number') user.kdfType = nextKdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
user.masterPasswordHint = body.masterPasswordHint;
}
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
@@ -1061,23 +1109,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('User not found', 404); return errorResponse('User not found', 404);
} }
let body: { masterPasswordHash?: string }; let body: { masterPasswordHash?: string; authenticationData?: Record<string, unknown> };
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (!body.masterPasswordHash) { const masterPasswordHash =
body.masterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400); return errorResponse('masterPasswordHash is required', 400);
} }
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
return new Response(null, { status: 200 }); return jsonResponse(masterPasswordPolicyResponse());
} }
// POST /api/accounts/api-key // POST /api/accounts/api-key
+15 -1
View File
@@ -31,6 +31,14 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'attachment';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
async function writeAttachmentAudit( async function writeAttachmentAudit(
storage: StorageService, storage: StorageService,
request: Request, request: Request,
@@ -415,7 +423,9 @@ export async function handlePublicDownloadAttachment(
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -463,9 +473,13 @@ export async function handleDeleteAttachment(
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
return jsonResponse({ return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments), Cipher: cipherResponse,
cipher: cipherResponse,
Object: 'deleteAttachment',
object: 'deleteAttachment',
}); });
} }
+19 -15
View File
@@ -140,6 +140,20 @@ function buildPreloginResponse(
}; };
} }
function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
Object: 'masterPasswordPolicy',
object: 'masterPasswordPolicy',
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to // Clients expose recovery-code entry points themselves; Android 2026.4 fails to
@@ -151,9 +165,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null, SsoEmail2faSessionToken: null,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
@@ -446,9 +458,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -566,9 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -696,9 +704,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -836,9 +842,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
+13
View File
@@ -33,6 +33,14 @@ import {
verifySendPasswordHashB64, verifySendPasswordHashB64,
} from './sends-shared'; } from './sends-shared';
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'send-file';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> { export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId); const sendId = fromAccessId(accessId);
@@ -282,6 +290,9 @@ export async function handleDownloadSendFile(
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const send = await storage.getSend(sendId);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) { if (!firstUse) {
@@ -292,7 +303,9 @@ export async function handleDownloadSendFile(
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
+4 -26
View File
@@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
buildAccountKeys,
buildUserDecryptionCompat, buildUserDecryptionCompat,
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules'; import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys'; import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
import { buildProfileResponse } from '../utils/profile-response';
// CONTRACT: // CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper. // /api/sync reuses cipherToResponse() as the single cipher response shaper.
@@ -84,36 +84,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
storage.getAttachmentsByUserId(userId), storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]); ]);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOptions = accountPasskeys const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const profile: ProfileResponse = { const profile: ProfileResponse = buildProfileResponse(user, env);
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
@@ -149,6 +125,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
{ omitExcludedGlobals: true } { omitExcludedGlobals: true }
), ),
policies: [], policies: [],
policiesNew: [],
sends: sendResponses, sends: sendResponses,
UserDecryption: { UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
@@ -156,6 +133,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
KeyConnectorOption: null, KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null, WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions, WebAuthnPrfOptions: webAuthnPrfOptions,
V2UpgradeToken: null,
Object: 'userDecryption', Object: 'userDecryption',
}, },
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
+5 -2
View File
@@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile, handleUpdateProfile,
handleGetKeys,
handleSetKeys, handleSetKeys,
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
@@ -115,8 +116,10 @@ export async function handleAuthenticatedRoute(
return handleChangePassword(request, env, userId); return handleChangePassword(request, env, userId);
} }
if (path === '/api/accounts/keys' && method === 'POST') { if (path === '/api/accounts/keys') {
return handleSetKeys(request, env, userId); if (method === 'GET') return handleGetKeys(request, env, userId);
if (method === 'POST') return handleSetKeys(request, env, userId);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/accounts/totp') { if (path === '/api/accounts/totp') {
+8 -3
View File
@@ -87,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null, archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null, deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
}; };
} catch { } catch {
console.error('Corrupted cipher data, id:', row.id); console.error('Corrupted cipher data, id:', row.id);
@@ -244,7 +244,9 @@ export async function getCiphersPage(
limit: number, limit: number,
offset: number offset: number
): Promise<Cipher[]> { ): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; const whereDeleted = includeDeleted
? ''
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
const res = await db const res = await db
.prepare( .prepare(
`SELECT ${selectCipherColumns()} FROM ciphers `SELECT ${selectCipherColumns()} FROM ciphers
@@ -341,7 +343,10 @@ export async function bulkArchiveCiphers(
`UPDATE ciphers `UPDATE ciphers
SET archived_at = ?, updated_at = ?, SET archived_at = ?, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL` WHERE user_id = ? AND id IN (${placeholders})
AND deleted_at IS NULL
AND json_extract(data, '$.deletedAt') IS NULL
AND json_extract(data, '$.deletedDate') IS NULL`
) )
.bind(now, now, userId, ...chunk) .bind(now, now, userId, ...chunk)
.run(); .run();
+15 -1
View File
@@ -465,7 +465,15 @@ export interface TokenResponse {
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireNumbers: boolean;
requireSpecial: boolean;
enforceOnLogin: boolean;
Object: string; Object: string;
object?: string;
} | null; } | null;
ApiUseKeyConnector?: boolean; ApiUseKeyConnector?: boolean;
AccountKeys?: any | null; AccountKeys?: any | null;
@@ -494,12 +502,13 @@ export interface ProfileResponse {
accountKeys: any | null; accountKeys: any | null;
securityStamp: string; securityStamp: string;
organizations: any[]; organizations: any[];
organizationsNew?: any[];
providers: any[]; providers: any[];
providerOrganizations: any[]; providerOrganizations: any[];
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
verifyDevices?: boolean; verifyDevices: boolean;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
object: string; object: string;
@@ -558,6 +567,7 @@ export interface SyncResponse {
ciphers: CipherResponse[]; ciphers: CipherResponse[];
domains: any; domains: any;
policies: any[]; policies: any[];
policiesNew?: any[];
sends: SendResponse[]; sends: SendResponse[];
UserDecryption?: { UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null; MasterPasswordUnlock: MasterPasswordUnlock | null;
@@ -565,6 +575,10 @@ export interface SyncResponse {
KeyConnectorOption?: null; KeyConnectorOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
V2UpgradeToken?: {
WrappedUserKey1: string;
WrappedUserKey2: string;
} | null;
Object?: string; Object?: string;
} | null; } | null;
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
+36
View File
@@ -0,0 +1,36 @@
import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env;
const organizations: any[] = [];
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations,
organizationsNew: organizations,
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
role: user.role,
status: user.status,
object: 'profile',
};
}
+1
View File
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
publicKeyEncryptionKeyPair: { publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey, wrappedPrivateKey: user.privateKey,
publicKey, publicKey,
signedPublicKey: null,
Object: 'publicKeyEncryptionKeyPair', Object: 'publicKeyEncryptionKeyPair',
}, },
Object: 'privateKeys', Object: 'privateKeys',
+24 -6
View File
@@ -500,7 +500,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('Authorization', `Bearer ${session.accessToken}`);
headers.set('X-NodeWarden-Web', '1');
let resp = await retryableRequest(headers); let resp = await retryableRequest(headers);
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
@@ -509,7 +508,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
if (latest?.accessToken && latest.accessToken !== session.accessToken) { if (latest?.accessToken && latest.accessToken !== session.accessToken) {
const latestHeaders = new Headers(init.headers || {}); const latestHeaders = new Headers(init.headers || {});
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
latestHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(latestHeaders); resp = await retryableRequest(latestHeaders);
if (resp.status !== 401) return resp; if (resp.status !== 401) return resp;
} }
@@ -535,7 +533,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
const retryHeaders = new Headers(init.headers || {}); const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
retryHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(retryHeaders); resp = await retryableRequest(retryHeaders);
return resp; return resp;
}; };
@@ -599,14 +596,35 @@ export async function changeMasterPassword(
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
currentPasswordHash: current.hash, masterPasswordHash: current.hash,
newMasterPasswordHash: bytesToBase64(nextHash), newMasterPasswordHash,
newKey, key: newKey,
authenticationData: {
kdf: {
kdfType: 0,
iterations: current.kdfIterations,
memory: null,
parallelism: null,
},
masterPasswordAuthenticationHash: newMasterPasswordHash,
salt: args.email.trim().toLowerCase(),
},
unlockData: {
kdf: {
kdfType: 0,
iterations: current.kdfIterations,
memory: null,
parallelism: null,
},
masterKeyWrappedUserKey: newKey,
salt: args.email.trim().toLowerCase(),
},
kdf: 0, kdf: 0,
kdfIterations: current.kdfIterations, kdfIterations: current.kdfIterations,
}), }),
+16 -6
View File
@@ -20,6 +20,7 @@ import { readResponseBytesWithProgress } from '../download';
import { loadVaultCoreSyncSnapshot } from './vault-sync'; import { loadVaultCoreSyncSnapshot } from './vault-sync';
type CipherLoginData = NonNullable<Cipher['login']>; type CipherLoginData = NonNullable<Cipher['login']>;
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
@@ -933,7 +934,7 @@ export async function repairCipherUriChecksums(
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', [NODEWARDEN_WEB_REPAIR_HEADER]: '1' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed')); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed'));
@@ -1092,9 +1093,14 @@ export async function repairCipherKeyMismatches(
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue; if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
if (hasUnresolvedEncryptedFields(cipher)) continue; if (hasUnresolvedEncryptedFields(cipher)) continue;
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), { await updateCipher(
preserveRevisionDate: true, authedFetch,
}); session,
cipher,
draftFromDecryptedCipher(cipher),
{ preserveRevisionDate: true },
{ webRepair: true }
);
repaired += 1; repaired += 1;
} }
@@ -1229,7 +1235,8 @@ export async function updateCipher(
session: SessionState, session: SessionState,
cipher: Cipher, cipher: Cipher,
draft: VaultDraft, draft: VaultDraft,
extraPayload?: Record<string, unknown> extraPayload?: Record<string, unknown>,
options?: { webRepair?: boolean }
): Promise<Cipher> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher); const payload = await buildCipherPayload(session, draft, cipher);
if (extraPayload) { if (extraPayload) {
@@ -1238,7 +1245,10 @@ export async function updateCipher(
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(options?.webRepair ? { [NODEWARDEN_WEB_REPAIR_HEADER]: '1' } : {}),
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error('Update item failed'); if (!resp.ok) throw new Error('Update item failed');