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>
175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { hashPassword, verifyPassword, encryptSecret, decryptSecret } from "./crypto.js";
|
|
import { generateTotpSecret, verifyTotpCode, generateHotp, base32Decode, getCurrentStep } from "./totp.js";
|
|
import { generateRecoveryCodes, generateAppPassword, hashAppPassword, verifyAppPassword } from "./mfaHelpers.js";
|
|
import { createJwt, verifyJwt, isSurfaceAuthorized, isStepUpMfaRequired } from "./session.js";
|
|
|
|
describe("crypto module", () => {
|
|
it("hashes and verifies passwords correctly", async () => {
|
|
const password = "SecretPassword123!";
|
|
const hash = await hashPassword(password);
|
|
expect(hash.startsWith("$argon2id$")).toBe(true);
|
|
expect(await verifyPassword(password, hash)).toBe(true);
|
|
expect(await verifyPassword("WrongPassword", hash)).toBe(false);
|
|
});
|
|
|
|
// The Go service (alexedwards/argon2id) writes the hashes this service has
|
|
// to accept, and vice versa. A mismatch means a password set through the
|
|
// mail stack cannot be used to log into the webmail.
|
|
it("verifies an Argon2id hash produced by the Go service", async () => {
|
|
const goHash = "$argon2id$v=19$m=65536,t=1,p=16$lw9+BftyPYW4ILsZu9ZAPw$ueO26QNhturq/vuNMlEgVZhXTZwQi+tPYeUFjaeRdIs";
|
|
expect(await verifyPassword("dev-password-only", goHash)).toBe(true);
|
|
expect(await verifyPassword("wrong", goHash)).toBe(false);
|
|
});
|
|
|
|
// Regression: verifyPassword used to fall back to comparing the password
|
|
// against the stored hash, so anyone holding a database dump could
|
|
// authenticate by sending the hash back as the password.
|
|
it("refuses to accept the stored hash itself as the password", async () => {
|
|
const hash = await hashPassword("RealPassword123!");
|
|
expect(await verifyPassword(hash, hash)).toBe(false);
|
|
});
|
|
|
|
it("rejects any hash that is not argon2id, instead of guessing a scheme", async () => {
|
|
const sha256OfPassword = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8";
|
|
expect(await verifyPassword("password", sha256OfPassword)).toBe(false);
|
|
expect(await verifyPassword("password", "pbkdf2$sha512$100000$abc$def")).toBe(false);
|
|
expect(await verifyPassword("password", "")).toBe(false);
|
|
});
|
|
|
|
it("encrypts and decrypts secrets using AES-256-GCM", () => {
|
|
const secret = "JBSWY3DPEHPK3PXP";
|
|
const encrypted = encryptSecret(secret);
|
|
const decrypted = decryptSecret(encrypted.encrypted, encrypted.nonce, encrypted.keyVersion);
|
|
expect(decrypted).toBe(secret);
|
|
});
|
|
});
|
|
|
|
describe("totp module", () => {
|
|
it("generates valid base32 secret and uri", () => {
|
|
const res = generateTotpSecret("user@domain.com");
|
|
expect(res.base32Secret).toBeDefined();
|
|
expect(res.uri).toContain("otpauth://totp/LambdaMail:user%40domain.com");
|
|
});
|
|
|
|
it("verifies valid code and enforces anti-replay step check", () => {
|
|
const { base32Secret } = generateTotpSecret("test@domain.com");
|
|
const secretBuffer = base32Decode(base32Secret);
|
|
const currentStep = getCurrentStep();
|
|
const validCode = generateHotp(secretBuffer, currentStep);
|
|
|
|
// Initial validation
|
|
const result1 = verifyTotpCode(base32Secret, validCode, null);
|
|
expect(result1.valid).toBe(true);
|
|
expect(result1.step).toBe(currentStep);
|
|
|
|
// Anti-replay: same step cannot be reused
|
|
const result2 = verifyTotpCode(base32Secret, validCode, result1.step);
|
|
expect(result2.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid code format and wrong code", () => {
|
|
const { base32Secret } = generateTotpSecret("test@domain.com");
|
|
expect(verifyTotpCode(base32Secret, "12345").valid).toBe(false);
|
|
expect(verifyTotpCode(base32Secret, "000000").valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("mfaHelpers module", () => {
|
|
it("generates 10 recovery codes and hashes them", async () => {
|
|
const { rawCodes, hashedCodes } = await generateRecoveryCodes(10);
|
|
expect(rawCodes.length).toBe(10);
|
|
expect(hashedCodes.length).toBe(10);
|
|
expect(await verifyPassword(rawCodes[0], hashedCodes[0])).toBe(true);
|
|
expect(hashedCodes.every((h) => h.startsWith("$argon2id$"))).toBe(true);
|
|
});
|
|
|
|
it("generates app passwords with prefix", async () => {
|
|
const appPass = generateAppPassword();
|
|
expect(appPass.startsWith("lmp_")).toBe(true);
|
|
const hash = await hashAppPassword(appPass);
|
|
expect(await verifyAppPassword(appPass, hash)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("surface isolation and session module", () => {
|
|
it("strictly rejects user token when presented to admin surface", () => {
|
|
const userToken = createJwt({
|
|
sub: "mailbox-123",
|
|
email: "admin@domain.com",
|
|
role: "SUPER_ADMIN", // Even with SUPER_ADMIN role!
|
|
domainId: "domain-123",
|
|
surface: "user",
|
|
aud: "lambdamail:user",
|
|
mfaSatisfied: false,
|
|
purpose: "session",
|
|
});
|
|
|
|
const parsed = verifyJwt(userToken);
|
|
expect(parsed).not.toBeNull();
|
|
// Rejection check:
|
|
expect(isSurfaceAuthorized(parsed, "admin")).toBe(false);
|
|
expect(isSurfaceAuthorized(parsed, "user")).toBe(true);
|
|
});
|
|
|
|
it("accepts valid admin token with mfaSatisfied=true", () => {
|
|
const adminToken = createJwt({
|
|
sub: "mailbox-123",
|
|
email: "admin@domain.com",
|
|
role: "SUPER_ADMIN",
|
|
domainId: "domain-123",
|
|
surface: "admin",
|
|
aud: "lambdamail:admin",
|
|
mfaSatisfied: true,
|
|
mfaSatisfiedAt: Date.now(),
|
|
purpose: "session",
|
|
});
|
|
|
|
const parsed = verifyJwt(adminToken);
|
|
expect(parsed).not.toBeNull();
|
|
expect(isSurfaceAuthorized(parsed, "admin")).toBe(true);
|
|
expect(isStepUpMfaRequired(parsed!, 15)).toBe(false);
|
|
});
|
|
|
|
it("requires step-up MFA if mfaSatisfiedAt is older than 15 minutes", () => {
|
|
const oldTimestamp = Date.now() - 20 * 60 * 1000; // 20 mins ago
|
|
const adminToken = createJwt({
|
|
sub: "mailbox-123",
|
|
email: "admin@domain.com",
|
|
role: "SUPER_ADMIN",
|
|
domainId: "domain-123",
|
|
surface: "admin",
|
|
aud: "lambdamail:admin",
|
|
mfaSatisfied: true,
|
|
mfaSatisfiedAt: oldTimestamp,
|
|
purpose: "session",
|
|
});
|
|
|
|
const parsed = verifyJwt(adminToken)!;
|
|
expect(isStepUpMfaRequired(parsed, 15)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// A challenge token is issued after the password but before the second
|
|
// factor. Treating it as a session would skip MFA entirely.
|
|
describe("challenge tokens are not sessions", () => {
|
|
it("refuses a challenge token wherever a session is required", () => {
|
|
const challenge = createJwt({
|
|
sub: "mailbox-123",
|
|
email: "admin@domain.com",
|
|
role: "SUPER_ADMIN",
|
|
domainId: "domain-123",
|
|
surface: "admin",
|
|
aud: "lambdamail:admin",
|
|
mfaSatisfied: true,
|
|
mfaSatisfiedAt: Date.now(),
|
|
purpose: "mfa_challenge",
|
|
});
|
|
|
|
const parsed = verifyJwt(challenge);
|
|
expect(parsed).not.toBeNull();
|
|
expect(isSurfaceAuthorized(parsed, "admin")).toBe(false);
|
|
expect(isSurfaceAuthorized(parsed, "user")).toBe(false);
|
|
});
|
|
});
|