Skip to content

HT-12: signed reply-token mint/verify (threading crypto core) - #8

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-12-reply-tokens
Jul 10, 2026
Merged

HT-12: signed reply-token mint/verify (threading crypto core)#8
zaridan merged 3 commits into
mainfrom
feat/ht-12-reply-tokens

Conversation

@zaridan

@zaridan zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

The cryptographic heart of Helpthread's threading — the signed tokens that let the engine trust which conversation a reply belongs to, per the charter's 'threading authority lives on the outbound side' principle.

src/mail/reply-token.ts — pure, no I/O:

  • mintReplyMessageId embeds a signed token in an outbound Message-ID: <ht.{keyId}.{conversationId}.{threadId}.{sig}@{domain}>, sig = base64url(HMAC-SHA256(secret, keyId.conversationId.threadId)).
  • verifyReplyMessageId returns the payload for a valid token, null for anything else. Total — never throws on hostile input (a crash on the ingest path would violate 'never lose customer mail'). Constant-time comparison with a length guard. Strict mint (throws on malformed ids).
  • Key rotation built in: keyId is inside the signature (can't be swapped), and a keyring separates the current minting key from retired verify-only keys — so rotating a secret never breaks tokens already in mailboxes.

Thoroughness (this is the crown jewel): 45 unit tests, 100% coverage. Beyond the suite, I ran an independent adversarial forge probe — forged signatures, tampered fields, key-swap, truncated sigs, a real Gmail Message-ID, and 1000 random signatures — all correctly rejected; rotation verified.

Also resolves the threading spec's keyId open question (§2(d) — keyId ships in v1) and updates spec §2 to the implemented format (it was a pre-implementation draft with no keyId and a truncated sig; a spec that contradicts the code is exactly what misleads future work).

Next: increment 3 wires this into the inbound threading decision (the 5-rule algorithm) against the HT-7 fixtures.

Jira: https://resonantiq.atlassian.net/browse/HT-12

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced securely minted email reply threading Message-ID tokens with a defined format and full HMAC-SHA256 base64url signature.
    • Verification now returns the thread identifiers on success and supports signing-key rotation (current and retired keys).
    • Added strict validation for token structure and payload fields; mailDomain is verified for format but does not affect the signature.
  • Tests
    • Expanded negative and rotation scenarios, including tamper/malformed token handling and keyring configuration validation.
  • Chores
    • Updated secret-scanning configuration to allow hardcoded test secrets.

src/mail/reply-token.ts: mint a signed token into an outbound Message-ID,
verify it on inbound. HMAC-SHA256 (full digest, base64url) over
keyId.conversationId.threadId. keyId is inside the signed payload, so a
token's key can't be swapped. Keyring model: current mints+verifies,
retired keys verify-only, so secret rotation never invalidates tokens in
customers' mailboxes.

Security: verify is TOTAL (never throws on hostile input — returns null),
constant-time comparison with an explicit length guard, strict mint
(throws on malformed ids). 45 unit tests; 100% coverage on reply-token.ts.
Independently adversarially verified: forged/tampered/key-swapped/
truncated tokens all rejected, 1000 random sigs none forged, rotation
works.

Resolves the threading spec's keyId open question (§2(d)) — keyId ships
in v1 — and updates spec §2 to the implemented format (was a pre-impl
draft with no keyId and a truncated sig).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7702aa00-c1bd-40b8-815f-afd8d3bb20c0

📥 Commits

Reviewing files that changed from the base of the PR and between d08414d and fda1e36.

📒 Files selected for processing (2)
  • .gitleaks.toml
  • src/mail/reply-token.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .gitleaks.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mail/reply-token.test.ts

📝 Walkthrough

Walkthrough

The PR finalizes the signed reply-token specification and adds a token engine for minting, parsing, HMAC verification, key rotation, and validation testing.

Changes

Signed reply token flow

Layer / File(s) Summary
Token contract and public types
specs/mail/threading.md, src/mail/reply-token.ts
Defines the keyId-based Message-ID format, full base64url HMAC signature, canonical signed fields, and current/retired keyring model.
Token minting and parsing
src/mail/reply-token.ts, src/mail/reply-token.test.ts, .gitleaks.toml
Validates mint inputs, constructs deterministic Message-IDs, parses exact token structure, and tests round trips, malformed inputs, determinism, domain handling, and test-secret scanning configuration.
Verification and key rotation
src/mail/reply-token.ts, src/mail/reply-token.test.ts
Validates keyrings, verifies signatures using matching current or retired keys with constant-time comparison, and tests tampering, wrong keys, rotation, and dropped keys.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant mintReplyMessageId
  participant Keyring
  participant verifyReplyMessageId
  Caller->>mintReplyMessageId: provide conversationId, threadId, mailDomain
  mintReplyMessageId->>Keyring: read current signing key
  mintReplyMessageId-->>Caller: return signed Message-ID
  Caller->>verifyReplyMessageId: provide Message-ID and keyring
  verifyReplyMessageId->>Keyring: inspect current and retired keys
  Keyring-->>verifyReplyMessageId: return matching key
  verifyReplyMessageId-->>Caller: return payload or null
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: signed reply-token minting and verification for threading.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-12-reply-tokens

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/mail/reply-token.test.ts (1)

326-330: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert verification behavior, not only signature equality.

Comparing the two signature segments does not prove that a token with a changed mailDomain is accepted by verifyReplyMessageId. Add an assertion that b verifies to the expected payload.

Proposed test assertion
     expect(segments(a).parts[4]).toBe(segments(b).parts[4])
+    expect(verifyReplyMessageId(b, ringA)).toEqual({
+      keyId: 'k1',
+      conversationId: 'c42',
+      threadId: 't7',
+    })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/reply-token.test.ts` around lines 326 - 330, Add an assertion in the
“mailDomain is not signed” test that calls verifyReplyMessageId with token b and
confirms it returns the expected payload, while retaining the existing
signature-equality check. Use the existing ringA and verification helpers to
validate acceptance after changing mailDomain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/mail/threading.md`:
- Line 11: The outbound-message list in the threading documentation conflates
actor types. Update the wording to explicitly distinguish human Agent replies
from Assistant responses and other non-AI automated replies, using the
repository’s reserved terminology consistently.
- Around line 13-15: Add the text language identifier to the fenced code block
containing the token format, changing its opening fence to ```text while
preserving the existing content.

In `@src/mail/reply-token.ts`:
- Around line 110-111: Update DOMAIN_PATTERN and the mailDomain validation used
by the reply-token creation flow to require non-empty dot-separated labels,
rejecting leading, trailing, and consecutive dots while preserving valid
letters, digits, hyphens, and dots. Add regression tests covering these three
malformed domains and confirm valid domains still pass.
- Around line 150-156: Reject non-string values before validation: update the
mailDomain guard in mintReplyMessageId and assertIdField to require typeof value
=== 'string' before applying regex checks, so undefined, null, and numbers
cannot be coerced into valid identifiers.

---

Nitpick comments:
In `@src/mail/reply-token.test.ts`:
- Around line 326-330: Add an assertion in the “mailDomain is not signed” test
that calls verifyReplyMessageId with token b and confirms it returns the
expected payload, while retaining the existing signature-equality check. Use the
existing ringA and verification helpers to validate acceptance after changing
mailDomain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a7a902f4-db7e-46ae-a590-484a73c6f8d1

📥 Commits

Reviewing files that changed from the base of the PR and between a8daccd and 0d418b5.

📒 Files selected for processing (3)
  • specs/mail/threading.md
  • src/mail/reply-token.test.ts
  • src/mail/reply-token.ts

Comment thread specs/mail/threading.md
## 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`):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Disambiguate Agents and Assistants in the outbound-message list.

Use the repository’s reserved terms explicitly: distinguish an Agent reply from an Assistant response or non-AI automated reply.

As per coding guidelines, human support staff are Agents and AI actors are Assistants; do not conflate them in documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/mail/threading.md` at line 11, The outbound-message list in the
threading documentation conflates actor types. Update the wording to explicitly
distinguish human Agent replies from Assistant responses and other non-AI
automated replies, using the repository’s reserved terminology consistently.

Source: Coding guidelines

Comment thread specs/mail/threading.md
Comment on lines 13 to 15
```
<ht.{conversationId}.{threadId}.{sig}@{mailDomain}>
<ht.{keyId}.{conversationId}.{threadId}.{sig}@{mailDomain}>
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced token-format block.

markdownlint-cli2 reports MD040 on Line 13. Use text (or another appropriate language) for the fence.

Proposed fix
-```
+```text
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
<ht.{conversationId}.{threadId}.{sig}@{mailDomain}>
<ht.{keyId}.{conversationId}.{threadId}.{sig}@{mailDomain}>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 13-13: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/mail/threading.md` around lines 13 - 15, Add the text language
identifier to the fenced code block containing the token format, changing its
opening fence to ```text while preserving the existing content.

Source: Linters/SAST tools

Comment thread src/mail/reply-token.ts Outdated
Comment thread src/mail/reply-token.ts Outdated
Independent Codex (OpenAI-lineage) review found two Majors that CodeRabbit
(Opus) and the author (both Claude) missed — the payoff of a different
model lineage on security-critical crypto:

- MAJOR (Codex): empty/weak secrets were accepted → trivial forgery on
  misconfig. Now assertValidKeyring enforces a 32-char minimum secret.
- MAJOR (Codex): duplicate keyId in the ring let a leaked retired secret
  verify under a live keyId. Now keyIds MUST be unique; rotation uses a new
  keyId and revocation drops the key. Rotation is safe by construction.
- MINOR (Codex): verify's 'never throws for any input' overclaimed — a
  malformed keyring (undefined secret, non-array retired) threw. Reframed:
  verify is total over the untrusted messageId given a valid keyring; a bad
  keyring is trusted-config and fails loud via assertValidKeyring.
- MINOR (Codex + CodeRabbit): DOMAIN_PATTERN accepted junk (.., a..b, -x.y).
  Now proper per-label DNS validation.
- MINOR (CodeRabbit): RegExp.test coerces non-strings — undefined/number ids
  could slip through. assertIdField now requires typeof string.

+14 tests (45 -> 59). Re-verified with an independent forge probe: forgery/
tamper/key-swap/2000-random-sigs rejected, new guards fire, verify total
over hostile message-ids. typecheck/lint/tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/mail/reply-token.ts (1)

197-218: 🚀 Performance & Scalability | 🔵 Trivial

Re-validating the keyring on every verify adds avoidable work to the ingest hot path.

verifyReplyMessageId runs on the untrusted inbound path (per the module doc) and calls assertValidKeyring on every message, which rebuilds a Set and iterates all keys each time even though the keyring is static trusted config. Consider validating the keyring once at config load and passing a pre-validated ring, or memoizing validation per ring instance, to keep per-message cost minimal.

Correctness is unaffected — this is purely a throughput consideration on a per-message code path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/reply-token.ts` around lines 197 - 218, Remove the per-message
keyring validation from verifyReplyMessageId by validating the trusted keyring
once during configuration loading and passing a pre-validated ring, or by
memoizing validation per ring instance. Preserve validation at the trust
boundary while ensuring verifyReplyMessageId only parses the message and checks
candidate signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/mail/reply-token.ts`:
- Around line 197-218: Remove the per-message keyring validation from
verifyReplyMessageId by validating the trusted keyring once during configuration
loading and passing a pre-validated ring, or by memoizing validation per ring
instance. Preserve validation at the trust boundary while ensuring
verifyReplyMessageId only parses the message and checks candidate signatures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ec76aba-c52e-49e5-8dcd-dcbb9a054e9b

📥 Commits

Reviewing files that changed from the base of the PR and between 0d418b5 and d08414d.

📒 Files selected for processing (2)
  • src/mail/reply-token.test.ts
  • src/mail/reply-token.ts

Two CI failures from the hardening commit, both mine:
- Lint: a long test assertion needed Biome's line-wrapping (format).
- Secret scan: gitleaks flagged the FAKE test secrets (HMAC token fixtures)
  as real credentials. Added .gitleaks.toml allowlisting *.test.ts — test
  files hold fixture secrets, never real ones (real secrets live in
  gitignored .env). Extends the default ruleset.

Also replaced 'as any' test casts with 'as unknown as' (no noExplicitAny
warnings) so lint is 0 warnings / 0 errors.

Verified locally: lint clean, typecheck exit 0, 79 tests pass, gitleaks
no leaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@zaridan
zaridan merged commit 6ceba2e into main Jul 10, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-12-reply-tokens branch July 10, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant