feat(ceremony): hold the token request's head to required and forbidden headers, not a fixed set - #35
Merged
Merged
Conversation
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
_checkTokenHeadcompared 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:
hostnaming the pinned authority, andcontent-type: application/x-www-form-urlencoded, which selects the parser (REQ-COMMON-21B).content-lengthequal to the body the notary signed, as before.authorization(which client the platform authenticates),content-encodingandtransfer-encoding(which bytes it parses),cookie(which session it answers for),x-http-method-override,x-http-methodandx-method-override(which method it runs). New errorForbiddenRequestHeader(bytes name).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.requireCrlfLineEndingsruns 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 secondauthorization: Basicorauthorization: tokenline 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 nowauthorization:under any scheme. And the forbidden names hold on the identity request too,authorizationexcepted since its one line is what the count checks:cookieis 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 fromprofiles.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, auser-agent, is its own.profiles.jsonalso gainsrequests.forbiddenHeaders, one list for both notarized requests, emitted everywhere asFORBIDDEN_REQUEST_HEADERS. The generator refuses a required list that is not exactlyhost, naming the pinned authority, andcontent-type; and a forbidden name that is not a lowercase field name or that the verifier itself reads.Minor bump for
libid-profilesand@libid/contractswhen released:request_headers/requestHeadersandrequest_header_block/requestHeaderBlockare replaced byrequired_headers/requiredHeaders.The generator now prints arrays the way rustfmt and biome keep them, from the widths in
rustfmt.tomlandbiome.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(acceptdropped),test_acceptsUnlistedHeadersOnTheTokenRequest,test_acceptsARequiredHeaderInAnotherSpelling,test_rejectsAHeaderLineWithoutAColon,test_rejectsABareCarriageReturnInTheTokenHead; on the identity requesttest_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 thecontent-lengththe browser sets itself, third of five),test_verifiesTheIdentityRequestTheBrowserSendson both platforms (GitHub's with the browser'suser-agentand 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 nameshostand 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-setcontent-lengthas 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.jsonandgithub-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 layoutslibid_transcript::ceremony's, the commitments tlsn's SHA-256 plaintext hashes, the recordAttestedData::from_observed, signed by anvil #0. A ceremony minus the MPC and the platforms' own bytes, with nothing written by hand.test_verifiesTheRecordsLibidRsProducesin 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.jsonandgithub-ceremony-real.json, captured on 2026-09-11 by libid-rsexamples/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_verifiesTheRecordsACeremonyProducedin 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
solidity/ forge testsolidity/ forge fmt --check,forge lintscripts/regen-ceremony-profiles.py --checkrust/ cargo +nightly fmt,clippy -D warnings,cargo test -p libid-profilests/ pnpm build && typecheck && test && lint && fmt:checkCompanion: the specification side is REQ-PLAT-56A/B/C and REQ-COMMON-39/39A in libid-org/libID#31.
🤖 Generated with Claude Code