feat(auth): record an identifier HMAC on failed-login audit rows

Failed-login audit rows currently carry `ipAddress` plus `reason`
(`user_not_found_or_no_password` or `invalid_password`). The typed
identifier is deliberately omitted (H-1 contract: PII never lands
in operator artefacts) — but that leaves no anchor a future spray
detector can group on.

Recording `hashToken(identifier)` alongside the reason keeps the
H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one-
way; the raw identifier never serialises into the row) while
giving any future "same identifier hit from many IPs" detector a
correlation key.

Test mock for `hashToken` now returns a deterministic hex string
that does not echo the raw input — mirrors the real HMAC shape
and keeps the existing "audit row must not contain the typed
email" assertion passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 07:40:05 +02:00
1 parent e74eff2979
commit 1757b35327
2 files changed
+21 -4

No files matched your search

@@ -47,7 +47,14 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
// Return a deterministic hex string that does NOT echo the raw
// value — mirrors the real HMAC-SHA256 shape. The audit row's
// privacy contract is "the typed identifier never serialises into
// the row's body", so the mock must not embed the raw input.
hashToken: vi.fn(
(raw: string) =>
"aa".repeat(32) + (raw.length % 8).toString(16),
),
}));
vi.mock("@/lib/logging/transports", () => ({
+13 -3
View File
@@ -3,6 +3,7 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
apiSuccess,
apiError,
@@ -54,6 +55,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { email, password } = parsed.data;
const identifier = email.trim();
// HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`,
// mirrors the `/api/auth/check-user` pattern. The raw identifier
// stays out of the audit row (H-1 contract); the hash gives a
// future spray-detector a forensic anchor it can correlate across
// IPs without having to look up users by email.
const identifierHash = hashToken(identifier);
const user = await prisma.user.findFirst({
where: {
@@ -67,10 +74,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!user || !user.passwordHash) {
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
// happened, and PII must not land in operator artefacts.
// happened, and PII must not land in operator artefacts. The
// HMAC anchor below is one-way — recoverable only with the HMAC
// key (operator secret), used purely to group same-identifier
// attempts across IPs.
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "user_not_found_or_no_password" },
details: { reason: "user_not_found_or_no_password", identifierHash },
});
return apiError("Invalid credentials", 401);
}
@@ -80,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
await auditLog("auth.login.failed", {
userId: user.id,
ipAddress: ip,
details: { reason: "invalid_password" },
details: { reason: "invalid_password", identifierHash },
});
return apiError("Invalid credentials", 401);
}