feat: the ceremony wire constructions — digest, PKCE, attested data, reveal layouts - #2
Conversation
xgreenx
left a comment
There was a problem hiding this comment.
The notary must be agnostic to the ceremony, notary only participates in the communication and returns an attestation over the data that was revealed by the client)client decides what to reveal. We should just return information about host, path, and commitment
|
Needs rebase |
One implementation of each construction the specification fixes, so the notary, the backend and the conformance suite cannot disagree about bytes. The Authorization Digest of ceremony-common section 5 binds one authorization to the transaction that will consume it. Only the transaction data varies in length, so every other field sits at a fixed offset and no boundary can be shifted to reinterpret one preimage as another. The chain id is the keccak256 of whatever bytes a chain's identifier contributes, never the identifier itself, because chains name themselves incompatibly and some too wide for 64 bits. The PKCE construction of section 7 is how X and GitHub carry that digest through an OAuth authorization, since neither can hold it the way Google holds it in an OIDC nonce. The verifier is revealed in the notarized token request and the Platform Verifier recomputes it, so retargeting an attestation to another digest would take a second preimage. The attested-data layout of section 9.1 is what the notary signs off chain and what the Platform Verifier rebuilds on chain, where it holds no transcript. Every boundary is derivable from bytes that precede it, so decoding is one forward pass; the decoder refuses trailing bytes, a count that outruns its buffer, and any truncation, and both directions reject ranges that are empty, out of order, overlapping, or past the signed transcript length. The three published conformance vectors are the tests, transcribed from the specification and reproduced independently with cast keccak before use: authorization digest b318fb55...4c0af5 with its full 102-byte preimage, and the PKCE triple ending in code_verifier iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The notary writes these bytes in Rust and the chain reads them in Solidity. A divergence does not fail loudly on its own: the notary would sign a preimage the Platform Verifier rebuilds differently, deriving a key nobody trusts and rejecting every genuine attestation. Both sides now carry the same fixture, so a change to either encoder breaks a test instead. Verified by reordering two header fields, which the specification forbids for exactly this reason: the assertion failed, and passed again on revert. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A security review found that shape validation accepts a transcript byte covered by neither a revealed range nor a commitment. Confirmed: moving the sample's commitment one byte forward leaves byte 20 covered by nothing and validation still passes. That is correct for validation and wrong to leave unavailable. REQ-COMMON-35 is conditional -- it governs an identity-session request committing a credential in an Authorization header, and REQ-COMMON-43 withholds it from a credential committed in a request body, which is GitHub's client_secret. A codec that tiled unconditionally would reject every valid GitHub token exchange. So coverage becomes something a caller asks for where its profile fixes it, rather than something nobody offers. A gap is where a prover hides bytes: exact coverage leaves the committed range as the only region the verifier cannot read, and makes its offset and length follow from the ranges around it. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A Platform Profile fixes what a Platform Verifier pins: the tags it compares an attestation against, the authority that must have answered, the method and path it expects, how the Authorization Digest reaches it, and the handle parameters. These now live in one place, so the notary that writes them and the verifier that compares them read one definition. Two things the profiles make explicit. The attestation count is derived from the session list rather than stated beside it, so the two cannot disagree. GitHub notarizes two different authorities -- github.com serves the exchange and api.github.com serves the identity read -- so one pinned authority per profile would be wrong. The namespaced strings are ours, not the specification's. ceremony-common fixes exactly one literal, libid.identity.pkce. formatTag, operationTag and the platform name are all required to exist and required to be pinned, with their bytes left to the profile author. That makes them a cross-implementation agreement: a notary emitting one string and a verifier pinning another derives a key nobody trusts and rejects every genuine attestation, with no error that says why. The module header says so. The GitHub Token-Exchange Service contract of section 6.3 joins them, with the bounded parsing its requirements fix. Its bearerOpening is the blinder that opens the committed bearer range: without it a browser holding the attestation and the bearer still cannot build the proof, because the blinder is prover-private material from a session only that service ran. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A security review found the coverage duty easy to miss: nothing pointed the next implementer at it, and the decoder read like a complete check. The fix is not another helper beside the first but one entry point named for the job, so the whole boundary is a single call the caller cannot half-apply. REQ-COMMON-35, -39 and -40 are one property, and two of them are worthless alone. The uniqueness scan counts the authorization needle across REVEALED bytes only, so a byte covered by nothing is a byte it never reads: without coverage a prover hides a second header in a gap, the count stays at one, and the platform honours whichever header it likes. Closes the obsolete-line-fold gap the review found. REQ-COMMON-39 keeps CR and LF so the needle counts header lines, but obs-fold ADDS a CRLF that normalization then preserves: `authorization:\r\n Bearer x` becomes `authorization:\r\nbearer`, the needle does not match, and the header is never counted. A server honouring the fold would authenticate with it. Obs-fold is illegal in HTTP/1.1 anyway, so it is rejected outright. Also requires exactly one commitment in the direction. REQ-COMMON-60 permits several, but REQ-COMMON-35 and -40 both say "the committed range" and the circuit opens one commitment; with several, nothing ties the range that was framed to the range that was proved. Coverage now names an overlap rather than reporting a backwards gap, which was reachable only by hand-building a block and skipping validation. Eight tests, one per attack: a planted second header, a case- and whitespace-evaded one, an obs-folded one, none at all, a gap, a commitment framed by the wrong header, and two commitments. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The adapter between what tlsn observed and what ceremony-common section 9.1 signs. It lives here because this crate is already the only one allowed to know tlsn exists: libid-ceremony owns the bytes and is publishable, this crate owns the translation and is git-only because tlsn is. Nothing above needs to know a RangeSet exists. The signed transcript lengths appear in no signed field today, and REQ-COMMON-36 makes them the only source of the length the coverage check uses. tlsn carries them already, as u32, which is the width the format wants. Three refusals, each closing a way a genuine-looking session would produce evidence nothing can verify. A BLAKE3 commitment is refused because that is the notarization library's default while the Proving Circuit computes SHA-256, so a prover left on defaults produces commitments the circuit cannot open. A commitment over disjoint ranges is refused because the format pairs one value with one offset pair, and a hash over a union cannot be split without inventing a value for each. An offset past 32 bits is refused rather than truncated. The authority arrives as a string rather than as tlsn's name type, so the mapping stays testable and nothing here depends on how upstream models a server name. It is a signed field rather than a revealed range because the transcript carries the authority only in a prover-composed Host header. REQ-COMMON-61 is tested structurally: two sessions whose responses name different accounts must produce identical header bytes, and the test also asserts the two sessions differ somewhere, so it cannot pass vacuously. The load-bearing test is that what this emits satisfies the coverage check the Platform Verifier runs. If it did not, no honest session would ever verify. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A security pass found the verifier had moved ahead of the prover: the contracts demand that every notarized direction tiles, and the only reveal-selection code in the tree still chose the pre-ceremony sparse ranges -- the request line and Host revealed, the whole response committed. An honest session would have been rejected. That is as bad as a hole, and no unit test on either side reaches it, because each is correct in isolation. The layouts live in libid-transcript, which is tlsn-free and publishable, and each names only what it REVEALS. The commitments are derived as the complement, so tiling holds by construction rather than by inspection -- listing both and hoping they agree is exactly the mistake the verifier exists to catch. Four layouts. The X token request is revealed whole, because X authenticates with a public client and hides nothing there, and because the verifier needs the head boundary visible to locate the body by the framing the server parsed at all. GitHub commits its client_secret alone, ordered last under REQ-COMMON-22, so the revealed run is a prefix and the commitment reaches the transcript end. The token response reveals the two `"access_token":"` anchors and commits everything between and around them. The identity request reveals every byte but the bearer; the identity response reveals the two identity members whole, delimiters included, so each match sits inside one revealed run rather than being spliced from several. The load-bearing tests are the round-trips in libid-tlsn: they build a real transcript, take the layout the prover would select, encode it through attested_data, and assert require_exact_coverage accepts it. That is the agreement between the two sides, and it is now asserted rather than assumed. The pre-ceremony selection stays behind RevealMode::Legacy, marked as not tiling, until every caller moves. BREAKING CHANGE: prover_generic takes a RevealMode. Existing callers pass RevealMode::Legacy for the behaviour they had. 126 tests, clippy clean. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
REQ-COMMON-47..61 and REQ-PLAT-61..74 are cited throughout this crate and are not in the published specification. They were written in libid PR #12, which defined the attestation byte layout and was closed on 2026-08-20 without merging; PR #15 does not restore it. What survives on main is REQ-COMMON-18, which requires a Platform Profile to PIN the format it accepts and leaves the format itself to the profile author. So this crate is the definition rather than a reading of one, and a reader looking those numbers up will not find them. The numbering is kept because it is the specification's own and the intent is to upstream the layout under it -- the specification follows what the implementation needs. Every rule the numbers name is stated in full beside them. Recorded because the alternative is a reviewer chasing a citation into a closed pull request, and because four components must agree on these bytes: this crate, the Solidity decoder, the TypeScript mirror, and the notary. A divergence between them is silent. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Measured, not guessed: every use of this crate outside libid-rs is three string constants, all in one function of the notary. notary/src/server.rs:649 profile::TOKEN_SESSION_TAG notary/src/server.rs:650 profile::IDENTITY_SESSION_TAG notary/src/server.rs:695 profile::FORMAT_TAG libid-server-rs uses none. Inside libid-rs, only `attestation` is referenced. `AuthorizationPreimage`, the PKCE derivation, `PlatformProfile`, `SessionProfile`, `HandleRules`, `DigestBinding`, `LAUNCH_PROFILES` and `LAUNCH_PARAMETERS` have no caller anywhere, and will not get one: nothing in Rust builds a digest, derives a verifier, or reads a profile record. The browser builds the first two and the contracts recompute them; a Platform Verifier pins the third. The notary derives nothing by rule (REQ-COMMON-33). They are worth keeping as a third reading of the specification's published vectors, so the conformance suite can check the Solidity and TypeScript implementations against something written independently. That makes them a test oracle. An oracle compiled into every consumer is code nothing calls, and a mistake in it is invisible -- so `authorization`, `pkce` and the launch records now sit behind a `vectors` feature that is off by default and on under `cfg(test)`. The launch records move out of `profile` into their own module, which leaves `profile` at 38 lines holding exactly the three tags the notary stamps. That is the honest shape of what Rust needs. No behaviour changes. 127 tests pass with the feature off and on; clippy clean both ways. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Everything a contract checks comes out of this crate. What is left is one
direction of one thing: the section 9.1 types and the encoder that lays them
out.
Removed, with the reason each had no business here:
validate / check_span the notary judging its own library's output.
Its only power was to refuse to sign a session
it really did observe.
require_exact_coverage tiling is the Platform Verifier's rule
require_bearer_header_request the uniqueness scan and framing are too
normalize_header_bytes, count_needle, revealed_slice parts of that scan
decode, Reader whoever decodes also checks, and that is the
chain and the client
authorization, pkce, launch the client BUILDS the digest and the verifier,
and a Platform Verifier pins the profile.
Nothing in Rust ever read them.
`encode` no longer calls `validate`. A malformed record is the prover's
problem and the verifier's decision; withholding a signature is neither.
Where a check IS wanted before spending gas it belongs in the client as a dry
run, and it already lives there: `@libid/contracts` exports
`decodeAttestedData`, `validate`, `requireExactCoverage` and
`requireBearerHeaderRequest` in TypeScript, which is what REQ-PLAT-44 has the
Canonical Runtime call before it spends a second session on an attestation.
Two implementations of those checks, one of them unreachable from any caller,
is worse than one.
`token_exchange` stays whole. Its validation is the Token-Exchange Service's
own input handling under REQ-PLAT-37 to -40; no contract sees that request.
2024 lines to 622. `base64` and `sha2` drop out of the dependency list with
PKCE. The tests that went were testing the code that went; 85 remain, clippy
and fmt clean.
The one assertion worth keeping moved into `libid-tlsn`'s tests as a local
helper: what the notary emits must tile, or no genuine session ever verifies.
That is an assertion about our layouts, not a rule we enforce on anyone.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`formatTag`, `platformId` and `operationTag` leave the record, and `profile.rs` goes with them: it held nothing else. Each was a value handed to the notary and written down as though observed. The format is fixed by the notary key a profile pins alongside it (REQ-COMMON-18), so the key already answers that question. The platform is whichever host answered, and `authorityId` is that host, authenticated in the handshake. Which session this is, is the request line the notary recorded as a revealed range -- the verifier already compares it against the path its profile pins, so the label was a second, weaker answer supplied by the party being attested. `AttestationInput` is down to one field, `created_at`, which REQ-COMMON-57 requires the notary to originate. Everything else in the record now comes from the session: the authenticated server name, the transcript lengths, the ranges the client chose to reveal, and the commitments over the rest. HEADER_LEN 144 -> 48 The test that pinned the two sessions apart by their tags now pins them apart by their request lines, which is where the difference actually lives. Also re-exports `RevealMode` and `CeremonySession` from `libid-tlsn`. `prover_generic` has taken a `RevealMode` since this branch began and the type was never exported, so no caller outside this crate could construct one -- the notary could not compile against it. Cross-language fixture and digest move with the layout; the Solidity and TypeScript sides carry the same new values. 85 tests, clippy and fmt clean. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`AttestationError` had ten variants and nine of them became unreachable when the decoder and the checks left. `Truncated` and `TrailingBytes` were the decoder's; `EmptyRange`, `OutOfOrder`, `PastTranscriptEnd`, `CommitmentOverlapsRevealed`, `CoverageGap` and `SpansOverlap` were the validator's and the coverage check's; `RangeLengthMismatch` belonged to both. All of that is the Platform Verifier's now, and the client's to preview. What remains is `CountTooLarge`: a direction holding more entries than the two-byte count can name. That is not a judgement about the session, it is the encoder declining to write down something it cannot represent. Which is also a fix. `encode_into` cast the length with `as u16`, and once `validate` went there was nothing left to catch the overflow -- a direction with more than 65535 entries would have truncated silently and produced a signed record describing a different session from the one observed. `count()` returns the error instead, with a test that fails without it. Two comments went stale in the same cut and are corrected: `digest` still carried the decoder's "parse and validate, trailing bytes are refused", and `encode` now says why it does not judge what it lays out. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The layout is the struct, in declaration order, and nothing restates it. What
was nine hand-written `extend_from_slice` calls is now
`bincode::encode_to_vec(self, WIRE)`.
The configuration is what makes this usable by a decoder that is not Rust:
bincode::config::standard().with_big_endian().with_fixed_int_encoding()
Big-endian because every other number in this system is, and fixed-width
because a varint costs the Solidity decoder a branch per integer to save bytes
nobody is short of. Fixed arrays encode raw, so the authority is still 32 bytes
with no length in front of it.
`RevealedRange` loses `end`. Its length is its bytes, and a `Vec<u8>` carries
that length itself -- writing both invites them to disagree, which is what
`RangeLengthMismatch` used to be the error for. The decoder computes
`end = start + bytes.len()`, so the disagreement cannot be expressed.
`RangeCommitment` keeps both offsets: it has no bytes to derive an end from.
Counts widen from two bytes to eight, which is `Vec`'s own length. That costs
24 bytes a record and removes `CountTooLarge`: a count that does not fit is no
longer a thing this format can be handed.
The bincode version is pinned exactly. These bytes are a signed preimage, so a
layout change in a patch release would silently change what every notary signs
-- and the cross-language fixture is what would catch it.
Fixture and digest move with the layout; the Solidity and TypeScript sides
carry the same values. 85 tests, clippy and fmt clean.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…tten `RevealMode::Legacy` was one name over two different things, and its doc promised something false about one of them: "kept only until the ceremony path replaces every caller". Two callers pass it. The pre-ceremony X `/me` flow is genuinely waiting to be replaced and goes at cutover. The notary's JWKS session is not a ceremony at all -- it reads a public document, carries no credential and reaches no Platform Verifier -- so nothing will ever replace it, and this variant outlives the legacy flow it was named after. What the variant actually selects is who picks the ranges: the caller, through its own closure, rather than the layouts of the specification. `CallerSelected` says that, and the doc now names both callers and which one is temporary. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`prover_generic` took two ways to decide the same thing: a `RevealMode` that
could supply full layouts for both directions, and a `compute_reveal_ranges`
closure that supplied received-direction reveals. Three `match` sites
reconciled them, and whichever the caller meant, the other was dead --
`Ceremony` made the closure unreachable, `CallerSelected` made the layouts
unreachable.
Worse, one of them was never reachable at all: nothing anywhere constructed
`RevealMode::Ceremony`. Every caller in the workspace passed the other variant,
so `CeremonySession`, `ceremony_layouts` and the whole arm behind them were code
no execution reached.
That was the wrong shape for a fact that does not vary: the prover chooses what
it reveals, because it is the party holding the session keys and nobody above
it can decide on its behalf. So there is one parameter now, and it always
applies:
S: FnOnce(&[u8], &[u8]) -> Result<(Layout, Layout)>
A caller producing a ceremony attestation calls `libid_transcript::ceremony`
inside it and returns what that gives back. A caller doing something else --
the pre-ceremony X `/me` flow, the JWKS session -- states its own. The ceremony
layouts stop being a mode the library selects and become helpers a caller may
use, which is what they always were.
`RevealMode` and `CeremonySession` are gone with the three reconciling matches
and the fallbacks they guarded.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`tag()` said it derives `formatTag`, `platformId` and `operationTag`. It derives one thing now -- `authorityId` -- because the other three went with the fields the notary was handed rather than saw. `libid-transcript::ceremony` says who calls it, since nothing in this workspace does yet: a prover notarizing a ceremony session, which in Rust will be the GitHub Token-Exchange Service for the token session. The other three sessions are the browser's. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…s callback
Two pieces of API that a crate already covered or nobody used.
`HttpRequestSpec` was a six-field bag -- host, path, method, body, bearer,
user-agent -- translated field by field into `hyper::Request::builder()`
twenty-eight lines further down. The crate was already a dependency and already
did the work; the struct was a detour with a cost. It injected three headers of
its own:
.header("Connection", "close")
.header("Accept", "application/json")
.header("Content-Type", "application/json") // when a body was set
A notarized request is bytes a Platform Verifier compares against a profile,
and the profile fixes the exact set -- X's identity request "carries exactly
four headers, in this order". A library that adds its own cannot produce that.
So `prover_generic` takes a finished `hyper::Request` and the party that knows
the profile writes the headers. SNI and the TCP peer come from the request's
own authority, and a request with no host is refused rather than guessed at.
`hyper::Request`, `Bytes` and `http_body_util::Full` are re-exported as
`HttpRequest`, `Bytes` and `HttpBody`, so a caller states its headers without
taking a direct dependency on the HTTP crates this uses.
`ProverStep` and the `F: Fn(ProverStep)` parameter go too. Four call sites fed
it inside the library, and both callers in the workspace passed `|_| {}` -- a
generic parameter threaded through two public functions to feed nothing.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`decode_chunked_body` swallowed everything. A chunk header that was not hexadecimal parsed as `unwrap_or(0)`, which the loop read as the terminating zero chunk and returned what it had. A chunk shorter than its declared size hit `break`. A missing terminator, the same. Every one of those produced a short body and no error. That body is not incidental: `compute_field_snippet_range` and `compute_id_snippet_range` read it, and the ceremony layouts compute their reveal ranges over what they find. A silent truncation therefore has the prover select ranges over bytes the server never sent, and the notary sign that selection, with nobody in a position to notice. `httparse::parse_chunk_size` replaces the hand-rolled size parsing. It has the three answers this needs -- complete, partial, malformed -- where the previous code had one. The rest is the framing around it: a chunk shorter than declared, an absent terminator, and one that is not CRLF are each their own error now. Two tests, both failing before the change: a body whose second chunk header is `zz`, and one whose chunk declares twenty bytes and carries five. Verified the guard is what closes them by putting the swallow back and watching the first fail again. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Dropping `Fn(ProverStep)` was right about the API and careless about what it carried. Two of its four phases were also `info!` lines a line away -- "Response: N bytes" and "MPC-TLS proof complete" -- so removing the callback cost nothing there. Two were not: `MpcSetupComplete` and `TlsHandshakeComplete` had no log of their own, and the nearest ones announce those phases STARTING rather than finishing. Those are the slow parts of an MPC session, so losing them lost the two moments a caller most wants to see. They are `tracing` events now, inside this function's span, alongside the two that were already there. The doc says why the callback went and when it should come back. `tracing` is already a dependency and needs no parameter threaded through the signature, but log text is not an interface: a caller driving something typed off these phases -- a progress bar rather than a log line -- wants the callback, and should have it back rather than parse messages. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`ProverStep` and `on_progress` come back on `prover_generic`. Removing them was
right about what existed and wrong about what is coming.
What existed: both callers passed `|_| {}`, and the browser's progress -- the
one a user actually sees -- comes from the tlsn wasm prover's own
`set_progress_callback`, which never reaches this function. libid-rs compiles
to no wasm, so it could not have.
What is coming: the GitHub Token-Exchange Service notarizes on the browser's
behalf, and its HTTP caller waits out the whole session. Reporting phases to
that caller means a typed value, not `tracing` lines it would have to parse.
The four boundaries are already known and already placed; rebuilding them later
would be rediscovering them.
`ProverStep` is ordered and carries `fraction()`, because a caller showing
progress wants a position and the wasm prover's own callback already gives one.
It is a position and not a time estimate -- setup and proving dominate.
Both audiences are served at each boundary now: a `tracing` event for whoever
reads logs, the callback for whoever drives something off it. The legacy `/me`
wrapper stubs it internally rather than making its callers carry a parameter
for a flow that goes at cutover.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Neither response layout revealed offset zero, so no verifier could tell a `200` from a `403`. Consent was inferred from the fields the layout looks for happening to be present -- which is an argument about what an error body does not contain, not a check. The circuit sees no HTTP at all, so the layouts are the only place the fact can be made available. Both layouts now reveal the status line, from the origin to its CRLF. Anchored there so the verifier reads a status line rather than bytes that look like one, and a response with no CRLF is refused outright rather than silently omitting it -- an attestation missing the range is one the verifier rejects for a reason the prover cannot see. The layouts still tile: the commitments are the complement, so the CRLF and the headers after it stay committed. Two tests, plus the two existing layout tests updated to the new shape. 89 tests. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
I added it an hour ago arguing that consent was otherwise inferred from the wanted fields happening to be present. The argument does not survive contact with what an attacker would gain. For an error response to pass, the platform's error body would have to carry `"id":"<digits>"` and `"username":"<value>"` with their full delimiters, each exactly once, at a request whose line is `GET /2/users/me ` to a cert-authenticated `api.x.com`. That is precisely what ASM-PROV-06 assumes away -- the profile chose those delimiters as its anchors -- and an error body carrying them would be a platform defect, not an attack. The token response is narrower still: an error yields no bearer, and the circuit requires the same bearer to open both sessions' commitments, so a refused token exchange cannot reach the chain at all. So it bought nothing, cost fifteen revealed bytes in every session, and diverged from a specification that lists the status line under "everything else | no" in all three reveal tables. The specification following the implementation is the agreed direction, but it should follow something that carries its weight. The tiling half of that work stays: the specification does require it, in the same tables -- "every committed range of this direction is bounded by a revealed delimiter on each side that faces one, and by the signed transcript boundary at the two ends". Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The notary answers a session with the section 9.1 record and nothing else. It
reads no attestation request and builds no Merkle tree, so two fields of
`ProverResult` had no reader left:
* `request` -- the tlsn attestation request. `build` still runs, because it is
also what produces `secrets`, but the request it returns goes nowhere.
* `recv_segments` -- saved so a caller could feed the notary's `recv:` Merkle
leaves. There are no such leaves.
`EvmProof` and `NotaryResponse` go with them. They described the pre-ceremony
wire: a Merkle transcript root bound to a chain id and a `verifyingContract`,
signed beside tlsn's own attestation. A ceremony attestation binds neither --
it describes an observed session and says nothing about where the evidence is
spent -- so the record that carried them has no shape left to hold.
`TlsHandshakeData` stays: the randoms and the server's ephemeral key are
observations, not derivations.
BREAKING for a caller that sends `prover_result.request` and reads a
`NotaryResponse` back. `identity-backend` and `libid-server-rs` both do, and
both pin `v0.1.0` -- they are unaffected until they move, and moving means
adopting the ceremony wire on purpose rather than by a version bump.
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The layout revealed the two identity members and committed the rest. The verifier requires the opposite -- zero commitments -- and it is right. Every reader on the verifying side scans revealed bytes: the per-range field read and the cross-range delimiter count alike. A commitment is invisible to all of them. So a response that genuinely names an authoritative field twice -- one member echoed out of a profile string the account controls -- lets a prover commit the real member and reveal the one it composed. Both checks then see exactly one, and the handle bound is the prover's rather than the account's. Uniqueness over a document cannot be established from part of it. Nothing is lost by revealing it whole: the response is the account's own public profile, and the credential that fetched it is in the request direction. The arguments stay and stay checked. A response missing either member fails here rather than at the verifier, where the reason would be an offset rather than a name. ### The test that should have caught this `tests/ceremony_end_to_end.rs` drives the join nothing covered: the layouts pick the ranges, `attest::attested_data` turns the session into the section 9.1 record, and the assertions are the rules the Solidity Platform Verifier applies to it -- coverage, the framing bytes, the line-anchored header count over the concatenation, one revealed sent range at the origin, one head boundary, and the response hiding nothing. Every piece here had tests. The JOIN had none, so a layout could be internally consistent, encode cleanly, and still be refused on chain -- which is exactly what happened. Put the old layout back and `the_identity_session_produces_a_record_the_verifier_accepts` fails on the commitment. Four cases: both X sessions, the GitHub exchange whose request commits a suffix, and the encoding both must survive. Signed-off-by: SupremaLex <georglutsenko@gmail.com>
351b144 to
239a4bb
Compare
`prover_generic` built its `TranscriptCommitConfig` and never chose a hash
algorithm, so tlsn's default stood: BLAKE3. The Proving Circuit computes
SHA-256, and `AttestedData::from_observed` refuses anything else -- so every
commitment this prover made was one the circuit could not open and the notary
would not sign. No Rust-proved ceremony could be attested at all.
The two halves of this repository disagreed with each other, and about a hazard
they both already knew: REQ-COMMON-38 names it in prose ("the notarization
library's default commit algorithm is BLAKE3 while the Proving Circuit computes
SHA-256, so a prover left on library defaults produces commitments the circuit
cannot open"), the refusal was implemented on the notary side, and the
selection that makes the refusal satisfiable was never made on the prover side.
Upstream's own zk example makes the call this omitted.
It failed closed, so this is liveness rather than safety -- but it failed late,
after a full MPC-TLS session had been paid for, with an opaque "a commitment
uses BLAKE3" at the notary.
Nothing caught it because nothing could. The tests that cover the record
synthesize their own commitments through a helper that hard-codes SHA-256, so
they assert on an algorithm no code path in this crate produced. So the builder
moves into `transcript_commit_config`, where the choice is visible and
assertable without an MPC session, and a test pins every commitment this prover
configures to SHA-256. That test fails on the parent commit.
The algorithm is set here rather than by a caller because `select_layout` hands
back ranges and no algorithm: no caller could have corrected it.
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
…was given
Two header fields the notary itself contributes were unasserted. Both mutants
passed the entire suite:
authority_id: AttestedData::authority_id_of("api.x.com") // ignore the session
created_at: 0 // drop the clock
The cause is that every test observed `api.x.com`, so nothing ever checked that
a different authority produces a different id -- including
`authority_is_the_authenticated_server_name`, whose name promises exactly that,
and whose `assert_ne!(.., "evil.example")` passed trivially because the record
said `api.x.com` either way.
These are the two fields worth the assertions. `authority_id` is the only
identity in the signed record, and the transcript cannot corroborate it: the
request carries the authority only in a header the prover composed
(REQ-COMMON-21, REQ-COMMON-21A). `created_at` is the notary's own clock, and
the verifier's freshness window is measured from it, so a record judged on a
time nobody observed is a window nobody chose.
One test observes a different host; one varies the clock. Each kills its mutant.
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Assisted-by: Claude Opus 5
Three, found by checking the Rust against libid-contracts v0.8.0 and the published specification rather than against itself. `libid-ceremony`'s module doc said `@libid/contracts` exports `decodeAttestedData`, `validate`, `requireExactCoverage` and `requireBearerHeaderRequest` as the client-side dry run REQ-PLAT-44 asks for. None of them exists: v0.8.0's ceremony package is the generated profile table and its index. Naming a checker that has not been written invites the next reader to skip writing one, so the paragraph now says the dry run is still owed. Six sites called the attested-data record "ceremony-common section 9.1". Section 9.1 is attestation verification and its fee, and fixes no byte of the layout -- which `attestation.rs` already says at the top of its own file, in capitals: the layout is the profile's, not the specification's. A citation that contradicts the file it sits in is worse than none. The "this crate is published" claim survived in a second place. An earlier commit removed it from `attest.rs` on the grounds that a reader who checks it and finds it false discounts the paragraph around it; it was still in `attestation.rs`, where the paragraph it discredits is the argument for the signed field's own parameter type. The honest reason is simpler and true: `libid-ceremony` carries three dependencies and no TLS library, so it knows nothing about how one models a server name. And the canonical authority is stated as what it is: OURS. REQ-COMMON-21A has the Platform Verifier compare the authenticated authority byte for byte against the constants its profile pins -- which bytes those are is the profile author's decision, exactly as the byte layout is under REQ-COMMON-18. The generated table in libid-contracts makes the same decision and refuses any authority that is not lowercase and free of a trailing dot, so the two agree at the source. The trailing dot this still does not strip is now described by what it does -- a dotted name hashes to an id no profile matches, so the session is refused rather than repaired. Also: the failure list on `from_observed` named three of four variants. Signed-off-by: xgreenx <xgreenx9999@gmail.com> Assisted-by: Claude Opus 5
…t says so `&[0..4]` trips `single_range_in_vec_init` -- the same lint that shaped `Layout::revealing`'s iterator signature -- so the test could not compile under `-D warnings`. Widening it to two ranges per direction is the better fix rather than the quieter one: the hash algorithm is a property of each commitment, not of the config, so a single range could not distinguish a default applied once from one applied to every commitment. Four commitments now, all asserted. Signed-off-by: xgreenx <xgreenx9999@gmail.com> Assisted-by: Claude Opus 5
PR #2 deleted `EvmProof` and `NotaryResponse`. Everything that FED them stayed, and it is most of what this workspace still carried that the ceremony protocol does not use: * the Merkle suite -- `build_merkle_tree`, `merkle_proof`, `merkle_verify`, `hash_pair`, `double_hash_leaf` -- which built `EvmProof.transcript_root` and its leaves. The attested-data record is bincode and one keccak; it has no tree. * `TlsHandshakeData`, `extract_handshake_data`, and `ProverResult.handshake` -- the client random, server random and server ephemeral key, which were `EvmProof` fields. tlsn's own `HandshakeData` is a different type, still used, and is what binds the session now. * the legacy `prover` flow and `UserInfoParams`, whose own comment said it "does NOT tile, so what it produces is not a ceremony attestation. It goes at cutover." Nothing calls it. * the reveal helpers only that flow used: `find_notary_reveal_ranges`, `find_presentation_commit_ranges`. And three exported finders nothing called at all: `find_json_field_range`, `compute_field_reveal_range`, `compute_id_snippet_range_after`. * `hex_to_address`, which parsed an `EvmProof` address, and `sign_message`/`recover_public_key`, the raw 0/1-recovery pair. A notary signature is EIP-191, made and checked with `sign_eth_claim` and `recover_eth_claim`, and those stay. Nothing removed here is used by anything tracking the current protocol: not by this workspace, not by the notary's ceremony branch, not by the keeper. The two backends do use the Merkle functions and `hex_to_address` -- and they pin the v0.1.0 and v0.2.0 tags, so they resolve against a tree that still has them. That is the same argument that retired `libid-attestations`, and it is why this is a removal rather than a deprecation: the old product has its own releases. 599 lines out, 8 in. 95 tests pass, and the four publishable crates still pack and rebuild from their own tarballs. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
fix: commit under SHA-256, and drop the claims that no longer hold
feat(transcript): take the ceremony profiles from the chain's own table
…o refactor/remove-the-dyaka-remnants # Conflicts: # crates/libid-crypto/src/lib.rs # crates/libid-tlsn/src/session.rs
refactor!: remove what the dyaka product left behind
Four `[workspace.dependencies]` entries survived the crates that used them. `alloy-primitives` and `alloy-sol-types` were `libid-attestations`', and went unnoticed when that crate was removed -- my own miss in #6. `base64` and `sha2` belonged to the proof types deleted with `EvmProof`. No crate names any of the four and no source imports them, so nothing is built differently and the lockfile does not move. What they cost is a reader believing the workspace depends on alloy, and the next crate that needs a base64 reaching for `base64.workspace = true` and inheriting a version nobody chose for it. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
chore: drop the workspace dependencies nothing claims
`origin_form`'s doc cites the contract that pins the origin-form request line. That contract is `GoogleJwtRoots`. `IdentityJwksRoots` does not exist at libid-contracts v0.8.0 and did not exist when the line was written. This is the second time it has been fixed. The rename landed in 9c7cdb4 on `fix/prover-commits-sha256`; the merge that brought that branch in, 8f994cd, resolved this hunk against the other side and dropped it while keeping the rest of the commit. Nothing failed, because the name lives in a doc comment and no build, test or lint has an opinion about it -- which is exactly why it survived a second time. The argument was made once and has not changed: a reader who greps libid-contracts for `IdentityJwksRoots` finds nothing, and cannot tell from here whether the contract was renamed or the doc was always wrong. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
`session()` is documented as revealing everything except the bearer and
committing the bearer. Its offsets did the opposite. In
GET /2/users/me HTTP/1.1\r\nauthorization: Bearer TOK\r\n\r\n
the request line ends at 24, its CRLF at 26, and `authorization: Bearer `
is 22 bytes, so `TOK` sits at 48..51. The fixture committed 45..48 -- `er `,
the tail of the header name -- and revealed the credential with everything
else.
Nothing caught it because nothing looked. Every test built on `session()`
asserts tiling, the two signed lengths, the authority or the clock, and all
of those hold just as well when the committed range is three bytes to the
left. So the tests that stand for "the shape an identity session actually
produces" stood for a session that publishes its own bearer.
The new test is the one that looks: it locates `TOK` in the request,
requires the single commitment to be exactly that range, and requires no
revealed range to overlap it. Putting the offsets back to 45..48 fails it on
the first assertion, which is the check the old fixture never had to pass.
Also slices by `HEADER_LEN` rather than by 144. That 144 was the header
length before this branch cut three 32-byte tags from the record; the header
is now 48. The comparison still passed, because both sides encode the same
prefix either way, but 144 named no boundary in the format and for this
fixture cut four bytes into the first commitment.
Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
`ts-rs` went when the `ts` feature did, and #16 has since taken the last orphaned workspace dependencies with it. The comment describing the TypeScript bindings codegen stayed behind, where it now introduces `webpki-root-certs` -- a crate that has nothing to do with TypeScript. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
A prover states its transcript commitments as bare offsets, and nothing between the wire and the allocation bounds them. `TranscriptCommitConfigBuilder` refuses an out-of-range commitment, but that builder is the honest prover's path: `ProveRequest` derives its deserializer with no validation of its own, so a prover composing its own wire bytes never runs the check. Reproduced with a 72-byte message carrying `0..2^40` against a 4096-byte transcript, which deserializes without complaint. What the verifier then does with those offsets is allocate over every committed range and index the transcript's plaintext with it. So a range no session carried is either an allocation nothing bounds -- the notary's memory, at roughly 128 bytes of key material per claimed byte -- or, where the prover reveals everything, an index straight past the end of the plaintext slice. This is the last point holding both the request and the transcript it describes: `verify()` has returned, so the commitments are readable, and `accept()` has not been called, so nothing has walked them yet. It sits beside the `server_identity` guard, which is the same shape for the same reason. The bound is the application data the session actually carried, summed the way the verifier itself sums it. Handshake and alert records ride the same wire and belong to no direction's offsets, so counting them would leave room for a commitment the transcript has no bytes for. This does NOT close the neighbouring hole, and cannot from here. A prover also declares its transcript's total length, and that number reaches `vec![0; n]` inside `verify()` -- before this code, or any libid-rs code, holds anything to inspect. Forty-eight bytes on the wire abort the process there, and the bound for it has to live in tlsn, on the fork the notary already patches in. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
fix: restore a lost rename, and a fixture that revealed the credential it hid
|
Ran this branch's
|
|
Follow-up. With the guard fixed the ceremony got two steps further and hit a
|
The sample `code_verifier` was the conformance vector of ceremony-common as first merged, under `SHA256(PKCE_DOMAIN || digest || pkceNonce)`. The specification moved to `SHA256(digest || authorizationNonce)` and the contracts followed; this crate did not, because it derives nothing and so had no test that could fail -- both vectors are 43 base64url bytes, which is all `TokenRequest::validate` reads. The value is transcribed again, and the comment now says that it can only be transcribed. `the_published_verifier_is_the_right_shape` claims to hold the specification's own vector to the request bounds; with the old one it held a vector no specification produces. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
libid-contracts v0.9.0 makes the token request's headers profile data: each `TokenSession` gains `request_headers`, the lines a builder sets, and `request_header_block`, the same lines joined by CRLF for a verifier to match as a set. Nothing here reads them yet; the point is that the profile re-exported as `ceremony::profiles` is the one the deployed verifiers compare against, so a prover built on this crate cannot hold another copy. `libid-identity` moves with it under the single-version invariant. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Three descriptions outlived what they described. The README's prover example called `libid_tlsn::prover` with `UserInfoParams`, both removed in #15, and its closing paragraph named a `bearer_token` parameter `prover_generic` never had; it now shows `prover_generic` with the ceremony layouts, which is the call the GitHub Token-Exchange Service makes. `libid-crypto` advertised Merkle trees the sweep deleted, in its description, its keywords and the README's first sentence. Two comments in `ranges.rs` named the contract function `_extractId`, which libid-contracts calls `tryJsonInteger`. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
…losing `verifier()` raced its setup against the mux driver and treated the driver finishing as a dead connection. That is right before the session runs -- a health probe connects and closes, and a request submitted to the mux would never resolve -- and wrong after: the prover closes the mux as its last act while this side is still verifying what it received, so every session that succeeded failed at the end, nothing was signed, and the prover read EOF. A flag set once the session has run decides which case it is. When the driver finishes after that, setup is let finish and the driver's result kept, since a finished handle cannot be polled again. `prover_generic` has the same shape and gets the same guard. Not a `select!` precondition: those are evaluated once, on entry, when nothing is established yet. Found by SupremaLex running this branch's prover and verifier as two real processes against GitHub; the fix is theirs, confirmed twice with fresh authorization codes. The openings a prover hands back are no longer promised in the layouts' order. tlsn builds the commitments from a set, so the order varies per process; a caller matches on the ranges an opening covers, which is what the one caller already does. Assisted-by: Claude Opus 5 Signed-off-by: xgreenx <xgreenx9999@gmail.com>
|
Thanks for running it for real. Status of everything in both comments, at The three bugs
The layout, and the decision you asked for Made, and it is not a record-format change. The token request is one revealed range: the request line, every header and the body, with GitHub's The one adjacency that remains is GitHub's Openings. The worker's Description. Rewritten to the branch as it is: no After the merge: a libid-rs release, then the notary moves from |
What this adds
The Rust side of the ceremony: what a prover reveals and commits, what the notary
signs, and the bytes the contracts decode. It absorbs #5 through #17.
libid-ceremony(new, published) holds what a notary needs to sign aceremony attestation and nothing else.
attestationis the attested-data formatthe launch profiles pin: big-endian fixed-width, encode only, and the keccak256
over it that is the only preimage a notary signs. The format is the profile's,
not the specification's (REQ-COMMON-18 leaves it to the profile author), so its
rules are stated in the module in full, and the encoding is pinned by a test
against the Solidity decoder's own fixture rather than against itself.
token_exchangeis the GitHub Token Service request and response with thebounds a served call must satisfy: a signed attestation, an exact 16-byte
opening, a nonempty printable token. It replaces
libid-attestations.libid-transcript::ceremonychooses what a notarized session reveals: oneLayoutper direction of each session, built from the profile tablelibid-contractsgenerates (libid-profiles0.9, re-exported asceremony::profiles). Commitments are the complement of the reveals, so the twotile the signed length by construction, which is what the Platform Verifier
checks. The token request is one revealed range (GitHub's
client_secretcommitted as the suffix); the identity request reveals everything but the bearer
value; responses reveal each field with its full delimiter.
rangesnow matchesexact JSON templates, refuses a snippet that spans chunk framing, and scans bare
integers the way the contract's
tryJsonIntegerdoes.libid-tlsn:prover_generic(socket, request, select_layout, on_progress)takes the layouts from a closure over both transcripts and hands back one
CommitmentOpeningper commitment;attest::AttestedData::from_observedbuildsthe record from what the verifier observed. Range commitments are pinned to
SHA-256 (REQ-COMMON-38; tlsn defaults to BLAKE3, which the circuit cannot open).
The request goes on the wire in origin-form. A commitment past the session's
bytes is refused before
acceptwalks it. A mux driver that finishes after thesession ran is the peer closing, not a fault, so a session that succeeds no
longer fails at its last step.
Removed:
libid-attestations,libid-transcript::types, the fixed-shapeprover()andUserInfoParams, handshake extraction, and the Merkle andrecovery helpers in
libid-cryptonothing called.Breaking
prover_genericchanged signature;libid-attestationsis gone. The notary(
cc40b0c) still calls the removedattest::attested_dataand moves tofrom_observedafter this merges.Verification
113 tests pass; clippy and fmt are clean. Records built from these layouts
verified end to end against the released
XPlatformVerifierandGitHubPlatformVerifierof libid-contracts v0.9.0. SupremaLex ran the prover andverifier as two real processes against GitHub with a fresh authorization code.
What waits on this
A libid-rs release, then the notary migration. The browser draft still plans one
revealed range per form field and must follow the one-range layout the spec now
states (libid#31); the record merges adjacent ranges before signing, so the
per-field plan cannot survive.
🤖 Generated with Claude Code