diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index dd78d1c..9512609 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -6,6 +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 { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; @@ -1194,29 +1195,28 @@ 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); - 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); - AuthService.invalidateUserCache(user.id); - await writeAuditEvent(storage, { - actorUserId: user.id, - action: rotate ? 'account.api_key.rotate' : 'account.api_key.create', - category: 'security', - level: rotate ? 'security' : 'info', - targetType: 'user', - targetId: user.id, - metadata: auditRequestMetadata(request), - }); + // 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); } + 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', + category: 'security', + level: rotate ? 'security' : 'info', + targetType: 'user', + targetId: user.id, + metadata: auditRequestMetadata(request), + }); return jsonResponse({ - apiKey: user.apiKey, + apiKey: plainApiKey, revisionDate: user.updatedAt, object: 'apiKey', }); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 00baa80..9c8a547 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -22,6 +22,7 @@ import { } from './account-passkeys'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; +import { constantTimeEquals, verifyApiKey } from '../utils/api-key'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -106,18 +107,6 @@ 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 readBodyValue(body: Record, names: string[]): string | undefined { for (const name of names) { const value = body[name]; @@ -688,7 +677,7 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } - if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { + if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) { await rateLimit.recordFailedLogin(loginIdentifier); await safeWriteAuditEvent(env, { actorUserId: user.id, diff --git a/src/utils/api-key.ts b/src/utils/api-key.ts new file mode 100644 index 0000000..8c03cbb --- /dev/null +++ b/src/utils/api-key.ts @@ -0,0 +1,36 @@ +const API_KEY_HASH_PREFIX = 'sha256:'; + +export 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 toHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +export function isStoredApiKeyHash(value: string | null | undefined): boolean { + return String(value || '').startsWith(API_KEY_HASH_PREFIX); +} + +export async function hashApiKey(apiKey: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey)); + return `${API_KEY_HASH_PREFIX}${toHex(digest)}`; +} + +export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise { + const stored = String(storedApiKey || '').trim(); + if (!isStoredApiKeyHash(stored)) return false; + + const hashed = await hashApiKey(apiKey); + return constantTimeEquals(hashed, stored); +}