From f40a760ec97f43e351e1e5a83e7d39a958dba35e Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 14:00:43 +0100 Subject: [PATCH 01/10] feat(ceremony): hold the token request's head to a subset, not a set 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 --- rust/profiles/src/profiles.rs | 24 ++- rust/profiles/tests/vectors.rs | 60 +++++-- rust/profiles/tests/wire.rs | 59 +++--- scripts/regen-ceremony-profiles.py | 168 +++++++++++++----- .../contracts/ceremony/CeremonyProfile.sol | 38 ++-- .../ceremony/GitHubPlatformVerifier.sol | 9 +- .../ceremony/TlsNotaryVerifierBase.sol | 138 ++++++++++---- .../contracts/ceremony/XPlatformVerifier.sol | 13 +- solidity/contracts/ceremony/profiles.json | 30 +++- .../test/GitHubPlatformVerifier.t.sol | 30 ++++ .../ceremony/test/XPlatformVerifier.t.sol | 121 +++++++++++-- .../contracts/src/ceremony/profiles.ts | 32 +++- 12 files changed, 546 insertions(+), 176 deletions(-) diff --git a/rust/profiles/src/profiles.rs b/rust/profiles/src/profiles.rs index 5d00170..5d3ea0a 100644 --- a/rust/profiles/src/profiles.rs +++ b/rust/profiles/src/profiles.rs @@ -65,10 +65,11 @@ pub struct TokenSession { /// is the body's own count: the HTTP client appends it and the verifier /// reads it rather than compares it. pub request_headers: &'static [&'static str], - /// The same lines joined by CRLF, which is the shape a Platform - /// Verifier splits and matches as a set -- order is the prover's, the - /// set is the profile's. - pub request_header_block: &'static str, + /// The subset of those a Platform Verifier requires, each exactly once + /// with its value: `host` and `content-type`. Every other header is + /// the runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` + /// lists. + pub required_headers: &'static [&'static str], } /// The identity session: the authenticated read that names the account. @@ -124,7 +125,7 @@ pub const X: Profile = Profile { }, secret_field: None, request_headers: &["host: api.x.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"], - request_header_block: "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close", + required_headers: &["host: api.x.com", "content-type: application/x-www-form-urlencoded"], }), identity: Some(IdentitySession { session: Session { @@ -155,7 +156,7 @@ pub const GITHUB: Profile = Profile { }, secret_field: Some("client_secret"), request_headers: &["host: github.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"], - request_header_block: "host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close", + required_headers: &["host: github.com", "content-type: application/x-www-form-urlencoded"], }), identity: Some(IdentitySession { session: Session { @@ -182,6 +183,17 @@ pub fn launch(platform: &str) -> Option<&'static Profile> { LAUNCH.iter().copied().find(|p| p.platform == platform) } +/// Header names a token request must not carry, compared lowercased by +/// every Platform Verifier. Each changes what the platform does with the +/// request in a way no revealed byte shows: `authorization` which client +/// it authenticates, `content-encoding` and `transfer-encoding` which +/// bytes it parses, `cookie` the context, `x-http-method-override` the +/// method. The verifier requires `host` and `content-type` from each token +/// session's requestHeaders, reads `content-length`, and ignores every +/// other header: one outside both lists changes only what the platform +/// answers, and a wrong answer is a response the verifier cannot read. +pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &["authorization", "content-encoding", "cookie", "transfer-encoding", "x-http-method-override"]; + /// Governance-owned launch parameters, in seconds. pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = 300; pub const PROOF_LIFETIME_SECONDS_X: u64 = 3600; diff --git a/rust/profiles/tests/vectors.rs b/rust/profiles/tests/vectors.rs index d503396..52d1bba 100644 --- a/rust/profiles/tests/vectors.rs +++ b/rust/profiles/tests/vectors.rs @@ -11,6 +11,7 @@ //! follows. use libid_profiles::{ + FORBIDDEN_TOKEN_REQUEST_HEADERS, GITHUB, GOOGLE, LAUNCH, @@ -155,22 +156,28 @@ fn the_launch_list_is_closed() { } #[test] -fn the_token_request_head_is_the_headers_beside_it() { - // Two representations of one agreement: the list a prover builds its - // request from, and the block the Platform Verifier matches against. They - // are generated together, and this is what says the two say the same thing. +fn the_required_headers_are_among_the_headers_sent() { + // Two lists per token session: what a prover sends, and the subset a + // Platform Verifier holds the head to. The second is generated from the + // first, and this is what says which two lines it is and that both are + // really sent. It carries no `content-length`: that value is the body's + // own and the verifier reads it off the transcript. for profile in LAUNCH { let Some(token) = profile.token else { continue; }; - // The block is those same lines joined, which is the shape a verifier - // splits and matches as a set. It carries no `content-length`: that - // value is the body's own and the verifier reads it off the transcript. - assert_eq!( - token.request_header_block, - token.request_headers.join("\r\n") - ); - + let names: Vec<&str> = token + .required_headers + .iter() + .map(|line| line.split(':').next().unwrap()) + .collect(); + assert_eq!(names, ["host", "content-type"]); + for line in token.required_headers { + assert!( + token.request_headers.contains(line), + "a required header the prover does not send: {line}" + ); + } assert!( !token .request_headers @@ -180,8 +187,33 @@ fn the_token_request_head_is_the_headers_beside_it() { ); let host = format!("host: {}", token.session.authority); assert!( - token.request_headers.contains(&host.as_str()), - "the pinned `host` header and the pinned authority must name one server" + token.required_headers.contains(&host.as_str()), + "the required `host` header and the pinned authority must name one server" ); } } + +#[test] +fn the_forbidden_names_are_lowercase_and_never_sent() { + // The verifier lowercases what it reads and compares against this list + // as it is, so a name here in any other case would forbid nothing. And a + // profile that both sends a name and forbids it rejects every honest + // session. + for name in FORBIDDEN_TOKEN_REQUEST_HEADERS { + assert_eq!(*name, name.to_ascii_lowercase(), "{name}"); + assert!(!name.is_empty()); + } + for profile in LAUNCH { + let Some(token) = profile.token else { + continue; + }; + for line in token.request_headers { + let name = line.split(':').next().unwrap(); + assert!( + !FORBIDDEN_TOKEN_REQUEST_HEADERS.contains(&name), + "{} sends a header it forbids: {name}", + profile.platform + ); + } + } +} diff --git a/rust/profiles/tests/wire.rs b/rust/profiles/tests/wire.rs index 4da4872..6f1b770 100644 --- a/rust/profiles/tests/wire.rs +++ b/rust/profiles/tests/wire.rs @@ -1,21 +1,21 @@ //! hyper writes a head this table admits. //! -//! The profile fixes which headers a token request carries, not the order they -//! go in, so what a verifier checks is a set: every line the profile lists, -//! once each, nothing else, plus the `content-length` HTTP framing owns. This -//! asserts that hyper, given the profile's headers, writes exactly that -- the -//! request built from `request_headers` and driven through the real +//! A verifier holds a token request's head to the profile's required headers, +//! each once with its value, refuses the forbidden names, and reads one +//! `content-length`; the rest of the head is the client's business. This +//! asserts that hyper, given the profile's headers, writes a head that passes +//! -- the request built from `request_headers` and driven through the real //! `hyper::client::conn::http1` encoder over an in-memory duplex, so what is -//! compared is the bytes hyper actually wrote. +//! checked is the bytes hyper actually wrote. //! -//! Order is deliberately not asserted. Nothing promises where a client puts a -//! header, and the browser reaches the wire through tlsn's wasm prover, whose -//! `HttpRequest` holds them in a `HashMap` -- a test demanding an order would -//! pass here and fail there for a reason neither end could name. +//! Order is not asserted. Nothing promises where a client puts a header, and +//! the browser reaches the wire through tlsn's wasm prover, whose +//! `HttpRequest` holds them in a `HashMap`. //! -//! What this still catches is a header hyper adds or drops on its own, and a -//! `content-length` it does not append for a known-length body. The lowercase -//! claim needs an input the profile cannot supply, which is the last case. +//! What this catches is hyper adding a forbidden header on its own, dropping +//! a required one, or not appending `content-length` for a known-length body. +//! The lowercase claim needs an input the profile cannot supply, which is the +//! last case. //! //! The GitHub exchange is the reason this exists. It runs in the deployment's //! backend, which is the prover for that session and reaches the wire through @@ -24,6 +24,7 @@ use hyper_util::rt::TokioIo; use libid_profiles::{ TokenSession, + FORBIDDEN_TOKEN_REQUEST_HEADERS, GITHUB, X, }; @@ -88,21 +89,27 @@ fn header_lines(wire: &[u8]) -> Vec { } fn assert_head_admits(session: &TokenSession, wire: &[u8], body_len: usize) { - let mut written = header_lines(wire); - written.sort(); - - let mut expected: Vec = session - .request_headers + let written = header_lines(wire); + let name_of = |line: &String| line.split(':').next().unwrap().to_ascii_lowercase(); + + for required in session.required_headers { + let count = written.iter().filter(|line| *line == required).count(); + assert_eq!( + count, 1, + "required header not written exactly once: {required}" + ); + } + for line in &written { + assert!( + !FORBIDDEN_TOKEN_REQUEST_HEADERS.contains(&name_of(line).as_str()), + "hyper wrote a forbidden header: {line}" + ); + } + let lengths: Vec<&String> = written .iter() - .map(|line| (*line).to_owned()) + .filter(|line| name_of(line) == "content-length") .collect(); - expected.push(format!("content-length: {body_len}")); - expected.sort(); - - assert_eq!( - written, expected, - "hyper wrote a head the profile does not admit" - ); + assert_eq!(lengths, [&format!("content-length: {body_len}")]); } #[tokio::test] diff --git a/scripts/regen-ceremony-profiles.py b/scripts/regen-ceremony-profiles.py index a917a3b..f06760d 100755 --- a/scripts/regen-ceremony-profiles.py +++ b/scripts/regen-ceremony-profiles.py @@ -48,7 +48,6 @@ # The transport's, not a platform's: TLSNotary speaks HTTP/1.1, and the length # header is written by the HTTP client rather than chosen by anyone. HTTP_VERSION = "HTTP/1.1" -LENGTH_HEADER = "content-length: " def header(comment: str) -> str: @@ -92,23 +91,54 @@ def request_line(session: dict[str, Any]) -> str: return f"{session['method']} {session['path']} " -def request_header_block(session: dict[str, Any]) -> str: - """The header lines a verifier must find, joined by CRLF. +# The header names a Platform Verifier requires of every token request, in the +# order the generated block lists them. `host` because a profile whose pinned +# authority and pinned host name different servers contradicts itself, and +# `content-type` because it selects the platform's request parser +# (REQ-COMMON-21B). Nothing else a runtime sends changes what the platform +# parses, so nothing else is compared. +REQUIRED_NAMES = ("host", "content-type") - A block rather than one run of the whole head, because the head's ORDER is - not fixed. A verifier splits both this and the head it was given into lines - and matches them as sets: every line here found exactly once there, nothing - there that is not here, plus the `content-length` HTTP framing owns. +# Lowercase field names, so a verifier lowercasing what it reads can compare +# against the list as it is. +NAME = re.compile(r"^[a-z][a-z0-9-]*$") - Order is left to the prover because it changes nothing a platform does with - the request, and fixing it would bind every prover to the order its HTTP - library emits -- the browser reaches the wire through tlsn's wasm prover, - whose `HttpRequest` holds headers in a `HashMap`. Neither is the version or - the length header profile data: hyper writes `HTTP/1.1`, lowercases every - field name, and appends its own length, so the profile states what a caller - chooses and the verifier expects what the client does with it. + +def crlf(lines: list[str]) -> str: + """Lines joined by CRLF, the shape a Solidity verifier splits.""" + return "\r\n".join(lines) + + +def required_headers(session: dict[str, Any]) -> list[str]: + """The lines a verifier holds a token request's head to, from what it sends. + + A subset rather than the whole list. A header outside it changes only what + the platform ANSWERS, and a wrong answer is a response no verifier can + read; the ones here, with the forbidden names, are what decide what the + platform DOES with the request. + """ + by_name = {line.split(":", 1)[0]: line for line in session["requestHeaders"]} + return [by_name[name] for name in REQUIRED_NAMES] + + +def forbidden_headers(spec: dict[str, Any]) -> list[str]: + """The header names no token request may carry, checked by name alone. + + Each changes what the platform does with the request in a way no revealed + byte shows, so a verifier can only refuse the name. Compared lowercased, + which is why the list must be. """ - return "\r\n".join(session["requestHeaders"]) + names = spec["tokenRequest"]["forbiddenHeaders"] + if not isinstance(names, list) or not names: + raise SystemExit("ERROR: tokenRequest.forbiddenHeaders must list at least one name") + for name in names: + if not isinstance(name, str) or not NAME.fullmatch(name): + raise SystemExit(f"ERROR: forbidden header {name!r} is not a lowercase field name") + if name in REQUIRED_NAMES or name == "content-length": + raise SystemExit(f"ERROR: {name!r} is read by the verifier and cannot be forbidden") + if len(set(names)) != len(names): + raise SystemExit("ERROR: tokenRequest.forbiddenHeaders names one header twice") + return names def sessions_of(profile: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: @@ -199,6 +229,7 @@ def request_headers(session: dict[str, Any], host: str) -> None: def validate(spec: dict[str, Any]) -> None: """Refuse a spec that would generate constants nothing can rely on.""" + forbidden = forbidden_headers(spec) seen: set[str] = set() for profile in spec["profiles"]: platform = profile["platform"] @@ -214,6 +245,9 @@ def validate(spec: dict[str, Any]) -> None: safe(session["path"], "path") if name == "token": request_headers(session, host) + for line in session["requestHeaders"]: + if line.split(":", 1)[0] in forbidden: + raise SystemExit(f"ERROR: {platform!r} sends a header it forbids: {line!r}") if session["secretField"] is not None: safe(session["secretField"], "secretField") if name == "identity": @@ -313,16 +347,10 @@ def gen_sol(spec: dict[str, Any]) -> str: lines += [ "", - " /// @dev The token request's head, byte for byte, ending at the", - " /// `content-length` value the verifier reads out of the transcript.", - " /// Its headers are revealed, so leaving them uncompared left the one", - " /// that decides how the platform parses the body -- the media type", - " /// REQ-COMMON-21B fixes -- public and unconstrained.", - " ///", - " /// Pinned as ONE run rather than as a set of lines: the head is fixed", - " /// bytes, so a comparison against it needs no header parser, and a", - " /// parser is where an added, reordered or restated header would have", - " /// to be caught one rule at a time.", + " /// @dev Every header the token request sends, CRLF-joined, lowercased", + " /// as the wire spells them. What a runtime sets and what fixtures", + " /// compose a head from; the verifier compares only the required", + " /// subset below.", "", ] for profile in profiles: @@ -331,9 +359,34 @@ def gen_sol(spec: dict[str, Any]) -> str: continue const = f"{upper(profile['platform'])}_TOKEN_REQUEST_HEADERS" lines.append( - f' bytes internal constant {const} = "{escaped(request_header_block(token))}";' + f' bytes internal constant {const} = "{escaped(crlf(token["requestHeaders"]))}";' ) + lines += [ + "", + " /// @dev The lines a verifier requires of the token request's head, each", + " /// exactly once with its value: `host` naming the pinned authority,", + " /// and the media type that selects the platform's request parser", + " /// (REQ-COMMON-21B). Revealed but uncompared, the media type was a", + " /// byte a prover chose in a request every other field of which is", + " /// pinned.", + "", + ] + for profile in profiles: + token = profile["sessions"].get("token") + if token is None: + continue + const = f"{upper(profile['platform'])}_TOKEN_REQUIRED_HEADERS" + lines.append( + f' bytes internal constant {const} = "{escaped(crlf(required_headers(token)))}";' + ) + + lines += [""] + lines += sol_doc(spec["tokenRequest"].get("note")) + lines.append( + f' bytes internal constant FORBIDDEN_TOKEN_REQUEST_HEADERS = "{escaped(crlf(forbidden_headers(spec)))}";' + ) + lines += [ "", " /// @dev How many committed ranges the token request carries. A confidential", @@ -504,10 +557,11 @@ def gen_rust(spec: dict[str, Any]) -> str: " /// is the body's own count: the HTTP client appends it and the verifier", " /// reads it rather than compares it.", " pub request_headers: &'static [&'static str],", - " /// The same lines joined by CRLF, which is the shape a Platform", - " /// Verifier splits and matches as a set -- order is the prover's, the", - " /// set is the profile's.", - " pub request_header_block: &'static str,", + " /// The subset of those a Platform Verifier requires, each exactly once", + " /// with its value: `host` and `content-type`. Every other header is", + " /// the runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS`", + " /// lists.", + " pub required_headers: &'static [&'static str],", "}", "", "/// The identity session: the authenticated read that names the account.", @@ -557,9 +611,8 @@ def gen_rust(spec: dict[str, Any]) -> str: lines += rust_session(token, 8) lines.append(f" secret_field: {rust_str(token['secretField'])},") lines.append(f" request_headers: &[{headers}],") - lines.append( - f' request_header_block: "{escaped(request_header_block(token))}",' - ) + required = ", ".join(f'"{line}"' for line in required_headers(token)) + lines.append(f" required_headers: &[{required}],") lines.append(" }),") identity = profile["sessions"].get("identity") @@ -589,6 +642,12 @@ def gen_rust(spec: dict[str, Any]) -> str: " LAUNCH.iter().copied().find(|p| p.platform == platform)", "}", "", + ] + lines += rust_doc(spec["tokenRequest"].get("note")) + forbidden = ", ".join(f'"{name}"' for name in forbidden_headers(spec)) + lines += [ + f"pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &[{forbidden}];", + "", "/// Governance-owned launch parameters, in seconds.", f"pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = " f"{spec['parameters']['maxFutureAttestationSkewSeconds']};", @@ -638,6 +697,26 @@ def ts_session(session: dict[str, Any], indent: int) -> list[str]: ] +def ts_line_width() -> int: + """The formatter's line width, read from its own config so the two agree.""" + config = REPO_ROOT / "ts" / "biome.json" + if not config.exists(): + return 80 + return int(json.loads(config.read_text(encoding="utf-8")).get("formatter", {}).get("lineWidth", 80)) + + +def ts_array(prefix: str, items: list[str], indent: str, suffix: str) -> list[str]: + """`prefix[items]suffix` on one line when it fits, else one item per line. + + That is the formatter's rule, and a generated file has to pass `fmt:check` + unformatted. + """ + one = f"{indent}{prefix}[{', '.join(ts_str(item) for item in items)}]{suffix}" + if len(one) <= ts_line_width(): + return [one] + return [f"{indent}{prefix}[", *(f"{indent} {ts_str(item)}," for item in items), f"{indent}]{suffix}"] + + def gen_ts(spec: dict[str, Any]) -> str: lines = [header("//").rstrip("\n"), ""] lines += ts_doc(spec.get("note")) @@ -662,9 +741,10 @@ def gen_ts(spec: dict[str, Any]) -> str: " /** Every header this request sends, lowercased, in no particular order.", " * `content-length` is absent: the HTTP client appends it. */", " readonly requestHeaders: readonly string[]", - " /** The same lines joined by CRLF, which a Platform Verifier splits and", - " * matches as a set. */", - " readonly requestHeaderBlock: string", + " /** The subset a Platform Verifier requires, each exactly once with its", + " * value: `host` and `content-type`. Every other header is the runtime's", + " * own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. */", + " readonly requiredHeaders: readonly string[]", "}", "", "export interface IdentitySession {", @@ -698,13 +778,8 @@ def gen_ts(spec: dict[str, Any]) -> str: lines.append(" token: {") lines += ts_session(token, 4) lines.append(f" secretField: {ts_str(token['secretField'])},") - # Broken across lines the way the repository's formatter would - # break them, so a generated file passes `fmt:check` unformatted. - lines.append(" requestHeaders: [") - lines += [f" {ts_str(line)}," for line in token["requestHeaders"]] - lines.append(" ],") - lines.append(" requestHeaderBlock:") - lines.append(f" '{escaped(request_header_block(token))}',") + lines += ts_array("requestHeaders: ", token["requestHeaders"], " ", ",") + lines += ts_array("requiredHeaders: ", required_headers(token), " ", ",") lines.append(" },") identity = profile["sessions"].get("identity") @@ -734,6 +809,13 @@ def gen_ts(spec: dict[str, Any]) -> str: " return (profile.token ? 1 : 0) + (profile.identity ? 1 : 0)", "}", "", + ] + lines += ts_doc(spec["tokenRequest"].get("note")) + lines += ts_array( + "export const FORBIDDEN_TOKEN_REQUEST_HEADERS: readonly string[] = ", forbidden_headers(spec), "", "" + ) + lines += [ + "", "/** Governance-owned launch parameters, in seconds. */", f"export const MAX_FUTURE_ATTESTATION_SKEW_SECONDS = " f"{spec['parameters']['maxFutureAttestationSkewSeconds']}", diff --git a/solidity/contracts/ceremony/CeremonyProfile.sol b/solidity/contracts/ceremony/CeremonyProfile.sol index 106a416..2ef2bc0 100644 --- a/solidity/contracts/ceremony/CeremonyProfile.sol +++ b/solidity/contracts/ceremony/CeremonyProfile.sol @@ -59,22 +59,40 @@ library CeremonyProfile { bytes internal constant GITHUB_TOKEN_REQUEST_LINE = "POST /login/oauth/access_token "; bytes internal constant GITHUB_IDENTITY_REQUEST_LINE = "GET /user "; - /// @dev The token request's head, byte for byte, ending at the - /// `content-length` value the verifier reads out of the transcript. - /// Its headers are revealed, so leaving them uncompared left the one - /// that decides how the platform parses the body -- the media type - /// REQ-COMMON-21B fixes -- public and unconstrained. - /// - /// Pinned as ONE run rather than as a set of lines: the head is fixed - /// bytes, so a comparison against it needs no header parser, and a - /// parser is where an added, reordered or restated header would have - /// to be caught one rule at a time. + /// @dev Every header the token request sends, CRLF-joined, lowercased + /// as the wire spells them. What a runtime sets and what fixtures + /// compose a head from; the verifier compares only the required + /// subset below. bytes internal constant X_TOKEN_REQUEST_HEADERS = "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close"; bytes internal constant GITHUB_TOKEN_REQUEST_HEADERS = "host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close"; + /// @dev The lines a verifier requires of the token request's head, each + /// exactly once with its value: `host` naming the pinned authority, + /// and the media type that selects the platform's request parser + /// (REQ-COMMON-21B). Revealed but uncompared, the media type was a + /// byte a prover chose in a request every other field of which is + /// pinned. + + bytes internal constant X_TOKEN_REQUIRED_HEADERS = + "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded"; + bytes internal constant GITHUB_TOKEN_REQUIRED_HEADERS = + "host: github.com\r\ncontent-type: application/x-www-form-urlencoded"; + + /// @dev Header names a token request must not carry, compared lowercased by + /// every Platform Verifier. Each changes what the platform does with the + /// request in a way no revealed byte shows: `authorization` which client + /// it authenticates, `content-encoding` and `transfer-encoding` which + /// bytes it parses, `cookie` the context, `x-http-method-override` the + /// method. The verifier requires `host` and `content-type` from each token + /// session's requestHeaders, reads `content-length`, and ignores every + /// other header: one outside both lists changes only what the platform + /// answers, and a wrong answer is a response the verifier cannot read. + bytes internal constant FORBIDDEN_TOKEN_REQUEST_HEADERS = + "authorization\r\ncontent-encoding\r\ncookie\r\ntransfer-encoding\r\nx-http-method-override"; + /// @dev How many committed ranges the token request carries. A confidential /// client commits its secret and a public client hides nothing, so this /// is one or zero and never a preference. diff --git a/solidity/contracts/ceremony/GitHubPlatformVerifier.sol b/solidity/contracts/ceremony/GitHubPlatformVerifier.sol index a4856e7..c1c76e7 100644 --- a/solidity/contracts/ceremony/GitHubPlatformVerifier.sol +++ b/solidity/contracts/ceremony/GitHubPlatformVerifier.sol @@ -113,11 +113,12 @@ contract GitHubPlatformVerifier is TlsNotaryVerifierBase { return CeremonyProfile.GITHUB_TOKEN_REQUEST_LINE; } - /// @dev REQ-COMMON-21B, and section 6.2's media type and `accept`. The + /// @dev REQ-COMMON-21B: `host` and section 6.2's media type. The /// Token-Exchange Service composes this request rather than a browser, - /// so these bytes are agreed here before that service is written. - function _tokenRequestHeaders() internal pure override returns (bytes memory) { - return CeremonyProfile.GITHUB_TOKEN_REQUEST_HEADERS; + /// so these bytes are agreed here before that service is written; what + /// else it sends is not compared. + function _tokenRequiredHeaders() internal pure override returns (bytes memory) { + return CeremonyProfile.GITHUB_TOKEN_REQUIRED_HEADERS; } function _identityRequestLine() internal pure override returns (bytes memory) { diff --git a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol index f1a24a5..0add558 100644 --- a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol +++ b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.24; import {CeremonyAttestation} from "./CeremonyAttestation.sol"; +import {CeremonyProfile} from "./CeremonyProfile.sol"; import {CeremonyAuthorization} from "./CeremonyAuthorization.sol"; import {CeremonyFields} from "./CeremonyFields.sol"; import {IPlatformVerifier} from "./IPlatformVerifier.sol"; @@ -41,7 +42,7 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// @dev HTTP framing owns this one, not the profile: the client appends /// it and its value is the body's own count, so the head carries it /// and no profile lists it. - bytes private constant LENGTH_HEADER = "content-length: "; + bytes private constant LENGTH_HEADER = "content-length"; bytes internal constant ACCESS_TOKEN_PREFIX = '"access_token":"'; bytes internal constant ACCESS_TOKEN_SUFFIX = '"'; @@ -98,10 +99,14 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// @dev The head/body separator is missing or ambiguous, so the body cannot /// be located by the framing the server itself parsed. error NoHeadBoundary(uint256 occurrences); - /// @dev The token request's head is not the profile's header set: a header - /// added, removed, repeated or given another value, or a declared body - /// length that is not plain decimal digits. + /// @dev The token request's head lacks a required header, repeats one, + /// gives one another value, carries a line no colon splits, or + /// declares a body length that is not plain decimal digits. error WrongTokenRequestHead(); + /// @dev The token request carries a header the profile forbids: one that + /// changes what the platform does with the request in a way no + /// revealed byte shows. The name, lowercased. + error ForbiddenTokenRequestHeader(bytes name); /// @dev The request declared a body of one length and the notary signed /// another, so the bytes the platform parsed as the form are not the /// bytes read below. @@ -114,15 +119,17 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa function _tokenRequestLine() internal pure virtual returns (bytes memory); function _identityRequestLine() internal pure virtual returns (bytes memory); - /// @dev The token request's header lines, CRLF-joined, which `_checkTokenHead` - /// splits and matches as a set. `content-length` is not among them: - /// this verifier reads its value out of the transcript itself. + /// @dev The header lines the token request must carry, CRLF-joined: `host` + /// naming the pinned authority, and the media type that selects the + /// platform's parser. `_checkTokenHead` requires each once with its + /// value, refuses the names `CeremonyProfile.FORBIDDEN_TOKEN_REQUEST_HEADERS` + /// lists, reads `content-length`, and ignores every other header. /// /// Only the token request has one. The identity request carries the /// bearer in a header, so its headers are not fixed and are held to /// `requireBearerHeaderRequest` instead: coverage, one line-anchored /// `authorization`, and the framing around the committed value. - function _tokenRequestHeaders() internal pure virtual returns (bytes memory); + function _tokenRequiredHeaders() internal pure virtual returns (bytes memory); /// @dev How many committed ranges the token request carries. X hides no /// body field and uses a public client, so zero; GitHub commits its @@ -483,21 +490,27 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// the length the NOTARY signed. Without that the platform could frame /// a shorter body than the one read below, and parse a form this /// verifier never saw. - /// @dev The head's header lines are the profile's, in any order, plus the - /// one `content-length` HTTP framing owns. Returns its value. + /// @dev The head's header lines: each required one exactly once with its + /// value, none of the forbidden names, one `content-length`, and + /// anything else ignored. Returns the declared length. /// - /// A SET rather than a fixed run of bytes. Order changes nothing a - /// platform does with a request -- field order is insignificant except - /// for repeated names, which this rejects, and the - /// `transfer-encoding` precedence is by presence rather than position - /// -- so pinning it would only bind every prover to the order its HTTP - /// library emits. The browser's reaches the wire through a `HashMap`. + /// Required and forbidden rather than a fixed set. A header outside + /// both lists changes only what the platform ANSWERS, and a wrong + /// answer is a response this verifier cannot read, not one it can be + /// fooled by. The forbidden names change what the platform does with + /// the request in ways no revealed byte shows: which client it + /// authenticates, which bytes it parses, which method it runs. Between + /// the two, what a prover's HTTP library adds is its own business. /// - /// Reading lines is what a fixed run avoided, so the leniencies a - /// parser invites are refused first: `requireCrlfLineEndings` is the - /// same guard REQ-COMMON-39 puts on the identity request, and without - /// it a bare line feed ends the head somewhere the platform's parser - /// does and this one does not. + /// Names are compared lowercased, because the platform reads them + /// case-insensitively and a forbidden name in another case is the same + /// header to it. Values are compared exactly, with the optional + /// whitespace HTTP allows around them removed. + /// + /// Reading lines is where the leniencies live, so `requireCrlfLineEndings` + /// goes first: the same guard REQ-COMMON-39 puts on the identity + /// request, without which a bare line feed ends the head somewhere the + /// platform's parser does and this one does not. // `1 << i` is the mask for line i. The lint's heuristic reads a literal on // the left of a shift as swapped operands, which is what building a mask // looks like. @@ -505,38 +518,87 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa function _checkTokenHead(bytes memory head) private pure returns (uint256 declared) { CeremonyAttestation.requireCrlfLineEndings(head); - bytes memory expected = _tokenRequestHeaders(); - uint256 wanted = _countLines(expected); - // One bit per expected line. A profile with more than 256 headers is - // not a profile, and `validate` in the generator refuses one long - // before this could matter. + bytes memory required = _tokenRequiredHeaders(); + uint256 wanted = _countLines(required); uint256 found; - uint256 lengths; + bool lengths; // Past the request line, which `_tokenSession` has already compared. uint256 from = _lineEnd(head, 0) + 2; while (from < head.length) { uint256 to = _lineEnd(head, from); - bytes memory line = _slice(head, from, to); + (bytes memory name, bytes memory value) = _field(_slice(head, from, to)); - if (_startsWith(line, LENGTH_HEADER)) { - if (lengths != 0) revert WrongTokenRequestHead(); - lengths = 1; - declared = _decimal(line, LENGTH_HEADER.length); + if (_indexOfLine(CeremonyProfile.FORBIDDEN_TOKEN_REQUEST_HEADERS, name) != type(uint256).max) { + revert ForbiddenTokenRequestHeader(name); + } + if (_equal(name, LENGTH_HEADER)) { + if (lengths) revert WrongTokenRequestHead(); + lengths = true; + declared = _decimal(value, 0); } else { - uint256 i = _indexOfLine(expected, line); - if (i == type(uint256).max) revert WrongTokenRequestHead(); - if (found & (1 << i) != 0) revert WrongTokenRequestHead(); - found |= 1 << i; + uint256 i = _indexOfName(required, name); + if (i != type(uint256).max) { + if (!_equal(value, _valueOf(required, i))) revert WrongTokenRequestHead(); + if (found & (1 << i) != 0) revert WrongTokenRequestHead(); + found |= 1 << i; + } } from = to + 2; } - if (lengths == 0) revert WrongTokenRequestHead(); - // Every expected line seen: the low `wanted` bits all set. + if (!lengths) revert WrongTokenRequestHead(); + // Every required line seen: the low `wanted` bits all set. if (found != (1 << wanted) - 1) revert WrongTokenRequestHead(); } + /// @dev A header line as the platform reads it: the name before the first + /// colon, lowercased, and the value after it with the optional + /// whitespace on either side removed. A line with no colon, or nothing + /// before it, is not a header. + function _field(bytes memory line) private pure returns (bytes memory name, bytes memory value) { + uint256 colon; + while (colon < line.length && line[colon] != ":") { + ++colon; + } + if (colon == 0 || colon == line.length) revert WrongTokenRequestHead(); + name = _slice(line, 0, colon); + for (uint256 i = 0; i < name.length; ++i) { + if (name[i] >= "A" && name[i] <= "Z") name[i] = bytes1(uint8(name[i]) + 32); + } + uint256 start = colon + 1; + uint256 end = line.length; + while (start < end && (line[start] == " " || line[start] == "\t")) { + ++start; + } + while (end > start && (line[end - 1] == " " || line[end - 1] == "\t")) { + --end; + } + value = _slice(line, start, end); + } + + /// @dev Which line of the CRLF-joined `block_` names `name`, or `max`. + function _indexOfName(bytes memory block_, bytes memory name) private pure returns (uint256 index) { + uint256 from; + while (from <= block_.length) { + uint256 to = _lineEnd(block_, from); + (bytes memory lineName,) = _field(_slice(block_, from, to)); + if (_equal(lineName, name)) return index; + ++index; + from = to + 2; + } + return type(uint256).max; + } + + /// @dev The value of line `index` of the CRLF-joined `block_`. + function _valueOf(bytes memory block_, uint256 index) private pure returns (bytes memory value) { + uint256 from; + for (uint256 i = 0; i < index; ++i) { + from = _lineEnd(block_, from) + 2; + } + (, value) = _field(_slice(block_, from, _lineEnd(block_, from))); + } + /// @dev The offset of the CRLF that ends the line beginning at `from`, or /// the end of `data` for the last line -- the head is sliced at the /// blank line, so its final header carries no CRLF of its own. diff --git a/solidity/contracts/ceremony/XPlatformVerifier.sol b/solidity/contracts/ceremony/XPlatformVerifier.sol index 817f329..6faa1af 100644 --- a/solidity/contracts/ceremony/XPlatformVerifier.sol +++ b/solidity/contracts/ceremony/XPlatformVerifier.sol @@ -85,12 +85,13 @@ contract XPlatformVerifier is TlsNotaryVerifierBase { return CeremonyProfile.X_TOKEN_REQUEST_LINE; } - /// @dev REQ-COMMON-21B. The four headers the browser sends, in any order, - /// among them the media type that makes X read the body the way - /// `formField` reads it. Revealed but uncompared, they were bytes a - /// prover chose in a request every other field of which is pinned. - function _tokenRequestHeaders() internal pure override returns (bytes memory) { - return CeremonyProfile.X_TOKEN_REQUEST_HEADERS; + /// @dev REQ-COMMON-21B. `host`, and the media type that makes X read the + /// body the way `formField` reads it. Revealed but uncompared, the + /// media type was a byte a prover chose in a request every other + /// field of which is pinned. The rest of what the browser sends is + /// not compared: it changes what X answers, never what X parses. + function _tokenRequiredHeaders() internal pure override returns (bytes memory) { + return CeremonyProfile.X_TOKEN_REQUIRED_HEADERS; } function _identityRequestLine() internal pure override returns (bytes memory) { diff --git a/solidity/contracts/ceremony/profiles.json b/solidity/contracts/ceremony/profiles.json index 2a75f4b..29706cf 100644 --- a/solidity/contracts/ceremony/profiles.json +++ b/solidity/contracts/ceremony/profiles.json @@ -31,6 +31,26 @@ "a deployment stores and updates them." ] }, + "tokenRequest": { + "forbiddenHeaders": [ + "authorization", + "content-encoding", + "cookie", + "transfer-encoding", + "x-http-method-override" + ], + "note": [ + "Header names a token request must not carry, compared lowercased by", + "every Platform Verifier. Each changes what the platform does with the", + "request in a way no revealed byte shows: `authorization` which client", + "it authenticates, `content-encoding` and `transfer-encoding` which", + "bytes it parses, `cookie` the context, `x-http-method-override` the", + "method. The verifier requires `host` and `content-type` from each token", + "session's requestHeaders, reads `content-length`, and ignores every", + "other header: one outside both lists changes only what the platform", + "answers, and a wrong answer is a response the verifier cannot read." + ] + }, "profiles": [ { "platform": "google", @@ -74,10 +94,12 @@ "deployment profile because it selects the platform's request", "parser, and a pin nothing compares is a pin in name only.", "", - "In no particular order: the verifier matches them as a set.", - "`content-length` is not among them: the HTTP client appends it,", - "its value is the body's own length, and the verifier checks it", - "against the length the notary signed." + "What the browser sends. The verifier requires `host` and", + "`content-type` of these, ignores the rest, and refuses the names", + "in tokenRequest.forbiddenHeaders. `content-length` is not among", + "them: the HTTP client appends it, its value is the body's own", + "length, and the verifier checks it against the length the notary", + "signed." ] }, "identity": { diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index 0868daf..faed304 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -426,6 +426,36 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } + /// @dev A header the profile never mentions is the Token-Exchange + /// Service's own business -- a `user-agent`, say -- as long as it is + /// not one of the forbidden names. The exchange still verifies. + function test_acceptsAnUnlistedHeaderOnTheExchange() public { + bytes memory prefix = abi.encodePacked( + "client_id=Iv1.8a61f9b3a7aba766&code=abc&redirect_uri=https%3A%2F%2Fa.example&code_verifier=", + CeremonyAuthorization.codeVerifier(digest, AUTH_NONCE) + ); + bytes memory head = _exchangeHead( + "host: github.com\r\nuser-agent: libid-bridge/0.3.0\r\ncontent-type: application/x-www-form-urlencoded\r\n" + "accept: application/json\r\nconnection: close\r\n", + prefix.length + 40 + ); + bytes memory whole = abi.encodePacked(head, prefix); + AttestationBuilder.Direction memory sent = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: whole})), + commitments: AttestationBuilder.one( + AttestationBuilder.Commitment({ + start: uint32(whole.length), end: uint32(whole.length) + 40, value: bytes32(uint256(0x5EC1E7)) + }) + ), + length: uint32(whole.length) + 40 + }); + bytes memory a = AttestationBuilder.encode(CeremonyProfile.AUTHORITY_GITHUB, T0, sent, _exchangeResponse()); + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.tokenSession = ICeremony.Attestation({attestedData: a, proof: _sign(a)}); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.handle, "octocat"); + } + /// @dev And the fixtures above compose that head from parts, so this is /// what says the parts are the profile's own. function test_theFixtureHeadIsTheProfilesOwn() public pure { diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index ae35ce5..1857fe1 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -1095,39 +1095,124 @@ contract XPlatformVerifierTest is Test { this.run{value: quote}(s); } - /// @dev A header the profile does not name is one the prover chose, in a - /// request every other byte of which is pinned. `authorization: Basic` - /// is the shape of it: X authenticates the client from that header - /// instead, so the revealed `client_id` this verifier returns stops - /// being the credential the exchange was made under. - function test_rejectsAnExtraHeaderOnTheTokenRequest() public { + /// @dev `authorization: Basic` is the header the forbidden list exists + /// for: X authenticates the client from it instead, so the revealed + /// `client_id` this verifier returns stops being the credential the + /// exchange was made under, and no revealed byte says so. + function test_rejectsAForbiddenHeaderOnTheTokenRequest() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\n" "authorization: Basic bXlDbGllbnQtMTpzM2NyZXQ=\r\nconnection: close\r\n" ); + vm.expectRevert( + abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenTokenRequestHeader.selector, bytes("authorization")) + ); + this.run{value: quote}(s); + } + + /// @dev Every forbidden name, each in a spelling the platform would read + /// as the same header: another case, no space after the colon. The + /// name comes back lowercased, which is how the list is compared. + function test_rejectsEachForbiddenHeaderOnTheTokenRequest() public { + string[5] memory lines = [ + "Transfer-Encoding: chunked", + "content-encoding:gzip", + "Cookie: session=abc", + "X-HTTP-Method-Override: GET", + "AUTHORIZATION: Basic bXlDbGllbnQtMTpzM2NyZXQ=" + ]; + string[5] memory names = + ["transfer-encoding", "content-encoding", "cookie", "x-http-method-override", "authorization"]; + for (uint256 i = 0; i < lines.length; ++i) { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + abi.encodePacked( + "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\n", + lines[i], + "\r\naccept: application/json\r\nconnection: close\r\n" + ) + ); + vm.expectRevert( + abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenTokenRequestHeader.selector, bytes(names[i])) + ); + this.run{value: quote}(s); + } + } + + /// @dev A required header has to be there. Without the media type nothing + /// says X read the bytes `formField` reads as a form at all; without + /// `host` nothing says which server the prover meant. + function test_rejectsATokenRequestMissingARequiredHeader() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = + _payloadWithHeaders("host: api.x.com\r\naccept: application/json\r\nconnection: close\r\n"); + vm.expectRevert(TlsNotaryVerifierBase.WrongTokenRequestHead.selector); + this.run{value: quote}(s); + + s = _payloadWithHeaders( + "content-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n" + ); vm.expectRevert(TlsNotaryVerifierBase.WrongTokenRequestHead.selector); this.run{value: quote}(s); } - /// @dev And a header the profile DOES name has to be there. Dropping - /// `accept` leaves X free to answer in another representation, which - /// is a response the framing around the committed bearer was chosen - /// for one shape of. - function test_rejectsATokenRequestMissingAHeader() public { + /// @dev And twice is not once: two media types leave X to pick one and + /// this verifier with no way to know which. + function test_rejectsARequiredHeaderTwice() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\n" + "content-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n" + ); + vm.expectRevert(TlsNotaryVerifierBase.WrongTokenRequestHead.selector); + this.run{value: quote}(s); + } + + /// @dev A header the profile sends but nothing verifies may be missing. + /// Without `accept`, X may answer in another representation, and that + /// is a response this verifier cannot read rather than one it can be + /// fooled by. + function test_acceptsATokenRequestWithoutAnUncomparedHeader() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\nconnection: close\r\n" ); + this.run{value: quote}(s); + } + + /// @dev And headers the profile never mentions may be present: what a + /// prover's HTTP library adds is its own business, as long as it is + /// not on the forbidden list. + function test_acceptsUnlistedHeadersOnTheTokenRequest() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + "host: api.x.com\r\nuser-agent: libid-ceremony\r\ncontent-type: application/x-www-form-urlencoded\r\n" + "accept: application/json\r\naccept-encoding: identity\r\nconnection: close\r\nx-request-id: 7\r\n" + ); + this.run{value: quote}(s); + } + + /// @dev A required header in another spelling the platform reads the + /// same: the name in another case, no space after the colon, a tab + /// before the value. HTTP reads all three as one header, and so does + /// this. + function test_acceptsARequiredHeaderInAnotherSpelling() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + "Host:\tapi.x.com \r\nContent-Type:application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n" + ); + this.run{value: quote}(s); + } + + /// @dev A line no colon splits is not a header, and a head carrying one is + /// a head some parser somewhere reads differently. + function test_rejectsAHeaderLineWithoutAColon() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\nnot a header\r\nconnection: close\r\n" + ); vm.expectRevert(TlsNotaryVerifierBase.WrongTokenRequestHead.selector); this.run{value: quote}(s); } - /// @dev Membership is pinned, not order. The same four headers in another - /// order are the same request: field order is insignificant in HTTP - /// except for repeated names, which this rejects separately, so a - /// reordering changes nothing X does with the request. Pinning it - /// would instead bind every prover to the order its HTTP library - /// emits -- and the browser reaches the wire through a `HashMap`, - /// which has none to promise. + /// @dev Nothing is pinned by position. The same headers in another order + /// are the same request: field order is insignificant in HTTP except + /// for repeated names, so a reordering changes nothing X does with the + /// request, and pinning it would bind every prover to the order its + /// HTTP library emits. function test_acceptsTheSameHeadersInAnotherOrder() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( "host: api.x.com\r\naccept: application/json\r\ncontent-type: application/x-www-form-urlencoded\r\nconnection: close\r\n" diff --git a/ts/packages/contracts/src/ceremony/profiles.ts b/ts/packages/contracts/src/ceremony/profiles.ts index 6d8c3e5..6d3a87d 100644 --- a/ts/packages/contracts/src/ceremony/profiles.ts +++ b/ts/packages/contracts/src/ceremony/profiles.ts @@ -44,9 +44,10 @@ export interface TokenSession { /** Every header this request sends, lowercased, in no particular order. * `content-length` is absent: the HTTP client appends it. */ readonly requestHeaders: readonly string[] - /** The same lines joined by CRLF, which a Platform Verifier splits and - * matches as a set. */ - readonly requestHeaderBlock: string + /** The subset a Platform Verifier requires, each exactly once with its + * value: `host` and `content-type`. Every other header is the runtime's + * own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. */ + readonly requiredHeaders: readonly string[] } export interface IdentitySession { @@ -97,8 +98,7 @@ export const X: Profile = { 'accept: application/json', 'connection: close', ], - requestHeaderBlock: - 'host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close', + requiredHeaders: ['host: api.x.com', 'content-type: application/x-www-form-urlencoded'], }, identity: { session: { @@ -136,8 +136,7 @@ export const GITHUB: Profile = { 'accept: application/json', 'connection: close', ], - requestHeaderBlock: - 'host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close', + requiredHeaders: ['host: github.com', 'content-type: application/x-www-form-urlencoded'], }, identity: { session: { @@ -165,6 +164,25 @@ export function attestationCount(profile: Profile): number { return (profile.token ? 1 : 0) + (profile.identity ? 1 : 0) } +/** + * Header names a token request must not carry, compared lowercased by + * every Platform Verifier. Each changes what the platform does with the + * request in a way no revealed byte shows: `authorization` which client + * it authenticates, `content-encoding` and `transfer-encoding` which + * bytes it parses, `cookie` the context, `x-http-method-override` the + * method. The verifier requires `host` and `content-type` from each token + * session's requestHeaders, reads `content-length`, and ignores every + * other header: one outside both lists changes only what the platform + * answers, and a wrong answer is a response the verifier cannot read. + */ +export const FORBIDDEN_TOKEN_REQUEST_HEADERS: readonly string[] = [ + 'authorization', + 'content-encoding', + 'cookie', + 'transfer-encoding', + 'x-http-method-override', +] + /** Governance-owned launch parameters, in seconds. */ export const MAX_FUTURE_ATTESTATION_SKEW_SECONDS = 300 export const PROOF_LIFETIME_SECONDS_X = 3600 From d906b0de51e6790650b3442f01114c227c568dda Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 14:04:35 +0100 Subject: [PATCH 02/10] chore(profiles): generate the Rust arrays in the shape rustfmt keeps 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 --- rust/profiles/src/profiles.rs | 32 +++++++++++++++++++++++----- scripts/regen-ceremony-profiles.py | 34 ++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/rust/profiles/src/profiles.rs b/rust/profiles/src/profiles.rs index 5d3ea0a..914f4c6 100644 --- a/rust/profiles/src/profiles.rs +++ b/rust/profiles/src/profiles.rs @@ -124,8 +124,16 @@ pub const X: Profile = Profile { request_line: "POST /2/oauth2/token ", }, secret_field: None, - request_headers: &["host: api.x.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"], - required_headers: &["host: api.x.com", "content-type: application/x-www-form-urlencoded"], + request_headers: &[ + "host: api.x.com", + "content-type: application/x-www-form-urlencoded", + "accept: application/json", + "connection: close", + ], + required_headers: &[ + "host: api.x.com", + "content-type: application/x-www-form-urlencoded", + ], }), identity: Some(IdentitySession { session: Session { @@ -155,8 +163,16 @@ pub const GITHUB: Profile = Profile { request_line: "POST /login/oauth/access_token ", }, secret_field: Some("client_secret"), - request_headers: &["host: github.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"], - required_headers: &["host: github.com", "content-type: application/x-www-form-urlencoded"], + request_headers: &[ + "host: github.com", + "content-type: application/x-www-form-urlencoded", + "accept: application/json", + "connection: close", + ], + required_headers: &[ + "host: github.com", + "content-type: application/x-www-form-urlencoded", + ], }), identity: Some(IdentitySession { session: Session { @@ -192,7 +208,13 @@ pub fn launch(platform: &str) -> Option<&'static Profile> { /// session's requestHeaders, reads `content-length`, and ignores every /// other header: one outside both lists changes only what the platform /// answers, and a wrong answer is a response the verifier cannot read. -pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &["authorization", "content-encoding", "cookie", "transfer-encoding", "x-http-method-override"]; +pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &[ + "authorization", + "content-encoding", + "cookie", + "transfer-encoding", + "x-http-method-override", +]; /// Governance-owned launch parameters, in seconds. pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = 300; diff --git a/scripts/regen-ceremony-profiles.py b/scripts/regen-ceremony-profiles.py index f06760d..935c2d8 100755 --- a/scripts/regen-ceremony-profiles.py +++ b/scripts/regen-ceremony-profiles.py @@ -510,6 +510,31 @@ def rust_session(session: dict[str, Any], indent: int) -> list[str]: ] +def rust_max_width() -> int: + """rustfmt's line width, read from its own config so the two agree.""" + config = REPO_ROOT / "rust" / "rustfmt.toml" + if not config.exists(): + return 100 + for line in config.read_text(encoding="utf-8").splitlines(): + key, _, rest = line.partition("=") + if key.strip() == "max_width": + return int(rest.split("#", 1)[0].strip()) + return 100 + + +def rust_array(prefix: str, items: list[str], indent: str, suffix: str) -> list[str]: + """`prefix&[items]suffix` on one line when it fits, else one item per line. + + rustfmt's rule, so a generated file passes `fmt --check` unformatted. It + used to pass by accident: an unbreakable string in the same struct made + rustfmt leave the whole expression alone. + """ + one = f"{indent}{prefix}&[{', '.join(f'\"{item}\"' for item in items)}]{suffix}" + if len(one) <= rust_max_width(): + return [one] + return [f"{indent}{prefix}&[", *(f'{indent} "{item}",' for item in items), f"{indent}]{suffix}"] + + def gen_rust(spec: dict[str, Any]) -> str: lines = [header("//").rstrip("\n"), ""] lines += rust_doc(spec.get("note"), marker="//!") @@ -606,13 +631,11 @@ def gen_rust(spec: dict[str, Any]) -> str: if token is None: lines.append(" token: None,") else: - headers = ", ".join(f'"{line}"' for line in token["requestHeaders"]) lines.append(" token: Some(TokenSession {") lines += rust_session(token, 8) lines.append(f" secret_field: {rust_str(token['secretField'])},") - lines.append(f" request_headers: &[{headers}],") - required = ", ".join(f'"{line}"' for line in required_headers(token)) - lines.append(f" required_headers: &[{required}],") + lines += rust_array("request_headers: ", token["requestHeaders"], " ", ",") + lines += rust_array("required_headers: ", required_headers(token), " ", ",") lines.append(" }),") identity = profile["sessions"].get("identity") @@ -644,9 +667,8 @@ def gen_rust(spec: dict[str, Any]) -> str: "", ] lines += rust_doc(spec["tokenRequest"].get("note")) - forbidden = ", ".join(f'"{name}"' for name in forbidden_headers(spec)) + lines += rust_array("pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = ", forbidden_headers(spec), "", ";") lines += [ - f"pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &[{forbidden}];", "", "/// Governance-owned launch parameters, in seconds.", f"pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = " From 5dc45fca8b85d806ef78142624426dedeffd97d1 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 14:13:21 +0100 Subject: [PATCH 03/10] refactor(profiles): a token session states its required headers and nothing 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 --- rust/profiles/src/profiles.rs | 31 ++--- rust/profiles/tests/vectors.rs | 38 +++---- rust/profiles/tests/wire.rs | 16 +-- scripts/regen-ceremony-profiles.py | 107 ++++++------------ .../contracts/ceremony/CeremonyProfile.sol | 16 +-- solidity/contracts/ceremony/profiles.json | 55 +++++---- .../test/GitHubPlatformVerifier.t.sol | 28 ++--- .../ceremony/test/XPlatformVerifier.t.sol | 37 +++--- .../contracts/src/ceremony/profiles.ts | 24 +--- 9 files changed, 133 insertions(+), 219 deletions(-) diff --git a/rust/profiles/src/profiles.rs b/rust/profiles/src/profiles.rs index 914f4c6..b24d4dd 100644 --- a/rust/profiles/src/profiles.rs +++ b/rust/profiles/src/profiles.rs @@ -60,15 +60,12 @@ pub struct TokenSession { /// committed run is a suffix (REQ-COMMON-22). `None` for a public client, /// whose request hides nothing and is revealed whole. pub secret_field: Option<&'static str>, - /// Every header this request sends, lowercased as the wire spells them, - /// in no particular order. `content-length` is absent because its value - /// is the body's own count: the HTTP client appends it and the verifier - /// reads it rather than compares it. - pub request_headers: &'static [&'static str], - /// The subset of those a Platform Verifier requires, each exactly once - /// with its value: `host` and `content-type`. Every other header is - /// the runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` - /// lists. + /// The header lines a Platform Verifier requires, each exactly once with + /// its value: `host` and `content-type`, lowercased as the wire spells + /// them. Every other header is the runtime's own, save the names + /// `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. `content-length` is absent + /// because its value is the body's own count: the HTTP client appends + /// it and the verifier reads it rather than compares it. pub required_headers: &'static [&'static str], } @@ -124,12 +121,6 @@ pub const X: Profile = Profile { request_line: "POST /2/oauth2/token ", }, secret_field: None, - request_headers: &[ - "host: api.x.com", - "content-type: application/x-www-form-urlencoded", - "accept: application/json", - "connection: close", - ], required_headers: &[ "host: api.x.com", "content-type: application/x-www-form-urlencoded", @@ -163,12 +154,6 @@ pub const GITHUB: Profile = Profile { request_line: "POST /login/oauth/access_token ", }, secret_field: Some("client_secret"), - request_headers: &[ - "host: github.com", - "content-type: application/x-www-form-urlencoded", - "accept: application/json", - "connection: close", - ], required_headers: &[ "host: github.com", "content-type: application/x-www-form-urlencoded", @@ -204,8 +189,8 @@ pub fn launch(platform: &str) -> Option<&'static Profile> { /// request in a way no revealed byte shows: `authorization` which client /// it authenticates, `content-encoding` and `transfer-encoding` which /// bytes it parses, `cookie` the context, `x-http-method-override` the -/// method. The verifier requires `host` and `content-type` from each token -/// session's requestHeaders, reads `content-length`, and ignores every +/// method. The verifier requires each token session's requiredHeaders, +/// `host` and `content-type`, reads `content-length`, and ignores every /// other header: one outside both lists changes only what the platform /// answers, and a wrong answer is a response the verifier cannot read. pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &[ diff --git a/rust/profiles/tests/vectors.rs b/rust/profiles/tests/vectors.rs index 52d1bba..30bd2df 100644 --- a/rust/profiles/tests/vectors.rs +++ b/rust/profiles/tests/vectors.rs @@ -156,35 +156,23 @@ fn the_launch_list_is_closed() { } #[test] -fn the_required_headers_are_among_the_headers_sent() { - // Two lists per token session: what a prover sends, and the subset a - // Platform Verifier holds the head to. The second is generated from the - // first, and this is what says which two lines it is and that both are - // really sent. It carries no `content-length`: that value is the body's - // own and the verifier reads it off the transcript. +fn the_required_headers_are_host_and_the_media_type() { + // The only header data a profile carries: the two lines a Platform + // Verifier holds the head to. `host` must name the pinned authority, or + // the profile contradicts itself; `content-type` selects the parser. No + // `content-length`: that value is the body's own and the verifier reads + // it off the transcript. for profile in LAUNCH { let Some(token) = profile.token else { continue; }; - let names: Vec<&str> = token + let mut names: Vec<&str> = token .required_headers .iter() .map(|line| line.split(':').next().unwrap()) .collect(); - assert_eq!(names, ["host", "content-type"]); - for line in token.required_headers { - assert!( - token.request_headers.contains(line), - "a required header the prover does not send: {line}" - ); - } - assert!( - !token - .request_headers - .iter() - .any(|header| header.starts_with("content-length:")), - "the HTTP client appends the length; a listed one would move it" - ); + names.sort_unstable(); + assert_eq!(names, ["content-type", "host"]); let host = format!("host: {}", token.session.authority); assert!( token.required_headers.contains(&host.as_str()), @@ -194,10 +182,10 @@ fn the_required_headers_are_among_the_headers_sent() { } #[test] -fn the_forbidden_names_are_lowercase_and_never_sent() { +fn the_forbidden_names_are_lowercase_and_never_required() { // The verifier lowercases what it reads and compares against this list // as it is, so a name here in any other case would forbid nothing. And a - // profile that both sends a name and forbids it rejects every honest + // profile that both requires a name and forbids it rejects every honest // session. for name in FORBIDDEN_TOKEN_REQUEST_HEADERS { assert_eq!(*name, name.to_ascii_lowercase(), "{name}"); @@ -207,11 +195,11 @@ fn the_forbidden_names_are_lowercase_and_never_sent() { let Some(token) = profile.token else { continue; }; - for line in token.request_headers { + for line in token.required_headers { let name = line.split(':').next().unwrap(); assert!( !FORBIDDEN_TOKEN_REQUEST_HEADERS.contains(&name), - "{} sends a header it forbids: {name}", + "{} requires a header it forbids: {name}", profile.platform ); } diff --git a/rust/profiles/tests/wire.rs b/rust/profiles/tests/wire.rs index 6f1b770..8a40213 100644 --- a/rust/profiles/tests/wire.rs +++ b/rust/profiles/tests/wire.rs @@ -3,10 +3,10 @@ //! A verifier holds a token request's head to the profile's required headers, //! each once with its value, refuses the forbidden names, and reads one //! `content-length`; the rest of the head is the client's business. This -//! asserts that hyper, given the profile's headers, writes a head that passes -//! -- the request built from `request_headers` and driven through the real -//! `hyper::client::conn::http1` encoder over an in-memory duplex, so what is -//! checked is the bytes hyper actually wrote. +//! asserts that hyper, given the profile's required headers and the two a +//! runtime adds of its own, writes a head that passes -- the request driven +//! through the real `hyper::client::conn::http1` encoder over an in-memory +//! duplex, so what is checked is the bytes hyper actually wrote. //! //! Order is not asserted. Nothing promises where a client puts a header, and //! the browser reaches the wire through tlsn's wasm prover, whose @@ -39,9 +39,11 @@ async fn head_hyper_writes(session: &TokenSession, body: &'static [u8]) -> Vec str: return f"{session['method']} {session['path']} " -# The header names a Platform Verifier requires of every token request, in the -# order the generated block lists them. `host` because a profile whose pinned -# authority and pinned host name different servers contradicts itself, and -# `content-type` because it selects the platform's request parser +# The header names a Platform Verifier requires of every token request, and +# the only header data a profile carries. `host` because a profile whose +# pinned authority and pinned host name different servers contradicts itself, +# and `content-type` because it selects the platform's request parser # (REQ-COMMON-21B). Nothing else a runtime sends changes what the platform -# parses, so nothing else is compared. +# parses, so nothing else is compared, and nothing else is stated: what a +# runtime adds beyond these is its own. REQUIRED_NAMES = ("host", "content-type") # Lowercase field names, so a verifier lowercasing what it reads can compare @@ -109,18 +110,6 @@ def crlf(lines: list[str]) -> str: return "\r\n".join(lines) -def required_headers(session: dict[str, Any]) -> list[str]: - """The lines a verifier holds a token request's head to, from what it sends. - - A subset rather than the whole list. A header outside it changes only what - the platform ANSWERS, and a wrong answer is a response no verifier can - read; the ones here, with the forbidden names, are what decide what the - platform DOES with the request. - """ - by_name = {line.split(":", 1)[0]: line for line in session["requestHeaders"]} - return [by_name[name] for name in REQUIRED_NAMES] - - def forbidden_headers(spec: dict[str, Any]) -> list[str]: """The header names no token request may carry, checked by name alone. @@ -195,41 +184,38 @@ def escaped(value: str) -> str: HEADER = re.compile(r"^[a-z][a-z0-9-]*: [\x20-\x21\x23-\x26\x28-\x5b\x5d-\x7e]+$") -def request_headers(session: dict[str, Any], host: str) -> None: - """Refuse a header list a verifier could not compare, or should not. +def required_headers(session: dict[str, Any]) -> list[str]: + """The lines a verifier holds a token request's head to, validated. - A verifier compares these lines raw, so a header the wire spells - differently -- another case, a second copy of a field name, a `content-length` - whose value no profile can know -- is a profile that rejects every honest - session, and says so here rather than as a rejection with no reason. + Exactly the names in `REQUIRED_NAMES`, each as `lowercase-name: value`, the + `host` one naming the pinned authority. A verifier compares these lines + raw, so a line the wire spells differently is a profile that rejects every + honest session, and says so here rather than as a rejection with no + reason. """ - headers = session["requestHeaders"] - if not isinstance(headers, list) or not headers: - raise SystemExit("ERROR: a token session must list the headers it sends") - + headers = session["requiredHeaders"] + if not isinstance(headers, list): + raise SystemExit("ERROR: a token session must list its required headers") names: list[str] = [] for line in headers: if not isinstance(line, str) or not HEADER.fullmatch(line): raise SystemExit(f"ERROR: header {line!r} is not `lowercase-name: value`") names.append(line.split(":", 1)[0]) - if len(set(names)) != len(names): - raise SystemExit(f"ERROR: {names} names one header twice") - + if sorted(names) != sorted(REQUIRED_NAMES): + raise SystemExit(f"ERROR: the required headers must be exactly {REQUIRED_NAMES}, got {names}") # The `Host` header is prover-composed text and says nothing about which # server answered -- but a profile whose pinned header names one host while # its pinned authority names another contradicts itself, and only one of the # two can be what the session did. + host = session["authority"] if f"host: {host}" not in headers: - raise SystemExit(f"ERROR: the headers must carry `host: {host}`, the pinned authority") - if "content-type" not in names: - raise SystemExit("ERROR: the media type selects the platform's request parser and is required") - if "content-length" in names: - raise SystemExit("ERROR: `content-length` is the body's own count; the verifier reads it, no profile can state it") + raise SystemExit(f"ERROR: the required headers must carry `host: {host}`, the pinned authority") + return headers def validate(spec: dict[str, Any]) -> None: """Refuse a spec that would generate constants nothing can rely on.""" - forbidden = forbidden_headers(spec) + forbidden_headers(spec) seen: set[str] = set() for profile in spec["profiles"]: platform = profile["platform"] @@ -244,10 +230,7 @@ def validate(spec: dict[str, Any]) -> None: safe(session["method"], "method") safe(session["path"], "path") if name == "token": - request_headers(session, host) - for line in session["requestHeaders"]: - if line.split(":", 1)[0] in forbidden: - raise SystemExit(f"ERROR: {platform!r} sends a header it forbids: {line!r}") + required_headers(session) if session["secretField"] is not None: safe(session["secretField"], "secretField") if name == "identity": @@ -345,23 +328,6 @@ def gen_sol(spec: dict[str, Any]) -> str: const = f"{name}_{upper(session_name)}_REQUEST_LINE" lines.append(f' bytes internal constant {const} = "{request_line(session)}";') - lines += [ - "", - " /// @dev Every header the token request sends, CRLF-joined, lowercased", - " /// as the wire spells them. What a runtime sets and what fixtures", - " /// compose a head from; the verifier compares only the required", - " /// subset below.", - "", - ] - for profile in profiles: - token = profile["sessions"].get("token") - if token is None: - continue - const = f"{upper(profile['platform'])}_TOKEN_REQUEST_HEADERS" - lines.append( - f' bytes internal constant {const} = "{escaped(crlf(token["requestHeaders"]))}";' - ) - lines += [ "", " /// @dev The lines a verifier requires of the token request's head, each", @@ -369,7 +335,7 @@ def gen_sol(spec: dict[str, Any]) -> str: " /// and the media type that selects the platform's request parser", " /// (REQ-COMMON-21B). Revealed but uncompared, the media type was a", " /// byte a prover chose in a request every other field of which is", - " /// pinned.", + " /// pinned. What else a runtime sends is its own and not stated here.", "", ] for profile in profiles: @@ -577,15 +543,12 @@ def gen_rust(spec: dict[str, Any]) -> str: " /// committed run is a suffix (REQ-COMMON-22). `None` for a public client,", " /// whose request hides nothing and is revealed whole.", " pub secret_field: Option<&'static str>,", - " /// Every header this request sends, lowercased as the wire spells them,", - " /// in no particular order. `content-length` is absent because its value", - " /// is the body's own count: the HTTP client appends it and the verifier", - " /// reads it rather than compares it.", - " pub request_headers: &'static [&'static str],", - " /// The subset of those a Platform Verifier requires, each exactly once", - " /// with its value: `host` and `content-type`. Every other header is", - " /// the runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS`", - " /// lists.", + " /// The header lines a Platform Verifier requires, each exactly once with", + " /// its value: `host` and `content-type`, lowercased as the wire spells", + " /// them. Every other header is the runtime's own, save the names", + " /// `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. `content-length` is absent", + " /// because its value is the body's own count: the HTTP client appends", + " /// it and the verifier reads it rather than compares it.", " pub required_headers: &'static [&'static str],", "}", "", @@ -634,7 +597,6 @@ def gen_rust(spec: dict[str, Any]) -> str: lines.append(" token: Some(TokenSession {") lines += rust_session(token, 8) lines.append(f" secret_field: {rust_str(token['secretField'])},") - lines += rust_array("request_headers: ", token["requestHeaders"], " ", ",") lines += rust_array("required_headers: ", required_headers(token), " ", ",") lines.append(" }),") @@ -760,12 +722,10 @@ def gen_ts(spec: dict[str, Any]) -> str: " readonly session: Session", " /** The body field committed rather than revealed, or null. */", " readonly secretField: string | null", - " /** Every header this request sends, lowercased, in no particular order.", + " /** The header lines a Platform Verifier requires, each exactly once with", + " * its value: `host` and `content-type`. Every other header is the", + " * runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists.", " * `content-length` is absent: the HTTP client appends it. */", - " readonly requestHeaders: readonly string[]", - " /** The subset a Platform Verifier requires, each exactly once with its", - " * value: `host` and `content-type`. Every other header is the runtime's", - " * own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. */", " readonly requiredHeaders: readonly string[]", "}", "", @@ -800,7 +760,6 @@ def gen_ts(spec: dict[str, Any]) -> str: lines.append(" token: {") lines += ts_session(token, 4) lines.append(f" secretField: {ts_str(token['secretField'])},") - lines += ts_array("requestHeaders: ", token["requestHeaders"], " ", ",") lines += ts_array("requiredHeaders: ", required_headers(token), " ", ",") lines.append(" },") diff --git a/solidity/contracts/ceremony/CeremonyProfile.sol b/solidity/contracts/ceremony/CeremonyProfile.sol index 2ef2bc0..69d5614 100644 --- a/solidity/contracts/ceremony/CeremonyProfile.sol +++ b/solidity/contracts/ceremony/CeremonyProfile.sol @@ -59,22 +59,12 @@ library CeremonyProfile { bytes internal constant GITHUB_TOKEN_REQUEST_LINE = "POST /login/oauth/access_token "; bytes internal constant GITHUB_IDENTITY_REQUEST_LINE = "GET /user "; - /// @dev Every header the token request sends, CRLF-joined, lowercased - /// as the wire spells them. What a runtime sets and what fixtures - /// compose a head from; the verifier compares only the required - /// subset below. - - bytes internal constant X_TOKEN_REQUEST_HEADERS = - "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close"; - bytes internal constant GITHUB_TOKEN_REQUEST_HEADERS = - "host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close"; - /// @dev The lines a verifier requires of the token request's head, each /// exactly once with its value: `host` naming the pinned authority, /// and the media type that selects the platform's request parser /// (REQ-COMMON-21B). Revealed but uncompared, the media type was a /// byte a prover chose in a request every other field of which is - /// pinned. + /// pinned. What else a runtime sends is its own and not stated here. bytes internal constant X_TOKEN_REQUIRED_HEADERS = "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded"; @@ -86,8 +76,8 @@ library CeremonyProfile { /// request in a way no revealed byte shows: `authorization` which client /// it authenticates, `content-encoding` and `transfer-encoding` which /// bytes it parses, `cookie` the context, `x-http-method-override` the - /// method. The verifier requires `host` and `content-type` from each token - /// session's requestHeaders, reads `content-length`, and ignores every + /// method. The verifier requires each token session's requiredHeaders, + /// `host` and `content-type`, reads `content-length`, and ignores every /// other header: one outside both lists changes only what the platform /// answers, and a wrong answer is a response the verifier cannot read. bytes internal constant FORBIDDEN_TOKEN_REQUEST_HEADERS = diff --git a/solidity/contracts/ceremony/profiles.json b/solidity/contracts/ceremony/profiles.json index 29706cf..0964dba 100644 --- a/solidity/contracts/ceremony/profiles.json +++ b/solidity/contracts/ceremony/profiles.json @@ -45,8 +45,8 @@ "request in a way no revealed byte shows: `authorization` which client", "it authenticates, `content-encoding` and `transfer-encoding` which", "bytes it parses, `cookie` the context, `x-http-method-override` the", - "method. The verifier requires `host` and `content-type` from each token", - "session's requestHeaders, reads `content-length`, and ignores every", + "method. The verifier requires each token session's requiredHeaders,", + "`host` and `content-type`, reads `content-length`, and ignores every", "other header: one outside both lists changes only what the platform", "answers, and a wrong answer is a response the verifier cannot read." ] @@ -76,30 +76,29 @@ "method": "POST", "path": "/2/oauth2/token", "secretField": null, - "requestHeaders": [ + "requiredHeaders": [ "host: api.x.com", - "content-type: application/x-www-form-urlencoded", - "accept: application/json", - "connection: close" + "content-type: application/x-www-form-urlencoded" ], "note": [ "A public client hides no body field, so the request is revealed", "whole and the verifier accepts exactly zero committed ranges in", "this direction.", "", - "Which is why the headers are listed. Revealed is not checked: they", - "were public and unconstrained until the verifier compared them, and", - "`content-type` is the one that decides how X parses the very bytes", - "`formField` reads. REQ-COMMON-21B fixes the media type in the", - "deployment profile because it selects the platform's request", - "parser, and a pin nothing compares is a pin in name only.", + "Which is why the media type is required. Revealed is not checked:", + "it was public and unconstrained until the verifier compared it,", + "and it decides how X parses the very bytes `formField` reads.", + "REQ-COMMON-21B fixes it in the deployment profile because it", + "selects the platform's request parser, and a pin nothing compares", + "is a pin in name only.", "", - "What the browser sends. The verifier requires `host` and", - "`content-type` of these, ignores the rest, and refuses the names", - "in tokenRequest.forbiddenHeaders. `content-length` is not among", - "them: the HTTP client appends it, its value is the body's own", - "length, and the verifier checks it against the length the notary", - "signed." + "The two lines the verifier requires of the head, each once with", + "its value. What else the browser sends -- `accept:", + "application/json`, `connection: close` -- is its own and", + "uncompared, as is any header a client adds, save the names in", + "tokenRequest.forbiddenHeaders. `content-length` is not among them:", + "the HTTP client appends it, its value is the body's own length,", + "and the verifier checks it against the length the notary signed." ] }, "identity": { @@ -132,23 +131,23 @@ "method": "POST", "path": "/login/oauth/access_token", "secretField": "client_secret", - "requestHeaders": [ + "requiredHeaders": [ "host: github.com", - "content-type: application/x-www-form-urlencoded", - "accept: application/json", - "connection: close" + "content-type: application/x-www-form-urlencoded" ], "note": [ "The client secret is committed rather than revealed, ordered last", "in the body under REQ-COMMON-22 so the committed run is a suffix", "and not a hole.", "", - "The media type and `accept` are section 6.2's; `host` names the", - "authority the notary authenticated, and `connection: close` is what", - "ends every notarized session here. The Token-Exchange Service is", - "the party that sends this one, so unlike X's it is agreed here", - "BEFORE it is written rather than read off a browser that already", - "sends it." + "The media type is section 6.2's and `host` names the authority", + "the notary authenticated. The Token-Exchange Service also sends", + "`accept: application/json`, without which GitHub answers", + "form-encoded and the verifier finds no `\"access_token\":\"`, and", + "`connection: close`, which ends every notarized session here;", + "neither is compared. The service is the party that sends this", + "request, so unlike X's it is agreed here BEFORE it is written", + "rather than read off a browser that already sends it." ] }, "identity": { diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index faed304..b5e315d 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -88,8 +88,8 @@ contract GitHubPlatformVerifierTest is Test { return abi.encodePacked(r, s, v); } - /// The header set `github/v1` fixes, laid out in the profile's order; the - /// verifier accepts any. + /// The head the Token-Exchange Service sends: the two headers the profile + /// requires, and two it does not compare. bytes constant EXCHANGE_HEADERS = "host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n"; @@ -457,18 +457,18 @@ contract GitHubPlatformVerifierTest is Test { } /// @dev And the fixtures above compose that head from parts, so this is - /// what says the parts are the profile's own. - function test_theFixtureHeadIsTheProfilesOwn() public pure { - assertEq( - string(_exchangeHead(0)), - string( - abi.encodePacked( - "POST /login/oauth/access_token HTTP/1.1\r\n", - CeremonyProfile.GITHUB_TOKEN_REQUEST_HEADERS, - "\r\ncontent-length: 0\r\n\r\n" - ) - ) - ); + /// what says the two lines the profile requires are among them. + function test_theFixtureHeadCarriesTheProfilesRequiredHeaders() public pure { + bytes memory head = _exchangeHead(0); + bytes memory needle = abi.encodePacked(CeremonyProfile.GITHUB_TOKEN_REQUIRED_HEADERS, "\r\n"); + bool found; + for (uint256 i = 0; i + needle.length <= head.length && !found; ++i) { + found = true; + for (uint256 j = 0; j < needle.length && found; ++j) { + found = head[i + j] == needle[j]; + } + } + assertTrue(found); } function test_rejectsAnExchangeResponseWithNoRevealedAnchors() public { diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 1857fe1..3762d8d 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -107,8 +107,8 @@ contract XPlatformVerifierTest is Test { return abi.encodePacked(r, s, v); } - /// The header set `x/v1` fixes, laid out in the profile's order; the - /// verifier accepts any. + /// The head the browser sends: the two headers the profile requires, and + /// two it does not compare. bytes constant TOKEN_HEADERS = "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n"; @@ -1065,23 +1065,28 @@ contract XPlatformVerifierTest is Test { s.tokenSession = _tokenSessionWithHead(head); } - /// @dev The fixtures compose their head from parts; this is what says the - /// parts are the profile's own. Without it an edit to `profiles.json` - /// that the fixtures did not follow would fail every test in this - /// file at once and name none of them as the reason. - function test_theFixtureHeadIsTheProfilesOwn() public pure { - assertEq( - string(_tokenHead(TOKEN_HEADERS, 0)), - string( - abi.encodePacked( - "POST /2/oauth2/token HTTP/1.1\r\n", - CeremonyProfile.X_TOKEN_REQUEST_HEADERS, - "\r\ncontent-length: 0\r\n\r\n" - ) - ) + /// @dev The fixtures compose their head from parts; this says the two + /// lines the profile requires are among them. Without it an edit to + /// `profiles.json` that the fixtures did not follow would fail every + /// test in this file at once and name none of them as the reason. + function test_theFixtureHeadCarriesTheProfilesRequiredHeaders() public pure { + assertTrue( + _contains(_tokenHead(TOKEN_HEADERS, 0), abi.encodePacked(CeremonyProfile.X_TOKEN_REQUIRED_HEADERS, "\r\n")) ); } + function _contains(bytes memory haystack, bytes memory needle) private pure returns (bool) { + if (needle.length > haystack.length) return false; + for (uint256 i = 0; i + needle.length <= haystack.length; ++i) { + bool same = true; + for (uint256 j = 0; j < needle.length && same; ++j) { + same = haystack[i + j] == needle[j]; + } + if (same) return true; + } + return false; + } + /// @dev REQ-COMMON-21B: the media type selects the platform's request /// parser, and `_tokenBody` reads those same bytes under a form /// encoding. Announcing JSON leaves X parsing one document while this diff --git a/ts/packages/contracts/src/ceremony/profiles.ts b/ts/packages/contracts/src/ceremony/profiles.ts index 6d3a87d..3e5663f 100644 --- a/ts/packages/contracts/src/ceremony/profiles.ts +++ b/ts/packages/contracts/src/ceremony/profiles.ts @@ -41,12 +41,10 @@ export interface TokenSession { readonly session: Session /** The body field committed rather than revealed, or null. */ readonly secretField: string | null - /** Every header this request sends, lowercased, in no particular order. + /** The header lines a Platform Verifier requires, each exactly once with + * its value: `host` and `content-type`. Every other header is the + * runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. * `content-length` is absent: the HTTP client appends it. */ - readonly requestHeaders: readonly string[] - /** The subset a Platform Verifier requires, each exactly once with its - * value: `host` and `content-type`. Every other header is the runtime's - * own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. */ readonly requiredHeaders: readonly string[] } @@ -92,12 +90,6 @@ export const X: Profile = { requestLine: 'POST /2/oauth2/token ', }, secretField: null, - requestHeaders: [ - 'host: api.x.com', - 'content-type: application/x-www-form-urlencoded', - 'accept: application/json', - 'connection: close', - ], requiredHeaders: ['host: api.x.com', 'content-type: application/x-www-form-urlencoded'], }, identity: { @@ -130,12 +122,6 @@ export const GITHUB: Profile = { requestLine: 'POST /login/oauth/access_token ', }, secretField: 'client_secret', - requestHeaders: [ - 'host: github.com', - 'content-type: application/x-www-form-urlencoded', - 'accept: application/json', - 'connection: close', - ], requiredHeaders: ['host: github.com', 'content-type: application/x-www-form-urlencoded'], }, identity: { @@ -170,8 +156,8 @@ export function attestationCount(profile: Profile): number { * request in a way no revealed byte shows: `authorization` which client * it authenticates, `content-encoding` and `transfer-encoding` which * bytes it parses, `cookie` the context, `x-http-method-override` the - * method. The verifier requires `host` and `content-type` from each token - * session's requestHeaders, reads `content-length`, and ignores every + * method. The verifier requires each token session's requiredHeaders, + * `host` and `content-type`, reads `content-length`, and ignores every * other header: one outside both lists changes only what the platform * answers, and a wrong answer is a response the verifier cannot read. */ From b633981c08e03866b9f71f2eba1529244a83d627 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 14:40:01 +0100 Subject: [PATCH 04/10] fix(ceremony): a header name is trimmed before it is compared 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 --- .../ceremony/TlsNotaryVerifierBase.sol | 17 +++++++---- .../ceremony/test/XPlatformVerifier.t.sol | 28 ++++++++++++------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol index 0add558..f934b44 100644 --- a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol +++ b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol @@ -553,16 +553,23 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa } /// @dev A header line as the platform reads it: the name before the first - /// colon, lowercased, and the value after it with the optional - /// whitespace on either side removed. A line with no colon, or nothing - /// before it, is not a header. + /// colon, lowercased and with any whitespace before the colon removed + /// -- the normalization common REQ-COMMON-39 gives the identity + /// request -- and the value after it with the optional whitespace on + /// either side removed. A line with no colon, or nothing before it, is + /// not a header. function _field(bytes memory line) private pure returns (bytes memory name, bytes memory value) { uint256 colon; while (colon < line.length && line[colon] != ":") { ++colon; } - if (colon == 0 || colon == line.length) revert WrongTokenRequestHead(); - name = _slice(line, 0, colon); + if (colon == line.length) revert WrongTokenRequestHead(); + uint256 nameEnd = colon; + while (nameEnd > 0 && (line[nameEnd - 1] == " " || line[nameEnd - 1] == "\t")) { + --nameEnd; + } + if (nameEnd == 0) revert WrongTokenRequestHead(); + name = _slice(line, 0, nameEnd); for (uint256 i = 0; i < name.length; ++i) { if (name[i] >= "A" && name[i] <= "Z") name[i] = bytes1(uint8(name[i]) + 32); } diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 3762d8d..24b009c 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -1116,18 +1116,26 @@ contract XPlatformVerifierTest is Test { } /// @dev Every forbidden name, each in a spelling the platform would read - /// as the same header: another case, no space after the colon. The - /// name comes back lowercased, which is how the list is compared. + /// as the same header: another case, no space after the colon, a + /// space before it. The name comes back lowercased and trimmed, which + /// is how the list is compared. function test_rejectsEachForbiddenHeaderOnTheTokenRequest() public { - string[5] memory lines = [ + string[6] memory lines = [ "Transfer-Encoding: chunked", "content-encoding:gzip", "Cookie: session=abc", "X-HTTP-Method-Override: GET", - "AUTHORIZATION: Basic bXlDbGllbnQtMTpzM2NyZXQ=" + "AUTHORIZATION: Basic bXlDbGllbnQtMTpzM2NyZXQ=", + "authorization : Basic bXlDbGllbnQtMTpzM2NyZXQ=" + ]; + string[6] memory names = [ + "transfer-encoding", + "content-encoding", + "cookie", + "x-http-method-override", + "authorization", + "authorization" ]; - string[5] memory names = - ["transfer-encoding", "content-encoding", "cookie", "x-http-method-override", "authorization"]; for (uint256 i = 0; i < lines.length; ++i) { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( abi.encodePacked( @@ -1193,12 +1201,12 @@ contract XPlatformVerifierTest is Test { } /// @dev A required header in another spelling the platform reads the - /// same: the name in another case, no space after the colon, a tab - /// before the value. HTTP reads all three as one header, and so does - /// this. + /// same: the name in another case, no space after the colon, a space + /// before it, a tab before the value. HTTP reads all of them as one + /// header, and so does this. function test_acceptsARequiredHeaderInAnotherSpelling() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( - "Host:\tapi.x.com \r\nContent-Type:application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n" + "Host:\tapi.x.com \r\nContent-Type :application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close\r\n" ); this.run{value: quote}(s); } From a285b122394529105e63f34a425cc43f6a7c1236 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 15:17:46 +0100 Subject: [PATCH 05/10] fix(ceremony): count every authorization header, and forbid on both requests 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 --- rust/profiles/src/profiles.rs | 28 +++-- rust/profiles/tests/vectors.rs | 6 +- rust/profiles/tests/wire.rs | 4 +- scripts/regen-ceremony-profiles.py | 24 ++-- .../ceremony/CeremonyAttestation.sol | 15 ++- .../contracts/ceremony/CeremonyProfile.sol | 26 ++-- .../ceremony/TlsNotaryVerifierBase.sol | 115 ++++++++++++------ solidity/contracts/ceremony/profiles.json | 30 +++-- .../test/GitHubPlatformVerifier.t.sol | 20 +++ .../ceremony/test/XPlatformVerifier.t.sol | 68 ++++++++++- .../contracts/src/ceremony/profiles.ts | 28 +++-- 11 files changed, 259 insertions(+), 105 deletions(-) diff --git a/rust/profiles/src/profiles.rs b/rust/profiles/src/profiles.rs index b24d4dd..d18ce49 100644 --- a/rust/profiles/src/profiles.rs +++ b/rust/profiles/src/profiles.rs @@ -63,7 +63,7 @@ pub struct TokenSession { /// The header lines a Platform Verifier requires, each exactly once with /// its value: `host` and `content-type`, lowercased as the wire spells /// them. Every other header is the runtime's own, save the names - /// `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. `content-length` is absent + /// `FORBIDDEN_REQUEST_HEADERS` lists. `content-length` is absent /// because its value is the body's own count: the HTTP client appends /// it and the verifier reads it rather than compares it. pub required_headers: &'static [&'static str], @@ -184,21 +184,27 @@ pub fn launch(platform: &str) -> Option<&'static Profile> { LAUNCH.iter().copied().find(|p| p.platform == platform) } -/// Header names a token request must not carry, compared lowercased by -/// every Platform Verifier. Each changes what the platform does with the -/// request in a way no revealed byte shows: `authorization` which client -/// it authenticates, `content-encoding` and `transfer-encoding` which -/// bytes it parses, `cookie` the context, `x-http-method-override` the -/// method. The verifier requires each token session's requiredHeaders, -/// `host` and `content-type`, reads `content-length`, and ignores every -/// other header: one outside both lists changes only what the platform -/// answers, and a wrong answer is a response the verifier cannot read. -pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = &[ +/// Header names no notarized request may carry, compared by every +/// Platform Verifier with the name lowercased, its whitespace removed and +/// `_` read as `-`. Each changes what the platform does with the request +/// in a way no revealed byte shows: `authorization` which client it +/// authenticates, `content-encoding` and `transfer-encoding` which bytes +/// it parses, `cookie` which session it answers for, the three override +/// names which method it runs. The identity request is excepted from +/// `authorization` alone: its one such header, under any scheme, is what +/// REQ-COMMON-39 counts. On the token request the verifier further +/// requires each session's requiredHeaders, `host` and `content-type`, +/// reads `content-length`, and ignores every other header: one outside +/// both lists changes only what the platform answers, and a wrong answer +/// is a response the verifier cannot read. +pub const FORBIDDEN_REQUEST_HEADERS: &[&str] = &[ "authorization", "content-encoding", "cookie", "transfer-encoding", + "x-http-method", "x-http-method-override", + "x-method-override", ]; /// Governance-owned launch parameters, in seconds. diff --git a/rust/profiles/tests/vectors.rs b/rust/profiles/tests/vectors.rs index 30bd2df..02eb000 100644 --- a/rust/profiles/tests/vectors.rs +++ b/rust/profiles/tests/vectors.rs @@ -11,7 +11,7 @@ //! follows. use libid_profiles::{ - FORBIDDEN_TOKEN_REQUEST_HEADERS, + FORBIDDEN_REQUEST_HEADERS, GITHUB, GOOGLE, LAUNCH, @@ -187,7 +187,7 @@ fn the_forbidden_names_are_lowercase_and_never_required() { // as it is, so a name here in any other case would forbid nothing. And a // profile that both requires a name and forbids it rejects every honest // session. - for name in FORBIDDEN_TOKEN_REQUEST_HEADERS { + for name in FORBIDDEN_REQUEST_HEADERS { assert_eq!(*name, name.to_ascii_lowercase(), "{name}"); assert!(!name.is_empty()); } @@ -198,7 +198,7 @@ fn the_forbidden_names_are_lowercase_and_never_required() { for line in token.required_headers { let name = line.split(':').next().unwrap(); assert!( - !FORBIDDEN_TOKEN_REQUEST_HEADERS.contains(&name), + !FORBIDDEN_REQUEST_HEADERS.contains(&name), "{} requires a header it forbids: {name}", profile.platform ); diff --git a/rust/profiles/tests/wire.rs b/rust/profiles/tests/wire.rs index 8a40213..e89add7 100644 --- a/rust/profiles/tests/wire.rs +++ b/rust/profiles/tests/wire.rs @@ -24,7 +24,7 @@ use hyper_util::rt::TokioIo; use libid_profiles::{ TokenSession, - FORBIDDEN_TOKEN_REQUEST_HEADERS, + FORBIDDEN_REQUEST_HEADERS, GITHUB, X, }; @@ -103,7 +103,7 @@ fn assert_head_admits(session: &TokenSession, wire: &[u8], body_len: usize) { } for line in &written { assert!( - !FORBIDDEN_TOKEN_REQUEST_HEADERS.contains(&name_of(line).as_str()), + !FORBIDDEN_REQUEST_HEADERS.contains(&name_of(line).as_str()), "hyper wrote a forbidden header: {line}" ); } diff --git a/scripts/regen-ceremony-profiles.py b/scripts/regen-ceremony-profiles.py index 29c6253..6357a21 100755 --- a/scripts/regen-ceremony-profiles.py +++ b/scripts/regen-ceremony-profiles.py @@ -111,22 +111,22 @@ def crlf(lines: list[str]) -> str: def forbidden_headers(spec: dict[str, Any]) -> list[str]: - """The header names no token request may carry, checked by name alone. + """The header names no notarized request may carry, checked by name alone. Each changes what the platform does with the request in a way no revealed byte shows, so a verifier can only refuse the name. Compared lowercased, which is why the list must be. """ - names = spec["tokenRequest"]["forbiddenHeaders"] + names = spec["requests"]["forbiddenHeaders"] if not isinstance(names, list) or not names: - raise SystemExit("ERROR: tokenRequest.forbiddenHeaders must list at least one name") + raise SystemExit("ERROR: requests.forbiddenHeaders must list at least one name") for name in names: if not isinstance(name, str) or not NAME.fullmatch(name): raise SystemExit(f"ERROR: forbidden header {name!r} is not a lowercase field name") if name in REQUIRED_NAMES or name == "content-length": raise SystemExit(f"ERROR: {name!r} is read by the verifier and cannot be forbidden") if len(set(names)) != len(names): - raise SystemExit("ERROR: tokenRequest.forbiddenHeaders names one header twice") + raise SystemExit("ERROR: requests.forbiddenHeaders names one header twice") return names @@ -348,9 +348,9 @@ def gen_sol(spec: dict[str, Any]) -> str: ) lines += [""] - lines += sol_doc(spec["tokenRequest"].get("note")) + lines += sol_doc(spec["requests"].get("note")) lines.append( - f' bytes internal constant FORBIDDEN_TOKEN_REQUEST_HEADERS = "{escaped(crlf(forbidden_headers(spec)))}";' + f' bytes internal constant FORBIDDEN_REQUEST_HEADERS = "{escaped(crlf(forbidden_headers(spec)))}";' ) lines += [ @@ -546,7 +546,7 @@ def gen_rust(spec: dict[str, Any]) -> str: " /// The header lines a Platform Verifier requires, each exactly once with", " /// its value: `host` and `content-type`, lowercased as the wire spells", " /// them. Every other header is the runtime's own, save the names", - " /// `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. `content-length` is absent", + " /// `FORBIDDEN_REQUEST_HEADERS` lists. `content-length` is absent", " /// because its value is the body's own count: the HTTP client appends", " /// it and the verifier reads it rather than compares it.", " pub required_headers: &'static [&'static str],", @@ -628,8 +628,8 @@ def gen_rust(spec: dict[str, Any]) -> str: "}", "", ] - lines += rust_doc(spec["tokenRequest"].get("note")) - lines += rust_array("pub const FORBIDDEN_TOKEN_REQUEST_HEADERS: &[&str] = ", forbidden_headers(spec), "", ";") + lines += rust_doc(spec["requests"].get("note")) + lines += rust_array("pub const FORBIDDEN_REQUEST_HEADERS: &[&str] = ", forbidden_headers(spec), "", ";") lines += [ "", "/// Governance-owned launch parameters, in seconds.", @@ -724,7 +724,7 @@ def gen_ts(spec: dict[str, Any]) -> str: " readonly secretField: string | null", " /** The header lines a Platform Verifier requires, each exactly once with", " * its value: `host` and `content-type`. Every other header is the", - " * runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists.", + " * runtime's own, save the names `FORBIDDEN_REQUEST_HEADERS` lists.", " * `content-length` is absent: the HTTP client appends it. */", " readonly requiredHeaders: readonly string[]", "}", @@ -791,9 +791,9 @@ def gen_ts(spec: dict[str, Any]) -> str: "}", "", ] - lines += ts_doc(spec["tokenRequest"].get("note")) + lines += ts_doc(spec["requests"].get("note")) lines += ts_array( - "export const FORBIDDEN_TOKEN_REQUEST_HEADERS: readonly string[] = ", forbidden_headers(spec), "", "" + "export const FORBIDDEN_REQUEST_HEADERS: readonly string[] = ", forbidden_headers(spec), "", "" ) lines += [ "", diff --git a/solidity/contracts/ceremony/CeremonyAttestation.sol b/solidity/contracts/ceremony/CeremonyAttestation.sol index 74249dc..4d995d7 100644 --- a/solidity/contracts/ceremony/CeremonyAttestation.sol +++ b/solidity/contracts/ceremony/CeremonyAttestation.sol @@ -95,6 +95,11 @@ library CeremonyAttestation { /// see -- and a platform parser that accepts it would honour that /// header. error BareLineFeed(uint256 at); + /// @dev A carriage return not followed by a line feed. A compliant parser + /// never ends a line on one, but a parser that does ends the head + /// somewhere this one does not, so the byte is refused rather than + /// trusted to every platform's handling of it. + error BareCarriageReturn(uint256 at); error NotOneAuthorizationHeader(uint256 count); error BadBearerFraming(); /// @dev No commitment in this direction is framed by the delimiters the @@ -139,8 +144,11 @@ library CeremonyAttestation { bytes internal constant BEARER_PREFIX = "\r\nauthorization: Bearer "; /// @dev And immediately after it. bytes internal constant BEARER_SUFFIX = "\r\n"; - /// @dev The normalized, line-anchored needle REQ-COMMON-39 counts. - bytes internal constant AUTHORIZATION_NEEDLE = "\r\nauthorization:bearer"; + /// @dev The normalized, line-anchored needle REQ-COMMON-39 counts: the + /// credential header under ANY scheme. Counting only `bearer` left a + /// second `authorization: Basic` or `authorization: token` line + /// uncounted, and the platform answering to whichever it honoured. + bytes internal constant AUTHORIZATION_NEEDLE = "\r\nauthorization:"; /// @notice The one commitment framed by exactly these revealed bytes. /// @@ -285,6 +293,9 @@ library CeremonyAttestation { if (revealed[i] == 0x0a && (i == 0 || revealed[i - 1] != 0x0d)) { revert BareLineFeed(i); } + if (revealed[i] == 0x0d && (i + 1 == revealed.length || revealed[i + 1] != 0x0a)) { + revert BareCarriageReturn(i); + } } } diff --git a/solidity/contracts/ceremony/CeremonyProfile.sol b/solidity/contracts/ceremony/CeremonyProfile.sol index 69d5614..8c3b471 100644 --- a/solidity/contracts/ceremony/CeremonyProfile.sol +++ b/solidity/contracts/ceremony/CeremonyProfile.sol @@ -71,17 +71,21 @@ library CeremonyProfile { bytes internal constant GITHUB_TOKEN_REQUIRED_HEADERS = "host: github.com\r\ncontent-type: application/x-www-form-urlencoded"; - /// @dev Header names a token request must not carry, compared lowercased by - /// every Platform Verifier. Each changes what the platform does with the - /// request in a way no revealed byte shows: `authorization` which client - /// it authenticates, `content-encoding` and `transfer-encoding` which - /// bytes it parses, `cookie` the context, `x-http-method-override` the - /// method. The verifier requires each token session's requiredHeaders, - /// `host` and `content-type`, reads `content-length`, and ignores every - /// other header: one outside both lists changes only what the platform - /// answers, and a wrong answer is a response the verifier cannot read. - bytes internal constant FORBIDDEN_TOKEN_REQUEST_HEADERS = - "authorization\r\ncontent-encoding\r\ncookie\r\ntransfer-encoding\r\nx-http-method-override"; + /// @dev Header names no notarized request may carry, compared by every + /// Platform Verifier with the name lowercased, its whitespace removed and + /// `_` read as `-`. Each changes what the platform does with the request + /// in a way no revealed byte shows: `authorization` which client it + /// authenticates, `content-encoding` and `transfer-encoding` which bytes + /// it parses, `cookie` which session it answers for, the three override + /// names which method it runs. The identity request is excepted from + /// `authorization` alone: its one such header, under any scheme, is what + /// REQ-COMMON-39 counts. On the token request the verifier further + /// requires each session's requiredHeaders, `host` and `content-type`, + /// reads `content-length`, and ignores every other header: one outside + /// both lists changes only what the platform answers, and a wrong answer + /// is a response the verifier cannot read. + bytes internal constant FORBIDDEN_REQUEST_HEADERS = + "authorization\r\ncontent-encoding\r\ncookie\r\ntransfer-encoding\r\nx-http-method\r\nx-http-method-override\r\nx-method-override"; /// @dev How many committed ranges the token request carries. A confidential /// client commits its secret and a public client hides nothing, so this diff --git a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol index f934b44..c745d1b 100644 --- a/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol +++ b/solidity/contracts/ceremony/TlsNotaryVerifierBase.sol @@ -43,6 +43,7 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// it and its value is the body's own count, so the head carries it /// and no profile lists it. bytes private constant LENGTH_HEADER = "content-length"; + bytes private constant AUTHORIZATION = "authorization"; bytes internal constant ACCESS_TOKEN_PREFIX = '"access_token":"'; bytes internal constant ACCESS_TOKEN_SUFFIX = '"'; @@ -103,10 +104,10 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// gives one another value, carries a line no colon splits, or /// declares a body length that is not plain decimal digits. error WrongTokenRequestHead(); - /// @dev The token request carries a header the profile forbids: one that - /// changes what the platform does with the request in a way no - /// revealed byte shows. The name, lowercased. - error ForbiddenTokenRequestHeader(bytes name); + /// @dev A request carries a header the profile forbids: one that changes + /// what the platform does with the request in a way no revealed byte + /// shows. The name, normalized. + error ForbiddenRequestHeader(bytes name); /// @dev The request declared a body of one length and the notary signed /// another, so the bytes the platform parsed as the form are not the /// bytes read below. @@ -122,7 +123,7 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa /// @dev The header lines the token request must carry, CRLF-joined: `host` /// naming the pinned authority, and the media type that selects the /// platform's parser. `_checkTokenHead` requires each once with its - /// value, refuses the names `CeremonyProfile.FORBIDDEN_TOKEN_REQUEST_HEADERS` + /// value, refuses the names `CeremonyProfile.FORBIDDEN_REQUEST_HEADERS` /// lists, reads `content-length`, and ignores every other header. /// /// Only the token request has one. The identity request carries the @@ -390,6 +391,7 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa CeremonyAttestation.RangeCommitment memory bearer = CeremonyAttestation.requireBearerHeaderRequest(data.sent, data.sentTranscriptLength); identityCommitment = bearer.commitment; + _checkIdentityHead(CeremonyAttestation.concatRevealed(data.sent)); // Tiled, not revealed whole. The response may hide bytes, which is // what keeps a platform's account metadata off chain when the profile's @@ -519,7 +521,9 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa CeremonyAttestation.requireCrlfLineEndings(head); bytes memory required = _tokenRequiredHeaders(); - uint256 wanted = _countLines(required); + // One bit per required line. A profile with more than 256 headers is + // not a profile, and `validate` in the generator refuses one long + // before this could matter. uint256 found; bool lengths; @@ -527,52 +531,68 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa uint256 from = _lineEnd(head, 0) + 2; while (from < head.length) { uint256 to = _lineEnd(head, from); - (bytes memory name, bytes memory value) = _field(_slice(head, from, to)); - - if (_indexOfLine(CeremonyProfile.FORBIDDEN_TOKEN_REQUEST_HEADERS, name) != type(uint256).max) { - revert ForbiddenTokenRequestHeader(name); - } - if (_equal(name, LENGTH_HEADER)) { - if (lengths) revert WrongTokenRequestHead(); - lengths = true; - declared = _decimal(value, 0); - } else { - uint256 i = _indexOfName(required, name); - if (i != type(uint256).max) { - if (!_equal(value, _valueOf(required, i))) revert WrongTokenRequestHead(); - if (found & (1 << i) != 0) revert WrongTokenRequestHead(); - found |= 1 << i; - } - } + (found, lengths, declared) = _tokenHeaderLine(_slice(head, from, to), required, found, lengths, declared); from = to + 2; } if (!lengths) revert WrongTokenRequestHead(); - // Every required line seen: the low `wanted` bits all set. - if (found != (1 << wanted) - 1) revert WrongTokenRequestHead(); + // Every required line seen: the low bits all set. + if (found != (1 << _countLines(required)) - 1) revert WrongTokenRequestHead(); + } + + /// @dev One line of the token head against the rule, returning the + /// bookkeeping it advanced: which required lines have been seen, + /// whether the length has, and what it declared. A function of its + /// own so the loop above keeps a stack the compiler can lay out. + // forge-lint: disable-next-item(incorrect-shift) + function _tokenHeaderLine(bytes memory line, bytes memory required, uint256 found, bool lengths, uint256 declared) + private + pure + returns (uint256, bool, uint256) + { + (bool isHeader, bytes memory name, bytes memory value) = _field(line); + if (!isHeader) revert WrongTokenRequestHead(); + + if (_indexOfLine(CeremonyProfile.FORBIDDEN_REQUEST_HEADERS, name) != type(uint256).max) { + revert ForbiddenRequestHeader(name); + } + if (_equal(name, LENGTH_HEADER)) { + if (lengths) revert WrongTokenRequestHead(); + return (found, true, _decimal(value, 0)); + } + uint256 i = _indexOfName(required, name); + if (i == type(uint256).max) return (found, lengths, declared); + if (!_equal(value, _valueOf(required, i))) revert WrongTokenRequestHead(); + if (found & (1 << i) != 0) revert WrongTokenRequestHead(); + return (found | (1 << i), lengths, declared); } /// @dev A header line as the platform reads it: the name before the first - /// colon, lowercased and with any whitespace before the colon removed - /// -- the normalization common REQ-COMMON-39 gives the identity - /// request -- and the value after it with the optional whitespace on - /// either side removed. A line with no colon, or nothing before it, is - /// not a header. - function _field(bytes memory line) private pure returns (bytes memory name, bytes memory value) { + /// colon, lowercased, with any whitespace before the colon removed -- + /// the normalization common REQ-COMMON-39 gives the identity request + /// -- and with `_` read as `-`, since a CGI-style stack maps both to + /// one key; then the value after the colon with the optional + /// whitespace on either side removed. A line with no colon, or + /// nothing before it, is not a header, and says so rather than + /// reverting: the token head refuses one, the identity head leaves it + /// to the platform. + function _field(bytes memory line) private pure returns (bool isHeader, bytes memory name, bytes memory value) { uint256 colon; while (colon < line.length && line[colon] != ":") { ++colon; } - if (colon == line.length) revert WrongTokenRequestHead(); + if (colon == line.length) return (false, name, value); uint256 nameEnd = colon; while (nameEnd > 0 && (line[nameEnd - 1] == " " || line[nameEnd - 1] == "\t")) { --nameEnd; } - if (nameEnd == 0) revert WrongTokenRequestHead(); + if (nameEnd == 0) return (false, name, value); name = _slice(line, 0, nameEnd); for (uint256 i = 0; i < name.length; ++i) { if (name[i] >= "A" && name[i] <= "Z") name[i] = bytes1(uint8(name[i]) + 32); + if (name[i] == "_") name[i] = "-"; } + isHeader = true; uint256 start = colon + 1; uint256 end = line.length; while (start < end && (line[start] == " " || line[start] == "\t")) { @@ -589,7 +609,7 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa uint256 from; while (from <= block_.length) { uint256 to = _lineEnd(block_, from); - (bytes memory lineName,) = _field(_slice(block_, from, to)); + (, bytes memory lineName,) = _field(_slice(block_, from, to)); if (_equal(lineName, name)) return index; ++index; from = to + 2; @@ -603,7 +623,32 @@ abstract contract TlsNotaryVerifierBase is IPlatformVerifier, PlatformVerifierBa for (uint256 i = 0; i < index; ++i) { from = _lineEnd(block_, from) + 2; } - (, value) = _field(_slice(block_, from, _lineEnd(block_, from))); + (,, value) = _field(_slice(block_, from, _lineEnd(block_, from))); + } + + /// @dev Every revealed line of the identity request carries none of the + /// forbidden names but `authorization`, whose one line + /// `requireBearerHeaderRequest` has counted under any scheme. `cookie` + /// is the case: another credential the platform might honour over the + /// bearer the exchange is bound to, which is the one thing the + /// cross-bind exists to fix. Read over the whole concatenation, as the + /// count is, so a header after a blank line is refused too; a line + /// that is not a header is the platform's to refuse. + function _checkIdentityHead(bytes memory revealed) private pure { + uint256 from = _lineEnd(revealed, 0) + 2; + while (from < revealed.length) { + uint256 to = _lineEnd(revealed, from); + if (to > from) { + (bool isHeader, bytes memory name,) = _field(_slice(revealed, from, to)); + if ( + isHeader && !_equal(name, AUTHORIZATION) + && _indexOfLine(CeremonyProfile.FORBIDDEN_REQUEST_HEADERS, name) != type(uint256).max + ) { + revert ForbiddenRequestHeader(name); + } + } + from = to + 2; + } } /// @dev The offset of the CRLF that ends the line beginning at `from`, or diff --git a/solidity/contracts/ceremony/profiles.json b/solidity/contracts/ceremony/profiles.json index 0964dba..a319a7b 100644 --- a/solidity/contracts/ceremony/profiles.json +++ b/solidity/contracts/ceremony/profiles.json @@ -31,24 +31,30 @@ "a deployment stores and updates them." ] }, - "tokenRequest": { + "requests": { "forbiddenHeaders": [ "authorization", "content-encoding", "cookie", "transfer-encoding", - "x-http-method-override" + "x-http-method", + "x-http-method-override", + "x-method-override" ], "note": [ - "Header names a token request must not carry, compared lowercased by", - "every Platform Verifier. Each changes what the platform does with the", - "request in a way no revealed byte shows: `authorization` which client", - "it authenticates, `content-encoding` and `transfer-encoding` which", - "bytes it parses, `cookie` the context, `x-http-method-override` the", - "method. The verifier requires each token session's requiredHeaders,", - "`host` and `content-type`, reads `content-length`, and ignores every", - "other header: one outside both lists changes only what the platform", - "answers, and a wrong answer is a response the verifier cannot read." + "Header names no notarized request may carry, compared by every", + "Platform Verifier with the name lowercased, its whitespace removed and", + "`_` read as `-`. Each changes what the platform does with the request", + "in a way no revealed byte shows: `authorization` which client it", + "authenticates, `content-encoding` and `transfer-encoding` which bytes", + "it parses, `cookie` which session it answers for, the three override", + "names which method it runs. The identity request is excepted from", + "`authorization` alone: its one such header, under any scheme, is what", + "REQ-COMMON-39 counts. On the token request the verifier further", + "requires each session's requiredHeaders, `host` and `content-type`,", + "reads `content-length`, and ignores every other header: one outside", + "both lists changes only what the platform answers, and a wrong answer", + "is a response the verifier cannot read." ] }, "profiles": [ @@ -96,7 +102,7 @@ "its value. What else the browser sends -- `accept:", "application/json`, `connection: close` -- is its own and", "uncompared, as is any header a client adds, save the names in", - "tokenRequest.forbiddenHeaders. `content-length` is not among them:", + "requests.forbiddenHeaders. `content-length` is not among them:", "the HTTP client appends it, its value is the body's own length,", "and the verifier checks it against the length the notary signed." ] diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index b5e315d..5db2a05 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -331,6 +331,26 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } + /// @dev GitHub honours `token` and Basic beside Bearer. A second + /// `authorization` under either is counted all the same; counting only + /// `bearer` left it uncounted, and a leaked personal token in it would + /// have named someone else's account under this exchange's bearer. + function test_rejectsASecondAuthorizationHeaderOfAnotherSchemeOnTheIdentityRead() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityWithHeadPrefix("Authorization: token ghp_stolen\r\n"); + vm.expectPartialRevert(CeremonyAttestation.NotOneAuthorizationHeader.selector); + this.run{value: quote}(s); + } + + /// @dev And `cookie`, the other credential a platform might honour over + /// the bearer, is refused on the identity read by name. + function test_rejectsACookieOnTheIdentityRead() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityWithHeadPrefix("cookie: user_session=stolen\r\n"); + vm.expectRevert(abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenRequestHeader.selector, bytes("cookie"))); + this.run{value: quote}(s); + } + /// @dev The wrong authority is still refused before any field is read. function test_rejectsAnIdentityReadFromTheWrongAuthority() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 24b009c..9996395 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -441,6 +441,43 @@ contract XPlatformVerifierTest is Test { this.run{value: quote}(s); } + /// @dev And a second one under another scheme. The count sees + /// `authorization:` whatever follows it; counting only `bearer` left + /// a Basic line uncounted, and X answering for whichever credential + /// it honoured -- the one the exchange is bound to, or the other. + function test_rejectsASecondAuthorizationHeaderOfAnotherScheme() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityAttestation("2244994945", "alice", "Authorization: Basic dmljdGltOnN0b2xlbg==\r\n"); + vm.expectPartialRevert(CeremonyAttestation.NotOneAuthorizationHeader.selector); + this.run{value: quote}(s); + } + + /// @dev `cookie` is the other credential a platform might honour over the + /// bearer, and the bearer is the one thing the cross-bind ties to the + /// exchange. Forbidden on the identity request as on the token one. + function test_rejectsACookieOnTheIdentityRequest() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityAttestation("2244994945", "alice", "Cookie: auth_token=stolen\r\n"); + vm.expectRevert(abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenRequestHeader.selector, bytes("cookie"))); + this.run{value: quote}(s); + } + + /// @dev Any other header on the identity request is the runtime's own. + function test_acceptsAnUnlistedHeaderOnTheIdentityRequest() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityAttestation("2244994945", "alice", "user-agent: libid-ceremony\r\n"); + this.run{value: quote}(s); + } + + /// @dev A bare carriage return on the identity request is refused the + /// same way, before anything is counted. + function test_rejectsABareCarriageReturnOnTheIdentityRequest() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityAttestation("2244994945", "alice", "user-agent: a\rcookie: b\r\n"); + vm.expectPartialRevert(CeremonyAttestation.BareCarriageReturn.selector); + this.run{value: quote}(s); + } + function test_rejectsAnObsoleteLineFold() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); s.identitySession = _identityAttestation("2244994945", "alice", "authorization:\r\n Bearer stolen\r\n"); @@ -1110,29 +1147,36 @@ contract XPlatformVerifierTest is Test { "authorization: Basic bXlDbGllbnQtMTpzM2NyZXQ=\r\nconnection: close\r\n" ); vm.expectRevert( - abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenTokenRequestHeader.selector, bytes("authorization")) + abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenRequestHeader.selector, bytes("authorization")) ); this.run{value: quote}(s); } /// @dev Every forbidden name, each in a spelling the platform would read /// as the same header: another case, no space after the colon, a - /// space before it. The name comes back lowercased and trimmed, which - /// is how the list is compared. + /// space before it, an underscore where a CGI-style stack folds it + /// into the dash. The name comes back normalized, which is how the + /// list is compared. function test_rejectsEachForbiddenHeaderOnTheTokenRequest() public { - string[6] memory lines = [ + string[9] memory lines = [ "Transfer-Encoding: chunked", "content-encoding:gzip", + "Content_Encoding: gzip", "Cookie: session=abc", "X-HTTP-Method-Override: GET", + "x-http-method: DELETE", + "X-Method-Override: PUT", "AUTHORIZATION: Basic bXlDbGllbnQtMTpzM2NyZXQ=", "authorization : Basic bXlDbGllbnQtMTpzM2NyZXQ=" ]; - string[6] memory names = [ + string[9] memory names = [ "transfer-encoding", "content-encoding", + "content-encoding", "cookie", "x-http-method-override", + "x-http-method", + "x-method-override", "authorization", "authorization" ]; @@ -1145,7 +1189,7 @@ contract XPlatformVerifierTest is Test { ) ); vm.expectRevert( - abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenTokenRequestHeader.selector, bytes(names[i])) + abi.encodeWithSelector(TlsNotaryVerifierBase.ForbiddenRequestHeader.selector, bytes(names[i])) ); this.run{value: quote}(s); } @@ -1211,6 +1255,18 @@ contract XPlatformVerifierTest is Test { this.run{value: quote}(s); } + /// @dev A carriage return no line feed follows. A compliant parser never + /// ends a line on one, so it is refused rather than left to every + /// platform's handling of it. Tucked inside an ignored header's value, + /// where a parser that did split on it would find a second header. + function test_rejectsABareCarriageReturnInTheTokenHead() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHeaders( + "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\rauthorization: Basic x\r\nconnection: close\r\n" + ); + vm.expectPartialRevert(CeremonyAttestation.BareCarriageReturn.selector); + this.run{value: quote}(s); + } + /// @dev A line no colon splits is not a header, and a head carrying one is /// a head some parser somewhere reads differently. function test_rejectsAHeaderLineWithoutAColon() public { diff --git a/ts/packages/contracts/src/ceremony/profiles.ts b/ts/packages/contracts/src/ceremony/profiles.ts index 3e5663f..2c7633b 100644 --- a/ts/packages/contracts/src/ceremony/profiles.ts +++ b/ts/packages/contracts/src/ceremony/profiles.ts @@ -43,7 +43,7 @@ export interface TokenSession { readonly secretField: string | null /** The header lines a Platform Verifier requires, each exactly once with * its value: `host` and `content-type`. Every other header is the - * runtime's own, save the names `FORBIDDEN_TOKEN_REQUEST_HEADERS` lists. + * runtime's own, save the names `FORBIDDEN_REQUEST_HEADERS` lists. * `content-length` is absent: the HTTP client appends it. */ readonly requiredHeaders: readonly string[] } @@ -151,22 +151,28 @@ export function attestationCount(profile: Profile): number { } /** - * Header names a token request must not carry, compared lowercased by - * every Platform Verifier. Each changes what the platform does with the - * request in a way no revealed byte shows: `authorization` which client - * it authenticates, `content-encoding` and `transfer-encoding` which - * bytes it parses, `cookie` the context, `x-http-method-override` the - * method. The verifier requires each token session's requiredHeaders, - * `host` and `content-type`, reads `content-length`, and ignores every - * other header: one outside both lists changes only what the platform - * answers, and a wrong answer is a response the verifier cannot read. + * Header names no notarized request may carry, compared by every + * Platform Verifier with the name lowercased, its whitespace removed and + * `_` read as `-`. Each changes what the platform does with the request + * in a way no revealed byte shows: `authorization` which client it + * authenticates, `content-encoding` and `transfer-encoding` which bytes + * it parses, `cookie` which session it answers for, the three override + * names which method it runs. The identity request is excepted from + * `authorization` alone: its one such header, under any scheme, is what + * REQ-COMMON-39 counts. On the token request the verifier further + * requires each session's requiredHeaders, `host` and `content-type`, + * reads `content-length`, and ignores every other header: one outside + * both lists changes only what the platform answers, and a wrong answer + * is a response the verifier cannot read. */ -export const FORBIDDEN_TOKEN_REQUEST_HEADERS: readonly string[] = [ +export const FORBIDDEN_REQUEST_HEADERS: readonly string[] = [ 'authorization', 'content-encoding', 'cookie', 'transfer-encoding', + 'x-http-method', 'x-http-method-override', + 'x-method-override', ] /** Governance-owned launch parameters, in seconds. */ From 7732b2f57204e82ee995856ec3a91b34a845251b Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 16:43:33 +0100 Subject: [PATCH 06/10] test(ceremony): the heads the runtimes actually send verify 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 --- rust/profiles/tests/wire.rs | 43 +++++++++++++ .../test/GitHubPlatformVerifier.t.sol | 49 +++++++++++++++ .../ceremony/test/XPlatformVerifier.t.sol | 62 +++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/rust/profiles/tests/wire.rs b/rust/profiles/tests/wire.rs index e89add7..1d5d68b 100644 --- a/rust/profiles/tests/wire.rs +++ b/rust/profiles/tests/wire.rs @@ -151,6 +151,49 @@ async fn the_declared_length_is_the_body_and_moves_with_it() { } } +#[tokio::test] +async fn a_length_the_builder_sets_itself_is_written_once() { + // X's browser builder sets `content-length` itself, third among five, and + // hyper is still the encoder underneath tlsn's prover. A verifier requires + // exactly one, so what matters is that hyper keeps the caller's rather than + // adding its own beside it -- and keeps the value. + let session = X.token.expect("x notarizes a token session"); + let body: &'static [u8] = b"grant_type=authorization_code&client_id=abc&code=xyz"; + let (client, mut server) = tokio::io::duplex(1 << 12); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(TokioIo::new(client)) + .await + .expect("handshake"); + tokio::spawn(connection); + let mut request = hyper::Request::builder() + .method(session.session.method) + .uri(session.session.path); + for header in [ + "host: api.x.com", + "content-type: application/x-www-form-urlencoded", + "content-length: 52", + "accept: application/json", + "connection: close", + ] { + let (name, value) = header.split_once(": ").expect("`name: value`"); + request = request.header(name, value); + } + let request = request + .body(http_body_util::Full::new(hyper::body::Bytes::from(body))) + .expect("valid request"); + let sending = tokio::spawn(async move { sender.send_request(request).await }); + let mut wire = Vec::new(); + let mut buf = [0u8; 1024]; + while !wire.windows(4).any(|w| w == b"\r\n\r\n") { + let read = server.read(&mut buf).await.expect("read"); + assert!(read > 0, "the connection closed before the request head"); + wire.extend_from_slice(&buf[..read]); + } + drop(server); + let _ = sending.await; + assert_head_admits(&session, &wire, body.len()); +} + #[tokio::test] async fn hyper_writes_field_names_in_lower_case() { // The generator lays the head out in lowercase and its `validate` refuses diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index 5db2a05..5924b68 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -351,6 +351,55 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } + /// @dev The identity request as the browser composes it (`identityRequest` + /// on libid `feat/ceremony-rebuild-plan`): `host`, `authorization`, + /// `accept`, the browser's own `user-agent`, which GitHub demands, + /// `x-github-api-version`, `connection`, in that order and lowercased + /// by hyper. The exchange the Token-Exchange Service sends is the + /// happy path above already: `host`, `content-type`, `accept`, + /// `connection`, hyper's `content-length` last, which Heorhii ran + /// against GitHub for real. + function test_verifiesTheIdentityRequestTheBrowserSends() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityWithHead( + "GET /user HTTP/1.1\r\nhost: api.github.com\r\nauthorization: Bearer ", + "\r\naccept: application/vnd.github+json\r\n" + "user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n" + "x-github-api-version: 2022-11-28\r\nconnection: close\r\n\r\n" + ); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.handle, "octocat"); + } + + /// The honest identity read for a request given as the bytes before the + /// committed bearer and the bytes after it. + function _identityWithHead(bytes memory head, bytes memory tail) + private + pure + returns (ICeremony.Attestation memory) + { + bytes memory bearer = "gho_TOKENTOKENTOKEN"; + uint32 start = uint32(head.length); + uint32 end = start + uint32(bearer.length); + AttestationBuilder.Direction memory sent = AttestationBuilder.Direction({ + revealed: AttestationBuilder.two( + AttestationBuilder.Range({start: 0, value: head}), AttestationBuilder.Range({start: end, value: tail}) + ), + commitments: AttestationBuilder.one( + AttestationBuilder.Commitment({start: start, end: end, value: IDENTITY_COMMITMENT}) + ), + length: end + uint32(tail.length) + }); + bytes memory b = abi.encodePacked("HTTP/1.1 200 OK\r\n\r\n", '{"login":"octocat","id":583231}'); + AttestationBuilder.Direction memory received = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: b})), + commitments: AttestationBuilder.none(), + length: uint32(b.length) + }); + bytes memory attested = AttestationBuilder.encode(CeremonyProfile.AUTHORITY_GITHUB_API, T0, sent, received); + return ICeremony.Attestation({attestedData: attested, proof: _sign(attested)}); + } + /// @dev The wrong authority is still refused before any field is read. function test_rejectsAnIdentityReadFromTheWrongAuthority() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 9996395..6625b6b 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -432,6 +432,68 @@ contract XPlatformVerifierTest is Test { this.run{value: quote}(s); } + // ─── The requests the runtime sends ───────────────────────────── + + /// @dev The token request as the browser composes it (`buildTokenRequest` + /// on libid `feat/ceremony-rebuild-plan`): five headers in its order, + /// `content-length` set by the builder itself and third, every name + /// lowercased by hyper on the way to the wire. The rule was written + /// for this head, and this is what says the rule admits it. + function test_verifiesTheTokenRequestTheBrowserSends() public { + bytes memory body = _honestTokenBody(); + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payloadWithHead( + abi.encodePacked( + "POST /2/oauth2/token HTTP/1.1\r\nhost: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\ncontent-length: ", + vm.toString(body.length), + "\r\naccept: application/json\r\nconnection: close\r\n\r\n" + ) + ); + this.run{value: quote}(s); + } + + /// @dev And the identity request as `buildIdentityRequest` composes it: + /// `authorization` first and inside the head, then `accept`, `host`, + /// `connection`. The fixtures elsewhere in this file put the bearer + /// line after a blank line, which the verifier tolerates; this one is + /// the head a real session carries. + function test_verifiesTheIdentityRequestTheBrowserSends() public { + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.identitySession = _identityAttestationWithHead( + "GET /2/users/me HTTP/1.1\r\nauthorization: Bearer ", + "\r\naccept: application/json\r\nhost: api.x.com\r\nconnection: close\r\n\r\n" + ); + this.run{value: quote}(s); + } + + /// The honest identity attestation for a request given as the bytes + /// before the committed bearer and the bytes after it. + function _identityAttestationWithHead(bytes memory head, bytes memory tail) + private + pure + returns (ICeremony.Attestation memory) + { + bytes memory bearer = "TOKENTOKENTOKEN"; + uint32 start = uint32(head.length); + uint32 end = start + uint32(bearer.length); + AttestationBuilder.Direction memory sent = AttestationBuilder.Direction({ + revealed: AttestationBuilder.two( + AttestationBuilder.Range({start: 0, value: head}), AttestationBuilder.Range({start: end, value: tail}) + ), + commitments: AttestationBuilder.one( + AttestationBuilder.Commitment({start: start, end: end, value: IDENTITY_COMMITMENT}) + ), + length: end + uint32(tail.length) + }); + bytes memory body = 'HTTP/1.1 200 OK\r\n\r\n{"id":"2244994945","username":"alice"}'; + AttestationBuilder.Direction memory received = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: body})), + commitments: AttestationBuilder.none(), + length: uint32(body.length) + }); + bytes memory attested = AttestationBuilder.encode(CeremonyProfile.AUTHORITY_X_API, T0, sent, received); + return ICeremony.Attestation({attestedData: attested, proof: _sign(attested)}); + } + // ─── The identity request ─────────────────────────────────────── function test_rejectsASecondAuthorizationHeader() public { From ba99851a0ed386d33d2241475f2c4b1ef97b5e15 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Thu, 10 Sep 2026 17:15:29 +0100 Subject: [PATCH 07/10] test(ceremony): verify the records the Rust pipeline produces 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 --- .../test/GitHubPlatformVerifier.t.sol | 34 ++++++ .../ceremony/test/XPlatformVerifier.t.sol | 37 ++++++ .../fixtures/github-ceremony-session.json | 111 ++++++++++++++++++ .../test/fixtures/x-ceremony-session.json | 101 ++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json create mode 100644 solidity/contracts/ceremony/test/fixtures/x-ceremony-session.json diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index 5924b68..056760f 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -400,6 +400,40 @@ contract GitHubPlatformVerifierTest is Test { return ICeremony.Attestation({attestedData: attested, proof: _sign(attested)}); } + string constant RUST_SESSION = "contracts/ceremony/test/fixtures/github-ceremony-session.json"; + + /// @dev The exchange as the Token-Exchange Service composes it and the + /// identity read as the browser composes it, both encoded by hyper, + /// laid out by `libid_transcript::ceremony`, committed with tlsn's + /// SHA-256 plaintext hashes, recorded by `AttestedData::from_observed` + /// and signed by the key this suite trusts -- the Rust pipeline minus + /// the MPC, with nothing written by hand. Verified with those + /// signatures unedited; the verifier inside was derived from this + /// suite's digest, which the first assertion checks. Generated by + /// `cargo run -p libid-tlsn --example ceremony_fixtures` in libid-rs. + function test_verifiesTheRecordsLibidRsProduces() public { + string memory json = vm.readFile(RUST_SESSION); + assertEq(vm.parseJsonBytes32(json, ".authorization_digest"), digest, "derived from this suite's digest"); + assertEq(vm.parseJsonBytes32(json, ".authorization_nonce"), AUTH_NONCE); + assertEq(vm.parseJsonAddress(json, ".notary"), vm.addr(NOTARY_KEY), "signed by the key this suite trusts"); + assertEq(uint64(vm.parseJsonUint(json, ".created_at")), T0); + + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.tokenSession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".token.attested_data"), + proof: vm.parseJsonBytes(json, ".token.notary_signature") + }); + s.identitySession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".identity.attested_data"), + proof: vm.parseJsonBytes(json, ".identity.notary_signature") + }); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.userId, "583231"); + assertEq(f.handle, "octocat"); + assertEq(string(f.clientIdentifier), "Iv1.8a61f9b3a7aba766"); + assertEq(f.sessionId, digest); + } + /// @dev The wrong authority is still refused before any field is read. function test_rejectsAnIdentityReadFromTheWrongAuthority() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 6625b6b..618a883 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -494,6 +494,43 @@ contract XPlatformVerifierTest is Test { return ICeremony.Attestation({attestedData: attested, proof: _sign(attested)}); } + // ─── The records libid-rs produces ────────────────────────────── + + string constant RUST_SESSION = "contracts/ceremony/test/fixtures/x-ceremony-session.json"; + + /// @dev Two records the Rust pipeline produced the way a ceremony produces + /// them minus the MPC: the requests composed as the browser composes + /// them and encoded by hyper, the layouts `libid_transcript::ceremony`'s, + /// the commitments tlsn's SHA-256 plaintext hashes, the record + /// `AttestedData::from_observed`, which is the notary's own path, + /// signed by the key this suite trusts. Nothing in the file was + /// written by hand, and it verifies with those signatures unedited: + /// the verifier inside was derived from the digest this suite derives, + /// which the first assertion checks. Generated by + /// `cargo run -p libid-tlsn --example ceremony_fixtures` in libid-rs. + function test_verifiesTheRecordsLibidRsProduces() public { + string memory json = vm.readFile(RUST_SESSION); + assertEq(vm.parseJsonBytes32(json, ".authorization_digest"), digest, "derived from this suite's digest"); + assertEq(vm.parseJsonBytes32(json, ".authorization_nonce"), AUTH_NONCE); + assertEq(vm.parseJsonAddress(json, ".notary"), vm.addr(NOTARY_KEY), "signed by the key this suite trusts"); + assertEq(uint64(vm.parseJsonUint(json, ".created_at")), T0); + + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.tokenSession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".token.attested_data"), + proof: vm.parseJsonBytes(json, ".token.notary_signature") + }); + s.identitySession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".identity.attested_data"), + proof: vm.parseJsonBytes(json, ".identity.notary_signature") + }); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.userId, "2244994945"); + assertEq(f.handle, "alice"); + assertEq(string(f.clientIdentifier), "myClient-1"); + assertEq(f.sessionId, digest); + } + // ─── The identity request ─────────────────────────────────────── function test_rejectsASecondAuthorizationHeader() public { diff --git a/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json b/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json new file mode 100644 index 0000000..86e3549 --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json @@ -0,0 +1,111 @@ +{ + "authorization_digest": "0x09951c39dc62c5e74abc8c8607ec5c01b78e8d8bf6ff6da82f4cecbe72ace31f", + "authorization_nonce": "0x5555555555555555555555555555555555555555555555555555555555555555", + "ceremony_version": 1, + "chain_id": 31337, + "code_verifier": "NaJ86Rvwho11eSM3UDVCvhXt8Ykz9mWqpo2EVki2PH0", + "created_at": 1770000000, + "generator": "libid-rs: cargo run -p libid-tlsn --example ceremony_fixtures -- ", + "identity": { + "attested_data": "0xa5d9c1d593bc385a23a2d56116aab1951e3c66296476c7a7396a515105e8b2c10000000069800e8000000147000000be0000000000000002000000000000000000000040474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a20426561726572200000006600000000000000e10d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a00000000000000010000004000000066f6c048a06d27f964210b300557a462fd3f8b3e81316b417a40b5ca657e4509c200000000000000020000006a0000000000000011226c6f67696e223a226f63746f636174220000007c000000000000000c226964223a3538333233312c0000000000000003000000000000006ab73e53fe962328d05952843bccd3b104bda254cb940114ad8f2883166416dba40000007b0000007c0926c778d3d5497ce8d4d9217f62cef798f5bb014a033891fbe5d27942bb959c00000088000000bec5d5eb285de856569fc13d632cab59f34d73ec364787ed6ee7d26aa71a5cf2db", + "endpoint": "https://api.github.com/user", + "notary_signature": "0x725710768e8e432368a707c74333f7006a5a65cbb02528bde173a9441cadc2bb2aeb69fa925e587e5c5b3487200d0368c407433d33f15bb37cd066bc2ba75d2e1b", + "openings": [ + { + "blinder": "0x2631c6c187d57b2da6ceec7ff3565948", + "direction": "sent", + "ranges": [ + [ + 64, + 102 + ] + ] + }, + { + "blinder": "0x65ad8aa35f5ac32f6ba05b70d40d487c", + "direction": "received", + "ranges": [ + [ + 0, + 106 + ] + ] + }, + { + "blinder": "0xefd54da72e5fe1aa9f438616231eb26d", + "direction": "received", + "ranges": [ + [ + 123, + 124 + ] + ] + }, + { + "blinder": "0x15aaf672c0f08b1692599ceaf0b18a70", + "direction": "received", + "ranges": [ + [ + 136, + 190 + ] + ] + } + ], + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a2038350d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b226c6f67696e223a226f63746f636174222c226964223a3538333233312c226e6f64655f6964223a224d44513656584e6c636a55344d7a497a4d513d3d222c226e616d65223a22546865204f63746f636174227d", + "sent": "0x474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a204265617265722067686f5f56476870637942706379427562335167595342795a57467349474a6c59584a6c63670d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a" + }, + "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "operation_domain": "0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b", + "platform": "github", + "token": { + "attested_data": "0x06785da520052bf40d5bf506fb493c41162f55d4e17dffa8b21f02598e9815330000000069800e800000017f000000c30000000000000001000000000000000000000140504f5354202f6c6f67696e2f6f617574682f6163636573735f746f6b656e20485454502f312e310d0a686f73743a206769746875622e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a636f6e74656e742d6c656e6774683a203230370d0a0d0a636c69656e745f69643d4976312e3861363166396233613761626137363626636f64653d6162633132332672656469726563745f7572693d68747470732533412532462532466170702e6578616d706c65253246636226636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b69325048300000000000000001000001400000017f20fc3ee20a3c9a01ba2fe4d83ee3d79aef04568ad7904faebf3197eb6954efdb00000000000000020000006a0000000000000010226163636573735f746f6b656e223a22000000a00000000000000001220000000000000003000000000000006a7e321e6797d9677799af03b5066aa04bf19ed953750f3ac525c42b984d34777f0000007a000000a0b03a9a05bacef13ee707781f408db6742e38a5ec05ebf4244a6237c710ae986f000000a1000000c3e651e1d0e45b96591c33265d677a2033572834836de79568b908170bf5a28dc8", + "endpoint": "https://github.com/login/oauth/access_token", + "notary_signature": "0x22a0630c0b94db766cdf35de49558f96d009f7a3f6d72cffe6533cf2611ef1be0ed3781fb25c745ac5dbeef3d493d1e68d3e5173f7585466b094de2dcde835561c", + "openings": [ + { + "blinder": "0xf2bea951f6b30b9ac6d20b9b5fc8d897", + "direction": "sent", + "ranges": [ + [ + 320, + 383 + ] + ] + }, + { + "blinder": "0xe906469f340e60279fee288dfa21d72c", + "direction": "received", + "ranges": [ + [ + 0, + 106 + ] + ] + }, + { + "blinder": "0x4e9159aa47b770b706d1b66d61435d7a", + "direction": "received", + "ranges": [ + [ + 122, + 160 + ] + ] + }, + { + "blinder": "0x7973e68ddcaa8649646a049a405fab62", + "direction": "received", + "ranges": [ + [ + 161, + 195 + ] + ] + } + ], + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a2039300d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b226163636573735f746f6b656e223a2267686f5f56476870637942706379427562335167595342795a57467349474a6c59584a6c6367222c22746f6b656e5f74797065223a22626561726572222c2273636f7065223a22227d", + "sent": "0x504f5354202f6c6f67696e2f6f617574682f6163636573735f746f6b656e20485454502f312e310d0a686f73743a206769746875622e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a636f6e74656e742d6c656e6774683a203230370d0a0d0a636c69656e745f69643d4976312e3861363166396233613761626137363626636f64653d6162633132332672656469726563745f7572693d68747470732533412532462532466170702e6578616d706c65253246636226636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b693250483026636c69656e745f7365637265743d303132333435363738396162636465663031323334353637383961626364656630313233343536373839616263646566" + }, + "transaction_data": "0x000000000000000000000000000000000000000000000000000000000000beef" +} diff --git a/solidity/contracts/ceremony/test/fixtures/x-ceremony-session.json b/solidity/contracts/ceremony/test/fixtures/x-ceremony-session.json new file mode 100644 index 0000000..9eed44e --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/x-ceremony-session.json @@ -0,0 +1,101 @@ +{ + "authorization_digest": "0x09951c39dc62c5e74abc8c8607ec5c01b78e8d8bf6ff6da82f4cecbe72ace31f", + "authorization_nonce": "0x5555555555555555555555555555555555555555555555555555555555555555", + "ceremony_version": 1, + "chain_id": 31337, + "code_verifier": "NaJ86Rvwho11eSM3UDVCvhXt8Ykz9mWqpo2EVki2PH0", + "created_at": 1770000000, + "generator": "libid-rs: cargo run -p libid-tlsn --example ceremony_fixtures -- ", + "identity": { + "attested_data": "0x4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d0000000069800e8000000094000000a70000000000000002000000000000000000000030474554202f322f75736572732f6d6520485454502f312e310d0a617574686f72697a6174696f6e3a20426561726572200000005200000000000000420d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a686f73743a206170692e782e636f6d0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a000000000000000100000030000000525be56986e68f1b897eb1c8afbbfa9456616288fd3e2d4dfa93cab04e408c23cd0000000000000002000000710000000000000011226964223a22323234343939343934352200000093000000000000001222757365726e616d65223a22616c69636522000000000000000300000000000000718ea45b4e3f64bc51585456193147791aec31b67325e6d630a6786779c637542c000000820000009302e1ca0b479f5032b3636e13d32afa669fb4146b10b36136639453113c3431ad000000a5000000a77402ee22700f0641e4240b9ed0d3d85e9be0e4677fa3ed39a176e2b49d089a06", + "endpoint": "https://api.x.com/2/users/me", + "notary_signature": "0xa32c0136e5815755c3726fef344652933be85274e0d901f2cf3ce5f8444f61d573e19dfdb8e6adb1fa0c9403cf4acb60a86a184c5776d5fc66186116e92783cf1c", + "openings": [ + { + "blinder": "0xcf072daf48b6cc42ffd7c4bfe491249c", + "direction": "sent", + "ranges": [ + [ + 48, + 82 + ] + ] + }, + { + "blinder": "0x2243bdf7bceea575ef731ac6ab71a361", + "direction": "received", + "ranges": [ + [ + 0, + 113 + ] + ] + }, + { + "blinder": "0xb2d7f429229b4e5dc944859620148954", + "direction": "received", + "ranges": [ + [ + 130, + 147 + ] + ] + }, + { + "blinder": "0xe0b4050c929eaf0c97dfe82cb136b35d", + "direction": "received", + "ranges": [ + [ + 165, + 167 + ] + ] + } + ], + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a2036330d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b2264617461223a7b226964223a2232323434393934393435222c226e616d65223a22416c20496365222c22757365726e616d65223a22616c696365227d7d", + "sent": "0x474554202f322f75736572732f6d6520485454502f312e310d0a617574686f72697a6174696f6e3a204265617265722056476870637942706379427562335167595342795a57467349474a6c59584a6c63670d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a686f73743a206170692e782e636f6d0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a" + }, + "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "operation_domain": "0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b", + "platform": "x", + "token": { + "attested_data": "0x4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d0000000069800e8000000149000000e60000000000000001000000000000000000000149504f5354202f322f6f61757468322f746f6b656e20485454502f312e310d0a686f73743a206170692e782e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a636f6e74656e742d6c656e6774683a203136340d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a6772616e745f747970653d617574686f72697a6174696f6e5f636f646526636c69656e745f69643d6d79436c69656e742d3126636f64653d6162633132332672656469726563745f7572693d68747470732533412532462532466170702e6578616d706c65253246636226636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b693250483000000000000000000000000000000002000000920000000000000010226163636573735f746f6b656e223a22000000c400000000000000012200000000000000030000000000000092125e59e648a8c1c6cb1b37d7153da2bff83daf2ebb9130fa9530388bb55a9be7000000a2000000c4d6c2b57cb9ea67472af16d3f11f3eb3fc484acaf0bf5ee908e5c2b89e05c2ac6000000c5000000e66aadbc7401d06e2e80f03d1d2ffa62e1fdac081a3868f8387613be7be92ce04a", + "endpoint": "https://api.x.com/2/oauth2/token", + "notary_signature": "0x5599428380d93a67619c20495515bb4d4492f8761e9065f65730188bd5edd015047b3a8828f02577294c88a9c91a543c5e3107727df97432c6c9d9962c7cd1c61c", + "openings": [ + { + "blinder": "0x1673d722bbc07af3aac8e5845a6fabb3", + "direction": "received", + "ranges": [ + [ + 0, + 146 + ] + ] + }, + { + "blinder": "0x450dd509cdc99600441854638a1fc449", + "direction": "received", + "ranges": [ + [ + 162, + 196 + ] + ] + }, + { + "blinder": "0xb26e4cb5b5fa0253539ef800cbe65744", + "direction": "received", + "ranges": [ + [ + 197, + 230 + ] + ] + } + ], + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a203132350d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b22746f6b656e5f74797065223a22626561726572222c22657870697265735f696e223a373230302c226163636573735f746f6b656e223a2256476870637942706379427562335167595342795a57467349474a6c59584a6c6367222c2273636f7065223a2275736572732e726561642074776565742e72656164227d", + "sent": "0x504f5354202f322f6f61757468322f746f6b656e20485454502f312e310d0a686f73743a206170692e782e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a636f6e74656e742d6c656e6774683a203136340d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a6772616e745f747970653d617574686f72697a6174696f6e5f636f646526636c69656e745f69643d6d79436c69656e742d3126636f64653d6162633132332672656469726563745f7572693d68747470732533412532462532466170702e6578616d706c65253246636226636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b6932504830" + }, + "transaction_data": "0x000000000000000000000000000000000000000000000000000000000000beef" +} From af2c0f08dd0b49f5233bef782fc996bd39fbad53 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Fri, 11 Sep 2026 00:09:15 +0100 Subject: [PATCH 08/10] test(ceremony): the GitHub fixture carries the formatting GitHub serves 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 --- .../test/GitHubPlatformVerifier.t.sol | 19 +++++++++++++++++++ .../fixtures/github-ceremony-session.json | 16 ++++++++-------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index 4930d9b..8da0fb8 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -420,6 +420,13 @@ contract GitHubPlatformVerifierTest is Test { assertEq(vm.parseJsonBytes32(json, ".authorization_nonce"), AUTH_NONCE); assertEq(vm.parseJsonAddress(json, ".notary"), vm.addr(NOTARY_KEY), "signed by the key this suite trusts"); assertEq(uint64(vm.parseJsonUint(json, ".created_at")), T0); + // The identity response is formatted as GitHub formats it for the + // media type the profile pins, whitespace and all. A compact body here + // once let this fixture pass a verifier that refused every real read. + assertTrue( + _contains(vm.parseJsonBytes(json, ".identity.received"), bytes('"login": "octocat"')), + "the fixture carries GitHub's pretty-printed response" + ); TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); s.tokenSession = ICeremony.Attestation({ @@ -437,6 +444,18 @@ contract GitHubPlatformVerifierTest is Test { assertEq(f.sessionId, digest); } + function _contains(bytes memory haystack, bytes memory needle) private pure returns (bool) { + if (needle.length > haystack.length) return false; + for (uint256 i = 0; i + needle.length <= haystack.length; ++i) { + bool same = true; + for (uint256 j = 0; j < needle.length && same; ++j) { + same = haystack[i + j] == needle[j]; + } + if (same) return true; + } + return false; + } + /// @dev GitHub pretty-prints `/user` for the media type the profile pins: /// a newline and two spaces before every member, a space after every /// colon. The readers remove JSON whitespace before they look, so the diff --git a/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json b/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json index 86e3549..5ae23b8 100644 --- a/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json +++ b/solidity/contracts/ceremony/test/fixtures/github-ceremony-session.json @@ -7,9 +7,9 @@ "created_at": 1770000000, "generator": "libid-rs: cargo run -p libid-tlsn --example ceremony_fixtures -- ", "identity": { - "attested_data": "0xa5d9c1d593bc385a23a2d56116aab1951e3c66296476c7a7396a515105e8b2c10000000069800e8000000147000000be0000000000000002000000000000000000000040474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a20426561726572200000006600000000000000e10d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a00000000000000010000004000000066f6c048a06d27f964210b300557a462fd3f8b3e81316b417a40b5ca657e4509c200000000000000020000006a0000000000000011226c6f67696e223a226f63746f636174220000007c000000000000000c226964223a3538333233312c0000000000000003000000000000006ab73e53fe962328d05952843bccd3b104bda254cb940114ad8f2883166416dba40000007b0000007c0926c778d3d5497ce8d4d9217f62cef798f5bb014a033891fbe5d27942bb959c00000088000000bec5d5eb285de856569fc13d632cab59f34d73ec364787ed6ee7d26aa71a5cf2db", + "attested_data": "0xa5d9c1d593bc385a23a2d56116aab1951e3c66296476c7a7396a515105e8b2c10000000069800e8000000147000001280000000000000002000000000000000000000040474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a20426561726572200000006600000000000000e10d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a00000000000000010000004000000066f6c048a06d27f964210b300557a462fd3f8b3e81316b417a40b5ca657e4509c200000000000000020000006e0000000000000012226c6f67696e223a20226f63746f6361742200000084000000000000000d226964223a203538333233312c0000000000000003000000000000006edb7618fa1bb6a64a9e94e5aee148c0a10b54efd38198efbfead2b453c7df572d0000008000000084490f62acb5ee7240852524459041ae668f222d39df74844d45d02ff2fa1de9fd0000009100000128373107ca82d268b129970919759210549ebacb84e96a90f21d0d2bfa756282c6", "endpoint": "https://api.github.com/user", - "notary_signature": "0x725710768e8e432368a707c74333f7006a5a65cbb02528bde173a9441cadc2bb2aeb69fa925e587e5c5b3487200d0368c407433d33f15bb37cd066bc2ba75d2e1b", + "notary_signature": "0x9b3259cd86944f9854456cc0a91d7f4a5b62990249c2e439ad6f81163ed5ec364a99eb66d4f1c3d038bff87355da930bf4d790aada2f0c6c5800c428f86f3df31b", "openings": [ { "blinder": "0x2631c6c187d57b2da6ceec7ff3565948", @@ -27,7 +27,7 @@ "ranges": [ [ 0, - 106 + 110 ] ] }, @@ -36,8 +36,8 @@ "direction": "received", "ranges": [ [ - 123, - 124 + 128, + 132 ] ] }, @@ -46,13 +46,13 @@ "direction": "received", "ranges": [ [ - 136, - 190 + 145, + 296 ] ] } ], - "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a2038350d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b226c6f67696e223a226f63746f636174222c226964223a3538333233312c226e6f64655f6964223a224d44513656584e6c636a55344d7a497a4d513d3d222c226e616d65223a22546865204f63746f636174227d", + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a203139300d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b0a2020226c6f67696e223a20226f63746f636174222c0a2020226964223a203538333233312c0a2020226e6f64655f6964223a20224d44513656584e6c636a55344d7a497a4d513d3d222c0a2020226176617461725f75726c223a202268747470733a2f2f617661746172732e67697468756275736572636f6e74656e742e636f6d2f752f3538333233313f763d34222c0a20202274797065223a202255736572222c0a2020226e616d65223a2022546865204f63746f636174220a7d", "sent": "0x474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a204265617265722067686f5f56476870637942706379427562335167595342795a57467349474a6c59584a6c63670d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a" }, "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", From ce3a1217da940c8fe0be964f65f628b60149a143 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Fri, 11 Sep 2026 00:51:24 +0100 Subject: [PATCH 09/10] test(ceremony): verify a GitHub ceremony that actually ran 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 --- .../test/GitHubPlatformVerifier.t.sol | 40 +++++++++++++++++++ .../test/fixtures/github-ceremony-real.json | 27 +++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 solidity/contracts/ceremony/test/fixtures/github-ceremony-real.json diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index 8da0fb8..4aec45c 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -480,6 +480,46 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } + string constant REAL_SESSION = "contracts/ceremony/test/fixtures/github-ceremony-real.json"; + + /// @dev A ceremony that actually ran: two MPC-TLS sessions against + /// github.com and api.github.com on 2026-09-11, the exchange with a + /// real authorization code under the PKCE challenge derived from this + /// suite's digest, the identity read with the bearer GitHub issued, + /// the verifier in the prover's process signing as the key this + /// suite trusts (libid-rs `examples/capture_ceremony.rs`). 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, and the bearer and the secret are committed, not + /// present. Verified with the signatures unedited, at a clock a minute + /// past the identity read. + function test_verifiesTheRecordsACeremonyProduced() public { + string memory json = vm.readFile(REAL_SESSION); + assertEq(vm.parseJsonBytes32(json, ".authorization_digest"), digest, "bound to this suite's digest"); + assertEq(vm.parseJsonBytes32(json, ".authorization_nonce"), AUTH_NONCE); + assertEq(vm.parseJsonAddress(json, ".notary"), vm.addr(NOTARY_KEY), "signed by the key this suite trusts"); + assertTrue( + _contains(vm.parseJsonBytes(json, ".identity.attested_data"), bytes('"login": "')), + "GitHub's pretty-printed response, as served" + ); + vm.warp(vm.parseJsonUint(json, ".identity.created_at") + 60); + + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.tokenSession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".token.attested_data"), + proof: vm.parseJsonBytes(json, ".token.notary_signature") + }); + s.identitySession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".identity.attested_data"), + proof: vm.parseJsonBytes(json, ".identity.notary_signature") + }); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.userId, "18346821"); + assertEq(f.handle, "xgreenx"); + assertEq(string(f.clientIdentifier), "Ov23liIOfT7uQ9707Fpz"); + assertEq(f.sessionId, digest); + } + /// @dev The wrong authority is still refused before any field is read. function test_rejectsAnIdentityReadFromTheWrongAuthority() public { TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); diff --git a/solidity/contracts/ceremony/test/fixtures/github-ceremony-real.json b/solidity/contracts/ceremony/test/fixtures/github-ceremony-real.json new file mode 100644 index 0000000..c93c89a --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/github-ceremony-real.json @@ -0,0 +1,27 @@ +{ + "authorization_digest": "0x09951c39dc62c5e74abc8c8607ec5c01b78e8d8bf6ff6da82f4cecbe72ace31f", + "authorization_nonce": "0x5555555555555555555555555555555555555555555555555555555555555555", + "captured_at": 1789084207, + "ceremony_version": 1, + "chain_id": 31337, + "code_verifier": "NaJ86Rvwho11eSM3UDVCvhXt8Ykz9mWqpo2EVki2PH0", + "identity": { + "attested_data": "0xa5d9c1d593bc385a23a2d56116aab1951e3c66296476c7a7396a515105e8b2c1000000006aa3422f0000014900000c710000000000000002000000000000000000000040474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a20426561726572200000006800000000000000e10d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a000000000000000100000040000000684a8b915bb6394ab6c2bfba66f54089a17e90eed92df06160e9796c24504a84130000000000000002000005b30000000000000012226c6f67696e223a202278677265656e7822000005c9000000000000000f226964223a2031383334363832312c000000000000000300000000000005b35183621029bef8d9d52e91675ec58ecf770847f2e499edfd905c55d3cd65f0bd000005c5000005c9d820f020ce766b8ba0e815ffb641af76a82c7fd66731482284a43b8aee093ca6000005d800000c71af235a0c9e39a1d376b0587288d38baebee103983763f194fbf81083912dc201", + "authority": "api.github.com", + "created_at": 1789084207, + "endpoint": "https://api.github.com/user", + "notary_signature": "0x6177d3bedb32a33c5396ead197a7cf2f787636b55143e8a722e2237975be4a232d0cc48b58cc6fd710f1f365f72a5073d6c369934151dc315cccebdcc9a9f79e1b" + }, + "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "operation_domain": "0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b", + "platform": "github", + "source": "captured: a real MPC-TLS session against the platform, the verifier in-process, by libid-rs examples/capture_ceremony.rs", + "token": { + "attested_data": "0x06785da520052bf40d5bf506fb493c41162f55d4e17dffa8b21f02598e981533000000006aa342050000019f000012410000000000000001000000000000000000000168504f5354202f6c6f67696e2f6f617574682f6163636573735f746f6b656e20485454502f312e310d0a686f73743a206769746875622e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a636f6e74656e742d6c656e6774683a203233390d0a0d0a636c69656e745f69643d4f7632336c69494f66543775513937303746707a26636f64653d63383434383335663636316164633930616266362672656469726563745f7572693d687474702533412532462532463132372e302e302e31253341383732322532466175746825324667697468756225324663616c6c6261636b26636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b69325048300000000000000001000001680000019f3c3c7c45453cdc6951880d71bc1928371f2074323b3d45f1b2937c775c9e767f0000000000000002000011d60000000000000010226163636573735f746f6b656e223a220000120e000000000000000122000000000000000300000000000011d6b4c1eeaa38e297c51ce58609d94b3e447c88ad940fbb782b63fa78e22a8b86ed000011e60000120e672ce8194b6527adae0462be6adb1693612c6d714a5ae2ea35d5b60a5435885c0000120f00001241e028481b94f4c033d88de66386fd9b4cefca86568ba9d09dd7f3af9f8918e1e9", + "authority": "github.com", + "created_at": 1789084165, + "endpoint": "https://github.com/login/oauth/access_token", + "notary_signature": "0xe25b86e34c0c354dab242be5c80af5f31886002077135a69511655dad048fda672ce3c2562f9b466c48ccada92dd4c21accd9ef1c3cdd79a682c4c12ed326f891b" + }, + "transaction_data": "0x000000000000000000000000000000000000000000000000000000000000beef" +} From 53d28717a7b214fe6293118d2af4deaf14b86cb1 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Fri, 11 Sep 2026 00:54:04 +0100 Subject: [PATCH 10/10] test(ceremony): verify an X ceremony that actually ran 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 --- .../ceremony/test/XPlatformVerifier.t.sol | 39 +++++++++++++++++++ .../test/fixtures/x-ceremony-real.json | 27 +++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 solidity/contracts/ceremony/test/fixtures/x-ceremony-real.json diff --git a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol index 618a883..134455a 100644 --- a/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/XPlatformVerifier.t.sol @@ -531,6 +531,45 @@ contract XPlatformVerifierTest is Test { assertEq(f.sessionId, digest); } + string constant REAL_SESSION = "contracts/ceremony/test/fixtures/x-ceremony-real.json"; + + /// @dev A ceremony that actually ran: two MPC-TLS sessions against + /// api.x.com on 2026-09-11, the exchange as a public client with a + /// real authorization code under the PKCE challenge derived from this + /// suite's digest, the identity read with the bearer X issued, the + /// verifier in the prover's process signing as the key this suite + /// trusts (libid-rs `examples/capture_ceremony.rs`). Nothing in the + /// file was written by hand; the bearer is committed and absent from + /// the bytes. X serializes both responses compact, which this file + /// records rather than assumes. Verified with the signatures unedited, + /// at a clock a minute past the identity read. + function test_verifiesTheRecordsACeremonyProduced() public { + string memory json = vm.readFile(REAL_SESSION); + assertEq(vm.parseJsonBytes32(json, ".authorization_digest"), digest, "bound to this suite's digest"); + assertEq(vm.parseJsonBytes32(json, ".authorization_nonce"), AUTH_NONCE); + assertEq(vm.parseJsonAddress(json, ".notary"), vm.addr(NOTARY_KEY), "signed by the key this suite trusts"); + assertTrue( + _contains(vm.parseJsonBytes(json, ".identity.attested_data"), bytes('"username":"')), + "X's compact response, as served" + ); + vm.warp(vm.parseJsonUint(json, ".identity.created_at") + 60); + + TlsNotaryVerifierBase.TlsNotaryProof memory s = _payload(); + s.tokenSession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".token.attested_data"), + proof: vm.parseJsonBytes(json, ".token.notary_signature") + }); + s.identitySession = ICeremony.Attestation({ + attestedData: vm.parseJsonBytes(json, ".identity.attested_data"), + proof: vm.parseJsonBytes(json, ".identity.notary_signature") + }); + ICeremony.VerifiedClaim memory f = this.run{value: quote}(s); + assertEq(f.userId, "1051915704843333634"); + assertEq(f.handle, "GreenToo3"); + assertEq(string(f.clientIdentifier), "MnY0bnJ6VzFGY2hVNmF2N2RFWkg6MTpjaQ"); + assertEq(f.sessionId, digest); + } + // ─── The identity request ─────────────────────────────────────── function test_rejectsASecondAuthorizationHeader() public { diff --git a/solidity/contracts/ceremony/test/fixtures/x-ceremony-real.json b/solidity/contracts/ceremony/test/fixtures/x-ceremony-real.json new file mode 100644 index 0000000..10e91f5 --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/x-ceremony-real.json @@ -0,0 +1,27 @@ +{ + "authorization_digest": "0x09951c39dc62c5e74abc8c8607ec5c01b78e8d8bf6ff6da82f4cecbe72ace31f", + "authorization_nonce": "0x5555555555555555555555555555555555555555555555555555555555555555", + "captured_at": 1789084391, + "ceremony_version": 1, + "chain_id": 31337, + "code_verifier": "NaJ86Rvwho11eSM3UDVCvhXt8Ykz9mWqpo2EVki2PH0", + "identity": { + "attested_data": "0x4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d000000006aa342e7000000cd000005120000000000000002000000000000000000000030474554202f322f75736572732f6d6520485454502f312e310d0a617574686f72697a6174696f6e3a20426561726572200000008b00000000000000420d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a686f73743a206170692e782e636f6d0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a0000000000000001000000300000008be39b869e9aad6588cc1ed1cb1e322a9db50e50647b33a16a41ee74feb95389fd0000000000000002000004c5000000000000001a226964223a223130353139313537303438343333333336333422000004f3000000000000001622757365726e616d65223a22477265656e546f6f3322000000000000000300000000000004c59e824b1e201e8941f8ef6e4c5a1850d220124ec50f7148a0a1f89ed1c2de9d3b000004df000004f35eb67d8854d7eb1ce4aac86ca0dc4b9818eeb7cc283fb68bcdd8e4b74c9ee251000005090000051299188017a902baeb528d572075e2501b85d4a2010bb07655dcced368d2c1698c", + "authority": "api.x.com", + "created_at": 1789084391, + "endpoint": "https://api.x.com/2/users/me", + "notary_signature": "0xc3840df6f300ecd363b652f9d7eb6486e564ef1bc63a4f70c4c4216ea69a287d4eab29efa9b9100f1e3ec32209e175d0fdea943e435d7811cbccea2f7789490a1b" + }, + "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "operation_domain": "0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b", + "platform": "x", + "source": "captured: a real MPC-TLS session against the platform, the verifier in-process, by libid-rs examples/capture_ceremony.rs", + "token": { + "attested_data": "0x4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d000000006aa342d6000001cb000005b000000000000000010000000000000000000001cb504f5354202f322f6f61757468322f746f6b656e20485454502f312e310d0a686f73743a206170692e782e636f6d0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a636f6e74656e742d6c656e6774683a203239340d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a6772616e745f747970653d617574686f72697a6174696f6e5f636f646526636c69656e745f69643d4d6e5930626e4a36567a4647593268564e6d46324e325246576b67364d54706a615126636f64653d4f456430576a5931596b6c6c526d4a745548523361456c564f45303558335934646b31554d6e566b63314e4654555a77526a6c57616b646e4e44564a4f6a45334f446b774f44517a4d7a55324d7a51364d546f774f6d466a4f6a452672656469726563745f7572693d687474702533412532462532463132372e302e302e3125334138373232253246617574682532467825324663616c6c6261636b26636f64655f76657269666965723d4e614a3836527677686f313165534d33554456437668587438596b7a396d5771706f3245566b6932504830000000000000000000000000000000020000051c0000000000000010226163636573735f746f6b656e223a22000005870000000000000001220000000000000003000000000000051c125e1fc2dd0f9f9978401c1915178bff8b0acc8a656457cced9f8d36c64a39320000052c000005873ff21750cd25e2bcf84804b9c692981028915037324f3918e05c3e3e34f92e6b00000588000005b00518f41bc4ef7c5aafef1ed73dc694389ecc22e9dda7e77d7ebb569e44202ac0", + "authority": "api.x.com", + "created_at": 1789084374, + "endpoint": "https://api.x.com/2/oauth2/token", + "notary_signature": "0x0ce76c7791d7c71c91925650bf5bfadb3b809f1b0f7a2b2a25ef63647b9c1f725b2376a272494a214e6efc5e6b717db85cb257d8f06d5b0fed74313278d7a11b1b" + }, + "transaction_data": "0x000000000000000000000000000000000000000000000000000000000000beef" +}