From 299eda597ff8a07bf0b7ddfb6e3a5e7f800096db Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 13 Jul 2026 17:41:00 +0800 Subject: [PATCH] fix(auth): align API keys and exclude device trust backups --- scripts/security-audit-api-key-semantics.mjs | 128 +++++++++++++++++ scripts/security-audit-backup-auth-state.mjs | 138 +++++++++++++++++++ src/handlers/accounts.ts | 33 +++-- src/services/backup-archive.ts | 50 +++---- src/services/backup-import.ts | 23 +--- src/utils/api-key.ts | 8 +- webapp/src/lib/api/backup.ts | 1 - 7 files changed, 320 insertions(+), 61 deletions(-) create mode 100644 scripts/security-audit-api-key-semantics.mjs create mode 100644 scripts/security-audit-backup-auth-state.mjs diff --git a/scripts/security-audit-api-key-semantics.mjs b/scripts/security-audit-api-key-semantics.mjs new file mode 100644 index 0000000..46996e5 --- /dev/null +++ b/scripts/security-audit-api-key-semantics.mjs @@ -0,0 +1,128 @@ +import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts'; +import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts'; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function createUserRow(apiKey) { + return { + id: 'user-1', + email: 'user@example.com', + name: 'User', + master_password_hint: null, + master_password_hash: 'master-proof', + key: 'wrapped-user-key', + private_key: null, + public_key: null, + kdf_type: 0, + kdf_iterations: 600000, + kdf_memory: null, + kdf_parallelism: null, + security_stamp: 'security-stamp-original', + role: 'user', + status: 'active', + verify_devices: 0, + totp_secret: null, + totp_recovery_code: null, + yubikey_key1: null, + yubikey_key2: null, + yubikey_key3: null, + yubikey_key4: null, + yubikey_key5: null, + yubikey_nfc: 0, + api_key: apiKey, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }; +} + +function createDb(apiKey) { + const state = { + user: createUserRow(apiKey), + userWrites: 0, + refreshDeletes: 0, + auditActions: [], + }; + const db = { + prepare(sql) { + let bindings = []; + const statement = { + bind(...values) { + bindings = values; + return statement; + }, + async first() { + if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user }; + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + if (/INSERT INTO users\(/i.test(sql)) { + state.userWrites += 1; + state.user.security_stamp = bindings[12]; + state.user.api_key = bindings[24]; + state.user.updated_at = bindings[26]; + } + if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1; + if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]); + return { meta: { changes: 1 } }; + }, + }; + return statement; + }, + async batch(statements) { + return statements.map(() => ({ success: true, meta: { changes: 1 } })); + }, + }; + return { db, state }; +} + +function request() { + return new Request('https://nodewarden.example/api/accounts/api-key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash: 'master-proof' }), + }); +} + +function env(db) { + return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' }; +} + +const view = createDb('ExistingReadableApiKey1234567'); +const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1'); +const viewBody = await viewResponse.json(); +assert(viewResponse.status === 200, 'Viewing an existing readable API key failed'); +assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key'); +assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user'); +assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens'); +assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing'); + +const rotate = createDb('ExistingReadableApiKey1234567'); +const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1'); +const rotateBody = await rotateResponse.json(); +assert(rotateResponse.status === 200, 'API key rotation failed'); +assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key'); +assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key'); +assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp'); +assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens'); +assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates'); +assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate'); + +const legacyPlain = 'LegacyHashedApiKey123456789'; +const legacy = createDb(await hashApiKey(legacyPlain)); +const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1'); +assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation'); +assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated'); +assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating'); + +const missing = createDb(null); +const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1'); +const missingBody = await missingResponse.json(); +assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized'); +assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted'); + +console.log('Bitwarden-compatible API key view and rotation semantics: PASS'); diff --git a/scripts/security-audit-backup-auth-state.mjs b/scripts/security-audit-backup-auth-state.mjs new file mode 100644 index 0000000..d5842ab --- /dev/null +++ b/scripts/security-audit-backup-auth-state.mjs @@ -0,0 +1,138 @@ +import { unzipSync, zipSync } from 'fflate'; +import { + buildBackupArchive, + parseBackupArchive, + validateBackupPayloadContents, +} from '../src/services/backup-archive.ts'; +import { importBackupArchiveBytes } from '../src/services/backup-import.ts'; + +const forbiddenRuntimeTables = [ + 'devices', + 'refresh_tokens', + 'auth_requests', + 'trusted_two_factor_device_tokens', + 'account_passkey_challenges', + 'used_attachment_download_tokens', +]; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function sqlTouchesTable(sql, table) { + return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql); +} + +function emptyBackupDb(extra = {}) { + return { + config: [], + users: [], + domain_settings: [], + user_revisions: [], + folders: [], + ciphers: [], + attachments: [], + webauthn_credentials: [], + ...extra, + }; +} + +function archiveBytes(db, tableCounts = {}) { + const encoder = new TextEncoder(); + return zipSync({ + 'manifest.json': encoder.encode(JSON.stringify({ + formatVersion: 1, + exportedAt: new Date(0).toISOString(), + appVersion: 'test', + storageKind: null, + tableCounts, + includes: { attachments: false }, + blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 }, + attachmentBlobs: [], + })), + 'db.json': encoder.encode(JSON.stringify(db)), + }, { level: 0 }); +} + +function createD1Mock({ exportMode = false } = {}) { + const preparedSql = []; + const db = { + prepare(sql) { + preparedSql.push(sql); + let bindings = []; + const statement = { + sql, + bind(...values) { + bindings = values; + return statement; + }, + async all() { + if (exportMode) return { results: [] }; + return { results: [] }; + }, + async first() { + if (/SELECT sql FROM sqlite_master/i.test(sql)) { + const table = String(bindings[0] || '').trim(); + return { sql: `CREATE TABLE ${table} (id TEXT)` }; + } + if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 }; + if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 }; + return null; + }, + async run() { + return { meta: { changes: 0 } }; + }, + }; + return statement; + }, + async batch(statements) { + return statements.map(() => ({ success: true, meta: { changes: 0 } })); + }, + }; + return { db, preparedSql }; +} + +const exportMock = createD1Mock({ exportMode: true }); +const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false }); +const exportedZip = unzipSync(exported.bytes); +const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json'])); +const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json'])); + +for (const table of forbiddenRuntimeTables) { + assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`); + assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`); + assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`); +} + +const legacyDb = emptyBackupDb({ + devices: [{ device_identifier: 'device-secret' }], + refresh_tokens: [{ token: 'refresh-secret' }], + auth_requests: [{ access_code: 'approval-secret' }], + trusted_two_factor_device_tokens: [{ token: 'remember-secret' }], + account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }], + used_attachment_download_tokens: [{ token_hash: 'download-secret' }], +}); +const legacyArchive = archiveBytes(legacyDb, { + devices: 1, + refresh_tokens: 1, + auth_requests: 1, + trusted_two_factor_device_tokens: 1, + account_passkey_challenges: 1, + used_attachment_download_tokens: 1, +}); +const parsedLegacy = parseBackupArchive(legacyArchive); +validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files); +for (const table of forbiddenRuntimeTables) { + assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`); +} + +const restoreMock = createD1Mock(); +await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false); +for (const table of forbiddenRuntimeTables) { + assert( + !restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), + `Restore touched forbidden runtime table: ${table}` + ); +} + +console.log('backup runtime authentication state exclusion: PASS'); diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index c9b44c5..89860e4 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; -import { hashApiKey } from '../utils/api-key'; +import { isStoredApiKeyHash } from '../utils/api-key'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; @@ -343,7 +343,9 @@ export async function handleRegister(request: Request, env: Env): Promise; + // Restore uses an explicit allowlist. Extra tables from old or modified + // archives, especially runtime authentication state, are intentionally ignored. + return { + config: source.config as SqlRow[], + users: source.users as SqlRow[], + domain_settings: source.domain_settings as SqlRow[], + user_revisions: source.user_revisions as SqlRow[], + folders: source.folders as SqlRow[], + ciphers: source.ciphers as SqlRow[], + attachments: source.attachments as SqlRow[], + webauthn_credentials: source.webauthn_credentials as SqlRow[] | undefined, + }; +} + function createZipEntries(files: Record): Record { const entries: Record = {}; for (const [path, bytes] of Object.entries(files)) { @@ -314,10 +334,10 @@ export function parseBackupArchive( const decoder = new TextDecoder(); let manifest: BackupManifest; - let db: BackupPayload['db']; + let rawDb: unknown; try { manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest; - db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db']; + rawDb = JSON.parse(decoder.decode(dbBytes)); } catch { throw new Error('Backup archive contains invalid JSON metadata'); } @@ -325,9 +345,7 @@ export function parseBackupArchive( if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) { throw new Error('Unsupported backup format version'); } - if (!db || typeof db !== 'object') { - throw new Error('Backup archive database payload is invalid'); - } + const db = normalizeParsedBackupDb(rawDb); const externalAttachmentKeys = new Set( options.allowExternalAttachmentBlobs @@ -364,7 +382,6 @@ export function validateBackupPayloadContents( const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); - const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens'); const externalAttachmentKeys = new Set( options.allowExternalAttachmentBlobs ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) @@ -455,20 +472,6 @@ export function validateBackupPayloadContents( accountPasskeyCredentialIds.add(credentialId); } - const trustedTwoFactorTokens = new Set(); - for (const row of trustedTwoFactorTokenRows) { - const token = String(row.token || '').trim(); - const userId = String(row.user_id || '').trim(); - const deviceIdentifier = String(row.device_identifier || '').trim(); - const expiresAt = Number(row.expires_at || 0); - if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) { - throw new Error('Backup archive contains an invalid trusted two-factor device token row'); - } - if (trustedTwoFactorTokens.has(token)) { - throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`); - } - trustedTwoFactorTokens.add(token); - } } export async function buildBackupArchive( @@ -487,7 +490,7 @@ export async function buildBackupArchive( includeAttachments, }); const encoder = new TextEncoder(); - const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([ + const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), @@ -496,7 +499,6 @@ export async function buildBackupArchive( queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), - queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()), ]); const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedAttachmentRows = includeAttachments ? attachmentRows : []; @@ -525,7 +527,6 @@ export async function buildBackupArchive( ciphers: cipherRows.length, attachments: exportedAttachmentRows.length, webauthn_credentials: accountPasskeyRows.length, - trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length, }, includes: { attachments: includeAttachments, @@ -549,7 +550,6 @@ export async function buildBackupArchive( ciphers: cipherRows, attachments: exportedAttachmentRows, webauthn_credentials: accountPasskeyRows, - trusted_two_factor_device_tokens: trustedTwoFactorTokenRows, }, null, BACKUP_JSON_INDENT)), }; diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index bc564e0..79f3b57 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -20,13 +20,14 @@ import { // shadow-table count validation, insert column lists, and frontend import // count types together. // - Do not import users.api_key, even if an older backup contains it. +// - Do not import, clear, or replace runtime authentication state such as +// devices, sessions, auth requests, or remembered 2FA device tokens. type SqlRow = Record; type BackupTableName = | 'config' | 'users' | 'domain_settings' | 'user_revisions' - | 'trusted_two_factor_device_tokens' | 'webauthn_credentials' | 'folders' | 'ciphers' @@ -37,7 +38,6 @@ const BACKUP_TABLES: BackupTableName[] = [ 'users', 'domain_settings', 'user_revisions', - 'trusted_two_factor_device_tokens', 'webauthn_credentials', 'folders', 'ciphers', @@ -55,7 +55,6 @@ export interface BackupImportResultBody { users: number; domainSettings: number; userRevisions: number; - trustedTwoFactorDeviceTokens: number; webauthnCredentials: number; folders: number; ciphers: number; @@ -177,7 +176,6 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] 'DELETE FROM ciphers', 'DELETE FROM folders', 'DELETE FROM webauthn_credentials', - 'DELETE FROM trusted_two_factor_device_tokens', 'DELETE FROM domain_settings', 'DELETE FROM user_revisions', 'DELETE FROM users', @@ -309,7 +307,6 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload[' })), domain_settings: cloneRows(payload.domain_settings || []), user_revisions: cloneRows(payload.user_revisions || []), - trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []), webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({ ...row, purpose: normalizeAccountPasskeyPurpose(row.purpose), @@ -662,16 +659,6 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us true ) ); - await runInsertBatch( - db, - tableName('trusted_two_factor_device_tokens'), - buildInsertStatements( - db, - tableName('trusted_two_factor_device_tokens'), - ['token', 'user_id', 'device_identifier', 'expires_at'], - payload.trusted_two_factor_device_tokens || [] - ) - ); await runInsertBatch( db, tableName('webauthn_credentials'), @@ -750,7 +737,6 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, - trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length, webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, @@ -774,7 +760,6 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, - trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length, webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, @@ -816,7 +801,6 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, - trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length, webauthnCredentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, @@ -894,7 +878,6 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, - trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length, webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, @@ -918,7 +901,6 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, - trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length, webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, @@ -966,7 +948,6 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, - trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length, webauthnCredentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, diff --git a/src/utils/api-key.ts b/src/utils/api-key.ts index 8c03cbb..a9b0171 100644 --- a/src/utils/api-key.ts +++ b/src/utils/api-key.ts @@ -29,7 +29,13 @@ export async function hashApiKey(apiKey: string): Promise { export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise { const stored = String(storedApiKey || '').trim(); - if (!isStoredApiKeyHash(stored)) return false; + if (!stored) return false; + + // Legacy NodeWarden rows stored a one-way hash. Keep them usable until the + // user explicitly rotates once into the Bitwarden-compatible readable form. + if (!isStoredApiKeyHash(stored)) { + return constantTimeEquals(apiKey, stored); + } const hashed = await hashApiKey(apiKey); return constantTimeEquals(hashed, stored); diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index d8f6ea2..b76cb5a 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -103,7 +103,6 @@ export interface AdminBackupImportCounts { users: number; domainSettings?: number; userRevisions: number; - trustedTwoFactorDeviceTokens?: number; webauthnCredentials?: number; folders: number; ciphers: number;