From d6e5a1c40b718cd03792ef1db104a9099a85cb32 Mon Sep 17 00:00:00 2001 From: maooyer Date: Tue, 21 Apr 2026 21:05:32 +0800 Subject: [PATCH 1/5] feat(server): Add the field api_key at the database --- migrations/0001_init.sql | 1 + src/services/storage-schema.ts | 1 + src/services/storage-user-repo.ts | 15 +++++++++------ src/types/index.ts | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 20ffa9b..f904b6a 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users ( verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, + api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 6a73b42..4cfee99 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -13,6 +13,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', + 'ALTER TABLE users ADD COLUMN api_key TEXT', 'CREATE TABLE IF NOT EXISTS user_revisions (' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts index 54faff2..1bad39c 100644 --- a/src/services/storage-user-repo.ts +++ b/src/services/storage-user-repo.ts @@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState const USER_SELECT_COLUMNS = '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'; + 'totp_secret, totp_recovery_code, api_key, created_at, updated_at'; function mapUserRow(row: any): User { return { @@ -26,6 +26,7 @@ function mapUserRow(row: any): User { verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, totpSecret: row.totp_secret ?? null, totpRecoveryCode: row.totp_recovery_code ?? null, + apiKey: row.api_key ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(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) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(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, api_key, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at' ); await safeBind( stmt, @@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.apiKey, user.createdAt, user.updatedAt ).run(); @@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User) export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(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) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(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, api_key, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await safeBind( @@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.apiKey, user.createdAt, user.updatedAt ).run(); diff --git a/src/types/index.ts b/src/types/index.ts index f188431..3803041 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,7 @@ export interface User { verifyDevices?: boolean; totpSecret: string | null; totpRecoveryCode: string | null; + apiKey: string | null; createdAt: string; updatedAt: string; } From 7d7562d19186e4553e011abeded79cb563bff7e7 Mon Sep 17 00:00:00 2001 From: maooyer Date: Wed, 22 Apr 2026 20:48:25 +0800 Subject: [PATCH 2/5] feat(server): Add api_key in backup repo --- src/services/backup-archive.ts | 2 +- src/services/backup-import.ts | 2 +- src/services/storage-schema.ts | 2 +- src/services/storage.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 590befb..c39b4e1 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -347,7 +347,7 @@ export async function buildBackupArchive( const encoder = new TextEncoder(); const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = 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 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, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders 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'), diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 24088c8..1addd94 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us buildInsertStatements( db, tableName('users'), - ['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'], + ['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', 'api_key', 'created_at', 'updated_at'], payload.users || [] ) ); diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 4cfee99..1916160 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -6,7 +6,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + - 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', diff --git a/src/services/storage.ts b/src/services/storage.ts index ec32f82..601ab49 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -108,7 +108,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-04-18.1'; +const STORAGE_SCHEMA_VERSION = '2026-04-22'; // D1-backed storage. // Contract: From 31ffd98166e745b1eb69512b3956cd94ca538d53 Mon Sep 17 00:00:00 2001 From: maooyer Date: Wed, 22 Apr 2026 20:50:17 +0800 Subject: [PATCH 3/5] feat(server): Add api key handler --- src/config/limits.ts | 3 ++ src/handlers/accounts.ts | 62 ++++++++++++++++++++++ src/handlers/identity.ts | 101 ++++++++++++++++++++++++++++++++++++ src/router-authenticated.ts | 10 ++++ 4 files changed, 176 insertions(+) diff --git a/src/config/limits.ts b/src/config/limits.ts index 8eb4a4c..589d4ce 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -24,6 +24,9 @@ // Default PBKDF2 iterations for account creation/prelogin fallback. // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 defaultKdfIterations: 600000, + // clientSecret length + // clientSecret 长度 + clientSecretLength: 30, }, rateLimit: { // Max failed login attempts before temporary lock. diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 9b4204b..11451a0 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise{ + return apiKey(request, env, userId, false); +} + +// POST /api/accounts/rotate-api-key +export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise { + return apiKey(request, env, userId, true); +} + +async function apiKey(request: Request, env: Env, userId: string,rotate: boolean): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + body = await request.json(); + } + } catch { + return errorResponse('Invalid JSON', 400); + } + + const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim(); + if (!currentHash) return errorResponse('masterPasswordHash is required', 400); + const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); + if (!valid) return errorResponse('Invalid password', 400); + + if (rotate || user.apiKey === null) { + // Upstream apikeys are 30-character random alphanumeric strings + user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + } + + return jsonResponse({ + apiKey: user.apiKey, + revisionDate: user.updatedAt, + object: 'apiKey', + }); +} + +// Generate a random alphanumeric string of the given length using crypto.getRandomValues. +function randomStringAlphanum(length: number): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const array = new Uint8Array(length); + crypto.getRandomValues(array); + + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[array[i] % chars.length]; + } + return result; +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index ceb7be1..ca8c49b 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -361,6 +361,94 @@ export async function handleToken(request: Request, env: Env): Promise ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; + } else if (grantType === 'client_credentials') { + // Login with client credentials + const clientId = body.client_id; + const clientSecret = body.client_secret; + const scope = body.scope; + const deviceInfo = readAuthRequestDeviceInfo(body, request); + + const loginIdentifier = `${clientIdentifier}:${clientId}`; + const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); + if (!parmValid) { + return identityErrorResponse('Parameter error', 'invalid_request', 400); + } + + // Check login lockout before user lookup to reduce user-enumeration signal + const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); + if (!loginCheck.allowed) { + return identityErrorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + + const uid = clientId.slice(5); + const user = await storage.getUserById(uid); + if (!user) { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); + } + if (user.status !== 'active') { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + } + + + // Persist device only after successful password + (optional) 2FA verification. + const deviceSession = + deviceInfo.deviceIdentifier + ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } + : null; + if (deviceSession) { + await storage.upsertDevice( + user.id, + deviceSession.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + deviceSession.sessionStamp + ); + } + + // Successful login - clear failed attempts + await rateLimit.clearLoginAttempts(loginIdentifier); + + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: LIMITS.auth.accessTokenTtlSeconds, + token_type: 'Bearer', + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken } ), + Key: user.key, + PrivateKey: user.privateKey, + AccountKeys: accountKeys, + accountKeys: accountKeys, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, + }; + + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; + } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (!sendAccessLimit.allowed) { @@ -553,3 +641,16 @@ export async function handleRevocation(request: Request, env: Env): Promise Date: Wed, 22 Apr 2026 21:51:21 +0800 Subject: [PATCH 4/5] feat(web): Add api key components --- webapp/src/App.tsx | 2 + webapp/src/components/AppMainRoutes.tsx | 4 + webapp/src/components/SettingsPage.tsx | 87 +++++++++++++++++++ webapp/src/hooks/useAccountSecurityActions.ts | 22 +++++ webapp/src/lib/api/auth.ts | 28 ++++++ webapp/src/lib/i18n.ts | 22 +++++ 6 files changed, 165 insertions(+) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9b5895d..5fd2867 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1203,6 +1203,8 @@ export default function App() { }, onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, + onGetApiKey: accountSecurityActions.getApiKey, + onRotateApiKey: accountSecurityActions.rotateApiKey, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index de8b18f..55a985f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -94,6 +94,8 @@ export interface AppMainRoutesProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onRefreshAuthorizedDevices: () => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; @@ -225,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onEnableTotp={props.onEnableTotp} onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} + onGetApiKey={props.onGetApiKey} + onRotateApiKey={props.onRotateApiKey} onNotify={props.onNotify} /> diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 5464219..da49ebb 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -14,6 +14,8 @@ interface SettingsPageProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onNotify?: (type: 'success' | 'error', text: string) => void; } @@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) { const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryCode, setRecoveryCode] = useState(''); + const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); useEffect(() => { if (!props.totpEnabled) { @@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) { props.onNotify?.('success', t('txt_recovery_code_loaded')); } + async function loadApiKey(): Promise { + try { + const key = await props.onGetApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + + async function doRotateApiKey(): Promise { + try { + const key = await props.onRotateApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + props.onNotify?.('success', t('txt_api_key_rotated')); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + function formatDateTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); const parsed = new Date(value); @@ -235,8 +262,68 @@ export default function SettingsPage(props: SettingsPageProps) { )} + +
+

{t('txt_api_key')}

+ +
+ + +
+
+ setApiKeyDialogOpen(false)} + onCancel={() => setApiKeyDialogOpen(false)} + > +
+ {([ + [t('txt_client_id'), `user.${props.profile.id}`], + [t('txt_client_secret'), apiKey], + [t('txt_scope'), 'api'], + ] as [string, string][]).map(([label, value]) => ( + + ))} +
+
+ { + setRotateApiKeyConfirmOpen(false); + void doRotateApiKey(); + }} + onCancel={() => setRotateApiKeyConfirmOpen(false)} + /> ); } diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 6b46ca4..797f6fb 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -5,7 +5,9 @@ import { deleteAuthorizedDevice, deriveLoginHash, getCurrentDeviceIdentifier, + getApiKey, getTotpRecoveryCode, + rotateApiKey, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, setTotp, @@ -148,6 +150,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct return code; }, + async getApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await getApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + + async rotateApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await rotateApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + async refreshAuthorizedDevices() { await refetchAuthorizedDevices(); }, diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 2f5e24c..b6a0ad7 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -594,3 +594,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom const resp = await authedFetch('/api/devices', { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); } + +export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/api_key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to get API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} + +export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/rotate_api_key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to rotate API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index bc7a1a0..f237e1c 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -601,6 +601,17 @@ const messages: Record> = { txt_recovery_code_copied: "Recovery code copied", txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_loaded: "Recovery code loaded", + txt_api_key: "API Key", + txt_view_api_key: "View API Key", + txt_rotate_api_key: "Rotate API Key", + txt_api_key_copied: "API key copied", + txt_api_key_loaded: "API key loaded", + txt_api_key_rotated: "API key rotated", + txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.", + txt_api_key_is_empty: "API key is empty", + txt_client_id: "client_id", + txt_client_secret: "client_secret", + txt_scope: "scope", txt_refresh: "Refresh", txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_regenerate: "Regenerate", @@ -1363,6 +1374,17 @@ const zhCNOverrides: Record = { txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_loaded: '恢复代码已加载', + txt_api_key: 'API 密钥', + txt_view_api_key: '查看 API 密钥', + txt_rotate_api_key: '轮换 API 密钥', + txt_api_key_copied: 'API 密钥已复制', + txt_api_key_loaded: 'API 密钥已加载', + txt_api_key_rotated: 'API 密钥已轮换', + txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。', + txt_api_key_is_empty: 'API 密钥为空', + txt_client_id: 'client_id', + txt_client_secret: 'client_secret', + txt_scope: 'scope', txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_remove_device: '移除设备', From fe8d9e0b7d98c522ce1e266335a1f3d69b47d5ca Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 23 Apr 2026 23:17:05 +0800 Subject: [PATCH 5/5] fix: harden API key authentication --- src/handlers/accounts.ts | 22 +++++++------ src/handlers/identity.ts | 24 +++++++++++--- src/router-authenticated.ts | 4 +-- src/services/storage-user-repo.ts | 2 +- webapp/src/components/SettingsPage.tsx | 43 ++++++++++++++++++++++++-- webapp/src/lib/api/auth.ts | 4 +-- webapp/src/lib/i18n.ts | 8 +++++ 7 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 11451a0..e1b4fb2 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -754,7 +754,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s } // POST /api/accounts/api-key -export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise{ +export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise { return apiKey(request, env, userId, false); } @@ -763,7 +763,7 @@ export async function handleRotateApiKey(request: Request, env: Env, userId: str return apiKey(request, env, userId, true); } -async function apiKey(request: Request, env: Env, userId: string,rotate: boolean): Promise { +async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise { const storage = new StorageService(env.DB); const auth = new AuthService(env); const user = await storage.getUserById(userId); @@ -786,28 +786,32 @@ async function apiKey(request: Request, env: Env, userId: string,rotate: boolean if (!currentHash) return errorResponse('masterPasswordHash is required', 400); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); if (!valid) return errorResponse('Invalid password', 400); - + if (rotate || user.apiKey === null) { // Upstream apikeys are 30-character random alphanumeric strings user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); + if (rotate) { + user.securityStamp = generateUUID(); + await storage.deleteRefreshTokensByUserId(user.id); + } user.updatedAt = new Date().toISOString(); await storage.saveUser(user); } - return jsonResponse({ + return jsonResponse({ apiKey: user.apiKey, - revisionDate: user.updatedAt, + revisionDate: user.updatedAt, object: 'apiKey', - }); + }); } // Generate a random alphanumeric string of the given length using crypto.getRandomValues. function randomStringAlphanum(length: number): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const array = new Uint8Array(length); crypto.getRandomValues(array); - - let result = ""; + + let result = ''; for (let i = 0; i < length; i++) { result += chars[array[i] % chars.length]; } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index ca8c49b..e8e1bf1 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null { return null; } +function constantTimeEquals(a: string, b: string): boolean { + const encA = new TextEncoder().encode(a); + const encB = new TextEncoder().encode(b); + if (encA.length !== encB.length) return false; + + let diff = 0; + for (let i = 0; i < encA.length; i++) { + diff |= encA[i] ^ encB[i]; + } + return diff === 0; +} + function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { const isHttps = new URL(request.url).protocol === 'https:'; const parts = [ @@ -395,8 +407,12 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } + if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); + } - // Persist device only after successful password + (optional) 2FA verification. + // Persist device only after successful client credential verification. const deviceSession = deviceInfo.deviceIdentifier ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } @@ -423,7 +439,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken } ), + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, @@ -643,10 +659,10 @@ export async function handleRevocation(request: Request, env: Env): Promise setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)} > -
+
+
{t('txt_warning')}
+
{t('txt_api_key_warning_body')}
+
+ +
+
+ + {t('txt_oauth_client_credentials')} +
{([ [t('txt_client_id'), `user.${props.profile.id}`], [t('txt_client_secret'), apiKey], [t('txt_scope'), 'api'], + [t('txt_grant_type'), 'client_credentials'], ] as [string, string][]).map(([label, value]) => ( ))}
diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index b6a0ad7..2d8ebce 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -596,7 +596,7 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom } export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { - const resp = await authedFetch('/api/accounts/api_key', { + const resp = await authedFetch('/api/accounts/api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), @@ -610,7 +610,7 @@ export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: st } export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { - const resp = await authedFetch('/api/accounts/rotate_api_key', { + const resp = await authedFetch('/api/accounts/rotate-api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index f237e1c..439a25f 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -609,9 +609,13 @@ const messages: Record> = { txt_api_key_rotated: "API key rotated", txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.", txt_api_key_is_empty: "API key is empty", + txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.", + txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.", + txt_oauth_client_credentials: "OAuth 2.0 Client Credentials", txt_client_id: "client_id", txt_client_secret: "client_secret", txt_scope: "scope", + txt_grant_type: "grant_type", txt_refresh: "Refresh", txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_regenerate: "Regenerate", @@ -1382,9 +1386,13 @@ const zhCNOverrides: Record = { txt_api_key_rotated: 'API 密钥已轮换', txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。', txt_api_key_is_empty: 'API 密钥为空', + txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。', + txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。', + txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据', txt_client_id: 'client_id', txt_client_secret: 'client_secret', txt_scope: 'scope', + txt_grant_type: 'grant_type', txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_remove_device: '移除设备',