mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
fix(auth): align API keys and exclude device trust backups
This commit is contained in:
+20
-13
@@ -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<Respon
|
||||
yubikeyKey4: null,
|
||||
yubikeyKey5: null,
|
||||
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,
|
||||
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);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
// Only the fresh secret is returned once; the database stores a hash.
|
||||
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
user.apiKey = await hashApiKey(plainApiKey);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
if (!rotate && isStoredApiKeyHash(user.apiKey)) {
|
||||
return errorResponse(
|
||||
'This API key was created by an older NodeWarden version and cannot be displayed. Rotate it once to use the Bitwarden-compatible readable format.',
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
let auditAction = 'account.api_key.view';
|
||||
if (rotate || !user.apiKey) {
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
auditAction = rotate ? 'account.api_key.rotate' : 'account.api_key.create';
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
action: auditAction,
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
@@ -1589,7 +1596,7 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: plainApiKey,
|
||||
apiKey: user.apiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||
// and validateBackupPayloadContents().
|
||||
// - 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.
|
||||
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||
// envelope must not leave the instance.
|
||||
@@ -70,7 +72,6 @@ export interface BackupPayload {
|
||||
ciphers: SqlRow[];
|
||||
attachments: 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[];
|
||||
}
|
||||
|
||||
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 }]> {
|
||||
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
|
||||
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<string>(
|
||||
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<string>(
|
||||
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<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(
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string | number | null>;
|
||||
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,
|
||||
|
||||
@@ -29,7 +29,13 @@ export async function hashApiKey(apiKey: string): Promise<string> {
|
||||
|
||||
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user