Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/dsgvo/deletionToken.server.ts
T

167 lines
6.0 KiB
TypeScript

/**
* Phase 10 Task #68 — DSGVO self-service deletion email-verification token.
*
* The self-service "Delete my account" route mints a short-lived signed
* token, emails the user a confirmation link, and ONLY flips
* `User.pendingDeletion=true` once the user clicks the link and the
* confirmation route verifies the token. This is the spec's defense
* against account-hijack-deletion attacks (Art. 17 §38 of the recital):
* if an attacker takes over a session but cannot read the user's email,
* they cannot complete a deletion.
*
* Token format: `dsgvo1.<base64url(payload)>.<base64url(hmac-sha256)>`.
* The HMAC key is read from `DELETION_TOKEN_SECRET`, falling back to
* `SESSION_SECRET` when unset so single-secret installations keep
* working. Both must be at least 32 chars; shorter secrets fail-closed.
*
* The format is intentionally distinct from the Phase 05 lead-token
* (`v1.…`) so a leaked lead-token cannot be replayed against the
* deletion-confirmation route and vice-versa.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
export interface DeletionTokenPayload {
/** User.id whose deletion this token confirms. */
userId: string;
/** Unix-seconds timestamp when the request was issued. */
requestedAt: number;
/** Issued-at (unix seconds). */
iat: number;
/** Expires-at (unix seconds). */
exp: number;
}
export interface SignDeletionTokenInput {
userId: string;
/** Override the recorded request time (test seam / replay protection). */
requestedAt?: Date;
/** Token lifetime in hours. Capped at 72 to bound exposure. Default 24. */
expiresInHours?: number;
/** Override system clock (test seam). */
now?: () => Date;
/** Override env lookup (test seam). */
secret?: string;
}
export interface VerifyDeletionTokenOptions {
now?: () => Date;
secret?: string;
}
export class DeletionTokenSecretMissingError extends Error {
override readonly name = "DeletionTokenSecretMissingError";
constructor() {
super(
"DELETION_TOKEN_SECRET (or SESSION_SECRET fallback) env var is not set or shorter than 32 chars; cannot sign or verify DSGVO deletion tokens.",
);
}
}
const TOKEN_VERSION = "dsgvo1";
const MAX_TTL_HOURS = 72;
const DEFAULT_TTL_HOURS = 24;
const MIN_SECRET_LENGTH = 32;
function getSecret(override?: string): Buffer {
const raw = override ?? process.env.DELETION_TOKEN_SECRET ?? process.env.SESSION_SECRET;
if (!raw || raw.length === 0 || raw.length < MIN_SECRET_LENGTH) {
throw new DeletionTokenSecretMissingError();
}
return Buffer.from(raw, "utf8");
}
function base64UrlEncode(input: Buffer | string): string {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
return buf.toString("base64url");
}
function base64UrlDecode(input: string): Buffer | null {
if (!/^[A-Za-z0-9_-]+$/u.test(input)) return null;
try {
return Buffer.from(input, "base64url");
} catch {
return null;
}
}
function nowSec(now?: () => Date): number {
return Math.floor((now ? now() : new Date()).getTime() / 1000);
}
function clampTtl(hours: number | undefined): number {
if (!hours || !Number.isFinite(hours) || hours <= 0) return DEFAULT_TTL_HOURS;
return Math.min(Math.floor(hours), MAX_TTL_HOURS);
}
/**
* Sign a deletion-confirmation token. Throws
* {@link DeletionTokenSecretMissingError} when no usable secret is
* configured — callers MUST handle that case (e.g. surface a
* "configuration error, contact support" page) so the route never
* silently sends a token that cannot be verified later.
*/
export function signDeletionToken(input: SignDeletionTokenInput): string {
if (!input.userId || typeof input.userId !== "string") {
throw new TypeError("signDeletionToken: userId required");
}
const secret = getSecret(input.secret);
const ttlHours = clampTtl(input.expiresInHours);
const iat = nowSec(input.now);
const requestedAt = input.requestedAt ? Math.floor(input.requestedAt.getTime() / 1000) : iat;
const exp = iat + ttlHours * 60 * 60;
const payload: DeletionTokenPayload = { userId: input.userId, requestedAt, iat, exp };
const payloadStr = JSON.stringify(payload);
const payloadB64 = base64UrlEncode(payloadStr);
const sig = createHmac("sha256", secret).update(`${TOKEN_VERSION}.${payloadB64}`).digest();
return `${TOKEN_VERSION}.${payloadB64}.${base64UrlEncode(sig)}`;
}
/**
* Verify a deletion-confirmation token. Returns the decoded payload on
* a valid + unexpired token; returns `null` otherwise. NEVER throws on
* malformed/expired input — fail-closed silently so the route can
* render a generic "this link has expired" page without leaking which
* check tripped.
*/
export function verifyDeletionToken(
token: string | null | undefined,
options: VerifyDeletionTokenOptions = {},
): DeletionTokenPayload | null {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [version, payloadB64, sigB64] = parts;
if (version !== TOKEN_VERSION) return null;
let secret: Buffer;
try {
secret = getSecret(options.secret);
} catch {
return null;
}
const expectedSig = createHmac("sha256", secret).update(`${version}.${payloadB64}`).digest();
const actualSig = base64UrlDecode(sigB64);
if (!actualSig || actualSig.length !== expectedSig.length) return null;
if (!timingSafeEqual(actualSig, expectedSig)) return null;
const payloadBuf = base64UrlDecode(payloadB64);
if (!payloadBuf) return null;
let parsed: unknown;
try {
parsed = JSON.parse(payloadBuf.toString("utf8"));
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const rec = parsed as Record<string, unknown>;
if (typeof rec.userId !== "string" || rec.userId.length === 0) return null;
if (typeof rec.iat !== "number" || typeof rec.exp !== "number" || typeof rec.requestedAt !== "number") return null;
if (rec.exp <= nowSec(options.now)) return null;
return {
userId: rec.userId,
requestedAt: rec.requestedAt,
iat: rec.iat,
exp: rec.exp,
};
}