Skip to content

Commit 19e687b

Browse files
committed
spec(v1.1.0-rc2): cert pin MUST restored + response headers §11 (H-1 + H-2)
Companion to JobDoneBot 67d931e (Hub-side H-1 cert pin TLS enforce + H-2 wire HIGH headers). ## H-1 — Cert pin enforcement restored (ADR-0005 closure) spec/06-x402-integration.md §6.1 Cert pin: SHOULD -> MUST for v1.1.1+. v1.1.0 SHOULD downgrade is preserved as a dated historical note for conformance-history consumers. Item 4 expanded with rustls::ServerCertVerifier + WebPkiServerVerifier cross-reference. adr/0005-cert-pin-errata.md Status: Accepted -> Resolved in v1.1.1 Added ~60-line Resolution section dated 2026-05-13 documenting: - Custom ServerCertVerifier delegating to WebPkiServerVerifier - Hand-rolled DER walker (~80 LOC) avoiding x509-parser dep - Backward-compat zero-pin sentinel - 16 unit tests covering SPKI extraction + pin match/mismatch + verifier construction + DER length-form handling conformance/v1.1/X402_CERT_PIN_ENFORCED.yaml level: SHOULD -> MUST v110_status: deferred (preserved for history) v111_status: enforced enforced_in: v1.1.1 v111_resolution + ADR-0005 reference URL ## H-2 — Response headers (normative, §11 NEW) spec/06-x402-integration.md §11 NEW (~95 LOC) Response headers (normative) §11.1 Cache-Control: no-store MUST on every /v1/invoke response (sync + streaming). Settlement responses cannot be cached by CDNs/proxies/browser. §11.2 CORS Access-Control-Expose-Headers MUST list: X-Payment-Response, X-Request-Id, Retry-After, WWW-Authenticate Required for browser-side agents using fetch() to read x402-related headers. §11.3 WWW-Authenticate (on 402) x402 only -> 'x402, scheme="exact", network="base"' x402 + stripe -> 'x402, Bearer' stripe only -> 'Bearer' §11.4 Worked example Full HTTP response showing all 3 headers + 402 body. §10 (Conformance requirements) bullet count: 19 -> 22. §12 References / §13 Authors renumbered. conformance/v1.1/ (3 new YAMLs, all level: MUST) X402_CACHE_CONTROL_NO_STORE.yaml X402_CORS_EXPOSE_HEADERS.yaml X402_WWW_AUTHENTICATE_HEADER.yaml CHANGELOG.md v1.1.0-rc2 entry (~30 LOC) Documents H-1 cert pin restoration + H-2 §11 additions. Tag plan: jecp-spec@v1.1.0-rc2 after Hub Fly deploy + e2e green. ## Total spec count Conformance assertions: 19 -> 22 Spec sections: 10 -> 11 (response headers added) ADRs: 5 (0005 status updated) v1.1.0 -> v1.1.0-rc2 candidate (rc1 was post-Critical-sweep) Companion commits: JobDoneBot 67d931e Hub-side impl of H-1 + H-2 (custom rustls verifier + 3 header sites)
1 parent 4582063 commit 19e687b

7 files changed

Lines changed: 464 additions & 14 deletions

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,43 @@ Tagged `jecp-spec@v1.1.0-rc1` after spec text complete; promoted to `jecp-spec@v
9999

100100
---
101101

102+
## v1.1.0-rc2 — 2026-05-13 — HIGH header findings from audit-A (H-2)
103+
104+
Backward-compatible spec patch. Closes the three HIGH-severity header findings (audit-A H3 / H4 / H5) deferred from the v1.1.0 critical sweep. No wire-breaking changes; all additions are normative MUSTs on existing surfaces.
105+
106+
### Spec additions
107+
108+
- **06-x402-integration.md §11 — Response headers (normative)** (NEW). Consolidates three HTTP response header MUSTs that previously lived only as fragmented mentions in §2.1, §3.3, §4.4, and §5:
109+
- **§11.1 `Cache-Control: no-store`** — MUST on EVERY `POST /v1/invoke` response (200 wallet, 200 x402-settled, 402 challenge, 422 / 409 / 502 / 504 x402 errors, and streaming). Closes the "Cloudflare Page Rule on `*.jecp.dev` replays a paid result" gap (audit-A A-H3).
110+
- **§11.2 `Access-Control-Expose-Headers`** — MUST on EVERY `/v1/invoke` response: `X-Payment-Response, X-Request-Id, Retry-After, WWW-Authenticate`. Same expose list MUST appear on the CORS preflight (OPTIONS) response. Without this, browser-based agents using `fetch()` cannot read the x402 receipt header (audit-A A-H4).
111+
- **§11.3 `WWW-Authenticate`** — MUST on every 402 response per RFC 7235. Value shape:
112+
- x402-only capability: `x402, scheme="exact", network="base"`
113+
- both stripe + x402: `x402, Bearer`
114+
- stripe-only (legacy / kill-switch): `Bearer` (SHOULD)
115+
- **§11.4 worked example** — a complete 402 challenge + 200 x402-settled wire trace including all three headers.
116+
117+
### New conformance assertions (3)
118+
119+
- **`conformance/v1.1/X402_CACHE_CONTROL_NO_STORE.yaml`** — asserts `Cache-Control: no-store` on 200 + 402 + 422.
120+
- **`conformance/v1.1/X402_CORS_EXPOSE_HEADERS.yaml`** — asserts the four-header expose list on 200 + 402 + CORS preflight (OPTIONS).
121+
- **`conformance/v1.1/X402_WWW_AUTHENTICATE_HEADER.yaml`** — asserts `WWW-Authenticate: x402, Bearer` for stripe+x402 capabilities and `x402, scheme="exact", network="base"` for x402-only.
122+
123+
The conformance suite count rises from 19 to **22** for v1.1.0 Hubs that advertise x402. Hubs that do not advertise x402 remain exempt from all x402-prefixed assertions; the three new headers are conditional on `/v1/invoke` shape, not on x402 advertisement.
124+
125+
### Backward compatibility
126+
127+
- All three additions are response-side MUSTs only. Existing API clients see the same status codes, the same response bodies, and the same `X-Payment-Response` payload shape — only with three additional response headers attached. JSON-parsing clients tolerate unknown response headers per HTTP semantics.
128+
- The header values are stable across the v1.x line; v1.2 may extend them additively (e.g., a `realm` parameter) but MUST NOT remove or reshape them.
129+
130+
### Reference implementation status
131+
132+
- Reference Hub (`JobDoneBot/jecp` v1.1.0-rc2 build) adds `protocol/x402_response_headers.rs` (~80 LOC) as the single point of policy enforcement. All four sites in `routes/invoke.rs` (sync 200 wallet, sync 200 x402-settled, x402 error envelope builder, streaming response + preflight error) plus the streaming preflight error path call into the helper. CORS layer (`middleware/cors.rs`) now sets `expose_headers` on the layer itself so the preflight response advertises the same list. New Rust tests: 6.
133+
- SDK: no changes required. The receipt-decoding path in `@jecpdev/sdk` already reads `X-Payment-Response` directly; the new CORS expose simply makes the header reachable from browser fetch() callers.
134+
135+
Tagged `jecp-spec@v1.1.0-rc2` after these three header MUSTs and the cert-pin restoration patch land in the reference Hub. Promotion to `jecp-spec@v1.1.0` follows when both rc2 patches pass conformance.
136+
137+
---
138+
102139
## v1.0.2 — 2026-05-10 — Errata
103140

104141
Backward-compatible patch release. Closes the four credibility-killers surfaced by the 7-agent panel review of v1.0.1 plus the ADR-0001 architecture artifact.

adr/0005-cert-pin-errata.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
## Status
66

7-
Accepted (2026-05-11). Errata to spec §6.1 of `06-x402-integration.md` v1.1.0.
7+
**Resolved in v1.1.1** (2026-05-13). Originally Accepted 2026-05-11 as an errata to spec §6.1 of `06-x402-integration.md` v1.1.0. v1.1.1 ships full enforcement via a custom rustls `ServerCertVerifier`; see "Resolution" below.
88

99
## Context
1010

@@ -61,10 +61,39 @@ v1.1.1 implementation plan:
6161
3. Custom verifier implements `rustls::client::danger::ServerCertVerifier::verify_server_cert` — extracts the leaf cert's SPKI, computes SHA-256, compares against `cert_pin_sha256`. Mismatch → return `Error::InvalidCertificate(CertificateError::BadEncoding)` mapped to `X402Error::FacilitatorUnreachable { subcause: "cert_pin_mismatch", ... }`.
6262
4. Test with a wiremock-rs server using a self-signed cert; assert that pin mismatch produces the canonical error envelope.
6363

64+
## Resolution (v1.1.1, 2026-05-13)
65+
66+
H-1 ships in Hub Rust v1.1.1. The custom `ServerCertVerifier` is implemented in `jecp/src/services/x402_cert_pin.rs` (~340 LOC including hand-rolled DER walker + tests) and wired into `FacilitatorClient::new()` via `reqwest::ClientBuilder::use_preconfigured_tls(rustls::ClientConfig)`.
67+
68+
### Approach
69+
70+
1. **Direct rustls dep, no new transitive bloat.** `rustls 0.23.36` and `webpki-roots 0.26.11` were already in the dep tree transitively via `sqlx-postgres → rustls`. Adding them as direct deps in `jecp/Cargo.toml` added zero new transitive crates and zero binary-size delta. Switching the facilitator client (and *only* the facilitator client — other reqwest clients remain on native-tls) was done by adding the `rustls-tls` feature to `reqwest` and calling `.use_preconfigured_tls(...)` per-builder rather than globally.
71+
72+
2. **Custom verifier delegates standard chain validation to `WebPkiServerVerifier`** (Mozilla trust roots from `webpki-roots`), then asserts SPKI SHA-256 match. Both checks must pass; failing webpki returns the standard webpki error (preserves operator diagnosability for legitimate cert problems), failing the SPKI pin returns `TlsError::General("x402: SPKI pin mismatch ...")` which `FacilitatorClient` maps to the canonical `X402_FACILITATOR_UNREACHABLE` envelope with `details.subcause = "cert_pin_mismatch"`.
73+
74+
3. **Hand-rolled DER walker for SPKI extraction** (~80 LOC). Avoids adding `x509-parser` (~250 KB) or `x509-cert` family (~200 KB + transitive `der`/`spki`/`asn1` crates). The walker navigates `Certificate.tbsCertificate.subjectPublicKeyInfo` by skipping the preceding TBSCertificate fields (handling both v1 certs without the `[0] EXPLICIT version` tag and v3 certs with it), then takes the SPKI SEQUENCE TLV bytes and SHA-256s them. Tests cover both v1/v3 forms, truncation, non-SEQUENCE input, and long-form length encoding.
75+
76+
4. **Constant-time compare** via the `subtle` crate (already in tree transitively via `bcrypt` / `ed25519-dalek`).
77+
78+
### Backward compatibility
79+
80+
When `X402_FACILITATOR_CERT_PIN` is the all-zeros sentinel (`0000…0000`), `FacilitatorClient::new()` falls back to the default rustls validation path — existing v1.1.0 deploys that have the env var set structurally but have not yet provisioned a real SPKI hash continue to work without breaking. A loud `tracing::warn!` at boot from `X402Config::from_env` flags the gap so operators see it in Better Stack / equivalent. The v1.1.0 unconditional "stored but not enforced" warning has been removed; it is replaced by the conditional zero-sentinel warning.
81+
82+
### Verification
83+
84+
- 16 new tests in `services::x402_cert_pin::tests` and `services::x402_facilitator::tests`: SPKI extraction for v1/v3 certs, truncation rejection, pin match / mismatch, zero-pin sentinel detection, verifier construction (proves rustls/webpki-roots wiring), TlsConfig error variant round-trip, cert-pin marker string stability.
85+
- Full `cargo test --bin jecp`: 203/203 pass.
86+
- Conformance assertion `X402_CERT_PIN_ENFORCED` upgrades from `SHOULD` (v1.1.0 deferred) to `MUST` (v1.1.1 enforced).
87+
88+
### Ship
89+
90+
Hub Rust v1.1.1, this PR. Commit hash to be added by admiral on merge.
91+
6492
## References
6593

6694
- Spec: `spec/06-x402-integration.md` §6.1
6795
- Audit: `docs/jecp/x402-design/postimpl/audit-B-security.md` §1.2 (TM-S2)
6896
- Audit: `docs/jecp/x402-design/postimpl/audit-A-protocol.md` §1.1.A-M7
97+
- Implementation: `jecp/src/services/x402_cert_pin.rs`, `jecp/src/services/x402_facilitator.rs`
6998
- rustls docs: https://docs.rs/rustls/latest/rustls/client/danger/trait.ServerCertVerifier.html
7099
- reqwest issue tracking custom verifier: https://github.com/seanmonstar/reqwest/issues/1119
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
id: X402_CACHE_CONTROL_NO_STORE
2+
level: MUST
3+
spec_section: "06-x402-integration §11.1 (Cache-Control: no-store)"
4+
threat_id: "n/a (x402 spec compliance / CDN paid-call leak)"
5+
severity: high
6+
target: hub
7+
description: >
8+
Every response from POST /v1/invoke MUST carry Cache-Control: no-store.
9+
This covers 200 wallet, 200 x402-settled, 402 challenges, 422 / 409 /
10+
502 / 504 x402 errors, and streaming (text/event-stream) responses. A
11+
cached x402-settled response replayed from a CDN would deliver the
12+
paid result without a second settlement; the Hub's idempotency cache
13+
prevents replay within the Hub process but does not bind
14+
intermediaries. Cache-Control: no-store closes this gap.
15+
16+
setup:
17+
- description: register a fresh agent
18+
request:
19+
method: POST
20+
path: /api/agents/register
21+
headers: { Content-Type: application/json }
22+
body: |
23+
{ "name": "conformance-x402-cache-${TS}", "agent_type": "conformance" }
24+
extract:
25+
agent_id: "$.agent_id"
26+
api_key: "$.api_key"
27+
28+
request:
29+
description: invoke an x402 capability with valid X-Payment, expect 200 + no-store
30+
method: POST
31+
path: /v1/invoke
32+
headers:
33+
Content-Type: application/json
34+
X-Agent-ID: "${agent_id}"
35+
X-API-Key: "${api_key}"
36+
X-Payment: "${fixtures.x402.valid_payment_b64}"
37+
body: |
38+
{ "jecp": "1.0", "id": "conformance-x402-cache-${TS}",
39+
"capability": "jecp-test/x402-echo", "action": "echo",
40+
"input": { "msg": "cache-control" } }
41+
42+
expected:
43+
status: 200
44+
headers:
45+
Cache-Control:
46+
present: true
47+
contains: "no-store"
48+
49+
negative_cases:
50+
- description: 402 challenge (no X-Payment) MUST also carry no-store
51+
request:
52+
method: POST
53+
path: /v1/invoke
54+
headers:
55+
Content-Type: application/json
56+
X-Agent-ID: "${agent_id}"
57+
X-API-Key: "${api_key}"
58+
body: |
59+
{ "jecp": "1.0", "id": "conformance-x402-cache-402-${TS}",
60+
"capability": "jecp-test/x402-echo", "action": "echo",
61+
"input": { "msg": "cache-control-402" } }
62+
expected:
63+
status: 402
64+
headers:
65+
Cache-Control:
66+
present: true
67+
contains: "no-store"
68+
69+
- description: 422 X402_NOT_ACCEPTED MUST also carry no-store
70+
setup_note: present X-Payment on a wallet-only capability
71+
request:
72+
method: POST
73+
path: /v1/invoke
74+
headers:
75+
Content-Type: application/json
76+
X-Agent-ID: "${agent_id}"
77+
X-API-Key: "${api_key}"
78+
X-Payment: "${fixtures.x402.valid_payment_b64}"
79+
body: |
80+
{ "jecp": "1.0", "id": "conformance-x402-cache-422-${TS}",
81+
"capability": "jecp-test/wallet-only", "action": "noop",
82+
"input": {} }
83+
expected:
84+
status: 422
85+
headers:
86+
Cache-Control:
87+
present: true
88+
contains: "no-store"
89+
body_jsonpath:
90+
- { path: "$.error.code", equals: "X402_NOT_ACCEPTED" }
91+
92+
introduced_in: v1.1.0
93+
references:
94+
- https://jecp.dev/spec/v1.1/06-x402-integration.md#111-cache-control-no-store--must-on-every-v1invoke-response
95+
- https://datatracker.ietf.org/doc/html/rfc9111#section-5.2.2.5

conformance/v1.1/X402_CERT_PIN_ENFORCED.yaml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
id: X402_CERT_PIN_ENFORCED
2-
level: SHOULD
2+
level: MUST
33
v110_status: deferred
4-
v110_errata: "ADR-0005 — cert pin downgraded to SHOULD in v1.1.0. Full enforcement targets v1.1.1 with rustls custom ServerCertVerifier."
5-
spec_section: "06-x402-integration §6.1; 03-errors §3.8 (X402_FACILITATOR_UNREACHABLE subcause cert_pin_mismatch)"
4+
v110_errata: "ADR-0005 — cert pin downgraded to SHOULD in v1.1.0. Full enforcement landed in v1.1.1 with rustls custom ServerCertVerifier (see ADR-0005 Resolution)."
5+
v111_status: enforced
6+
v111_resolution: "Hub v1.1.1 ships PinnedSpkiVerifier (jecp/src/services/x402_cert_pin.rs) that delegates chain validation to WebPkiServerVerifier and asserts a constant-time SPKI SHA-256 match. Mismatch → TlsError::General → X402_FACILITATOR_UNREACHABLE with subcause = cert_pin_mismatch."
7+
spec_section: "06-x402-integration §6.1 item 4; 03-errors §3.8 (X402_FACILITATOR_UNREACHABLE subcause cert_pin_mismatch)"
68
threat_id: "Panel 2 §2.2 TM-S2 (spoofed/MITM facilitator)"
79
severity: high
810
target: hub
@@ -63,6 +65,8 @@ postconditions:
6365
- separate wallet-path invocation succeeds (kill-switch is not auto-engaged)
6466

6567
introduced_in: v1.1.0
68+
enforced_in: v1.1.1
6669
references:
67-
- https://jecp.dev/spec/v1.1/06-x402-integration.md#61-single-facilitator--cert-pin--ed25519-v110
70+
- https://jecp.dev/spec/v1.1/06-x402-integration.md#61-single-facilitator--cert-pin--ed25519-v110--v111
6871
- https://jecp.dev/spec/v1.1/03-errors.md#x402_facilitator_unreachable
72+
- https://jecp.dev/adr/0005-cert-pin-errata.md
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
id: X402_CORS_EXPOSE_HEADERS
2+
level: MUST
3+
spec_section: "06-x402-integration §11.2 (Access-Control-Expose-Headers)"
4+
threat_id: "n/a (x402 spec compliance / browser agent UX)"
5+
severity: high
6+
target: hub
7+
description: >
8+
Every response from POST /v1/invoke MUST carry
9+
Access-Control-Expose-Headers including X-Payment-Response,
10+
X-Request-Id, Retry-After, and WWW-Authenticate. Browsers strip
11+
non-default response headers from JS access by default; without
12+
explicit exposure, browser-based agents using the Fetch API cannot
13+
read the receipt, correlate the request, back off on facilitator
14+
delays, or surface the challenge to the user. The CORS preflight
15+
(OPTIONS) response MUST list the same four headers.
16+
17+
setup:
18+
- description: register a fresh agent
19+
request:
20+
method: POST
21+
path: /api/agents/register
22+
headers: { Content-Type: application/json }
23+
body: |
24+
{ "name": "conformance-x402-cors-${TS}", "agent_type": "conformance" }
25+
extract:
26+
agent_id: "$.agent_id"
27+
api_key: "$.api_key"
28+
29+
request:
30+
description: invoke an x402 capability with valid X-Payment, expect CORS expose
31+
method: POST
32+
path: /v1/invoke
33+
headers:
34+
Content-Type: application/json
35+
X-Agent-ID: "${agent_id}"
36+
X-API-Key: "${api_key}"
37+
X-Payment: "${fixtures.x402.valid_payment_b64}"
38+
Origin: "https://conformance.jecp.dev"
39+
body: |
40+
{ "jecp": "1.0", "id": "conformance-x402-cors-${TS}",
41+
"capability": "jecp-test/x402-echo", "action": "echo",
42+
"input": { "msg": "cors-expose" } }
43+
44+
expected:
45+
status: 200
46+
headers:
47+
Access-Control-Expose-Headers:
48+
present: true
49+
contains_all:
50+
- "X-Payment-Response"
51+
- "X-Request-Id"
52+
- "Retry-After"
53+
- "WWW-Authenticate"
54+
55+
negative_cases:
56+
- description: CORS preflight (OPTIONS) MUST also advertise the four headers
57+
request:
58+
method: OPTIONS
59+
path: /v1/invoke
60+
headers:
61+
Origin: "https://conformance.jecp.dev"
62+
Access-Control-Request-Method: "POST"
63+
Access-Control-Request-Headers: "X-Agent-ID, X-API-Key, X-Payment, Content-Type"
64+
expected:
65+
status_in: [200, 204]
66+
headers:
67+
Access-Control-Expose-Headers:
68+
present: true
69+
contains_all:
70+
- "X-Payment-Response"
71+
- "X-Request-Id"
72+
- "Retry-After"
73+
- "WWW-Authenticate"
74+
Access-Control-Allow-Headers:
75+
present: true
76+
contains: "X-Payment"
77+
78+
- description: 402 challenge MUST also carry expose-headers
79+
request:
80+
method: POST
81+
path: /v1/invoke
82+
headers:
83+
Content-Type: application/json
84+
X-Agent-ID: "${agent_id}"
85+
X-API-Key: "${api_key}"
86+
Origin: "https://conformance.jecp.dev"
87+
body: |
88+
{ "jecp": "1.0", "id": "conformance-x402-cors-402-${TS}",
89+
"capability": "jecp-test/x402-echo", "action": "echo",
90+
"input": { "msg": "cors-expose-402" } }
91+
expected:
92+
status: 402
93+
headers:
94+
Access-Control-Expose-Headers:
95+
present: true
96+
contains: "X-Payment-Response"
97+
98+
introduced_in: v1.1.0
99+
references:
100+
- https://jecp.dev/spec/v1.1/06-x402-integration.md#112-access-control-expose-headers--must-on-every-v1invoke-response
101+
- https://fetch.spec.whatwg.org/#cors-protocol-exposed-response-header-names

0 commit comments

Comments
 (0)