diff --git a/rust/profiles/src/profiles.rs b/rust/profiles/src/profiles.rs index 5d00170..d18ce49 100644 --- a/rust/profiles/src/profiles.rs +++ b/rust/profiles/src/profiles.rs @@ -60,15 +60,13 @@ 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 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 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_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], } /// The identity session: the authenticated read that names the account. @@ -123,8 +121,10 @@ 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"], - 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 { @@ -154,8 +154,10 @@ 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"], - 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 +184,29 @@ pub fn launch(platform: &str) -> Option<&'static Profile> { LAUNCH.iter().copied().find(|p| p.platform == platform) } +/// 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. 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..02eb000 100644 --- a/rust/profiles/tests/vectors.rs +++ b/rust/profiles/tests/vectors.rs @@ -11,6 +11,7 @@ //! follows. use libid_profiles::{ + FORBIDDEN_REQUEST_HEADERS, GITHUB, GOOGLE, LAUNCH, @@ -155,33 +156,52 @@ 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_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; }; - // 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") - ); - - assert!( - !token - .request_headers - .iter() - .any(|header| header.starts_with("content-length:")), - "the HTTP client appends the length; a listed one would move it" - ); + let mut names: Vec<&str> = token + .required_headers + .iter() + .map(|line| line.split(':').next().unwrap()) + .collect(); + names.sort_unstable(); + assert_eq!(names, ["content-type", "host"]); 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_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 requires a name and forbids it rejects every honest + // session. + for name in FORBIDDEN_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.required_headers { + let name = line.split(':').next().unwrap(); + assert!( + !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 4da4872..1d5d68b 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 -//! `hyper::client::conn::http1` encoder over an in-memory duplex, so what is -//! compared is the bytes hyper actually wrote. +//! 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 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 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_REQUEST_HEADERS, GITHUB, X, }; @@ -38,9 +39,11 @@ async fn head_hyper_writes(session: &TokenSession, body: &'static [u8]) -> Vec 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_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] @@ -142,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/scripts/regen-ceremony-profiles.py b/scripts/regen-ceremony-profiles.py index a917a3b..6357a21 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,43 @@ 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, 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, and nothing else is stated: what a +# runtime adds beyond these is its own. +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 forbidden_headers(spec: dict[str, Any]) -> list[str]: + """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. """ - return "\r\n".join(session["requestHeaders"]) + names = spec["requests"]["forbiddenHeaders"] + if not isinstance(names, list) or not names: + 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: requests.forbiddenHeaders names one header twice") + return names def sessions_of(profile: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: @@ -165,40 +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_headers(spec) seen: set[str] = set() for profile in spec["profiles"]: platform = profile["platform"] @@ -213,7 +230,7 @@ def validate(spec: dict[str, Any]) -> None: safe(session["method"], "method") safe(session["path"], "path") if name == "token": - request_headers(session, host) + required_headers(session) if session["secretField"] is not None: safe(session["secretField"], "secretField") if name == "identity": @@ -313,27 +330,29 @@ 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 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. What else a runtime sends is its own and not stated here.", "", ] for profile in profiles: token = profile["sessions"].get("token") if token is None: continue - const = f"{upper(profile['platform'])}_TOKEN_REQUEST_HEADERS" + const = f"{upper(profile['platform'])}_TOKEN_REQUIRED_HEADERS" lines.append( - f' bytes internal constant {const} = "{escaped(request_header_block(token))}";' + f' bytes internal constant {const} = "{escaped(crlf(required_headers(token)))}";' ) + lines += [""] + lines += sol_doc(spec["requests"].get("note")) + lines.append( + f' bytes internal constant FORBIDDEN_REQUEST_HEADERS = "{escaped(crlf(forbidden_headers(spec)))}";' + ) + lines += [ "", " /// @dev How many committed ranges the token request carries. A confidential", @@ -457,6 +476,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="//!") @@ -499,15 +543,13 @@ 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 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 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_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],", "}", "", "/// The identity session: the authenticated read that names the account.", @@ -552,14 +594,10 @@ 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}],") - lines.append( - f' request_header_block: "{escaped(request_header_block(token))}",' - ) + lines += rust_array("required_headers: ", required_headers(token), " ", ",") lines.append(" }),") identity = profile["sessions"].get("identity") @@ -589,6 +627,11 @@ def gen_rust(spec: dict[str, Any]) -> str: " LAUNCH.iter().copied().find(|p| p.platform == platform)", "}", "", + ] + 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.", f"pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = " f"{spec['parameters']['maxFutureAttestationSkewSeconds']};", @@ -638,6 +681,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")) @@ -659,12 +722,11 @@ 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_REQUEST_HEADERS` lists.", " * `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", + " readonly requiredHeaders: readonly string[]", "}", "", "export interface IdentitySession {", @@ -698,13 +760,7 @@ 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("requiredHeaders: ", required_headers(token), " ", ",") lines.append(" },") identity = profile["sessions"].get("identity") @@ -734,6 +790,13 @@ def gen_ts(spec: dict[str, Any]) -> str: " return (profile.token ? 1 : 0) + (profile.identity ? 1 : 0)", "}", "", + ] + lines += ts_doc(spec["requests"].get("note")) + lines += ts_array( + "export const FORBIDDEN_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/CeremonyAttestation.sol b/solidity/contracts/ceremony/CeremonyAttestation.sol index 1334575..fdb4bf3 100644 --- a/solidity/contracts/ceremony/CeremonyAttestation.sol +++ b/solidity/contracts/ceremony/CeremonyAttestation.sol @@ -97,6 +97,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 @@ -141,8 +146,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 these revealed bytes, JSON /// whitespace aside. @@ -327,6 +335,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 106a416..8c3b471 100644 --- a/solidity/contracts/ceremony/CeremonyProfile.sol +++ b/solidity/contracts/ceremony/CeremonyProfile.sol @@ -59,21 +59,33 @@ 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. - - 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. 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"; + bytes internal constant GITHUB_TOKEN_REQUIRED_HEADERS = + "host: github.com\r\ncontent-type: application/x-www-form-urlencoded"; + + /// @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/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 c303e7d..f3402b6 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,8 @@ 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 private constant AUTHORIZATION = "authorization"; bytes internal constant ACCESS_TOKEN_PREFIX = '"access_token":"'; bytes internal constant ACCESS_TOKEN_SUFFIX = '"'; @@ -98,10 +100,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 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. @@ -114,15 +120,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_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 @@ -385,6 +393,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 @@ -485,21 +494,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. @@ -507,36 +522,135 @@ 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 + bytes memory required = _tokenRequiredHeaders(); + // 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; - 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); - - if (_startsWith(line, LENGTH_HEADER)) { - if (lengths != 0) revert WrongTokenRequestHead(); - lengths = 1; - declared = _decimal(line, LENGTH_HEADER.length); - } else { - uint256 i = _indexOfLine(expected, line); - if (i == type(uint256).max) 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 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, 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) return (false, name, value); + uint256 nameEnd = colon; + while (nameEnd > 0 && (line[nameEnd - 1] == " " || line[nameEnd - 1] == "\t")) { + --nameEnd; + } + 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")) { + ++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))); + } - if (lengths == 0) revert WrongTokenRequestHead(); - // Every expected line seen: the low `wanted` bits all set. - if (found != (1 << wanted) - 1) revert WrongTokenRequestHead(); + /// @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/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..a319a7b 100644 --- a/solidity/contracts/ceremony/profiles.json +++ b/solidity/contracts/ceremony/profiles.json @@ -31,6 +31,32 @@ "a deployment stores and updates them." ] }, + "requests": { + "forbiddenHeaders": [ + "authorization", + "content-encoding", + "cookie", + "transfer-encoding", + "x-http-method", + "x-http-method-override", + "x-method-override" + ], + "note": [ + "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": [ { "platform": "google", @@ -56,28 +82,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.", "", - "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." + "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", + "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." ] }, "identity": { @@ -110,23 +137,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 9deb9f2..4aec45c 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"; @@ -334,6 +334,128 @@ 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 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)}); + } + + 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); + // 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({ + 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); + } + + 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 @@ -358,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(); @@ -453,19 +615,49 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } - /// @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" - ) - ) + /// @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 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 ae35ce5..134455a 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"; @@ -432,6 +432,144 @@ 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 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); + } + + 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 { @@ -441,6 +579,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"); @@ -1065,23 +1240,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 @@ -1095,39 +1275,151 @@ 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.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, 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[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[9] memory names = [ + "transfer-encoding", + "content-encoding", + "content-encoding", + "cookie", + "x-http-method-override", + "x-http-method", + "x-method-override", + "authorization", + "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.ForbiddenRequestHeader.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 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 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 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 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" + ); + 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 { + 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/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" +} 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..5ae23b8 --- /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": "0xa5d9c1d593bc385a23a2d56116aab1951e3c66296476c7a7396a515105e8b2c10000000069800e8000000147000001280000000000000002000000000000000000000040474554202f7573657220485454502f312e310d0a686f73743a206170692e6769746875622e636f6d0d0a617574686f72697a6174696f6e3a20426561726572200000006600000000000000e10d0a6163636570743a206170706c69636174696f6e2f766e642e6769746875622b6a736f6e0d0a757365722d6167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f3132382e302e302e30205361666172692f3533372e33360d0a782d6769746875622d6170692d76657273696f6e3a20323032322d31312d32380d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a00000000000000010000004000000066f6c048a06d27f964210b300557a462fd3f8b3e81316b417a40b5ca657e4509c200000000000000020000006e0000000000000012226c6f67696e223a20226f63746f6361742200000084000000000000000d226964223a203538333233312c0000000000000003000000000000006edb7618fa1bb6a64a9e94e5aee148c0a10b54efd38198efbfead2b453c7df572d0000008000000084490f62acb5ee7240852524459041ae668f222d39df74844d45d02ff2fa1de9fd0000009100000128373107ca82d268b129970919759210549ebacb84e96a90f21d0d2bfa756282c6", + "endpoint": "https://api.github.com/user", + "notary_signature": "0x9b3259cd86944f9854456cc0a91d7f4a5b62990249c2e439ad6f81163ed5ec364a99eb66d4f1c3d038bff87355da930bf4d790aada2f0c6c5800c428f86f3df31b", + "openings": [ + { + "blinder": "0x2631c6c187d57b2da6ceec7ff3565948", + "direction": "sent", + "ranges": [ + [ + 64, + 102 + ] + ] + }, + { + "blinder": "0x65ad8aa35f5ac32f6ba05b70d40d487c", + "direction": "received", + "ranges": [ + [ + 0, + 110 + ] + ] + }, + { + "blinder": "0xefd54da72e5fe1aa9f438616231eb26d", + "direction": "received", + "ranges": [ + [ + 128, + 132 + ] + ] + }, + { + "blinder": "0x15aaf672c0f08b1692599ceaf0b18a70", + "direction": "received", + "ranges": [ + [ + 145, + 296 + ] + ] + } + ], + "received": "0x485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a636f6e74656e742d6c656e6774683a203139300d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a7b0a2020226c6f67696e223a20226f63746f636174222c0a2020226964223a203538333233312c0a2020226e6f64655f6964223a20224d44513656584e6c636a55344d7a497a4d513d3d222c0a2020226176617461725f75726c223a202268747470733a2f2f617661746172732e67697468756275736572636f6e74656e742e636f6d2f752f3538333233313f763d34222c0a20202274797065223a202255736572222c0a2020226e616d65223a2022546865204f63746f636174220a7d", + "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-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" +} 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" +} diff --git a/ts/packages/contracts/src/ceremony/profiles.ts b/ts/packages/contracts/src/ceremony/profiles.ts index 6d8c3e5..2c7633b 100644 --- a/ts/packages/contracts/src/ceremony/profiles.ts +++ b/ts/packages/contracts/src/ceremony/profiles.ts @@ -41,12 +41,11 @@ 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_REQUEST_HEADERS` lists. * `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 + readonly requiredHeaders: readonly string[] } export interface IdentitySession { @@ -91,14 +90,7 @@ 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', - ], - 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: { @@ -130,14 +122,7 @@ 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', - ], - 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 +150,31 @@ export function attestationCount(profile: Profile): number { return (profile.token ? 1 : 0) + (profile.identity ? 1 : 0) } +/** + * 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_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. */ export const MAX_FUTURE_ATTESTATION_SKEW_SECONDS = 300 export const PROOF_LIFETIME_SECONDS_X = 3600