fix: update server hash prefix handling for password hashing and verification

This commit is contained in:
shuaiplus
2026-05-23 03:00:58 +08:00
parent ea9e238aa7
commit 749de4e2e1
+7 -9
View File
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations). // The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive. // This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000; const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000; const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry { interface CachedUserEntry {
@@ -133,7 +134,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes. // Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> { async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', 'raw',
@@ -151,20 +152,17 @@ export class AuthService {
const bytes = new Uint8Array(bits); const bytes = new Uint8Array(bits);
let binary = ''; let binary = '';
for (const b of bytes) binary += String.fromCharCode(b); for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary); return SERVER_HASH_PREFIX + btoa(binary);
} }
// Verify password: hash the input the same way, then constant-time compare. // Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$". if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
// Legacy accounts (created before the upgrade) store raw client hashes without prefix. return false;
if (email && storedHash.startsWith('$s$')) { }
const serverHash = await this.hashPasswordServer(inputHash, email); const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash); return this.constantTimeEquals(serverHash, storedHash);
} }
// Legacy path: direct constant-time comparison of raw client hashes.
return this.constantTimeEquals(inputHash, storedHash);
}
private constantTimeEquals(a: string, b: string): boolean { private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a); const encA = new TextEncoder().encode(a);