diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..0fc979d --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +# gitleaks configuration. +# Extends the default ruleset; adds an allowlist for test files, which +# legitimately contain hardcoded FAKE secrets (e.g. HMAC token test fixtures), +# never real credentials. Real config secrets live in gitignored .env files. +[extend] +useDefault = true + +[allowlist] +description = "Test files use fake fixture secrets, not real credentials." +paths = [ + '''.*\.test\.ts$''', +] diff --git a/specs/mail/threading.md b/specs/mail/threading.md index 6d47ae6..65f13db 100644 --- a/specs/mail/threading.md +++ b/specs/mail/threading.md @@ -8,20 +8,20 @@ Threading authority lives on the outbound side (charter §2). `In-Reply-To` and ## 2. The reply token -Every outbound message (agent reply, auto-response, and any future first-party auto-reply) embeds a signed token in its `Message-ID`. Proposed format: +Every outbound message (agent reply, auto-response, and any future first-party auto-reply) embeds a signed token in its `Message-ID`. Format (implemented in `src/mail/reply-token.ts`): ``` - + ``` -where `sig = HMAC(secret, canonical(conversationId, threadId))`, truncated and hex/base32-encoded. This is Helpthread's own design, not derived from any observed system's internals — we only observed a black-box Message-ID *shape*, never a secret or algorithm. +where `sig = base64url( HMAC-SHA256( secret, "{keyId}.{conversationId}.{threadId}" ) )` — the full 32-byte HMAC, base64url-encoded, unpadded (not truncated; the extra bytes are trivial inside a Message-ID and full length is the safest choice). The `keyId` names the signing key and is itself part of the signed payload, so a token's key cannot be swapped without invalidating it. `mailDomain` is not signed (it is not part of threading identity). The id fields are constrained at mint time to `[A-Za-z0-9_-]` (the base64url alphabet, excluding the `.` delimiter), so a well-formed local part splits unambiguously into five segments. This is Helpthread's own design, not derived from any observed system's internals — we only observed a black-box Message-ID *shape*, never a secret or algorithm. The properties that ARE the spec, independent of encoding: - **(a) Unguessable without the secret** — not forgeable by an attacker who has seen valid tokens (cf. §3 rule 3). - **(b) Verifiable offline** — no DB round-trip to detect tampering; pure computation against the signing secret(s). - **(c) Carries the conversation+thread identity** — a verified token deterministically identifies its conversation/thread; no lookup table of issued tokens required. -- **(d) Rotation-tolerant** — the signing secret must be rotatable without invalidating outstanding tokens, implying a `keyId` alongside the signature. **OPEN QUESTION:** does `keyId` ship in v1, or wait for the first rotation? +- **(d) Rotation-tolerant** — the signing secret must be rotatable without invalidating outstanding tokens. **RESOLVED (HT-12): `keyId` ships in v1.** A keyring has one `current` key (mints and verifies) and zero or more `retired` keys (verify only); rotating means retiring the old key and promoting a new `current`, which never invalidates tokens already in customers' mailboxes. Dropping a key from the ring entirely stops its tokens from verifying. **Contrast with the observed reference format.** The fixtures show a reference helpdesk emitting Message-IDs shaped like `` — e.g. `` (reply-with-reference.json, `agentReplyEmail.messageId`; the token value in the committed fixtures is a redacted placeholder — the real capability token is never published). Notably `{threadId}` there is a *thread* id (36), not the conversation id (15) — conversation is resolved via the thread's parent, not encoded directly. This is cited only as evidence the "signed token in the outbound Message-ID" pattern works in production (charter §2); Helpthread's `sig` derivation, secret, and truncation are unrelated to whatever that system does internally, which was never observed. diff --git a/src/mail/reply-token.test.ts b/src/mail/reply-token.test.ts new file mode 100644 index 0000000..9520b37 --- /dev/null +++ b/src/mail/reply-token.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from 'vitest' +import { + assertValidKeyring, + type Keyring, + mintReplyMessageId, + type SigningKey, + verifyReplyMessageId, +} from './reply-token.js' + +// --- fixtures ------------------------------------------------------------- + +/** A generic valid (≥32-char) secret for tests not exercising secret strength. */ +const VALID_SECRET = 'valid-secret-0123456789abcdefghijklmno' + +const KEY_A: SigningKey = { keyId: 'k1', secret: 'secret-A-high-entropy-0123456789abcdef' } +const KEY_B: SigningKey = { keyId: 'k2', secret: 'secret-B-high-entropy-fedcba9876543210' } + +const ringA: Keyring = { current: KEY_A } +const ringB: Keyring = { current: KEY_B } + +const PAYLOAD = { conversationId: 'c42', threadId: 't7', mailDomain: 'mail.example.test' } + +/** Flip one character in a string at `index` (deterministically, to a different char). */ +function flipChar(s: string, index: number): string { + const c = s[index] + const replacement = c === 'A' ? 'B' : 'A' + return s.slice(0, index) + replacement + s.slice(index + 1) +} + +/** Pull the five local-part segments out of a minted `` id. */ +function segments(messageId: string): { local: string; domain: string; parts: string[] } { + const inner = messageId.slice(1, -1) + const [local, domain] = inner.split('@') + return { local, domain, parts: local.split('.') } +} + +/** Reassemble a Message-ID from five segments + domain. */ +function reassemble(parts: string[], domain: string): string { + return `<${parts.join('.')}@${domain}>` +} + +// --- round-trip ----------------------------------------------------------- + +describe('round-trip', () => { + it('mint → verify recovers the exact payload', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(verifyReplyMessageId(id, ringA)).toEqual({ + keyId: 'k1', + conversationId: 'c42', + threadId: 't7', + }) + }) + + it('minted id has angle brackets and the ht. prefix', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(id.startsWith('')).toBe(true) + }) + + it('matches the documented shape regex and still round-trips', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(id).toMatch(/^]+>$/) + expect(verifyReplyMessageId(id, ringA)).not.toBeNull() + }) + + it('signature is full 32-byte HMAC → 43-char unpadded base64url', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts } = segments(id) + const sig = parts[4] + expect(sig).toHaveLength(43) + expect(sig).toMatch(/^[A-Za-z0-9_-]+$/) // base64url alphabet, no padding + }) +}) + +// --- tampering: each segment independently -------------------------------- + +describe('tampering returns null', () => { + it('tampered keyId → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[1] = `${parts[1]}x` // k1 → k1x (unknown key), signature no longer matches + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('tampered conversationId → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[2] = 'c99' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('tampered threadId → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[3] = 't8' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('tampered sig (flip one char) → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[4] = flipChar(parts[4], 0) + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('a same-length garbage sig → null (length guard path exercised on equal lengths)', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[4] = 'A'.repeat(parts[4].length) + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('a wrong-LENGTH sig with a known keyId → null (timingSafeEqual length guard)', () => { + // keyId still matches KEY_A, so signatureMatches IS reached — but the sig + // is short, so the length guard must reject instead of letting + // timingSafeEqual throw on unequal-length buffers. + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[4] = 'AAA' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) +}) + +// --- wrong / unknown / rotated keys -------------------------------------- + +describe('key handling', () => { + it('wrong secret (same keyId, different secret) → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const imposter: Keyring = { + current: { keyId: 'k1', secret: 'a-completely-different-secret-0123456789' }, + } + expect(verifyReplyMessageId(id, imposter)).toBeNull() + }) + + it("unknown keyId (token's key not in ring) → null", () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(verifyReplyMessageId(id, ringB)).toBeNull() + }) + + it('rotation: mint with A, then A retired + B current → still verifies', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const rotated: Keyring = { current: KEY_B, retired: [KEY_A] } + expect(verifyReplyMessageId(id, rotated)).toEqual({ + keyId: 'k1', + conversationId: 'c42', + threadId: 't7', + }) + }) + + it('rotation: after A is dropped from the ring entirely → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const droppedA: Keyring = { current: KEY_B, retired: [] } + expect(verifyReplyMessageId(id, droppedA)).toBeNull() + }) + + it('verifies against a retired key even when current has a different secret for reuse of keyId', () => { + // Defensive: multiple keys may match a keyId; the matching secret wins. + const id = mintReplyMessageId(PAYLOAD, ringA) + const ring: Keyring = { + current: { keyId: 'k9', secret: 'k9-current-secret-0123456789abcdefghij' }, + retired: [KEY_A], + } + expect(verifyReplyMessageId(id, ring)).not.toBeNull() + }) +}) + +// --- non-token Message-IDs → null ---------------------------------------- + +describe('non-token Message-IDs → null', () => { + it('a Gmail-style Message-ID → null', () => { + expect( + verifyReplyMessageId('', ringA), + ).toBeNull() + }) + + it('empty string → null', () => { + expect(verifyReplyMessageId('', ringA)).toBeNull() + }) + + it('<> → null', () => { + expect(verifyReplyMessageId('<>', ringA)).toBeNull() + }) + + it('4 segments → null', () => { + expect(verifyReplyMessageId('', ringA)).toBeNull() + }) + + it('6 segments → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts.splice(3, 0, 'extra') // inject a sixth segment + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('missing ht. prefix → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[0] = 'xx' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('a . injected into the conversationId position → null (becomes 6 segments)', () => { + // A hostile token trying to smuggle a dot into an id splits into too many parts. + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[2] = 'c4.2' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('no @domain → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { local } = segments(id) + expect(verifyReplyMessageId(`<${local}>`, ringA)).toBeNull() + }) + + it('empty domain (trailing @) → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { local } = segments(id) + expect(verifyReplyMessageId(`<${local}@>`, ringA)).toBeNull() + }) + + it('multiple @ → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { local, domain } = segments(id) + expect(verifyReplyMessageId(`<${local}@evil@${domain}>`, ringA)).toBeNull() + }) + + it('no angle brackets → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(verifyReplyMessageId(id.slice(1, -1), ringA)).toBeNull() + }) + + it('missing opening bracket → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(verifyReplyMessageId(id.slice(1), ringA)).toBeNull() + }) + + it('missing closing bracket → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + expect(verifyReplyMessageId(id.slice(0, -1), ringA)).toBeNull() + }) + + it('empty keyId segment (ht..c.t.sig) → null', () => { + const id = mintReplyMessageId(PAYLOAD, ringA) + const { parts, domain } = segments(id) + parts[1] = '' + expect(verifyReplyMessageId(reassemble(parts, domain), ringA)).toBeNull() + }) + + it('a plain bare word → null', () => { + expect(verifyReplyMessageId('not-a-message-id', ringA)).toBeNull() + }) +}) + +// --- mint input validation → throws -------------------------------------- + +describe('mint input validation throws', () => { + it('conversationId containing . → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, conversationId: 'c.42' }, ringA)).toThrow( + /conversationId/, + ) + }) + + it('conversationId containing @ → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, conversationId: 'c@42' }, ringA)).toThrow( + /conversationId/, + ) + }) + + it('empty conversationId → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, conversationId: '' }, ringA)).toThrow( + /conversationId/, + ) + }) + + it('threadId containing . → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, threadId: 't.7' }, ringA)).toThrow(/threadId/) + }) + + it('empty threadId → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, threadId: '' }, ringA)).toThrow(/threadId/) + }) + + it('threadId containing angle bracket → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, threadId: 't<7' }, ringA)).toThrow(/threadId/) + }) + + it('keyId containing . → throws', () => { + const badRing: Keyring = { current: { keyId: 'k.1', secret: VALID_SECRET } } + expect(() => mintReplyMessageId(PAYLOAD, badRing)).toThrow(/keyId/) + }) + + it('empty keyId → throws', () => { + const badRing: Keyring = { current: { keyId: '', secret: VALID_SECRET } } + expect(() => mintReplyMessageId(PAYLOAD, badRing)).toThrow(/keyId/) + }) + + it('invalid mailDomain (@) → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, mailDomain: 'a@b' }, ringA)).toThrow(/mailDomain/) + }) + + it('empty mailDomain → throws', () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, mailDomain: '' }, ringA)).toThrow(/mailDomain/) + }) +}) + +// --- determinism & distinctness ------------------------------------------ + +describe('determinism & distinctness', () => { + it('minting the same payload+key twice is identical (HMAC is deterministic)', () => { + expect(mintReplyMessageId(PAYLOAD, ringA)).toBe(mintReplyMessageId(PAYLOAD, ringA)) + }) + + it('different conversationId → different sig', () => { + const a = mintReplyMessageId(PAYLOAD, ringA) + const b = mintReplyMessageId({ ...PAYLOAD, conversationId: 'c43' }, ringA) + expect(segments(a).parts[4]).not.toBe(segments(b).parts[4]) + }) + + it('different threadId → different sig', () => { + const a = mintReplyMessageId(PAYLOAD, ringA) + const b = mintReplyMessageId({ ...PAYLOAD, threadId: 't8' }, ringA) + expect(segments(a).parts[4]).not.toBe(segments(b).parts[4]) + }) + + it('different key → different sig for the same payload', () => { + const a = mintReplyMessageId( + { ...PAYLOAD }, + { current: { keyId: 'k1', secret: 'secret-one-0123456789abcdefghijklmno' } }, + ) + const b = mintReplyMessageId( + { ...PAYLOAD }, + { current: { keyId: 'k1', secret: 'secret-two-0123456789abcdefghijklmno' } }, + ) + expect(segments(a).parts[4]).not.toBe(segments(b).parts[4]) + }) + + it('mailDomain is not signed: same payload, different domain → same sig', () => { + const a = mintReplyMessageId(PAYLOAD, ringA) + const b = mintReplyMessageId({ ...PAYLOAD, mailDomain: 'other.example.test' }, ringA) + expect(segments(a).parts[4]).toBe(segments(b).parts[4]) + }) +}) + +// --- keyring validation (Codex/CodeRabbit adversarial findings) ----------- + +describe('keyring validation', () => { + it('duplicate keyId in the ring → throws (rotation must use a new keyId)', () => { + const ring: Keyring = { + current: { keyId: 'k1', secret: VALID_SECRET }, + retired: [{ keyId: 'k1', secret: 'a-different-old-secret-0123456789abcd' }], + } + expect(() => assertValidKeyring(ring)).toThrow(/duplicate keyId/) + // and the entry points that consume a keyring reject it too + expect(() => mintReplyMessageId(PAYLOAD, ring)).toThrow(/duplicate keyId/) + expect(() => verifyReplyMessageId('', ring)).toThrow(/duplicate keyId/) + }) + + it('empty secret → throws (HMAC key must be strong)', () => { + const ring: Keyring = { current: { keyId: 'k1', secret: '' } } + expect(() => mintReplyMessageId(PAYLOAD, ring)).toThrow(/secret/) + }) + + it('short secret (< 32 chars) → throws', () => { + const ring: Keyring = { current: { keyId: 'k1', secret: 'too-short' } } + expect(() => mintReplyMessageId(PAYLOAD, ring)).toThrow(/secret/) + }) + + it('a leaked old secret cannot be revived under a live keyId', () => { + // Rotating correctly (new keyId) means a token forged with the old, leaked + // secret carries the OLD keyId — which is no longer in the ring → null. + const leaked: Keyring = { + current: { keyId: 'old', secret: 'leaked-secret-0123456789abcdefghijkl' }, + } + const forged = mintReplyMessageId(PAYLOAD, leaked) + const rotated: Keyring = { + current: { keyId: 'new', secret: 'fresh-secret-0123456789abcdefghijklmn' }, + } + expect(verifyReplyMessageId(forged, rotated)).toBeNull() + }) + + it('retired is not an array → throws', () => { + // Deliberately malformed config (simulating an untyped source / bad JSON). + const ring = { current: KEY_A, retired: {} as unknown as SigningKey[] } + expect(() => assertValidKeyring(ring)).toThrow(/retired/) + }) +}) + +// --- non-string mint inputs (CodeRabbit: RegExp.test coerces) ------------- + +describe('non-string mint inputs are rejected', () => { + it('non-string conversationId → throws (not silently coerced)', () => { + // Simulate an untyped JS caller passing a non-string id. + expect(() => + mintReplyMessageId({ ...PAYLOAD, conversationId: undefined as unknown as string }, ringA), + ).toThrow(/conversationId/) + expect(() => + mintReplyMessageId({ ...PAYLOAD, threadId: 42 as unknown as string }, ringA), + ).toThrow(/threadId/) + }) +}) + +// --- malformed mail domains (Codex/CodeRabbit) --------------------------- + +describe('malformed mail domains → throw', () => { + for (const bad of ['..', '.', 'a..b', '-x.test', 'x-.test', 'a.', '.a']) { + it(`rejects mailDomain ${JSON.stringify(bad)}`, () => { + expect(() => mintReplyMessageId({ ...PAYLOAD, mailDomain: bad }, ringA)).toThrow(/mailDomain/) + }) + } + + it('accepts a normal domain', () => { + expect(() => + mintReplyMessageId({ ...PAYLOAD, mailDomain: 'mail.helpthread.dev' }, ringA), + ).not.toThrow() + }) +}) diff --git a/src/mail/reply-token.ts b/src/mail/reply-token.ts new file mode 100644 index 0000000..9a0e57a --- /dev/null +++ b/src/mail/reply-token.ts @@ -0,0 +1,352 @@ +/** + * Signed reply tokens — the cryptographic core of outbound-anchored email + * threading (specs/mail/threading.md §2; charter §2 "threading authority + * lives on the outbound side", invariant #3 "threading correctness outranks + * feature velocity"). + * + * The engine controls threading by embedding a signed token in every + * OUTBOUND `Message-ID` and verifying it when a reply comes back. Inbound + * `References`/`In-Reply-To` values written by arbitrary mail clients are + * never trusted on their own; the only authority is a token this module + * minted and can re-verify offline. This file is exactly that mint + verify + * pair — pure functions, no DB, no I/O. + * + * ## Token format + * + * Carried as the local part of an outbound `Message-ID`: + * + * ``` + * + * ``` + * + * - `sig = base64url( HMAC-SHA256( secret, canonicalString ) )` — the FULL + * 32-byte HMAC, base64url-encoded, unpadded. Not truncated: full length is + * the safest choice and the extra bytes are trivial inside a Message-ID. + * - `canonicalString = `${keyId}.${conversationId}.${threadId}`` — the exact + * bytes that are signed. `mailDomain` is NOT signed (it isn't part of the + * threading identity; a message that reaches us is threaded by its token + * regardless of the domain it claims). + * + * `.` is the field delimiter, so the three id fields are constrained at mint + * time to `[A-Za-z0-9_-]` (no dot, no `@`, no `<`/`>`). base64url uses that + * same charset, so a well-formed local part always splits into exactly five + * dot-separated segments — unambiguous by construction. + * + * ## Spec properties this satisfies (threading.md §2) + * + * - (a) Unguessable without the secret — HMAC-SHA256. + * - (b) Verifiable offline — pure computation, no lookup table of issued + * tokens. + * - (c) Carries conversation + thread identity — recovered directly from a + * verified token. + * - (d) Rotation-tolerant — a `keyId` names the signing key; see below. + * + * ## Key rotation model + * + * A {@link Keyring} has one `current` key and zero or more `retired` keys. + * Minting ALWAYS uses `current`. Verification accepts `current` OR any + * `retired` key — so rotating the secret (retire the old key, promote a new + * `current`) never invalidates tokens already in customers' mailboxes. + * Dropping a key from the ring entirely stops its tokens from verifying. + * + * ## Security invariants + * + * - {@link verifyReplyMessageId} is TOTAL over the `messageId` — the untrusted + * input: for ANY `messageId` string, given a valid keyring, it returns a + * payload or `null`, never throws. A hostile inbound header must never crash + * the ingest path (charter invariant #1: never lose or corrupt customer + * mail). A malformed KEYRING is different — that is trusted configuration, a + * deploy-time bug, and is rejected loudly by {@link assertValidKeyring}. + * - Secrets are validated: HMAC's security is only as good as its key, so an + * empty/short secret is rejected ({@link MIN_SECRET_LENGTH}), and keyIds must + * be unique so a retired secret can't be revived under a live keyId. + * - Signature comparison is constant-time ({@link https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b | crypto.timingSafeEqual}), + * with an explicit length guard first (timingSafeEqual throws on + * unequal-length buffers — that is treated as "invalid", not an error). + * - {@link mintReplyMessageId} is STRICT: minting a malformed token is a bug, + * so it throws on invalid input rather than emitting something unverifiable. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto' + +/** + * The threading identity carried by a verified token: which signing key + * produced it, and the conversation/thread lineage it belongs to. + */ +export interface ReplyTokenPayload { + keyId: string + conversationId: string + threadId: string +} + +/** + * A single HMAC signing key. `secret` is a high-entropy string (the caller's + * responsibility); `keyId` names it inside a token so verification can pick + * the right secret without trial-decrypting. + */ +export interface SigningKey { + keyId: string + secret: string +} + +/** + * The set of keys in play. `current` both mints and verifies; `retired` keys + * only ever verify (never mint), which is what makes secret rotation + * non-breaking for tokens already in the wild. See the rotation model in the + * module doc. + */ +export interface Keyring { + current: SigningKey + retired?: SigningKey[] +} + +/** Fixed literal prefix marking a Message-ID local part as one of our tokens. */ +const TOKEN_PREFIX = 'ht' + +/** Number of dot-separated segments in a well-formed local part: `ht`, keyId, conversationId, threadId, sig. */ +const SEGMENT_COUNT = 5 + +/** + * Charset for the three id fields and the keyId at mint time. Excludes the + * `.` delimiter and the `@`/`<`/`>` structural characters of a Message-ID. + * Matches the base64url alphabet, so no field can be confused for a delimiter. + */ +const ID_PATTERN = /^[A-Za-z0-9_-]+$/ + +/** + * A single DNS label inside the mail domain: 1–63 chars, alphanumeric, with + * internal (not leading/trailing) hyphens. The full domain is one or more of + * these joined by dots — so `..`, `a..b`, `-x.test`, and `x-.test` are all + * rejected. The domain is not signed; this only keeps the minted Message-ID + * syntactically sane so mail infrastructure doesn't reject or rewrite it. + */ +const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/ + +/** + * Minimum secret length (characters). HMAC-SHA256's security rests entirely on + * the key: an empty or short secret makes forgery trivial. Secrets should be + * high-entropy random strings (e.g. `openssl rand -base64 32`); this is a floor, + * not a guarantee of entropy. + */ +const MIN_SECRET_LENGTH = 32 + +/** + * The exact bytes signed by the HMAC: `keyId.conversationId.threadId`. + * Deterministic, so the same payload + key always yields the same signature. + */ +function canonicalString(keyId: string, conversationId: string, threadId: string): string { + return `${keyId}.${conversationId}.${threadId}` +} + +/** + * Compute the token signature: the full 32-byte HMAC-SHA256 over the + * canonical string, base64url-encoded without padding. Node's `'base64url'` + * digest encoding is URL-safe (`-`/`_`) and unpadded by definition. + */ +function sign(secret: string, canonical: string): string { + return createHmac('sha256', secret).update(canonical).digest('base64url') +} + +/** + * Mint the outbound `Message-ID` (WITH angle brackets) carrying a signed + * reply token, signing with `keyring.current`. + * + * STRICT by design: `conversationId`, `threadId`, and the current key's + * `keyId` must each be a non-empty string of `[A-Za-z0-9_-]` (no `.`/`@`/ + * angle brackets), and `mailDomain` must be a plausible domain. Any violation + * throws — emitting a token that can't later verify would be a threading bug, + * so we fail loud at the source. See specs/mail/threading.md §2. + * + * @returns e.g. `@mail.example.test>` + * @throws {Error} on any invalid input field. + */ +export function mintReplyMessageId( + payload: Omit & { mailDomain: string }, + keyring: Keyring, +): string { + assertValidKeyring(keyring) + const { conversationId, threadId, mailDomain } = payload + const { keyId, secret } = keyring.current + + assertIdField('conversationId', conversationId) + assertIdField('threadId', threadId) + assertValidDomain('mailDomain', mailDomain) + + const sig = sign(secret, canonicalString(keyId, conversationId, threadId)) + return `<${TOKEN_PREFIX}.${keyId}.${conversationId}.${threadId}.${sig}@${mailDomain}>` +} + +/** + * Verify a candidate `Message-ID` and, if it is one of our tokens with a + * signature that checks out against a known key, return its payload. + * + * TOTAL and never throws — every rejection path returns `null`: + * not our format (a Gmail Message-ID, an empty string, `<>`), missing/extra + * angle brackets, missing `@domain`, wrong segment count, an id with an + * injected `.`, an unknown/removed `keyId`, or any tampered field (the HMAC + * won't match). A tampered-but-well-shaped token is indistinguishable from a + * forgery and is rejected the same way. + * + * Verification tries `keyring.current` and every `keyring.retired[]` key + * whose `keyId` matches the token, using a constant-time comparison with a + * length guard (see module doc). + * + * @returns the recovered {@link ReplyTokenPayload}, or `null` for anything + * that isn't a valid token signed by a known key. + */ +export function verifyReplyMessageId( + messageId: string, + keyring: Keyring, +): ReplyTokenPayload | null { + // Trusted config: a malformed keyring is a deploy bug, so fail loud here. + // This does NOT weaken totality over the messageId, which is the untrusted + // input — see the doc note. + assertValidKeyring(keyring) + + const parsed = parseToken(messageId) + if (parsed === null) return null + + const { keyId, conversationId, threadId, sig } = parsed + const canonical = canonicalString(keyId, conversationId, threadId) + + for (const key of candidateKeys(keyring, keyId)) { + if (signatureMatches(key.secret, canonical, sig)) { + return { keyId, conversationId, threadId } + } + } + return null +} + +/** A token's segments after structural parsing, before signature verification. */ +interface ParsedToken { + keyId: string + conversationId: string + threadId: string + sig: string +} + +/** + * Structurally parse a candidate `Message-ID` into token segments, or return + * `null` if it is not shaped like one of our tokens. Does NOT verify the + * signature — that's the caller's job. Total: never throws. + * + * Steps: require surrounding `<`…`>`; strip them; require exactly one `@` + * separating a non-empty local part from a non-empty domain; split the local + * part on `.` into exactly five segments; require the first to be the literal + * `ht`; require the four remaining fields to be non-empty (no injected empty + * segment). The domain is discarded — it isn't signed. + */ +function parseToken(messageId: string): ParsedToken | null { + if ( + typeof messageId !== 'string' || + messageId.length < 2 || + messageId[0] !== '<' || + messageId[messageId.length - 1] !== '>' + ) { + return null + } + + const inner = messageId.slice(1, -1) + const atParts = inner.split('@') + if (atParts.length !== 2) return null + const [local, domain] = atParts + if (local.length === 0 || domain.length === 0) return null + + const segments = local.split('.') + if (segments.length !== SEGMENT_COUNT) return null + + const [prefix, keyId, conversationId, threadId, sig] = segments + if (prefix !== TOKEN_PREFIX) return null + if ( + keyId.length === 0 || + conversationId.length === 0 || + threadId.length === 0 || + sig.length === 0 + ) { + return null + } + + return { keyId, conversationId, threadId, sig } +} + +/** Keys in the ring (current first, then retired) whose keyId matches the token's. */ +function candidateKeys(keyring: Keyring, keyId: string): SigningKey[] { + const all = keyring.retired ? [keyring.current, ...keyring.retired] : [keyring.current] + return all.filter((key) => key.keyId === keyId) +} + +/** + * Constant-time check that `providedSig` is the base64url HMAC of `canonical` + * under `secret`. Compares the base64url STRINGS byte-for-byte: a length + * mismatch (guarded before {@link timingSafeEqual}, which throws on unequal + * lengths) counts as "no match", and a non-canonical re-encoding of a valid + * HMAC is likewise rejected rather than accepted. + */ +function signatureMatches(secret: string, canonical: string, providedSig: string): boolean { + const expected = Buffer.from(sign(secret, canonical)) + const provided = Buffer.from(providedSig) + if (expected.length !== provided.length) return false + return timingSafeEqual(expected, provided) +} + +/** Throw a clear, field-named error if `value` isn't a non-empty id-charset string. */ +function assertIdField(field: string, value: unknown): void { + // Explicit string check first: RegExp.test() coerces its argument, so a + // non-string (undefined, a number) would otherwise be silently stringified + // and could pass — minting a token with a bogus identifier. + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { + throw new Error( + `mintReplyMessageId: ${field} must be a string matching ${ID_PATTERN} (got ${JSON.stringify(value)})`, + ) + } +} + +/** Throw unless `value` is a syntactically valid mail domain (dot-joined DNS labels). */ +function assertValidDomain(field: string, value: unknown): void { + const ok = + typeof value === 'string' && + value.length <= 253 && + value.split('.').every((label) => DOMAIN_LABEL.test(label)) + if (!ok) { + throw new Error( + `mintReplyMessageId: ${field} must be a valid mail domain (got ${JSON.stringify(value)})`, + ) + } +} + +/** + * Validate a {@link Keyring} — this is trusted configuration, so a malformed + * ring is a deploy-time bug that must fail loudly (it is NOT hostile input). + * Enforces: every secret is a string of at least {@link MIN_SECRET_LENGTH} + * chars; every keyId is a valid id string; and — critically — all keyIds are + * UNIQUE. Uniqueness makes rotation safe by construction: a new key must use a + * new keyId, so a retired/leaked secret can never be revived under a keyId that + * a current token would match. Revoke a compromised key by dropping it from the + * ring entirely. + * + * @throws {Error} on any malformed or unsafe keyring. + */ +export function assertValidKeyring(keyring: Keyring): void { + if (keyring?.current == null) { + throw new Error('reply-token: keyring.current is required') + } + if (keyring.retired !== undefined && !Array.isArray(keyring.retired)) { + throw new Error('reply-token: keyring.retired must be an array when present') + } + const keys = keyring.retired ? [keyring.current, ...keyring.retired] : [keyring.current] + const seen = new Set() + for (const key of keys) { + if (typeof key?.keyId !== 'string' || !ID_PATTERN.test(key.keyId)) { + throw new Error(`reply-token: invalid keyId ${JSON.stringify(key?.keyId)}`) + } + if (typeof key.secret !== 'string' || key.secret.length < MIN_SECRET_LENGTH) { + throw new Error( + `reply-token: secret for keyId ${JSON.stringify(key.keyId)} must be a string of at least ${MIN_SECRET_LENGTH} chars`, + ) + } + if (seen.has(key.keyId)) { + throw new Error(`reply-token: duplicate keyId ${JSON.stringify(key.keyId)} in keyring`) + } + seen.add(key.keyId) + } +}