diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index e5a5fdc..953a1fb 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -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 { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; @@ -9,6 +9,7 @@ import { LIMITS } from '../config/limits'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; +import { buildProfileResponse } from '../utils/profile-response'; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; @@ -174,6 +175,24 @@ function readBodyString(body: Record, names: string[]): string 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)[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)[key]; + } + return typeof current === 'number' ? current : undefined; +} + async function readRequestBody(request: Request): Promise> { const contentType = request.headers.get('content-type') || ''; if (contentType.includes('application/x-www-form-urlencoded')) { @@ -183,34 +202,32 @@ async function readRequestBody(request: Request): Promise { + return { + minComplexity: 0, + minLength: 0, + requireUpper: false, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + enforceOnLogin: false, + object: 'masterPasswordPolicy', + }; +} + +function keysResponse(user: User): Record { 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, + PublicKey: user.publicKey ?? '', + PrivateKey: user.privateKey ?? '', + AccountKeys: accountKeys, + Object: 'keys', key: user.key, - privateKey: user.privateKey, + publicKey: user.publicKey ?? '', + privateKey: user.privateKey ?? '', accountKeys, - securityStamp: user.securityStamp || user.id, - organizations: [], - providers: [], - providerOrganizations: [], - forcePasswordReset: false, - avatarColor: null, - creationDate: user.createdAt, - verifyDevices: user.verifyDevices, - role: user.role, - status: user.status, - object: 'profile', + object: 'keys', }; } @@ -445,7 +462,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); - return jsonResponse(toProfile(user, env)); + return jsonResponse(buildProfileResponse(user, env)); } // 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 @@ -498,6 +515,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: secret?: string; masterPasswordHash?: string; verifyDevices?: boolean; + VerifyDevices?: boolean; }; try { body = await request.json(); @@ -505,7 +523,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: 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); } @@ -514,7 +533,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: return errorResponse('User verification failed.', 400); } - user.verifyDevices = body.verifyDevices; + user.verifyDevices = verifyDevices; user.updatedAt = new Date().toISOString(); await storage.saveUser(user); await writeAuditEvent(storage, { @@ -533,6 +552,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: return new Response(null, { status: 200 }); } +// GET /api/accounts/keys +export async function handleGetKeys(request: Request, env: Env, userId: string): Promise { + 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 export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { 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 @@ -607,6 +639,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s masterPasswordHash?: string; currentPasswordHash?: string; newMasterPasswordHash?: string; + masterPasswordHint?: string | null; key?: string; newKey?: string; encryptedPrivateKey?: string; @@ -617,6 +650,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s kdfIterations?: number; kdfMemory?: number; kdfParallelism?: number; + authenticationData?: Record; + unlockData?: Record; }; try { 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); 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); } - const nextKey = body.newKey || body.key; + const nextKey = + body.newKey || + body.key || + readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']); const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPublicKey = body.newPublicKey || body.publicKey; 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); } - 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); - user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); + user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); if (nextKey) user.key = nextKey; if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPublicKey) user.publicKey = nextPublicKey; - if (typeof body.kdf === 'number') user.kdfType = body.kdf; - if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; - if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; - if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; + if (typeof nextKdf === 'number') user.kdfType = nextKdf; + if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations; + if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory; + if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism; + if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) { + user.masterPasswordHint = body.masterPasswordHint; + } user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); await storage.saveUser(user); @@ -1061,23 +1109,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s return errorResponse('User not found', 404); } - let body: { masterPasswordHash?: string }; + let body: { masterPasswordHash?: string; authenticationData?: Record }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } - if (!body.masterPasswordHash) { + const masterPasswordHash = + body.masterPasswordHash || + readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']); + if (!masterPasswordHash) { 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) { return errorResponse('Invalid password', 400); } - return new Response(null, { status: 200 }); + return jsonResponse(masterPasswordPolicyResponse()); } // POST /api/accounts/api-key diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index c1dea72..854abaa 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -31,6 +31,14 @@ function notifyVaultSyncForRequest( 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( storage: StorageService, request: Request, @@ -415,7 +423,9 @@ export async function handlePublicDownloadAttachment( headers: { 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), + 'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Cache-Control': 'private, no-cache', + 'X-Content-Type-Options': 'nosniff', }, }); } @@ -463,9 +473,13 @@ export async function handleDeleteAttachment( // Get updated cipher for response const updatedCipher = await storage.getCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId); + const cipherResponse = cipherToResponse(updatedCipher!, attachments); return jsonResponse({ - cipher: cipherToResponse(updatedCipher!, attachments), + Cipher: cipherResponse, + cipher: cipherResponse, + Object: 'deleteAttachment', + object: 'deleteAttachment', }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index f801883..0701c2e 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -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 { // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // 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, TwoFactorProviders2: providers2, SsoEmail2faSessionToken: null, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), }; // 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 KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -566,9 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -696,9 +704,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -836,9 +842,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index 064fbce..c710a02 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -33,6 +33,14 @@ import { verifySendPasswordHashB64, } 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 { const storage = new StorageService(env.DB); const sendId = fromAccessId(accessId); @@ -282,6 +290,9 @@ export async function handleDownloadSendFile( if (!object) { 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); if (!firstUse) { @@ -292,7 +303,9 @@ export async function handleDownloadSendFile( headers: { 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), + 'Content-Disposition': contentDispositionAttachment(fileName), 'Cache-Control': 'private, no-cache', + 'X-Content-Type-Options': 'nosniff', }, }); } diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 86cd097..1ea0125 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira import { sendToResponse } from './sends'; import { LIMITS } from '../config/limits'; import { - buildAccountKeys, buildUserDecryptionCompat, buildUserDecryptionOptions, } from '../utils/user-decryption'; import { buildDomainsResponse } from '../services/domain-rules'; import { buildWebAuthnPrfOption } from '../utils/account-passkeys'; +import { buildProfileResponse } from '../utils/profile-response'; // CONTRACT: // /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), excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), ]); - const accountKeys = buildAccountKeys(user); const webAuthnPrfOptions = accountPasskeys .map(buildWebAuthnPrfOption) .filter((option): option is NonNullable => !!option); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); - const profile: ProfileResponse = { - 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 profile: ProfileResponse = buildProfileResponse(user, env); const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { @@ -149,6 +125,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr { omitExcludedGlobals: true } ), policies: [], + policiesNew: [], sends: sendResponses, UserDecryption: { MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, @@ -156,6 +133,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr KeyConnectorOption: null, WebAuthnPrfOption: webAuthnPrfOptions[0] || null, WebAuthnPrfOptions: webAuthnPrfOptions, + V2UpgradeToken: null, Object: 'userDecryption', }, UserDecryptionOptions: userDecryptionOptions, diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 46d091d..b0efa0b 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response'; import { handleGetProfile, handleUpdateProfile, + handleGetKeys, handleSetKeys, handleGetRevisionDate, handleVerifyPassword, @@ -115,8 +116,10 @@ export async function handleAuthenticatedRoute( return handleChangePassword(request, env, userId); } - if (path === '/api/accounts/keys' && method === 'POST') { - return handleSetKeys(request, env, userId); + if (path === '/api/accounts/keys') { + 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') { diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts index a9a6d80..e118cbe 100644 --- a/src/services/storage-cipher-repo.ts +++ b/src/services/storage-cipher-repo.ts @@ -87,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null { createdAt: row.created_at, updatedAt: row.updated_at, archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null, - deletedAt: row.deleted_at ?? null, + deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null, }; } catch { console.error('Corrupted cipher data, id:', row.id); @@ -244,7 +244,9 @@ export async function getCiphersPage( limit: number, offset: number ): Promise { - 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 .prepare( `SELECT ${selectCipherColumns()} FROM ciphers @@ -341,7 +343,10 @@ export async function bulkArchiveCiphers( `UPDATE ciphers SET archived_at = ?, updated_at = ?, 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) .run(); diff --git a/src/types/index.ts b/src/types/index.ts index e0eadc4..245773b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -465,7 +465,15 @@ export interface TokenResponse { scope: string; unofficialServer: boolean; MasterPasswordPolicy?: { + minComplexity: number; + minLength: number; + requireUpper: boolean; + requireLower: boolean; + requireNumbers: boolean; + requireSpecial: boolean; + enforceOnLogin: boolean; Object: string; + object?: string; } | null; ApiUseKeyConnector?: boolean; AccountKeys?: any | null; @@ -494,12 +502,13 @@ export interface ProfileResponse { accountKeys: any | null; securityStamp: string; organizations: any[]; + organizationsNew?: any[]; providers: any[]; providerOrganizations: any[]; forcePasswordReset: boolean; avatarColor: string | null; creationDate: string; - verifyDevices?: boolean; + verifyDevices: boolean; role?: UserRole; status?: UserStatus; object: string; @@ -558,6 +567,7 @@ export interface SyncResponse { ciphers: CipherResponse[]; domains: any; policies: any[]; + policiesNew?: any[]; sends: SendResponse[]; UserDecryption?: { MasterPasswordUnlock: MasterPasswordUnlock | null; @@ -565,6 +575,10 @@ export interface SyncResponse { KeyConnectorOption?: null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; + V2UpgradeToken?: { + WrappedUserKey1: string; + WrappedUserKey2: string; + } | null; Object?: string; } | null; // PascalCase for desktop/browser clients diff --git a/src/utils/profile-response.ts b/src/utils/profile-response.ts new file mode 100644 index 0000000..3343be8 --- /dev/null +++ b/src/utils/profile-response.ts @@ -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', + }; +} diff --git a/src/utils/user-decryption.ts b/src/utils/user-decryption.ts index 497b783..379ba50 100644 --- a/src/utils/user-decryption.ts +++ b/src/utils/user-decryption.ts @@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick): publicKeyEncryptionKeyPair: { wrappedPrivateKey: user.privateKey, publicKey, + signedPublicKey: null, Object: 'publicKeyEncryptionKeyPair', }, Object: 'privateKeys', diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 98f9e86..dda14c4 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -500,7 +500,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); const headers = new Headers(init.headers || {}); headers.set('Authorization', `Bearer ${session.accessToken}`); - headers.set('X-NodeWarden-Web', '1'); let resp = await retryableRequest(headers); 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) { const latestHeaders = new Headers(init.headers || {}); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); - latestHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(latestHeaders); if (resp.status !== 401) return resp; } @@ -535,7 +533,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess const retryHeaders = new Headers(init.headers || {}); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); - retryHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(retryHeaders); return resp; }; @@ -599,14 +596,35 @@ export async function changeMasterPassword( const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); + const newMasterPasswordHash = bytesToBase64(nextHash); const resp = await authedFetch('/api/accounts/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - currentPasswordHash: current.hash, - newMasterPasswordHash: bytesToBase64(nextHash), - newKey, + masterPasswordHash: current.hash, + newMasterPasswordHash, + 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, kdfIterations: current.kdfIterations, }), diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index a531b13..4da0d0b 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -20,6 +20,7 @@ import { readResponseBytesWithProgress } from '../download'; import { loadVaultCoreSyncSnapshot } from './vault-sync'; type CipherLoginData = NonNullable; +const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web'; export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise { const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); @@ -933,7 +934,7 @@ export async function repairCipherUriChecksums( const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', [NODEWARDEN_WEB_REPAIR_HEADER]: '1' }, body: JSON.stringify(payload), }); 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 (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; if (hasUnresolvedEncryptedFields(cipher)) continue; - await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), { - preserveRevisionDate: true, - }); + await updateCipher( + authedFetch, + session, + cipher, + draftFromDecryptedCipher(cipher), + { preserveRevisionDate: true }, + { webRepair: true } + ); repaired += 1; } @@ -1229,7 +1235,8 @@ export async function updateCipher( session: SessionState, cipher: Cipher, draft: VaultDraft, - extraPayload?: Record + extraPayload?: Record, + options?: { webRepair?: boolean } ): Promise { const payload = await buildCipherPayload(session, draft, cipher); if (extraPayload) { @@ -1238,7 +1245,10 @@ export async function updateCipher( const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(options?.webRepair ? { [NODEWARDEN_WEB_REPAIR_HEADER]: '1' } : {}), + }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Update item failed');