fix(auth): align API keys and exclude device trust backups

This commit is contained in:
shuaiplus
2026-07-13 17:41:00 +08:00
parent 19de8d6e57
commit 299eda597f
7 changed files with 320 additions and 61 deletions
@@ -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');
@@ -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');
+17 -10
View File
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { hashApiKey } from '../utils/api-key'; import { isStoredApiKeyHash } from '../utils/api-key';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } 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';
@@ -343,7 +343,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
yubikeyKey4: null, yubikeyKey4: null,
yubikeyKey5: null, yubikeyKey5: null,
yubikeyNfc: false, yubikeyNfc: false,
apiKey: null, // Bitwarden creates a readable personal API key with the account. It is
// returned only after fresh user verification and is excluded from backups.
apiKey: randomStringAlphanum(LIMITS.auth.clientSecretLength),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -1568,19 +1570,24 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
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);
// Only the fresh secret is returned once; the database stores a hash. if (!rotate && isStoredApiKeyHash(user.apiKey)) {
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); return errorResponse(
user.apiKey = await hashApiKey(plainApiKey); 'This API key was created by an older NodeWarden version and cannot be displayed. Rotate it once to use the Bitwarden-compatible readable format.',
if (rotate) { 409
user.securityStamp = generateUUID(); );
await storage.deleteRefreshTokensByUserId(user.id);
} }
let auditAction = 'account.api_key.view';
if (rotate || !user.apiKey) {
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(user.id); AuthService.invalidateUserCache(user.id);
auditAction = rotate ? 'account.api_key.rotate' : 'account.api_key.create';
}
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create', action: auditAction,
category: 'security', category: 'security',
level: rotate ? 'security' : 'info', level: rotate ? 'security' : 'info',
targetType: 'user', targetType: 'user',
@@ -1589,7 +1596,7 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
}); });
return jsonResponse({ return jsonResponse({
apiKey: plainApiKey, apiKey: user.apiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+25 -25
View File
@@ -17,6 +17,8 @@ import {
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts, // - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents(). // and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json. // - Keep secrets and transient runtime rows sanitized before writing db.json.
// - Runtime authentication state (devices, sessions, auth requests, remembered
// 2FA devices, and one-time tokens) must never enter an instance backup.
// - users.api_key is intentionally not exported. // - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime // - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance. // envelope must not leave the instance.
@@ -70,7 +72,6 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -261,6 +262,25 @@ function ensureRowArray(value: unknown, table: string): SqlRow[] {
return value as SqlRow[]; return value as SqlRow[];
} }
function normalizeParsedBackupDb(value: unknown): BackupPayload['db'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Backup archive database payload is invalid');
}
const source = value as Record<string, unknown>;
// 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<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> { function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {}; const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
for (const [path, bytes] of Object.entries(files)) { for (const [path, bytes] of Object.entries(files)) {
@@ -314,10 +334,10 @@ export function parseBackupArchive(
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let manifest: BackupManifest; let manifest: BackupManifest;
let db: BackupPayload['db']; let rawDb: unknown;
try { try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest; manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db']; rawDb = JSON.parse(decoder.decode(dbBytes));
} catch { } catch {
throw new Error('Backup archive contains invalid JSON metadata'); throw new Error('Backup archive contains invalid JSON metadata');
} }
@@ -325,9 +345,7 @@ export function parseBackupArchive(
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) { if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
throw new Error('Unsupported backup format version'); throw new Error('Unsupported backup format version');
} }
if (!db || typeof db !== 'object') { const db = normalizeParsedBackupDb(rawDb);
throw new Error('Backup archive database payload is invalid');
}
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
@@ -364,7 +382,6 @@ export function validateBackupPayloadContents(
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); 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<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (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); accountPasskeyCredentialIds.add(credentialId);
} }
const trustedTwoFactorTokens = new Set<string>();
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( export async function buildBackupArchive(
@@ -487,7 +490,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); 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 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 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'), 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, 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, 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 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 exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -525,7 +527,6 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -549,7 +550,6 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+2 -21
View File
@@ -20,13 +20,14 @@ import {
// shadow-table count validation, insert column lists, and frontend import // shadow-table count validation, insert column lists, and frontend import
// count types together. // count types together.
// - Do not import users.api_key, even if an older backup contains it. // - 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<string, string | number | null>; type SqlRow = Record<string, string | number | null>;
type BackupTableName = type BackupTableName =
| 'config' | 'config'
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -37,7 +38,6 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -55,7 +55,6 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -177,7 +176,6 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', 'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -309,7 +307,6 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), 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) => ({ webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row, ...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose), purpose: normalizeAccountPasskeyPurpose(row.purpose),
@@ -662,16 +659,6 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true 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( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
@@ -750,7 +737,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -774,7 +760,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -816,7 +801,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -894,7 +878,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -918,7 +901,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -966,7 +948,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+7 -1
View File
@@ -29,7 +29,13 @@ export async function hashApiKey(apiKey: string): Promise<string> {
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> { export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim(); 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); const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored); return constantTimeEquals(hashed, stored);
-1
View File
@@ -103,7 +103,6 @@ export interface AdminBackupImportCounts {
users: number; users: number;
domainSettings?: number; domainSettings?: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens?: number;
webauthnCredentials?: number; webauthnCredentials?: number;
folders: number; folders: number;
ciphers: number; ciphers: number;