From f6169b7610dd1db43258001ee6e352c3b663c7cd Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 13 Jun 2026 17:45:01 +0800 Subject: [PATCH] fix: add support for trusted two-factor device tokens in backup import and export --- src/services/backup-archive.ts | 22 +++++++++++++++++++++- src/services/backup-import.ts | 21 +++++++++++++++++++++ webapp/src/lib/api/backup.ts | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 2c22cd6..0ae3cf2 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -68,6 +68,7 @@ export interface BackupPayload { ciphers: SqlRow[]; attachments: SqlRow[]; webauthn_credentials?: SqlRow[]; + trusted_two_factor_device_tokens?: SqlRow[]; }; } @@ -302,6 +303,7 @@ 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`) @@ -390,6 +392,21 @@ export function validateBackupPayloadContents( accountPasskeyIds.add(id); 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( @@ -408,7 +425,7 @@ export async function buildBackupArchive( includeAttachments, }); const encoder = new TextEncoder(); - const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ + const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = 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, 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'), @@ -417,6 +434,7 @@ 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, 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 : []; @@ -445,6 +463,7 @@ export async function buildBackupArchive( ciphers: cipherRows.length, attachments: exportedAttachmentRows.length, webauthn_credentials: accountPasskeyRows.length, + trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length, }, includes: { attachments: includeAttachments, @@ -468,6 +487,7 @@ 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 d39b80f..e95c249 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -24,6 +24,7 @@ type BackupTableName = | 'users' | 'domain_settings' | 'user_revisions' + | 'trusted_two_factor_device_tokens' | 'webauthn_credentials' | 'folders' | 'ciphers' @@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [ 'users', 'domain_settings', 'user_revisions', + 'trusted_two_factor_device_tokens', 'webauthn_credentials', 'folders', 'ciphers', @@ -51,6 +53,7 @@ export interface BackupImportResultBody { users: number; domainSettings: number; userRevisions: number; + trustedTwoFactorDeviceTokens: number; webauthnCredentials: number; folders: number; ciphers: number; @@ -172,6 +175,7 @@ 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', @@ -296,6 +300,7 @@ 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 || []), folders: cloneRows(payload.folders || []), ciphers: cloneRows(payload.ciphers || []).map((row) => ({ @@ -634,6 +639,16 @@ 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'), @@ -712,6 +727,7 @@ 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, @@ -735,6 +751,7 @@ 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, @@ -776,6 +793,7 @@ 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, @@ -853,6 +871,7 @@ 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, @@ -876,6 +895,7 @@ 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, @@ -923,6 +943,7 @@ 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/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 7c8fb1b..ffd0b49 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -96,6 +96,7 @@ export interface AdminBackupImportCounts { users: number; domainSettings?: number; userRevisions: number; + trustedTwoFactorDeviceTokens?: number; webauthnCredentials?: number; folders: number; ciphers: number;