Skip to content

feat(ceremony): hold the token request's head to required and forbidden headers, not a fixed set - #35

Merged
xgreenx merged 11 commits into
mainfrom
feat/token-request-header-subset
Sep 11, 2026
Merged

xgreenx merged 11 commits into
mainfrom
feat/token-request-header-subset

Conversation

@xgreenx

@xgreenx xgreenx commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What changes

_checkTokenHead compared the revealed head against the profile's whole header list: every line once, nothing else. The "nothing else" bound every prover to sending exactly what one HTTP library sends, for no verification gain: a header outside the list changes only what the platform answers, and a wrong answer is a response the verifier cannot read, not one it can be fooled by.

The head is now held to what actually decides what the platform did with the request:

  • Required, each exactly once with its value: host naming the pinned authority, and content-type: application/x-www-form-urlencoded, which selects the parser (REQ-COMMON-21B).
  • One content-length equal to the body the notary signed, as before.
  • Forbidden by name, any spelling: authorization (which client the platform authenticates), content-encoding and transfer-encoding (which bytes it parses), cookie (which session it answers for), x-http-method-override, x-http-method and x-method-override (which method it runs). New error ForbiddenRequestHeader(bytes name).
  • Everything else ignored.

Names are compared lowercased, with whitespace before the colon removed and _ read as - (a CGI-style stack folds them); values exactly with the optional whitespace removed; a line no colon splits is refused. requireCrlfLineEndings runs first and now also refuses a bare carriage return.

The identity request

Two closures of the same shape there. The uniqueness count of REQ-COMMON-39 matched authorization:bearer, so a second authorization: Basic or authorization: token line was never counted, and the platform answered for whichever credential it honoured; the bearer the exchange is bound to is the one thing the cross-bind fixes, so a leaked token in the uncounted line named someone else's account under it. The needle is now authorization: under any scheme. And the forbidden names hold on the identity request too, authorization excepted since its one line is what the count checks: cookie is the other credential a platform might honour over the bearer.

Profile data

A token session now states requiredHeaders, the two lines the verifier compares, and nothing else: the send list (requestHeaders, request_headers, X_TOKEN_REQUEST_HEADERS) is gone from profiles.json, the generator and all three tables, since nothing reads it once only the required pair is checked. What a runtime sends beyond those two, accept, connection, a user-agent, is its own. profiles.json also gains requests.forbiddenHeaders, one list for both notarized requests, emitted everywhere as FORBIDDEN_REQUEST_HEADERS. The generator refuses a required list that is not exactly host, naming the pinned authority, and content-type; and a forbidden name that is not a lowercase field name or that the verifier itself reads.

Minor bump for libid-profiles and @libid/contracts when released: request_headers / requestHeaders and request_header_block / requestHeaderBlock are replaced by required_headers / requiredHeaders.

The generator now prints arrays the way rustfmt and biome keep them, from the widths in rustfmt.toml and biome.json, so the generated tables pass both formatters unformatted; the one-line arrays used to pass rustfmt only because an unbreakable string beside them made it skip the struct.

Tests

479 pass, up from 450. XPlatformVerifier.t.sol: test_rejectsAForbiddenHeaderOnTheTokenRequest, test_rejectsEachForbiddenHeaderOnTheTokenRequest (all five names, in other cases and without the space), test_rejectsATokenRequestMissingARequiredHeader, test_rejectsARequiredHeaderTwice, test_acceptsATokenRequestWithoutAnUncomparedHeader (accept dropped), test_acceptsUnlistedHeadersOnTheTokenRequest, test_acceptsARequiredHeaderInAnotherSpelling, test_rejectsAHeaderLineWithoutAColon, test_rejectsABareCarriageReturnInTheTokenHead; on the identity request test_rejectsASecondAuthorizationHeaderOfAnotherScheme, test_rejectsACookieOnTheIdentityRequest, test_acceptsAnUnlistedHeaderOnTheIdentityRequest, test_rejectsABareCarriageReturnOnTheIdentityRequest. GitHubPlatformVerifier.t.sol: test_acceptsAnUnlistedHeaderOnTheExchange, test_rejectsASecondAuthorizationHeaderOfAnotherSchemeOnTheIdentityRead, test_rejectsACookieOnTheIdentityRead. The heads the runtimes send, byte for byte: test_verifiesTheTokenRequestTheBrowserSends (X, with the content-length the browser sets itself, third of five), test_verifiesTheIdentityRequestTheBrowserSends on both platforms (GitHub's with the browser's user-agent and API version); the service's exchange is the happy path already; both fixture tests now say the profile's required pair is among the head's lines. Rust: the vectors tests check the required pair names host and the media type and that no forbidden name is required; the hyper wire tests drive the required pair plus the runtime's own two through the encoder, and a caller-set content-length as X's browser sets it, and check the head passes the new rule with the length written once.

The records the Rust pipeline produces

Two fixtures under test/fixtures/, x-ceremony-session.json and github-ceremony-session.json, generated by libid-rs (cargo run -p libid-tlsn --example ceremony_fixtures, libid-org/libID-rs#19): the requests composed as the browser and the Token-Exchange Service compose them and encoded by hyper, the layouts libid_transcript::ceremony's, the commitments tlsn's SHA-256 plaintext hashes, the record AttestedData::from_observed, signed by anvil #0. A ceremony minus the MPC and the platforms' own bytes, with nothing written by hand. test_verifiesTheRecordsLibidRsProduces in each suite verifies its two records with those signatures unedited and reads the claim out; it first asserts the fixture's digest is the suite's, so a drift between the Rust derivation and the chain's fails by name.

Two ceremonies that actually ran

x-ceremony-real.json and github-ceremony-real.json, captured on 2026-09-11 by libid-rs examples/capture_ceremony.rs (libid-org/libID-rs#19): for each platform a real MPC-TLS token exchange with a real authorization code under the PKCE challenge derived from the suites' digest, then a real identity read with the bearer the platform issued, the verifier in the prover's process signing as anvil #0. Nothing in either file was written by hand; the bearers and GitHub's secret are committed and absent from the bytes. test_verifiesTheRecordsACeremonyProduced in each suite verifies both records with their signatures unedited, at a clock a minute past the identity read, and reads the claim out. The GitHub identity record carries the pretty-printed response GitHub serves for the pinned media type, which is what #37's normalization reads; X's are compact, recorded rather than assumed. This merge also brought #37 in.

Verification

Command Result
solidity/ forge test 479 passed, 0 failed
solidity/ forge fmt --check, forge lint clean
scripts/regen-ceremony-profiles.py --check up to date
rust/ cargo +nightly fmt, clippy -D warnings, cargo test -p libid-profiles clean, 12 passed
ts/ pnpm build && typecheck && test && lint && fmt:check 24 tests, clean

Companion: the specification side is REQ-PLAT-56A/B/C and REQ-COMMON-39/39A in libid-org/libID#31.

🤖 Generated with Claude Code

The verifier compared the revealed head against the profile's whole
header list: every line once, nothing else. "Nothing else" was where the
rule overreached. A header outside the list changes only what the
platform answers, and a wrong answer is a response this verifier cannot
read, not one it can be fooled by; refusing it bound every prover to
sending exactly what one HTTP library happens to send.

What the head has to satisfy is narrower, and is now what is checked:
`host` naming the pinned authority and `content-type` selecting the
parser, each once with its value; one `content-length` equal to the
body the notary signed; none of the names that change what the platform
does with the request in a way no revealed byte shows -- `authorization`
the client it authenticates, `content-encoding` and `transfer-encoding`
the bytes it parses, `cookie` the context, `x-http-method-override` the
method. Everything else is ignored.

Names are compared lowercased, since the platform reads them that way
and a forbidden name in another case is the same header to it; values
exactly, with the optional whitespace removed. A line no colon splits is
refused.

The forbidden names live in profiles.json beside the profiles and reach
all three tables. Each token session's required subset is generated from
the headers it sends, and replaces the CRLF block the set check matched
against in the Rust and TypeScript tables.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The one-line arrays passed rustfmt only by accident: the unbreakable
header-block string beside them made it leave the whole struct alone.
With that string gone it formats the struct and breaks them, so the
generator breaks them the same way, from the width in rustfmt.toml.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
…othing else

`requestHeaders` listed what a runtime sends, and the required pair was
derived from it. Once the verifier holds the head to that pair alone,
the send list is profile data nothing reads: `accept` and `connection`
are the runtime's own needs, uncompared, and a builder that takes them
from the profile is no more right than one that does not.

So the profile states `requiredHeaders`, the two lines the verifier
compares, and the send list is gone from the JSON, the generator and
all three tables, with the Solidity constant that carried it. The
generator now checks the required list is exactly `host`, naming the
pinned authority, and `content-type`. The fixtures keep sending the
runtime's four and the tests say the required pair is among them.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The identity request counts its authorization header over bytes
normalized the way REQ-COMMON-39 says: lowercased, whitespace removed.
The token head lowercased the name and left whitespace before the
colon in it, so `authorization :` was not the forbidden name. A
compliant server refuses that line with 400, which fails safe, but the
forbidden list exists for not trusting what a platform does with odd
input. The name is now trimmed as well, which is the same normalization
in both places.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
…equests

Five closures of the same shape, each a way a request could carry a
second meaning the verifier does not see.

The identity request's uniqueness count matched `authorization:bearer`,
so a second `authorization: Basic` or `authorization: token` line was
not counted, and the platform answered for whichever credential it
honoured. The bearer the exchange is bound to is the one thing the
cross-bind fixes; a leaked token in the uncounted line named someone
else's account under it. The needle is now `authorization:` under any
scheme, as REQ-COMMON-39 will say.

The forbidden names now hold on the identity request too, `authorization`
excepted since its one line is what the count checks. `cookie` is the
case: the other credential a platform might honour over the bearer.
`x-http-method` and `x-method-override` join the list beside the
override name already on it.

A header name folds `_` to `-` before the comparison, since a CGI-style
stack reads `content_encoding` as `content-encoding`. And a carriage
return no line feed follows is refused with the bare line feed and the
fold: a compliant parser never ends a line on one, and this does not
depend on every platform being compliant.

The per-line work of the token head moved into a function of its own;
the loop had grown past what the compiler could lay out on the stack.
The list is `requests.forbiddenHeaders` in profiles.json now that it
covers both, and `FORBIDDEN_REQUEST_HEADERS` in the three tables.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The fixtures composed their heads from parts and let the harness append
the length; the rule was written for what the browser and the
Token-Exchange Service send, and nothing said it admitted those heads
as they are. Three tests now carry them byte for byte: X's token
request with the length the browser sets itself, third among five;
X's identity request with the bearer line first and inside the head,
where the older fixtures put it after a blank line; GitHub's identity
request with the browser's user-agent and API version. The exchange the
service sends was the happy path already.

A wire test drives the caller-set length through hyper, since tlsn's
prover encodes with the same hyper, and finds it written once with its
value kept.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Two fixtures, one per platform, generated by libid-rs (`cargo run -p
libid-tlsn --example ceremony_fixtures`): the requests composed as the
browser and the Token-Exchange Service compose them and encoded by
hyper, the layouts `libid_transcript::ceremony`'s, the commitments
tlsn's SHA-256 plaintext hashes, the record `AttestedData::from_observed`
-- the notary's own path -- signed by anvil #0, the key the suites
trust. A ceremony minus the MPC and the platform's own bytes, with
nothing written by hand.

Each suite verifies its two records with those signatures unedited and
reads the claim out. The verifier in the token body was derived from
the same digest the suite derives, which the test asserts first, so a
drift between the Rust digest and the chain's fails by name.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
#37 landed the JSON whitespace normalization on main and touched the
same three files. The two library files merged on their own; the
GitHub suite needed the two sides of one hunk kept in order, with the
closing brace git had moved past the conflict put back. 477 tests,
both sets.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The generated identity record had a compact body composed from the
documented shape, and it passed a verifier that refused every real
read: GitHub pretty-prints /user for the media type the profile pins,
which only a live session showed. The fixture is regenerated over that
formatting -- newline and two spaces before every member, a space after
every colon -- with the whitespace inside each revealed member, and the
test now asserts the response is pretty-printed, so a compact body
cannot pass here again.

With the normalization merged from main the record verifies as before;
without it this test would fail, which is what the fixture was for.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Two MPC-TLS sessions against github.com and api.github.com on
2026-09-11, captured by libid-rs `examples/capture_ceremony.rs`: the
exchange with a real authorization code under the PKCE challenge
derived from the suite's digest, the identity read with the bearer
GitHub issued, the verifier in the prover's process signing as anvil #0.
Nothing in the file was written by hand -- the head is what hyper put on
the wire, the body is what GitHub answered, pretty-printed as GitHub
prints it, the bearer and the secret committed and absent from the
bytes -- and it verifies with the signatures unedited.

This is the record the generated fixture reproduces, and the one it
could not have caught the whitespace with: the platform's bytes, not a
reproduction of them.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Two MPC-TLS sessions against api.x.com on 2026-09-11, captured by
libid-rs `examples/capture_ceremony.rs`: the exchange as a public client
with a real authorization code under the PKCE challenge derived from
the suite's digest, the identity read with the bearer X issued, the
verifier in the prover's process signing as anvil #0. Nothing in the
file was written by hand; the bearer is committed and absent from the
bytes. X serializes both responses compact, which the file records
where the generated fixture had assumed it.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
@xgreenx xgreenx self-assigned this Sep 11, 2026
@xgreenx
xgreenx merged commit 2632852 into main Sep 11, 2026
11 checks passed
@xgreenx xgreenx mentioned this pull request Sep 11, 2026
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