167 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
}
|