#21: make the auth service actually authenticate
The F4 auth service issued a valid session for any email and any password, and accepted any non-empty string as a second factor. Reproduced against the router before touching it: an invented password plus the code "000000" returned a SUPER_ADMIN token that opened /api/v1/admin/dashboard. Passwords. Hashing moves from PBKDF2 to Argon2id via hash-wasm, which is what PLAN.md section 9 and the Go service already use - the previous scheme meant a password set through the mail stack could never verify here. Interoperability was checked in both directions before adopting the library: a hash written by alexedwards/argon2id verifies in Node and one written here verifies in Go. hash-wasm is pure WASM, so the multi-arch Alpine images need no native build. verifyPassword no longer falls back to comparing the password against the stored hash. That fallback turned every stored hash into a working credential: anyone with a database dump could authenticate by sending the hash back. Anything that is not an argon2id PHC string is now refused. Logins are decided against the database, with the failure counter and the temporary lockout of PLAN.md section 5.2. An unknown address and a wrong password give the same answer, and the unknown case still pays for a hash so response time does not enumerate valid addresses. Second factor. The TOTP secret is generated server-side, stored encrypted and never accepted back from the client - the confirm step used to take whichever secret the caller sent, so anyone could confirm with their own. Recovery codes are Argon2id, single-use, and consumed under a guard so two concurrent requests cannot spend the same one. App passwords are issued and revoked through real endpoints instead of browser state. Sessions gain a "purpose" claim. Without it the challenge token - handed out after the password but before the second factor - satisfied every check a real session did, which skipped MFA entirely. The dev seed carried a hand-written password_hash whose digest was 24 bytes instead of 32, so it matched no password at all. Nothing noticed while passwords went unverified; it is a real hash now. Verified with 30 tests, 13 of them against a real Postgres: wrong password, unknown account, pass-the-hash, wrong TOTP code, challenge token replay, lockout, single-use recovery codes, and the surface isolation that F4's acceptance criteria require. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23da5f43e4
commit
833dc76c2e
11 files changed
+1193
-374
No files matched your search
+64
-20
@@ -1,32 +1,66 @@
|
||||
import crypto from "node:crypto";
|
||||
import { argon2id, argon2Verify } from "hash-wasm";
|
||||
|
||||
const MASTER_KEY_ENV = "LAMBDAMAIL_MASTER_KEY";
|
||||
const DEFAULT_KEY_FOR_DEV = "lambdamail_default_master_key_32bytes!!";
|
||||
|
||||
// Argon2id parameters. These match what the Go side (alexedwards/argon2id)
|
||||
// and PLAN.md section 9 expect, so a password set through the mail stack
|
||||
// verifies here and vice versa - both produce and consume the same PHC string.
|
||||
const ARGON2_MEMORY_KIB = 65536;
|
||||
const ARGON2_ITERATIONS = 3;
|
||||
const ARGON2_PARALLELISM = 4;
|
||||
const ARGON2_HASH_LENGTH = 32;
|
||||
const ARGON2_SALT_BYTES = 16;
|
||||
|
||||
/**
|
||||
* Derives the key-encryption key. Unlike the previous implementation there is
|
||||
* no development fallback: a missing master key silently encrypting every TOTP
|
||||
* secret under a value published in the source tree is worse than refusing to
|
||||
* start (PLAN.md risk R9).
|
||||
*/
|
||||
function getMasterKey(): Buffer {
|
||||
const envKey = process.env[MASTER_KEY_ENV] || DEFAULT_KEY_FOR_DEV;
|
||||
const envKey = process.env[MASTER_KEY_ENV];
|
||||
if (!envKey || envKey.length < 16) {
|
||||
throw new Error(
|
||||
`${MASTER_KEY_ENV} must be set to at least 16 characters; refusing to encrypt secrets under a default key`,
|
||||
);
|
||||
}
|
||||
return crypto.createHash("sha256").update(envKey).digest();
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex");
|
||||
return `pbkdf2$sha512$100000$${salt}$${hash}`;
|
||||
/** Produces an Argon2id PHC string, the only format this service stores. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2id({
|
||||
password,
|
||||
salt: crypto.randomBytes(ARGON2_SALT_BYTES),
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
iterations: ARGON2_ITERATIONS,
|
||||
memorySize: ARGON2_MEMORY_KIB,
|
||||
hashLength: ARGON2_HASH_LENGTH,
|
||||
outputType: "encoded",
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||
if (!storedHash.startsWith("pbkdf2$sha512$")) {
|
||||
// Fallback for simple SHA256 hashes in tests/seeds if present
|
||||
const simpleHash = crypto.createHash("sha256").update(password).digest("hex");
|
||||
return simpleHash === storedHash || password === storedHash;
|
||||
/**
|
||||
* Verifies a password against an Argon2id PHC string.
|
||||
*
|
||||
* Anything that is not an argon2id PHC string is rejected outright. The
|
||||
* previous implementation fell back to comparing the password against the
|
||||
* stored hash itself, which turned every stored hash into a usable credential:
|
||||
* anyone holding a database dump could authenticate by sending the hash back.
|
||||
* There is no format to be lenient about here - the schema stores exactly one.
|
||||
*/
|
||||
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
||||
if (!storedHash || !storedHash.startsWith("$argon2id$")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await argon2Verify({ password, hash: storedHash });
|
||||
} catch {
|
||||
// A malformed stored hash is a failed login, not a crash that would take
|
||||
// the login endpoint down for everyone.
|
||||
return false;
|
||||
}
|
||||
const parts = storedHash.split("$");
|
||||
if (parts.length !== 5) return false;
|
||||
const iterations = parseInt(parts[2], 10);
|
||||
const salt = parts[3];
|
||||
const originalHash = parts[4];
|
||||
const hash = crypto.pbkdf2Sync(password, salt, iterations, 64, "sha512").toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(originalHash, "hex"));
|
||||
}
|
||||
|
||||
export function encryptSecret(plaintext: string): { encrypted: Buffer; nonce: Buffer; keyVersion: number } {
|
||||
@@ -37,11 +71,21 @@ export function encryptSecret(plaintext: string): { encrypted: Buffer; nonce: Bu
|
||||
return { encrypted, nonce, keyVersion: 1 };
|
||||
}
|
||||
|
||||
export function decryptSecret(encrypted: Buffer, nonce: Buffer, keyVersion = 1): string {
|
||||
export function decryptSecret(encrypted: Buffer, nonce: Buffer, _keyVersion = 1): string {
|
||||
const key = getMasterKey();
|
||||
const authTag = encrypted.subarray(encrypted.length - 16);
|
||||
const ciphertext = encrypted.subarray(0, encrypted.length - 16);
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(ciphertext) + decipher.final("utf8");
|
||||
// Concatenated as buffers before decoding: decoding each chunk separately
|
||||
// would corrupt any multi-byte character that straddles the boundary.
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two short strings of possibly different length. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
Reference in new issue
Block a user