feat(mcp): client-assertion primitives — strict EdDSA verify + jti replay store - #165
Conversation
…play store (#160) Part 1 of 4 for #159 (RFC 7523 client_credentials for headless agents). Pure primitives, not yet wired into the token endpoint — zero behavior change until the grant lands (#162). - clientAssertion.ts: RFC 7523 §3 verification via node:crypto only (jsonwebtoken cannot verify EdDSA; no new dependency). Exact-alg pinning, typ/crit header rules, JWKS kid selection mirroring verifyAccessTokenWithKeySet, public-OKP-only key loading (private d rejected), strict base64url + 64-byte signature checks, and the full claim contract from the #159 design review (iss=sub=client_id, exact single aud, exp ≤ 60s, iat/nbf, bounded jti). Fails closed with typed reasons; never throws. - assertionJtiStore.ts + mcp_assertion_jtis table (expiration: 120): per-client replay guard keyed by sha256 with a length-prefixed client_id (plain delimiter concat would let ("a","b\nc") collide with ("a\nb","c")). Read errors propagate — the one store that must never fail open. Accepted get-then-put race documented, same precedent as MCPAuthCodeStore.consume, bounded by the 60s exp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Code Review
This pull request introduces an RFC 7523 client-assertion replay guard and verification mechanism for EdDSA/Ed25519 private_key_jwt authentication. It adds a new GraphQL table mcp_assertion_jtis, implements MCPAssertionJtiStore for tracking seen JTIs, and provides verifyClientAssertion for robust JWT verification, complete with comprehensive unit tests. Feedback suggests strengthening the replay check in MCPAssertionJtiStore by checking for any truthy database record instead of specifically verifying existing.id to prevent fail-open vulnerabilities. Additionally, defensive checks should be added to selectKey in clientAssertion.ts to handle potentially malformed or non-object elements in the jwks array and avoid runtime TypeErrors.
|
Reviewed; no blockers found. |
- Cap assertion length at 8KB before any decode work (Gemini: large-input defense-in-depth, same family as the 2048-char request-path cap) - Replay guard treats ANY truthy stored record as a replay — requiring a well-formed row could turn a malformed record into a fail-open bypass (Gemini, security-high) - selectKey guards null/primitive JWK Set entries so the never-throws contract holds even against malformed stored key sets (Gemini) - Fix stale module-header comment still describing the pre-fix delimiter keying (Claude review; jtiKey JSDoc was updated, header was not) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view) Codex flagged a possible >60s assertion window via an old iat + far-future exp. The described attack is already blocked — exp is bounded relative to NOW (exp <= now + maxExpiresIn + tolerance), so a single assertion's usable life is capped at ~60s regardless of iat. But the verifier did not enforce that an assertion's self-declared lifetime (exp - iat) conforms to the policy window, so a token advertising a far-longer lifetime with exp parked just inside the now-relative bound was accepted. Not exploitable on its own, but it doesn't honor the <=maxExpiresIn contract; a strict verifier refuses it. Adds the exp - iat bound (with clock tolerance) plus a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
I found one issue worth fixing before these primitives are wired into the grant:
src/lib/mcp/clientAssertion.ts:157 trusts maxExpiresInSeconds and clockToleranceSeconds as already-valid finite numbers. If either option is NaN or Infinity, the time-window checks fail open: maxExpiresInSeconds: NaN/Infinity accepts a far-future exp, and clockToleranceSeconds: NaN/Infinity accepts expired assertions plus future iat/nbf. These options are the enforcement boundary for RFC 7523 §3's validity-window checks and the issue's ≤60s assertion policy, so they should be normalized before the comparisons. Please coerce/validate them like the existing TTL helpers (finite, positive, bounded; otherwise default or fail closed) and add tests for NaN/Infinity and config-shaped inputs before the next PR wires these knobs from runtime/config.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
…dex review) Codex (security/perf/standards angle) flagged that verifyClientAssertion trusted maxExpiresInSeconds and clockToleranceSeconds as valid finite numbers. A NaN/Infinity would make the time-window comparisons fail open: maxExpiresIn=NaN/Infinity accepts a far-future exp; clockTolerance=NaN/ Infinity accepts expired assertions and future iat/nbf. Not reachable today (callers only default to 60/5), but #162 wires these from mcp config where ${ENV}/quoted-YAML delivers strings/garbage — same reason token.ts has coerceTtl. Coerce both up front (finite, non-negative; max window must be positive, tolerance may be 0), falling back to the conservative default otherwise. Adds tests for NaN/Infinity/negative/zero and numeric-string config-shaped inputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the Codex review (security/perf/standards angle) in Finding — window-option fail-open: Fix: coerce both options up front (finite, non-negative; the max window must be positive, tolerance may be 0), falling back to the conservative default otherwise — mirroring |
…tedtime) Per review: don't hand-roll a record timestamp Harper maintains for free. The new store was the copy-paste template for future MCP stores, so fixing it here avoids propagating the hand-written-timestamp pattern. Annotate created_at with @createdtime (Harper stamps epoch ms on insert) and drop the manual Math.floor(Date.now()/1000) write; keep client_id (real denormalized audit data). Test now asserts the app does NOT hand-write created_at. Pre-existing tables (csrf_tokens, mcp_auth_codes, mcp_refresh_families) remain in #168. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Followed up on the timestamp discussion by fixing it at the source in |
|
Nice primitives — EdDSA verification, alg pinning, and claims validation all check out cleanly against the real crypto/claims logic. One thing worth a second look before #162 wires this in: — KrAIs |
An oversized jti returned 'jti is required', which is inaccurate — the jti is present, just too long — and misleading in the audit log (the deny reason is emitted). Split the check so an oversized jti reports a length-specific message. Test split to assert each path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(review) Deep-dive follow-up to kriszyp's review question about an atomic insert-if-absent primitive: Harper HAS one — Table.create() throws a 409 ClientError on an existing record — but it's undocumented (docs describe only the auto-id form) and its existence check is enforced pre-staging only, so concurrent in-flight creates currently degrade to last-write-wins (filed as HarperFast/harper#1745, including the by-design analysis: cross-node LWW convergence is intentional; per-node enforcement is implementable via the existing retry/sourceApply seam). Switch checkAndRecord from awaited get-then-put to create() + catch-409: - sequential replays now rejected by the storage layer, not an awaited read a concurrent request can interleave; - the same-thread get→put window is gone; residual exposure is the staging→commit interval per node / replication lag across nodes, documented in the module header per review; - if harper#1745 lands per-node 409 enforcement, this guard becomes fully atomic per node with zero code change (the catch already handles it). Adds create() to the plugin Table type, a concurrency test pinning the properties stable under both semantics (>=1 winner; replay rejected once settled), and non-409 error-propagation tests (fail closed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@kriszyp — deep-dive results on your open question, and it changed the code ( Your open question resolved: Harper does have an insert-if-absent primitive — But it's not commit-time-enforced. Traced the write path: the 409 check runs pre-staging only ( What this PR now does about it:
Nice side effect of your review: the disclosure comment did its job, and the primitive it surfaced was hiding behind a docs gap. |
…d length-prefixed keying (review) Schema comment still described the pre-9f1d2e0 get-then-put race and the pre-length-prefix keying. Point it at the module header's residual-race notes (Table.create() insert-if-absent, pre-staging snapshot only — harper#1745) so the table entry reads correctly if copied as a template. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
Fresh pass on 918cced: no additional findings from this review.
I rechecked the prior window-option issue; maxExpiresInSeconds / clockToleranceSeconds now normalize NaN, Infinity, negative, zero, and config-shaped string inputs before the RFC 7523 validity-window comparisons, and the old repro rejects as expected. I also rechecked the replay guard after the Table.create() switch: the 409 path matches Harper's ClientError.statusCode contract, non-409 storage failures still fail closed, and the residual in-flight create race is explicitly documented and covered by the focused test semantics. The EdDSA/key-selection/claim-validation path still looks aligned with the #159 contract.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
Part 1 of 4 for #159 — closes #160. Pure primitives with no wiring: zero behavior change until the grant (#162) consumes them. Contract per the design review.
What's here
src/lib/mcp/clientAssertion.ts— RFC 7523 §3 client-assertion verification (private_key_jwt), EdDSA/Ed25519 only (RFC 8037), implemented withnode:cryptoalone — no new dependency (jsonwebtokencannot verify EdDSA). Fails closed with a typed{ valid: false, reason }on every path; never throws.alg: EdDSApinning before any key work (blocksnoneand RS/HS confusion);typoptional-but-JWTwhen present; anycritrejected (no extensions implemented).verifyAccessTokenWithKeySet: presentedkidmust match exactly one registered key (no fallback scanning); absentkidrequires a single-key set.dis rejected as defense-in-depth against a registration-validation gap.Buffer.from(…, 'base64url')silently skips invalid characters, letting distinct strings decode identically) and a 64-byte Ed25519 signature length check.iss=sub= client_id;audexactly the token endpoint (string or single-element array; multi-audience rejected);exprequired and ≤ 60s out;iatrequired, not future;nbfhonored when present;jtirequired and length-bounded (caps what a client can force into the replay table). 5s clock tolerance, both knobs overridable.src/lib/mcp/assertionJtiStore.ts+mcp_assertion_jtistable (expiration: 120) — the replay guard (#159 security req 1).sha256of a length-prefixed (client_id, jti) pair — replay scope is per client (RFC 7523 definesjtiuniqueness per issuer), and the length prefix prevents delimiter-stuffing collisions (a test caught the naiveclientId + '\n' + jticoncat colliding on crafted inputs).MCPAuthCodeStore.consume, bounded by the ≤60s assertionexp.Tests
35 new tests (841 total, all green): verify success paths (incl.
aud-as-array,typcase-insensitivity, multi-keykidselection), structural rejections (segment counts, invalid base64url,alg: none, RS/HS/case downgrades,crit), key-material rejections (privated, RSA/X25519/malformed JWKs, empty set, ambiguous/unknown/duplicatekid), signature rejections (wrong key, tampered payload, short signature), the full claims matrix (iss/sub/aud incl. prefix-of-endpoint, exp/iat/nbf windows, jti bounds), and the store (first-sighting/replay through the tracked-object Proxy read path, per-client scoping, delimiter-stuffing keys, fail-closed error propagation).Lint clean (the 4 warnings are pre-existing in
hookManager.test.js); prettier clean.Review status
CI review bots (Gemini, Claude) found no blockers; all four inline suggestions were adjudicated as real and fixed in
bf00888(8KB assertion cap, replay guard hardened to treat any truthy record as a replay,selectKeynull/primitive JWK-entry guards, stale module-header comment).Two Codex cross-model passes:
iat+ far-futureexp. Adjudicated — the attack is already blocked (expis bounded relative to now), but it exposed a real strictness gap: the verifier didn't enforce that the self-declared lifetime (exp - iat) conforms to the policy window. Closed in024ab58as defense-in-depth.maxExpiresInSeconds/clockToleranceSeconds) were trusted as finite — aNaN/Infinitywould fail the time-window checks open. Not reachable today (callers default to 60/5), but client_credentials (3/4): token-endpoint grant — resource binding, per-grant TTL, discovery #162 wires these frommcpconfig. Coerced up front likecoerceTtlin1828274.No open cross-model findings remain.
Where to look
verifyClientAssertion(signature before claims) and theaud/expwindow rules — this is the security contract of Add RFC 7523 client_credentials grant (private_key_jwt, EdDSA) for headless agent auth #159, so a second pair of eyes on the RFC 7523 §3 reading is the highest-value review.Replay-guard residual risk (explicit sign-off item, per review)
The jti guard uses
Table.create()(insert-if-absent, 409 on existing) — not an awaited get-then-put, after @kriszyp's review prompted a deep dive. What a merge signs off on:create()'s existence check pre-staging only; the losing create currently degrades to last-write-wins with both callers reporting success (Table.create() existence check not enforced at commit — concurrent creates silently become last-write-wins (and the with-id form is undocumented) harper#1745, filed from this review — includes the by-design analysis and a per-node enforcement path via the existing retry/sourceApplyseam). Exposure window: the staging→commit interval, not the assertion's ~60s validity.Docs
No docs change in this PR: these primitives have no user-facing surface until the grant wires them up in #162, which will carry the documentation for the whole flow.
🤖 Generated with Claude Code — model: Claude Fable 5