diff --git a/src/libs/auth/types.ts b/src/libs/auth/types.ts index a0d1521..8aa3d49 100644 --- a/src/libs/auth/types.ts +++ b/src/libs/auth/types.ts @@ -286,6 +286,15 @@ export interface AuthCsrfVerifyResult { readonly ok: true; } +/** + * Public result for flow-request endpoints (password reset, email + * verification). The shape MUST be indistinguishable for known and + * unknown identifiers — that is the framework's anti-enumeration + * contract. `expiresAt` stays in the type as a forward-compatible slot + * for flows that authenticate the caller before responding (e.g. an + * authenticated session triggering its own verification), but the + * unauthenticated request flows always return `{ ok: true }`. + */ export interface AuthFlowPublicResult { readonly ok: true; readonly expiresAt?: number; diff --git a/src/svrs/auth/recovery-flow.ts b/src/svrs/auth/recovery-flow.ts index 7f0482d..075a1f1 100644 --- a/src/svrs/auth/recovery-flow.ts +++ b/src/svrs/auth/recovery-flow.ts @@ -79,7 +79,11 @@ export function createRecoveryAuthFlow(context: AuthEngineFlowContext) { flowId: flow.id, meta: input.meta }); - return { ok: true, expiresAt: flow.expiresAt }; + // Same anti-enumeration contract as `requestPasswordReset`: the + // unknown-identifier and already-verified branches return + // `{ ok: true }`, so this branch matches the shape rather than + // leaking the live flow's `expiresAt` to the caller. + return { ok: true }; } async function completeEmailVerification( @@ -168,7 +172,11 @@ export function createRecoveryAuthFlow(context: AuthEngineFlowContext) { flowId: flow.id, meta: input.meta }); - return { ok: true, expiresAt: flow.expiresAt }; + // `flow.expiresAt` is intentionally NOT exposed: the unknown- + // identifier branch returns `{ ok: true }`. Matching the shape + // makes the public response indistinguishable so a caller cannot + // enumerate accounts by inspecting the field's presence. + return { ok: true }; } async function completePasswordReset( diff --git a/src/svrs/auth/test/anti-enumeration.test.ts b/src/svrs/auth/test/anti-enumeration.test.ts new file mode 100644 index 0000000..874e613 --- /dev/null +++ b/src/svrs/auth/test/anti-enumeration.test.ts @@ -0,0 +1,200 @@ +/** + * Bloque G5 — anti-enumeration contract for the recovery and email + * verification flows. + * + * Both `requestPasswordReset` and `requestEmailVerification` MUST present + * an indistinguishable response shape regardless of whether the + * identifier corresponds to an existing credential. Anything else (an + * extra field, a different `expiresAt`, a side effect that only fires + * for known accounts, a thrown error) leaks account existence to a + * caller that knows the API surface. + * + * The flows already short-circuit silently when the credential is + * unknown — these tests pin that contract so a future refactor cannot + * regress it. + */ + +import { describe, expect, it } from 'vitest'; +import { AUTH_TEST_TENANT_ID } from '$libs/auth'; +import { + createDeterministicAuthCrypto, + createEngineAuth, + createMemoryAuthActors, + createMemoryAuthAdapter, + createMemoryAuthCache, + createMemoryAuthClock, + createMemoryAuthLogr, + createMemoryAuthMailer, + createMemoryAuthSessPort, + createTestPasswordHasher +} from '$svrs/auth'; + +function createHarness() { + const crypto = createDeterministicAuthCrypto('auth-anti-enum'); + const clock = createMemoryAuthClock(1_000); + const store = createMemoryAuthAdapter(); + const actors = createMemoryAuthActors(crypto); + const sess = createMemoryAuthSessPort({ crypto, clock }); + const logger = createMemoryAuthLogr(); + const cache = createMemoryAuthCache(); + const mailer = createMemoryAuthMailer(); + const engine = createEngineAuth({ + security: { csrf: { signingKey: 'test-csrf-key' } }, + ports: { + store, + actors, + sess, + logger, + timer: clock, + crypto, + cache, + mailer, + passwordHasher: createTestPasswordHasher() + } + }); + return { engine, store, mailer, logger }; +} + +describe('auth recovery flows — anti-enumeration', () => { + it('requestPasswordReset returns the same shape for known and unknown identifiers', async () => { + const { engine } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'known@example.com', + password: 'correct horse battery staple' + }); + + const known = await engine.requestPasswordReset({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'known@example.com' + }); + + const unknown = await engine.requestPasswordReset({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'ghost@example.com' + }); + + // Public response must look identical to the API caller. The flow + // for the known identifier internally creates a token + sets + // `expiresAt`, but the public response intentionally drops it so + // the unknown branch can return the same shape. + expect(Object.keys(known).sort()).toEqual(Object.keys(unknown).sort()); + expect(known.ok).toBe(true); + expect(unknown.ok).toBe(true); + }); + + it('requestPasswordReset does not create a flow for unknown identifiers', async () => { + const { engine, store } = createHarness(); + + await engine.requestPasswordReset({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'ghost@example.com' + }); + + expect(store.snapshot().flows).toHaveLength(0); + }); + + it('requestPasswordReset does not send a mail for unknown identifiers', async () => { + const { engine, mailer } = createHarness(); + + await engine.requestPasswordReset({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'ghost@example.com' + }); + + expect(mailer.messages).toHaveLength(0); + }); + + it('requestEmailVerification returns the same shape for unknown and already-verified identifiers', async () => { + const { engine } = createHarness(); + + // Sign up (creates an unverified credential) then mark verified + // directly through the store snapshot side-effects: the sign-up + // flow leaves verifiedAt unset, so we just request again and + // observe the short-circuit. + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'unverified@example.com', + password: 'correct horse battery staple' + }); + + const firstRequest = await engine.requestEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'unverified@example.com' + }); + + const ghost = await engine.requestEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'ghost@example.com' + }); + + // First request creates a flow → returns expiresAt. Ghost short- + // circuits → returns just `ok`. The shape comparison here + // intentionally accepts that the verification flow is allowed to + // expose `expiresAt` to genuine owners (the password-reset flow + // must not). What we DO require is that a wrong identifier never + // leaks a flow / expiresAt by mistake. + expect(firstRequest.ok).toBe(true); + expect(ghost.ok).toBe(true); + expect(Object.keys(ghost)).toEqual(['ok']); + }); + + it('requestEmailVerification does not send mail for unknown identifiers', async () => { + const { engine, mailer } = createHarness(); + + await engine.requestEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'ghost@example.com' + }); + + expect(mailer.messages).toHaveLength(0); + }); + + it('requestEmailVerification short-circuits when the credential is already verified', async () => { + const { engine, store, mailer } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'verified@example.com', + password: 'correct horse battery staple' + }); + + // Run the full verification flow once so `verifiedAt` is set. + const initial = await engine.requestEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'verified@example.com' + }); + expect(initial.ok).toBe(true); + + const firstFlow = store.snapshot().flows[0]; + const flowId = firstFlow.id; + + // Read the verification token directly from the mailer queue. + const sent = mailer.messages.find( + (message) => message.kind === 'email_verification' + ); + const token = sent?.variables?.token as string; + expect(typeof token).toBe('string'); + + await engine.completeEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + flowId, + token + }); + + // After verification, requesting again must short-circuit silently + // — same response shape, no new flow, no new mail. + const messagesBefore = mailer.messages.length; + const flowsBefore = store.snapshot().flows.length; + + const second = await engine.requestEmailVerification({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'verified@example.com' + }); + + expect(second).toEqual({ ok: true }); + expect(mailer.messages).toHaveLength(messagesBefore); + expect(store.snapshot().flows).toHaveLength(flowsBefore); + }); +}); diff --git a/src/svrs/auth/test/security-events-redaction.test.ts b/src/svrs/auth/test/security-events-redaction.test.ts new file mode 100644 index 0000000..161803f --- /dev/null +++ b/src/svrs/auth/test/security-events-redaction.test.ts @@ -0,0 +1,166 @@ +/** + * Bloque G6 — security event redaction contract. + * + * The framework's design keeps secrets out of `AuthLogEntry`s by + * convention: identifiers go through `identifierHash`, network metadata + * goes into `AuthRequestMeta` as `ipHash`/`userAgentHash`, tokens are + * stored as `challengeHash`. These tests pin that contract for the + * common flows so a future refactor cannot regress it by accident. + * + * What we assert: + * - `password` never appears in the emitted log entry tree (sign-up, + * sign-in, password reset complete). + * - `token` never appears in the emitted log entry tree (recovery flows + * pass tokens to the mailer, not to the security log). + * - The user identifier in plaintext does NOT appear in the log entry + * (the framework hashes it before it reaches the log). + */ + +import { describe, expect, it } from 'vitest'; +import { AUTH_TEST_TENANT_ID } from '$libs/auth'; +import { + createDeterministicAuthCrypto, + createEngineAuth, + createMemoryAuthActors, + createMemoryAuthAdapter, + createMemoryAuthCache, + createMemoryAuthClock, + createMemoryAuthLogr, + createMemoryAuthMailer, + createMemoryAuthSessPort, + createTestPasswordHasher +} from '$svrs/auth'; +import type { AuthLogEntry } from '$libs/auth'; + +function createHarness() { + const crypto = createDeterministicAuthCrypto('auth-redaction'); + const clock = createMemoryAuthClock(1_000); + const store = createMemoryAuthAdapter(); + const actors = createMemoryAuthActors(crypto); + const sess = createMemoryAuthSessPort({ crypto, clock }); + const logger = createMemoryAuthLogr(); + const cache = createMemoryAuthCache(); + const mailer = createMemoryAuthMailer(); + const engine = createEngineAuth({ + security: { csrf: { signingKey: 'test-csrf-key' } }, + ports: { + store, + actors, + sess, + logger, + timer: clock, + crypto, + cache, + mailer, + passwordHasher: createTestPasswordHasher() + } + }); + return { engine, logger, mailer }; +} + +function flattenEntry(entry: AuthLogEntry): string { + // Stable JSON of every field that could carry a secret — the + // `data`/`meta` tree is the only place where new code might + // accidentally splat sensitive input. + return JSON.stringify({ + message: entry.message, + category: entry.category, + eventName: entry.eventName, + actorRef: entry.actorRef, + sessionId: entry.sessionId, + meta: entry.meta, + data: entry.data + }).toLowerCase(); +} + +describe('auth security events — redaction contract', () => { + const PASSWORD = 'correct horse battery staple'; + const IDENTIFIER = 'redaction.user@example.com'; + + it('sign-up never logs the plaintext password or identifier', async () => { + const { engine, logger } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: PASSWORD + }); + + for (const entry of logger.entries) { + const flat = flattenEntry(entry); + expect(flat).not.toContain('correct horse battery staple'); + expect(flat).not.toContain('redaction.user@example.com'); + } + }); + + it('sign-in never logs the plaintext password or identifier', async () => { + const { engine, logger } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: PASSWORD + }); + + await engine.signInPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: PASSWORD + }); + + for (const entry of logger.entries) { + const flat = flattenEntry(entry); + expect(flat).not.toContain('correct horse battery staple'); + expect(flat).not.toContain('redaction.user@example.com'); + } + }); + + it('password reset request never logs the verification token', async () => { + const { engine, logger, mailer } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: PASSWORD + }); + + await engine.requestPasswordReset({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER + }); + + // The mailer received the token; the logger MUST NOT. + const tokenInMail = mailer.messages + .flatMap((message) => Object.values(message.variables ?? {})) + .find((value): value is string => typeof value === 'string' && value.length > 16); + expect(tokenInMail).toBeDefined(); + + for (const entry of logger.entries) { + const flat = flattenEntry(entry); + expect(flat).not.toContain((tokenInMail as string).toLowerCase()); + } + }); + + it('failed sign-in does not log the attempted password', async () => { + const { engine, logger } = createHarness(); + + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: PASSWORD + }); + + await expect( + engine.signInPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: IDENTIFIER, + password: 'wrong-attempt' + }) + ).rejects.toBeDefined(); + + for (const entry of logger.entries) { + const flat = flattenEntry(entry); + expect(flat).not.toContain('wrong-attempt'); + } + }); +});