fix(auth): hash stored api keys

This commit is contained in:
shuaiplus
2026-07-02 16:27:20 +08:00
parent 680e287c8d
commit 1545881eae
3 changed files with 58 additions and 33 deletions
+5 -5
View File
@@ -6,6 +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 { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } 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';
@@ -1194,9 +1195,9 @@ 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);
if (rotate || user.apiKey === null) { // Only the fresh secret is returned once; the database stores a hash.
// Upstream apikeys are 30-character random alphanumeric strings const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = await hashApiKey(plainApiKey);
if (rotate) { if (rotate) {
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
@@ -1213,10 +1214,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
targetId: user.id, targetId: user.id,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
}
return jsonResponse({ return jsonResponse({
apiKey: user.apiKey, apiKey: plainApiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+2 -13
View File
@@ -22,6 +22,7 @@ import {
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; 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_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -106,18 +107,6 @@ function parseCookieValue(request: Request, name: string): string | null {
return 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<string, string>, names: string[]): string | undefined { function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const value = body[name]; const value = body[name];
@@ -688,7 +677,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); 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 rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
+36
View File
@@ -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<string> {
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<boolean> {
const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false;
const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored);
}