feat!: the OAuth Bridge — configuration and the callback document - #8
Open
SupremaLex wants to merge 91 commits into
Open
SupremaLex wants to merge 91 commits into
SupremaLex wants to merge 91 commits into
Conversation
The ceremony belongs to the browser. It derives its own PKCE verifier, opens the provider, consumes the redirect against its own live state, notarizes what it needs and builds the proof. The one thing it cannot hold is a confidential client secret, and that — GitHub's token exchange — is the whole of what a platform ceremony asks of a server. Google and X ask for nothing. So this removes what the server was doing instead: the challenge, the OAuth callback, the result endpoint, the whole MPC-TLS proving flow, the proof assembly and the in-memory maps the three shared. With them go the ABI types of a proof this service no longer builds, the platform table it no longer dispatches on, and twelve error variants that only a proof verifier can reach. The pins move to a revision of libid-rs#2 rather than a release tag: the ceremony constructions the replacement route needs are on that branch and in no tag yet. That revision also carries the merged alpha.15 upgrade, hence the Rust 1.95 MSRV. The old flow could not have survived the move regardless — `libid-transcript` is reworked there, `prover` changed shape and `ProverResult` lost the field the verification read. Four settings go with the code that read them: `NOTARY_ADDRESS`, `CHAIN_ID` and `VERIFIER_CONTRACT_ADDRESS` verified a proof this service no longer sees, and `CHALLENGE_TTL_SECS` timed a challenge that no longer exists. A setting that is accepted and does nothing is worse than one that is absent — the README called the third of them the most commonly misconfigured value. What remains: `/health`, and Google's fragment relay, which is load-bearing because a fragment never reaches a server at all. 589 lines where there were 2,316. `POST /api/v1/ceremony/github-token` lands next; until it does, this branch serves no GitHub flow. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The browser owns the ceremony and cannot own this one step: the exchange needs a client secret, and a secret in a browser is not a secret. So `POST /api/v1/ceremony/github-token` performs it inside a TLSNotary session. What proves the request belongs to the ceremony is revealed -- the client id, the code, the redirect URI and the PKCE verifier -- and the `client_secret` and the returned bearer are committed instead of disclosed. The secret is ordered last in the body so the committed run is a suffix rather than a hole, which is what lets the transcript tile; a secret carrying `&` or `=` cannot forge a field, because the serializer percent-encodes both. The three values handed back are one result. The attestation without the opening proves nothing about the bearer, and the opening without the attestation proves nothing at all, so a failure returns none of them. Nothing is stored: a timeout, a duplicate or a restart leaves nothing to resume from, and recovery is a fresh ceremony. Three things the route must not get wrong, all now pinned by tests. `prover_generic` writes no header of its own, so `Host` is set here and read back off the same URI rather than spelled twice -- omit it and GitHub answers an error object, the layout finds no `access_token` to anchor on, and the session fails somewhere that looks like a notary fault. The bearer's opening is selected by the range it covers, not by position: nothing upstream promises the openings arrive in layout order, and the wrong blinder opens nothing while looking like an answer. And both directions must tile, or the Platform Verifier refuses the record the notary signed over them. Reaching the notary and running the session get separate budgets, because an unroutable address otherwise spends the kernel's retry schedule out of the time meant for the protocol. A notary that runs a session and then closes without writing a record -- its own signer failing, say -- is reported as that, not as a bare end-of-file that reads like a network fault. The notary address is resolved from its URL once at startup, so a URL naming no host or port stops the process coming up rather than failing the first ceremony that reaches it. CRAP: `exchange` 132 (CC 11, 0% -- what is left is a sequence of `?` over the notary, the session and GitHub, and it cannot be covered without all three). Everything separable from it was separated and is covered: the handler 7.8, `select_layouts` 4.0, `token_http_request` 4.1, `bearer_blinder` 3.0, `connect_notary` 2.0, `bearer_range` and `token_request_body` 1.0. `build_state` fell 20 -> 4 and `notary_addr` 30 -> 5 on the way past. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The route carried its own copy of the notary's wire record, and reached into `tlsn` for the one enum a commitment opening holds. Both are gone. The record now comes from `libid_transcript`, which is where the notary writes it from -- so the two cannot drift into disagreeing about a field name and finding out at parse time. `Direction` and `CommitmentOpening` come re-exported from `libid-tlsn`, which lets the direct dependency on upstream TLSNotary go entirely: this service was pinning an alpha tag of a large git dependency for a single enum, and a second pin is a second chance to hold two incompatible copies of the same types. The libid-rs pin moves to the revision carrying those, spelled in full rather than abbreviated -- it names one commit for as long as the branch lives, which is what the comment above it already promises. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…se fault a failure is REQ-PLAT-38 asks for the exact bearer committed by the token-exchange attestation. The route was returning a bearer parsed out of the DECODED response body, while the commitment covers a range of the RAW notarized transcript. Those are the same string most of the time and not always: a JSON escape decodes, and a chunk boundary landing inside the value shifts everything after it. When they differ the browser gets a 200, an attestation, and a bearer its opening does not open -- and finds out in the circuit, where nothing says why. The bearer is now read off the attested transcript at the committed range, inside the same function that chose that range. A code that was spent, replayed, or issued to another client is answered `400` with a reason, at warn level. It was a `502` and an error-level log -- indistinguishable from a dead notary, for a double-clicked button. GitHub says so by returning an error object with no `access_token`, which is a failure of the response layout and nothing else; the two are now told apart at the point where that distinction still exists. Three ways this service could be held open or overrun, all closed. The record read after a session had no budget, so a notary that ran a session and then stalled parked the request until the process restarted -- which is precisely what the session budget beside it exists to rule out. Any number of exchanges could run at once, each a full MPC-TLS session and an outbound request that spends the client secret, gated only by an origin check whose own comment says it is not caller authentication; there is now a ceiling, and a request that finds it is shed rather than queued. And `BASE_URL` is parsed at startup into the origin a browser actually sends, refusing a path, query, fragment or credentials: `https://ID.x/` and `https://id.x:443` used to start cleanly and then refuse every ceremony forever. Also gone: three error variants nothing could reach, the libid-crypto and alloy-primitives dependencies the last of them was holding up, a doc link to the deleted `flow` module, config docs describing three settings that no longer exist and a callback path that moved, and a README line promising base64 for a bearer that is returned verbatim. `main` reads the allowed origins off the state instead of recomputing them from the config, so the two cannot disagree. The request fixture now carries the `content-length` hyper writes, so the layout tests measure the transcript a session really produces. CRAP: `exchange` 182 (CC 13, 0% -- a sequence of `?` over the notary, the session and GitHub). Everything else is under 10: `server_origin` 9.0, the handler 8.7, `select_layouts` 6.2, `build_state` 5.1. The two new pieces of judgement -- which failures are the caller's, and which opening is the bearer's -- are covered outright. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…user of The relay at `/auth/gmail/callback` was a verbatim port of the dyaka monorepo's `oidc.rs`, and it is superseded twice over. The specification gives one redirect document for every platform, and reading Google's fragment is that document's job: the Redirect Runtime copies the bounded fragment into memory, CLEARS it before any storage or network access, and requires exactly one `state` with exactly one `id_token` XOR `error`. The relay did none of that. It copied `location.hash` onto another URL and navigated, which does not clear the fragment so much as move the ID Token into the application's address bar. And delivery no longer travels by navigation at all. The redirect document hands its response to the ceremony code in the popup, which forwards to the application over the popup's own connection. Bouncing a credential between origins through a URL is the shape the whole ceremony refactor exists to leave behind. Nothing referenced it. No package in the organisation calls the route, and Google binds revert with `UntrustedModulus` regardless until a JWKS rotator runs somewhere, which is not this service. `APP_URL` goes with it, having no other reader. The CORS layer goes too, because the relay was the last thing it could have applied to. What remains is a probe and a route that requires a request's `Origin` to be this service's own -- and a same-origin POST never asks for a preflight, so the layer decided nothing the handler does not already decide more strictly. It was also matching origins by WILDCARD, with no test over the matcher: `prefix*` admits `https://app.example.com.evil.tld`. The specification asks the opposite twice, of the exchange route and of the signaling service both -- an exact check against one compiled origin. `ALLOWED_ORIGINS` will come back with the popup program and the signaling service, which is where an application-origin list belongs, and it will be exact. BREAKING CHANGE: `GET /auth/gmail/callback` is gone, and with it the `APP_URL` and `ALLOWED_ORIGINS` settings. A deployment serving Google must repoint its registered redirect URI at the ceremony's redirect document once that ships. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The route moves to `/oauth/github/token-exchange`, the fixed path the specification gives the Token-Exchange Service on the redirect origin. `/api/v1/ceremony/github-token` was this service's own naming, from a revision that left endpoint naming to the deployment; #13 is the document that naming was left to, and it names one. Both interfaces gain `schema: 1`, and a body naming another revision is answered rather than read. A later schema changes what the fields mean, and reading new fields under old rules is how two components agree on nothing while both believe they succeeded. An absent schema is not taken for the current one. `tokenAttestation` becomes the one canonical unpadded base64url string the interface describes, instead of an object of two. What decodes out of it is the section 9.1 record followed by the notary's signature -- `TokenAttestation::encode` in libid-ceremony, which the browser side reads too. The specification requires the attestation to carry the signature (REQ-PLAT-45) and gives it one string to travel in, but does not say where in that string it sits; the layout is one function so that two implementations cannot answer it differently, and it is worth a sentence in the specification. The CORS layer comes back, which reverses part of the previous commit. REQ-PLAT-43A requires the preflight for the compiled redirect-runtime origin to be answered, and "no route needs CORS" was an argument from mechanism where the specification states a requirement. It is one exact origin now, not an allow-list, and never the caller's own: a page elsewhere is told what is allowed and finds it is not itself. What does NOT come back is pattern matching -- REQ-PLAT-43 says only the compiled origin, and a wildcard in an allow-list is not that. BREAKING CHANGE: the route is `/oauth/github/token-exchange`; requests must carry `schema: 1`; `tokenAttestation` is a base64url string rather than an object with `attestedData` and `signature`. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…edes Yesterday's conformance pass read `specs/platform-ceremonies.md` §6.3. That document says of itself that it fixes the semantic call and leaves endpoint naming, transport framing, serialization, parsing bounds, caller authentication and cache policy to the browser and deployment specifications. `ts/packages/ceremony/SERVER.md` is the one it leaves them to, and it says something else in all three places I changed. The route is `POST /api/v1/ceremony/github-token` again, which is where it was before yesterday. `tokenAttestation` is two members again, `attestedData` and `signature`. They are bounded apart — the record to 2 MiB, the signature to exactly the 65 bytes a notary signature is — and a single string would have to be split before either bound could apply. The `schema` member is gone from both records, because the specification says why one is not there: the path already versions this transport. What was a version check is now nothing special — a body offering `schema` is a body with an additional field, and the contract already refuses those. Everything else from yesterday stands: the origin check, the concurrency ceiling, the bearer read off the attested transcript, the refusal classification, and the separate connect, session and record budgets. The two specifications disagree on this wire while sitting on one branch. That is worth raising where they live rather than deciding again here. BREAKING CHANGE: the route returns to `/api/v1/ceremony/github-token`; neither record carries `schema`; `tokenAttestation` is an object with `attestedData` and `signature` rather than one base64url string. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`GET /api/v1/ceremony/config` returns the record a ceremony client reads
once when it is created: the registered redirect URI, and for each enabled
platform its public client id and the ceremony versions this deployment
supports. It carries no secret, no admitted origin, no asset URL and
nothing about a particular ceremony, which is what lets one record answer
every request -- so it is built once at startup rather than assembled per
call, and no request can alter it.
It is readable only from an admitted application origin, echoed back
exactly and never as a wildcard, with no credentials. A caller that is not
admitted is answered the same way whether its `Origin` was absent or
merely unlisted, and the refusal carries neither the record nor an
allow-origin header: which of the two it was is not the caller's business.
The enabled platforms become one setting. `CEREMONY_PLATFORMS` names each
platform once with its client id and one circuit per advertised version,
and the deployment contract says why it must be one: the public
configuration and the prover profiles the shell will embed are
"projections of one enabled set, not independently maintained platform
lists". `GH_OAUTH_CLIENT_ID` is gone into that record. It was the second
list -- kept in step by hand, with nothing to say when it stopped being.
The redirect URI becomes one string with three uses: the route a provider
returns to, the bytes the notarized token request sends, and the value the
configuration publishes. It moves to `{BASE_URL}{CALLBACK_ALIAS_PATH}`,
default `/auth/v1/callback`, which is where the callback document will
answer -- the old `/api/v1/ceremony/callback` named a path this service
has never served.
The confidential route is mounted only where a secret backs it, and the
secret is refused where no `github` platform is enabled. A path that
answered without a secret would be worse than a path that is not there,
and a secret nothing can spend is a compromise target with no purpose.
Startup gained the checks that keep these from failing on someone's
ceremony instead: every admitted origin is read by the same rule as this
service's own, the alias may not carry a brace axum would read as a
pattern or collide with a fixed route, a circuit URL carrying a query is
not immutable, and `github` may not advertise a version its token exchange
does not implement.
BREAKING CHANGE: `GH_OAUTH_CLIENT_ID` is replaced by `CEREMONY_PLATFORMS`;
`ALLOWED_APP_ORIGINS` is required; the registered OAuth callback URL moves
to `{BASE_URL}/auth/v1/callback` and must be re-registered with GitHub.
Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…dge contract fixes
The deployment contract this branch targeted was replaced on 2026-09-04.
`SERVER.md` is gone; `OAUTH_BRIDGE.md` and `CCDP_DISTRIBUTION.md` stand in
its place, and the server is now the OAuth Bridge. It publishes
configuration, serves one callback shell, and performs GitHub's exchange.
Everything the browser executes -- the Callback module, Airlock, the
prover, its circuits and notarization client -- is served by a separate
static CCDP Distribution at a configured origin that knows nothing about
this bridge. So the prover shell, the root-module and proving-asset
settings, and the callback alias go, and three routes remain.
The callback shell is one document at one configured path, default
`/auth/callback`, rendered once at startup and served as frozen bytes. Its
inline module bootstrap bounds and copies the provider's return, clears it
with `history.replaceState`, reads the CCDP version from the OAuth
`state`, checks it against the closed supported list, and imports
`{ccdpOrigin}/ccdp/v{N}/callback.js`, calling `startCallback` once with
the deployment's frozen inputs. Anything else renders fixed text after
clearing and imports nothing. The CSP names the SHA-256 of the exact
bootstrap bytes that are served -- computed from the same string that is
spliced into the document, so the two cannot disagree -- and admits only
the exact module URL per supported version, the CCDP origin as a frame,
and no connection at all: no popup fallback is configured.
The handler reads nothing from the request. No `Uri`, no query, no
`Origin`, no `Referer`. The provider's return arrives in the query, and
the strongest way to keep it out of every log and error this service
could ever produce is for no code here to be able to see it.
The token route is called by the prover, which runs on the distribution:
its `Origin` must equal the CCDP origin, and its preflight is answered for
that origin alone -- the reverse of the same-origin-only reading of the
previous document. The CORS layer moves from the whole router to that one
route.
The configuration record gains `ccdpOrigin`. Platform versions carry no
circuit URL: proving assets are the distribution's to pin, and a bridge
advertises only the pairs it serves. `allowedAppOrigins` refuses a
duplicate rather than folding it, as the contract asks, and every origin
this bridge trusts or publishes must be HTTPS unless loopback.
BREAKING CHANGE: `CALLBACK_ALIAS_PATH` is `CALLBACK_PATH` with default
`/auth/callback`; `CCDP_ORIGIN` is required; `CEREMONY_PLATFORMS` versions
are plain integers; the token route admits the CCDP origin, not the
bridge's own; the registered OAuth callback URL is
`{BASE_URL}/auth/callback`.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…mbers Two documents on the same libid branch describe this server's wire, and they disagree: `specs/platform-ceremonies.md` §6.3 gives a different route, a `schema` member and a single-string attestation. Decided 2026-09-04: OAUTH_BRIDGE.md is primary. The README now says so, so the next reader does not re-derive the choice -- or, as happened once on this branch, implement the other one and revert it. The last two requirement-number citations go with it. `REQ-PLAT-38` names the codeVerifier grammar on `main` and the exact-bearer rule on PR #20; the same number, two meanings, depending on which branch is checked out. OAUTH_BRIDGE.md states the rule in prose and carries no numbers, so the comments point at it by name. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…mand that starts Two findings recovered from a cloud review that died before reporting. The callback shell and the configuration record were built once at startup, as the contract asks, and then copied on every request: the shell as a fresh `String`, the record by re-serializing its JSON value, the CSP by re-parsing it into a header value. Correct output, wrong mechanism -- "one document" was one allocation per request. Both are `Bytes` now and the CSP is a `HeaderValue`, all made once; serving them is a reference count, and a request can no longer fail on a parse that already succeeded at startup. The README's `docker run` example still passed `GH_OAUTH_CLIENT_ID`, which no longer exists, and omitted `ALLOWED_APP_ORIGINS`, `CCDP_ORIGIN` and `CEREMONY_PLATFORMS`, which are required -- the documented command refused to start. It names the current inputs and has been run. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ator could forge Fifteen findings from an xhigh review, all verified against the code and OAUTH_BRIDGE.md before anything was changed. `CALLBACK_STYLE_HASH` went into the shell's Content-Security-Policy unchecked. A `;` in it does not break the policy -- it prepends the operator's own directives, and CSP honours the FIRST occurrence of each, so `connect-src *; frame-src *` ahead of the intended `connect-src 'none'` silently wins and the shell can send the OAuth return anywhere. `HeaderValue::from_str` accepts it happily. It is now checked at startup against the CSP hash grammar, and the test for it fails if the check is removed. Google's profile is `response_mode=fragment`, so its routing `state` arrives in the fragment and never in the query -- and the bootstrap read the query alone. Every Google ceremony would have failed closed while `deployment.rs` admitted `google` and started cleanly. It searches both halves now; "exactly one `state`" means one across the two. `"access_token":""` frames an empty run that the layout's complement never commits, so no opening matched it and the caller got a 502 naming the notary -- for GitHub's answer, after the secret had been spent. An empty bearer is the same thing as no bearer and takes the same refusal. A callback path carrying a byte a browser percent-encodes registered one route and received requests for another: `/auth/cällback` starts, then 404s every callback. `Config` derived `Debug` while holding the one secret this service has; the impl is written by hand and redacts it. The token route now bounds its body -- the extractor parses before the handler's origin check whatever order the handler reads in -- and sends `nosniff` like every other route. `server_origin` is described correctly in four places that still said the token route compares callers against it; it admits the CCDP origin, and `server_origin` is only what `redirectUri` is built on. The crate doc described the deleted challenge/proof service, and two test comments asserted things that were not true: that the origin is checked before the body is parsed, and that the admitted-origin list is withheld for secrecy when the callback shell publishes it to anyone. The startup validators that had no test now have one apiece, and `build_state` feeds `build_router` in a test for the first time -- the `FIXED_PATHS` collision guard exists only to prevent a `Router::route` panic, and nothing exercised it. The endpoint constant is parsed once rather than per exchange, the semaphore drops an `Arc` inside an already shared state, and the last requirement number leaves the manifest. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ossible
Four reviews ran over this branch -- contract conformance, structure,
interfaces, and the documented architecture. What they agreed on was not
a list of bugs but a list of invariants the prose asserted and the types
did not. This closes them in the types where that was possible.
**The token route's state.** `AppState` carried a notary address, a CCDP
origin, a permit semaphore and GitHub's credentials beside everything
else, with a doc comment saying the four are meaningful only where GitHub
is enabled. A deployment without GitHub therefore held a notary address
nothing dialled and a permit nobody acquired, and the handler opened by
re-checking that the router and the state agreed -- a request-time
`Error::Config` for a condition decided at startup. They are now a
`GithubExchange` the token route takes as its own state. `build_router`
returns a finished `Router` rather than one awaiting `with_state`, so the
two halves carry their own and the disagreement cannot be spelled.
**The one decision, stated once.** `build_state`'s XNOR asserted that a
`github` platform and a client secret are present together, then
re-destructured the pair to pick an error message, then derived the same
fact a third time to build the credentials. It is one `match` on the
pair now, and each arm is a whole answer.
**The platform catalog.** `PlatformProfile.id` was a `String` checked
against a three-element array after parsing, and `is_github` was a string
comparison one typo away from matching nothing. It is a `PlatformId`
enum: serde refuses a name outside the catalog while parsing, and names
the whole catalog when it does.
**The projection that lived in a route.** `deployment.rs` says "the
public ceremony configuration is a projection of it" -- and the
projection was in `routes/config.rs`, reached into from `build_state` to
build a startup artifact. It is `deployment::config_record` now, and the
route module holds only the handler.
**Three error variants with no producer**, `Io`, `Json` and `Transcript`,
each a `#[from]`. A `#[from]` with no producer is not dead code that
costs nothing: it silently admits a whole foreign error type into this
one the first time somebody writes `?`.
**`token_http_request` returned a `Result`** whose error branch no input
can reach -- a constant URI, its own authority as the host, literal
header names and values, and bytes. It is total now, and says why.
Also from the same passes: the token route refused no query, which the
contract requires ("the query is empty"); it accepted any
`application/*+json` because that is what axum's extractor accepts,
where the contract says exactly `application/json`; `CALLBACK_PATH` was
checked for braces and percent-encodable bytes but not for a leading
`//`, which axum routes happily and a browser reads as scheme-relative --
`history.replaceState(null, '', location.pathname)` then throws
cross-origin, the bootstrap dies before it clears, and the authorization
code stays in the address bar with no server-side symptom at all; and
`AllowOrigin::exact` was doing no comparing, because tower-http's
`OriginInner::Const` emits its value unconditionally. It is a
one-member `AllowOrigin::list`, which is filtered against the request.
BREAKING CHANGE: the token route now answers 415 to any media type but
exactly `application/json`, where it previously accepted every
`application/*+json` axum's extractor does, and 400 to a request carrying
a query. A `CALLBACK_PATH` beginning with `//` now stops the process at
startup. A caller sending `application/json` and no query is unaffected.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The documentation pass over this branch found claims that were true of an
earlier shape and claims that were never checked. Each one below was
verified against the code before it was changed.
**A configured origin could forge a CSP directive, and only one setting
was guarded against it.** `CALLBACK_STYLE_HASH` was held to an exact
shape because a `;` in it prepends the operator's own directives and CSP
honours the FIRST occurrence of each. `CCDP_ORIGIN` reaches the same
policy, unescaped, in `script-src` and `frame-src` -- and `Url` keeps a
`;` or a `'` in a host: `https://a;b.example` parses, and its origin
serializes with the `;` intact. The effect is truncation rather than
widening, so this is availability, but it is the same class and it is
now closed the same way: every configured origin is held to the bytes an
origin is made of. Verified by feeding both spellings to `Url` first.
**The CSP charset guard had no test.** The one case in the refusal table
carries no `sha256-` prefix, so it exercised the prefix branch and left
the base64 branch untested -- the earlier commit claiming otherwise was
wrong. `sha256-abc'; connect-src *` and `sha256-` are in the table now.
**The bootstrap's ordering had no test**, and a comment in `tests/http.rs`
pointed at one in `shell.rs` that did not exist. There is one now, and it
asserts the property that matters: the return is read before the clear
and nothing that acts on it runs before. Written the obvious way it
passed for the wrong reason -- `fail`'s DEFINITION is hoisted above the
clear -- so it matches the call.
**The 3 MiB response bound was never asserted.** It holds by arithmetic
from bounds that live in another crate, which is exactly the kind of
thing that stops holding silently. The test builds the largest response
those bounds admit and serializes it.
**`/health` was the one route without `nosniff`**, which is not a hole
but is an exception, and an exception is what a reader has to account
for.
Prose that had gone stale: the crate description still described a
handle-claim flow whose UI submits a bind on-chain; `config.rs` was
"the handles backend"; `routes/config.rs` said the prover document does
not read the configuration, and no prover document is served here at
all; `deployment.rs` gave "a circuit that does not exist" as an example
of what `platforms` refuses, three lines after the module doc explains
that circuits are deliberately not configured here; a test doc named a
"callback alias" that no longer exists; `canonical_origin` said it read
two kinds of origin and reads three. The `section 9.1` reference is
gone -- OAUTH_BRIDGE.md is the contract and it has no such section.
The README claimed `rustc >= 1.94.1` where `Cargo.toml` says 1.95, and
described `ALLOWED_APP_ORIGINS` and `CCDP_ORIGIN` as HTTPS with no
mention of the loopback exception both accept. It now also states the
two things the operator has to supply: redacting the callback query from
proxy access logs, and rate-limiting the token route by client -- the
concurrency ceiling bounds what one caller holds at once and is not the
same thing, since nothing here identifies a client.
Two test names overstated what they check, and one described a state
field that no longer exists.
Coverage gaps closed while measuring CRAP: the loopback exception in
each of its three host spellings, the non-ASCII and astral-plane escapes
in the shell's JSON islands, every platform in the published
configuration, and the two layout refusals that tell a spent
authorization code apart from a notary fault.
CRAP over the 43 production functions, from line coverage: 34 at or
under 5, and these above it.
117.0 CC 12 10.0% exchange
30.0 CC 5 0.0% main
14.2 CC 14 89.3% build_state
13.0 CC 13 97.2% canonical_origin
11.1 CC 9 70.3% github_token
11.0 CC 11 97.1% platforms
10.1 CC 10 91.4% callback_path
7.1 CC 7 89.5% style_hash
7.0 CC 7 100.0% select_layouts
`exchange` and the tail of `github_token` need a notary speaking the
TLSNotary protocol, which nothing here has; that is the known gap the
PR already records. `main` is the binary entry point. Everything else
is cyclomatic complexity in validators whose branches are independent
refusals rather than a coverage gap -- at 100% coverage CRAP is CC, and
splitting a flat list of guards into named halves moves the number
without helping a reader.
Measured from line coverage rather than llvm-cov's per-function region
counts: an `async fn` body compiles into a separate coroutine symbol, so
the region count attributed to the function itself is 1, and `exchange`
reads as fully covered when a tenth of it runs.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…fallback
Four fresh reviews over the finished branch — contract conformance,
structure and types, interfaces, and documented architecture. Every
finding below was reproduced before it was changed; the ones that
survived that check are here, and the ones that did not are named at the
end.
**CORS reached every path this bridge does not serve.** `Router::layer`
wraps a sub-router's FALLBACK as well as its routes, and `merge` carries
that layered fallback out into the whole service. Measured on the branch
as it stood:
OPTIONS /does-not-exist Origin: https://ccdp.example
200 OK
access-control-allow-methods: POST
access-control-allow-origin: https://ccdp.example
GET /does-not-exist Origin: https://ccdp.example
404 Not Found
access-control-allow-origin: https://ccdp.example
So a preflight for any nonexistent path advertised `POST`, and the CCDP
origin was handed a CORS relationship with paths the contract grants it
none. That is the opposite of the contract's "unsupported methods fail
without route work". `route_layer` runs only where a route matched.
**A platform response body could reach the logs.** The session driver
builds one of its details as `format!("API returned {status}: {body}")`,
so GitHub's whole response rides inside a `libid_tlsn::Error` and went
into `tracing::error!(%cause, ...)` verbatim. The contract's clause has
no exception: "credentials and OAuth-platform-return values never enter
logs, traces, analytics, metrics labels, or error bodies". The doc
comment on that very function claimed every detail was "a literal, an io
error, or a length and an index" — true of the details this crate
authors, and those were the only ones traced. A foreign cause is now
answered without repeating a word of it: the variant and the number of
bytes withheld.
That costs real diagnosis and the code says so rather than hiding it:
GitHub's status is inside the withheld text, so a notary handshake
failure and a `403` from the token endpoint write the same line.
Recovering it needs `libid_tlsn::Error::MpcTlsFailed` to carry the status
apart from the body — an upstream change, filed as a follow-up. From
here the string can only be withheld whole or leaked whole.
**The refusal body echoed the caller's own bytes.** `JsonRejection::
body_text()` embeds field names and byte offsets, and the contract says a
failure returns "no partial credential, attestation, or caller-selected
diagnostic content". The message is this route's own and fixed now. The
status moves only where it was actively wrong: a body over the ceiling
this route sets came back `400`, which made the ceiling invisible to the
caller it exists for, and is `413` now. Everything else stays `400`.
**A non-canonical admitted origin was folded, not refused.** The contract
singles this list out — "a duplicate or invalid member is a deployment
error rather than something the bridge normalizes" — and a trailing
slash, an uppercase host or a default port was quietly admitted under a
spelling the operator did not write. Refused now, naming the canonical
form. `BASE_URL` and `CCDP_ORIGIN` still normalize: the clause is about
this list.
**Three claims a test made that its assertions did not.**
- `a_response_with_no_access_token_field_at_all_is_not_the_callers_to_fix`
asserted `platform_refusal(...).is_some()`, which is the caller's to
fix — the name said the opposite of the body. It also used a
transcript production cannot present. Renamed to what it proves, and
it now covers both halves: a missing anchor is a refused code, and
every other layout refusal is not.
- `a_secret_carrying_form_delimiters_cannot_forge_a_field` searched the
revealed prefix for the raw bytes of a secret the serializer
percent-encodes, so the needle occurred nowhere in the transcript and
the assertion held for any layout at all. It now searches for the
bytes the wire actually carries and checks they fall inside a
committed range.
- `building_the_router_..._does_not_panic` claimed to catch a callback
path colliding with a fixed route. Both halves use the default path;
no collision is ever built. The doc now says what it does catch.
**`TOKEN_ENDPOINT` failed at exchange time, not startup**, the opposite
of what its own comment argued for. `LazyLock` defers to first use, and
first use is inside a ceremony. `build_state` forces it.
Also: `/health` carries `no-store` and is documented as what it is — a
deliberate fourth route outside the contract's closed surface, kept for
the image's `HEALTHCHECK`, and the one route that tolerates a query
because a probe answered `400` for a cache-buster reports a healthy
service as unhealthy. `config_record` is total, by the same argument
already made for `token_http_request`. `MAX_CONCURRENT_EXCHANGES` moved
beside the `Semaphore` it sizes. The runtime image drops
`ca-certificates`: the binary's MPC-TLS roots are compiled in from
`webpki_root_certs`, the notary link is raw TCP and the healthcheck is
plaintext loopback, so nothing in the image reads a system root. The
explicit `libssl3` goes with it — the binary links no OpenSSL — but it
stays in the image, because curl pulls it in; verified by building the
image and listing both paths inside it. `Cargo.toml` said "all
four" of three dependencies. Two module docs disagreed on the size of the
route surface, and `state.rs` said "one synchronous request at a time"
beside a semaphore admitting eight.
**The test fixture built a deployment production cannot.** `tests/http.rs`
hand-assembled an `AppState`, so it re-implemented three derivations
`build_state` performs and then asserted the literals it had typed — the
`redirectUri` assertion would have passed with the origin normalization
and the path join both removed. It goes through `build_state` now, which
also means no test can construct a state `build_state` would refuse, and
the permit-exhaustion test holds the real ceiling rather than configuring
it to zero.
CRAP over the 45 production functions, 35 at or under 5:
117.0 CC 12 10.0% exchange
30.0 CC 5 0.0% main
13.1 CC 13 91.1% build_state
13.0 CC 13 97.2% canonical_origin
11.8 CC 10 73.6% github_token
11.0 CC 11 97.1% platforms
10.1 CC 10 91.4% callback_path
7.1 CC 7 89.5% style_hash
7.0 CC 7 100.0% select_layouts
6.0 CC 6 100.0% allowed_app_origins
BREAKING CHANGE: a request body over this route's 8 KiB ceiling answers
`413` where it answered `400`. A refusal body no longer quotes the
caller's field names or byte offsets. An `ALLOWED_APP_ORIGINS` member
that is not already canonical — a trailing slash, an uppercase host, a
default port — stops the process instead of being folded. The published
image no longer ships `ca-certificates`.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…nts need Three of the four reviews converged on the same hole from different directions: `AppState`, `GithubExchange` and `RenderedShell` had every field `pub`, so a deployment could be assembled field by field from outside the crate — and `build_state`, which is where every invariant between those fields is established, was optional. That was not theoretical. `build_router` takes an `AppState` and calls `Router::route(&state.callback_path, ...)`, which PANICS on a path axum rejects or one already mounted; the guard for that lives in `lib.rs::callback_path`. A hand-built state could also hold a `GithubExchange.ccdp_origin` that differs from the one in the published record and in the shell's `script-src`, which is three ways for a ceremony to fail with no server-side symptom. And `RenderedShell`'s one property — the policy names the hash of the script the body carries — is established inside `shell::callback` and by nothing in the type. The reason those fields were public was the integration fixture, which the previous commit moved onto `build_state`. So they are `pub(crate)` now, and the modules that exist only to serve them — `deployment`, `oauth`, `shell`, `routes::github_token` — are `pub(crate)` too. What is left public is what the binary and the suite actually name: `build_state`, `Config`, `build_router`, `Error`, the two state types as opaque handles, `MAX_CONCURRENT_EXCHANGES`, and one accessor — `AppState::exchange_permits` — for the test that has to observe a full ceiling. Everything else a caller outside this crate could reach was either a secret or a value only a handler in here decodes. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…help`
One more read of the finished branch before push, observing the code
rather than the commit messages. Six findings, each reproduced here
before it was changed.
**`--help` printed the client secret.** clap writes an argument's
environment VALUE into its own help text unless `hide_env_values` is set,
and the image's entrypoint is this binary — so `docker run <image>
--help` with the env file attached wrote the secret to stdout. Measured:
--gh-oauth-client-secret <GH_OAUTH_CLIENT_SECRET>
[env: GH_OAUTH_CLIENT_SECRET=zzSECRETzz]
and now `[env: GH_OAUTH_CLIENT_SECRET]`. The doc beside `Config`'s
hand-written `Debug` claimed `Debug` was "the one thing that can take a
client secret out of this process". It was not; it says so now.
**A broken deployment was answered as the caller's mistake.** GitHub
returns `200` with an error object for a spent code AND for a wrong
client secret AND for a redirect URI that does not match, so the status
tells them apart from nothing — and this route treated every one of them
as `bad_verification_code`: `400`, "start a fresh ceremony", logged at
`warn`. A rotated secret therefore produced a fleet of ordinary-looking
refusals telling users to retry something that cannot succeed, with
nothing above `warn` while it happened. The error code decides now, read
off the received transcript inside the session where it already exists.
Only one bit survives that reading, and the sentence that reaches the log
is this service's own — the platform's words are still never written
down. An unrecognised code is treated as this deployment's fault: a
caller told to come back later when the code was merely spent costs one
retry, an operator not told their credentials are rejected costs the
whole deployment.
**A test opened a real MPC-TLS session.** The two accepting arms of
`github_token_takes_exactly_one_media_type` sent a VALID body from the
admitted origin, so they passed every check, took a permit and called
`exchange()`, which dials the fixture's `--notary-url` — the production
default. On a machine running a notary on 7047, `cargo test` would open a
session to github.com carrying the fixture's client secret. They also
asserted only "not 415", so any answer passed. They now send a body the
media check admits and validation refuses, which proves the same thing
with a `400` and never reaches a socket. Verified: zero notary dial
attempts across the whole suite.
**`CALLBACK_PATH` had a third pattern spelling.** `callback_path` refused
braces; axum also rejects a segment beginning with `:` or `*`, at
`Router::route`, with a panic naming neither the setting nor the path —
the exact failure that function exists to move earlier. `/auth/:cb` and
`/auth/*rest` passed `build_state` and panicked in `build_router`. Both
are startup errors now.
**A shell assertion matched the wrong thing.** It looked for `[1]` in the
rendered document to prove the version list was embedded; the bootstrap
contains `Number(match[1])`, so the needle matched whatever
`__VERSIONS__` became. It is the whole assignment now.
**A module that loaded without an entrypoint left the popup blank.** The
bootstrap's rejection handler covers the import; a module resolving
without a usable `startCallback` threw inside the FULFILLED handler
instead — an unhandled rejection, no fixed text. The contract says any
failure renders only fixed text after clearing. The export is checked
before it is called and the call is guarded.
That last change costs coverage, and the number should say so rather
than be quietly restated: `exchange` goes from 10% to 0% and CRAP 117 to
156, and `github_token` from 73.6% to 69.4%. The coverage that went was
entirely the accidental kind — it existed because a test dialled a real
notary — so this is a truer zero rather than a regression. Covering it
honestly still needs a fake notary speaking the TLSNotary protocol, which
is the gap the pull request already records. 36 of 46 production
functions remain at or under CRAP 5.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ow allows `OAUTH_BRIDGE.md` at `a8f2dbf`, which this branch was built against, said a missing `Origin` always fails. It says something else now: > When present, `Origin` must exactly match an `allowedAppOrigins` member. > … A same-origin browser `GET` may omit `Origin`: accept that case only when > `Sec-Fetch-Site` is `same-origin` **and** the Bridge's configured public > origin is itself in `allowedAppOrigins`. It needs no CORS response header. > Do not infer admission from `Referer`, the request host, or absent Fetch > Metadata; an explicit invalid, `null`, or unlisted `Origin` always fails. So admission is now two paths, and the interesting half is what closes them. `Origin` decides whenever it is present, and only its absence reaches Fetch metadata -- otherwise an unlisted page could drop to the same-origin path by sending `Sec-Fetch-Site: same-origin` beside its own origin. Absent metadata is refused rather than assumed: it is not evidence of anything, and treating it as same-origin would admit every client that simply does not send it. And the deployment has to admit its own origin, or there is no same-origin application to admit -- `AppState` carries that as one bool computed at startup from the `server_origin` `build_state` already derives and previously discarded. Both headers are read with `get_all` and require exactly one. Two `Origin` headers is not a request a browser sends, and taking the first would let the caller choose which one is read. `Vary: Origin, Sec-Fetch-Site` on success and on refusals. Two headers decide the body, so a shared cache has to be told both; on refusals `no-store` should already prevent it, and a cache that ignores `no-store` must not get the chance to replay a 403 to an origin this deployment admits. The response headers are a `HeaderMap` rather than an array or `AppendHeaders`, because the body is `Bytes` and sets its own `application/octet-stream` -- these have to replace rather than append, or a caller reads the first of two content types. Found by a test, not by reading. Ten tests cover the matrix TEST_PLAN.md KIT-022 asks for: exact origin, `null`, unlisted, malformed, either header sent twice, absent with each of `same-origin`/`same-site`/`cross-site`/`none`/nothing, a deployment not in its own allowlist, and `Referer`/`Host` granting nothing. The suite gains a `config_with` helper because `get_config` could only ever send an `Origin`, and an `admits_itself()` deployment because the default fixture's `--base-url` is deliberately absent from its `--allowed-app-origins` -- which is why the existing refusal test survives unchanged, now for a second reason. BREAKING CHANGE: `GET /api/v1/ceremony/config` answers a same-origin request carrying no `Origin` where it previously answered `403`, when and only when `Sec-Fetch-Site` is exactly `same-origin` and the bridge's own origin is an admitted application origin. Every response on that route now carries `Vary: Origin, Sec-Fetch-Site` in place of `Vary: Origin`. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The contract's deployment table now reads "One canonical HTTPS origin selected by the operator; defaults to `https://lib.id` when omitted". It was required here, so a deployment that wanted the canonical Distribution had to name it, and one that forgot was refused rather than pointed at it. A default, not a fallback: a deployment that sets this gets exactly what it set. The only change is what an absent value means. Verified against a running binary with the variable unset -- the process starts and the record an application reads carries `"ccdpOrigin":"https://lib.id"`. The test asserts that path rather than the setting alone, and builds its `Config` without the flag rather than with the flag set to that value, so it is about the default and not about a string typed twice. Note the interaction with what comes next: once the bridge fetches its callback artifact from this origin, the default points at a host that does not resolve today. That is the situation the embedded floor exists for, and it is why the artifact work refuses to make startup depend on reaching the Distribution. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`412df21` makes the token route's rule explicit where it was previously one clause: > every preflight and POST carries **one valid** `Origin` exactly equal to > `ccdpOrigin`; missing, `null`, malformed, **multiple**, or different origins > reject before notary resolution, connection, or token exchange. Successful > preflight never substitutes for checking the actual POST. Every part of that held here except `multiple`. The check read `headers.get(header::ORIGIN)`, which returns the FIRST of several, so a request carrying the configured origin followed by any other was admitted on the strength of the one the caller put first. It did not merely get past the gate. Measured, by reverting this hunk and keeping the test: the request went on to take an exchange permit and dial the notary, answering `502`. So the cost of reading the first header was a session opened for a caller the contract refuses. `get_all` with exactly-one, the same shape the configuration route already uses. Missing, `null` and malformed fall through it unchanged. The comment also now says the thing the contract spelled out and the code only implied: this route admits `ccdpOrigin` and never the union with `allowedAppOrigins`. The caller is the Prover on the distribution, not the application. That was already true and is now covered -- both admitted application origins are asserted to be refused here. The admitted case is proved with a body the NEXT check refuses, so the test shows admission without opening a notary session. Verified across the whole suite: zero notary dial attempts. BREAKING CHANGE: `POST /api/v1/ceremony/github-token` answers `403` to a request carrying more than one `Origin` header, where it previously read the first and could proceed to a notary session. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…writing one
The contract moved the callback document out of this service. The Distribution
now builds one self-contained artifact at `/ccdp/callback.html` carrying every
supported Callback implementation, and the bridge's job shrank to inserting
deployment data into the one slot it leaves:
> Its artifact contract owns the HTML, configuration slot, response policy,
> browser startup, version selection, and failure UI.
So `src/shell.rs` and `src/shells/callback.js` are gone -- with them the
generated document, the inline bootstrap, the version dispatch, the dynamic
import, `CCDP_SUPPORTED_VERSIONS` and `CALLBACK_STYLE_HASH`. The bridge
"neither enumerates versions nor reads input declarations from the artifact",
and the inserted data is ONE UNVERSIONED LIST, `[allowedAppOrigins,
ccdpOrigin]`, both values it had already validated for other reasons. A
compatible Callback change now needs no bridge rebuild.
**Reading the artifact is a tokenizer, not a substring search.** A `<script`
finder cannot tell a tag from the same text inside an attribute value, and the
cost of being wrong is not a missed element: it is a policy naming the hash of
a different span than the browser runs, served `200`, with the document
silently refusing to execute. Three of the rejection rules are about that
rather than tidiness, and say so where they appear -- a `\r`, because the HTML
input stream normalises CRLF to LF *before* tokenizing; an `<!--`, because
inside script data it enters the escaped states where `</script>` no longer
necessarily closes the element; and `<svg`/`<math`, because in foreign content
`<script>` is markup rather than raw text. Every script must be exactly the
slot or exactly `<script type="module">`; anything else is refused rather than
guessed at.
One comment overstated and was corrected rather than enforced: an UNQUOTED
attribute value is safe, because it cannot contain `>`. Single-quoted values
are refused, because they can.
**The hashes are computed here, over the bytes about to be served.** Never
taken from an upstream header -- the contract asks for "the artifact's
executable hashes with its own deployment-specific policy, not an upstream
policy", and a policy trusted from the document it constrains is not a
constraint. `script-src` therefore carries hashes and no source at all;
`style-src` is `'unsafe-inline'`, since the package owns its styles and there
is no stylesheet hash left to configure.
The re-scan after substitution earns its place. "Data substitution does not
change executable bytes" is true by construction -- the slot is
`application/json` and nothing hashes it -- and this makes it something that
runs on every compose instead of an argument in a comment. Two deployments with
different allowlists produce different documents and the same `script-src`.
Provenance is stated by the caller rather than inferred. The first attempt
compared `std::ptr::eq(html, EMBEDDED)`, which a test caught: a `const` is
inlined at each use, so two references to it need not share an address.
**The compiled-in artifact is a floor, and deliberately not a working
Callback.** It clears the OAuth return and renders fixed text; a deployment
running on it completes no ceremony, and the process says so at `warn` on every
start. It exists so there is always a valid document to serve, which is why the
contract's "inert unavailable response" has no representation here -- a route
that can answer nothing is a state a reader must account for and an operator
must discover. Replace the file with a vendored snapshot and no code changes.
Verified against a running binary: the warning fires, the served policy is
`script-src 'sha256-…'; style-src 'unsafe-inline'; frame-src {ccdpOrigin};
connect-src 'none'` with the five `'none'` directives, the slot holds
`[["https://app.example"],"https://ccdp.lib.id"]`, and the marker is gone.
The integration suite's forbidden-token assertion is now DIRECTIVE-SCOPED.
`'unsafe-inline'` is legitimate in `style-src`, so a substring search over the
whole policy would either fail or get "fixed" by deleting the token it exists
to catch in `script-src`.
CRAP over the new module: `scan` 15.0 (CC 15, 100% covered) is complexity in a
flat list of independent refusals; everything else is at or under 8, and the
escaping test that moved from `shell.rs` keeps `json` honest.
BREAKING CHANGE: `CCDP_SUPPORTED_VERSIONS` and `CALLBACK_STYLE_HASH` are gone
and a deployment setting either will not start. The callback document is the
Distribution's artifact rather than a generated shell, its `script-src` names
only hashes and no CCDP module URL, and its `style-src` is `'unsafe-inline'`
rather than a pinned stylesheet hash.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…d its own answer
Fifteen findings, each reproduced before anything moved.
**An empty bearer was answered as a broken deployment, and the test that
covered it passed a value production never computes.** `select_layouts` turns
`"access_token":""` into `MissingField("access_token")`, and `platform_refusal`
then asked one question -- does the response name `bad_verification_code`? For
an empty bearer the answer is no, so it became `PlatformMisconfigured`: a 502
and `error!("every ceremony on this deployment will fail")`, over credentials
that are fine. The comment above it said "it is GitHub's answer, not ours" and
the test asserted `400`. Both were wrong, and the test only passed because it
called `platform_refusal(.., true)` with a bit that production computes as
`false` from that exact body.
There are three answers, not two, so there is now a `PlatformAnswer` with three
variants and a `classify` that reads it off the transcript.
`bad_verification_code` is the caller's and gets `400`. Another named error is
this deployment's and gets `502` plus the alarm. A `200` naming NO error while
carrying no bearer is neither -- not the caller's to retry, not a credentials
problem -- so it gets an ordinary `502` and no alarm. Every test now classifies
from the same bytes production classifies.
**A conformant caller was refused for its capitalisation.** RFC 9110 makes
media types case-insensitive, and this route compared `!=` against a literal,
so `Content-Type: Application/JSON` was answered `415` for being exactly what
the contract asks for. Measured before: `415`; after: `400`, refused on its
body like any other request.
**`scan` was quadratic.** `rest[1..].to_ascii_lowercase()` allocated a copy of
the whole remaining document for every tag, and `compose` scans twice, so a
4 MiB artifact with tens of thousands of tags would have copied on the order of
100 GB before the process ever reached `bind`. It compares six bytes now. The
forbidden-needle loop lowercased the whole document once per needle; hoisted.
**Refusing `<svg` would have refused every real artifact.** The contract
documents an inline logo, so the outright refusal would have surfaced only
after vendoring -- as a fallback to the floor, which completes no ceremony. The
hazard is narrower than the rule was: inside foreign content `<script>` is
parsed as markup, so the subtree is now stepped over with depth counting and
refused only if it holds a `<script`.
**The test fixtures inherited the developer's environment.** clap falls back to
the process environment for any flag an argv omits, and both helpers omitted
several -- so `CALLBACK_PATH=/cb` exported for a local run mounted the router
elsewhere and failed every callback assertion with an unexplained 404.
Reproduced, then fixed by listing every env-backed flag. The whole suite now
passes with `CALLBACK_PATH`, `BASE_URL`, `PORT`, `HOST`, `NOTARY_URL`,
`CCDP_ORIGIN`, `ALLOWED_APP_ORIGINS` and `GH_OAUTH_CLIENT_SECRET` all set to
hostile values. The CCDP-default test could not survive that -- clap prefers
env over a default -- so it asserts the declared default off the command itself
and the plumbing separately.
**An underscore is legal in a host** and is not CSP syntax, so the byte filter
that keeps delimiters out of the policy should not have taken it too:
`https://app_staging.example` could not start. **A default port is dropped by
`Url` during normalisation**, so `NOTARY_URL=https://notary.example:443` was
refused for naming no port while plainly naming one; it reads
`port_or_known_default` now.
**A foreign crate's `Display` reached the caller.** `TokenRequest::validate`'s
error carries byte offsets derived from what the caller sent, and this route's
contract says a failure returns no caller-selected diagnostic content -- the
same rule that already governs the JSON rejection three lines above. It is
logged at `debug` and answered with this route's own words.
Also: the "exactly one `Origin`" rule was written twice in two spellings and is
now one `routes::origins`, because two spellings of one security rule drift the
first time either is tightened; `Source` had a single variant, so the branch it
gated was always taken and it is gone until a second source exists; and `json`
allocated a `String` per escaped character.
Not changed, with reasons: the configuration route still answers no CORS
preflight, because the contract's route surface is closed and lists `GET`
alone, and the client is specified to fetch with `credentials: 'omit'`. A
dynamic `import` inside module text is still invisible to `scan`, but
`script-src` admits hashes only, so the browser blocks it loudly rather than
this service serving something that half-works.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…s to match it
`bb013dd` unifies what were three admission rules into one derived set, and
inverts the token route's rule two commits after `412df21` fixed it the other
way:
> After resolving the default or configured `ccdpOrigin`, the bridge derives one
> effective set: `allowedOrigins = allowedAppOrigins ∪ {ccdpOrigin}`. … The
> effective set governs configuration GET, callback connection authentication,
> and GitHub preflight/POST admission.
So the token route no longer admits `ccdpOrigin` alone — "this route uses the
same `allowedOrigins` rule as configuration and Callback: configured
application origins are also admitted". The test asserting the opposite,
written yesterday against the previous revision, inverts with it.
The set is derived once in `build_state` and shared as an `Arc<[String]>`
rather than re-derived per surface: three copies of one rule is three things to
keep in step. Only the RESOLVED origin joins, so a deployment that overrides
`CCDP_ORIGIN` does not keep `https://lib.id` admitted unless it lists it, and
an already-listed origin is not added twice — both of which KIT-004 names and
both of which are tested.
**The CORS layer had to move with it, and that is the part worth reading.**
`token_cors` admitted the CCDP origin alone. Left there, a configured
application origin would pass the handler's gate and then have its answer
discarded by the browser for want of a matching allow-origin header — a
failure with no server-side symptom at all, on the route that spends the client
secret. KIT-013 says so directly: noncredentialed CORS "for that origin", and
"test admission for a configured application origin distinct from
`ccdpOrigin`". The layer now carries the whole set, and the preflight test
covers all three members plus a refusal.
`GithubExchange.ccdp_origin` is gone: both its readers moved to the effective
set, so it decided nothing.
Verified against a running binary. The token route answers `400` (admitted,
refused on its body) for the CCDP origin and for a configured application
origin — the latter was `403` before — and `403` for an unlisted one. The
configuration route now admits the CCDP origin. The callback document carries
`[["https://app.example","https://ccdp.lib.id"],"https://ccdp.lib.id"]`,
which is the contract's own example shape.
One comment had to go rather than be reworded: it said `allowedAppOrigins`
grants nothing on this route, directly above code that now admits it.
BREAKING CHANGE: `POST /api/v1/ceremony/github-token` and its preflight admit
any member of `ALLOWED_APP_ORIGINS` in addition to the CCDP origin, where they
previously admitted the CCDP origin alone. `GET /api/v1/ceremony/config` admits
the resolved CCDP origin without it being listed. The callback document is told
the effective set rather than the configured list.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The pinned revision was 26 commits behind libid-rs#2, and one of them is the reason no ceremony this bridge ran could have been attested at all: > `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. It failed closed, so this was liveness rather than safety, but it failed after a full MPC-TLS session had been paid for. Nothing in this repository could have caught it: the two halves that disagreed both live in libid-rs, and this bridge only calls the prover. The move also brings the layout constructors onto `Layout`, so `ceremony::token_request(sent, Some(SECRET_FIELD))` becomes `Layout::token_request(sent, &token_session())`. **And the secret field is no longer ours to name.** `SECRET_FIELD` was a `const` in this file spelling `client_secret`; it now comes from `libid_profiles::GITHUB.token.secret_field`, generated in libid-contracts from the same `profiles.json` that `CeremonyProfile.sol` is generated from. What this prover reveals and what the Platform Verifier compares it against now come from one place. A constant here was a second spelling of a profile fact, which is the drift that table exists to remove -- and this service ordering the field last in its request body is only correct if it is the same field the layout commits. BREAKING CHANGE: the notarized commitments are SHA-256 rather than BLAKE3, so an attestation produced by an earlier build of this service is not one the Proving Circuit opens. There is no migration: those attestations could not be signed by a conforming notary either. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…from disk
Two things stood between this service and a manual ceremony run. Neither
needed anything from libid-rs.
**`notaryAddress` is a required request field.** The contract moved the Notary
Service address out of bridge configuration: "Prover supplies its already
resolved address … and uses that same address for identity notarization",
because one resolution has to serve both sessions -- a bridge that re-derived
it could disagree with the browser about which notary signed, and the two
attestations would then name different services. Until now this route answered
a conforming Prover with `400`, because `deny_unknown_fields` cuts both ways.
It was never blocked on the shared `TokenRequest` type gaining the field:
nothing but `code` and `code_verifier` reaches the form body, so the address
belongs to this service's own request type and stays there.
**This bridge does not dial what a caller names.** The contract makes the
destination request-controlled and leaves egress safeguards to carry the
weight. Rather than take that on, the request is checked to name the notary
this deployment is already configured for and refused otherwise -- so nothing
caller-supplied ever reaches a socket, and the SSRF surface the contract warns
about does not open here at all. When the transport moves to
`wss://{notaryAddress}` it is the configured value that disappears, not this
check. Compared as `host:port`, because the two spell one service differently:
the request carries an HTTPS origin and this bridge dials the notary's own TCP
endpoint.
The address is held to a narrower shape than a configured origin: HTTPS only,
no credentials, path, query or fragment, and host bytes that are host bytes.
`canonical_origin`'s loopback exception is deliberately not shared with it -- a
caller does not get to name a plaintext destination.
**`CALLBACK_ARTIFACT_PATH` serves a real Callback.** The bridge does not fetch
the artifact yet, and the floor completes no ceremony, so there was no way to
run one at all. A path is read once at startup and goes through the same
validation and the same composition as the compiled-in bytes: where they came
from changes what is logged and nothing else, because a supplied artifact is
not more trusted than an embedded one. It is read rather than fetched, so
startup still touches no network.
**And the token route admits the CCDP origin alone**, reverting that half of
`4ef40dc`. `bb013dd` widened it to the effective set, and the owner's decision
is the narrower rule: the caller here is the Prover, which runs on that origin
and nowhere else, so admitting an application origin widens the one route that
spends the client secret for no caller that exists. `/config` and the inserted
list keep the union -- `callback.ts` requires the CCDP origin to be in the
allowlist it receives. NOTE FOR THE NEXT READER: `OAUTH_BRIDGE.md:199` at
`a73e31f` still says this route uses the same `allowedOrigins` rule as
configuration. The narrower behaviour here is deliberate and the document needs
to catch up, or this will be reversed a third time.
Verified against a running binary with an artifact supplied from disk: the
floor warning is replaced by the configured-artifact line, the CCDP origin with
a matching notary reaches the notary dial (`502`, nothing listening), an
application origin is `403` here and `200` on `/config`, and a request naming
another notary is `403`.
BREAKING CHANGE: `POST /api/v1/ceremony/github-token` requires `notaryAddress`
in the body and refuses one that does not name the configured notary. It admits
the configured CCDP origin only, where `4ef40dc` briefly admitted the whole
effective set.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Standing the real stack up refused every token request. The notary listens on two ports -- a TCP wire listener the bridge dials for MPC-TLS, and a WebSocket endpoint a browser Prover reaches -- and a Prover naturally sends the address it uses. Requiring the ports to agree therefore refuses the normal deployment, where both live on one host. The port was never a destination. This bridge dials its CONFIGURED notary whatever the request says, so nothing caller-supplied is connected to and dropping the port from the comparison opens no egress. What the check establishes is only that the caller and this deployment mean the same notary, and the host is what carries that. The fixture now sends a DIFFERENT port from the one it dials, so the case that motivated this is the case under test rather than a coincidence. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Standing the stack up found the real blocker, and it was not credentials.
TLSNotary gates a session on a protocol version, so the bridge's prover --
built against upstream alpha.15 -- could not talk to the notary's verifier
on the libid-org fork at all:
prover version does not match with verifier:
0.1.0-alpha.15 != 0.1.0-alpha.16-pre
It failed at "Setting up MPC-TLS", 8ms in, before any ceremony logic ran.
No configuration reaches that; the two graphs have to agree.
So this repins the three libid-rs crates to cafd9a0b, the revision the
notary pins, and copies the notary's [patch] block verbatim so tlsn and mpz
resolve to the same fork. Cargo cannot move a git dependency across a
pre-release boundary in an existing lock -- it reports the patch as "not
used in the crate graph" -- so Cargo.lock is regenerated rather than
updated. The churn is real (157 insertions, 202 deletions) and almost all
of it is mpz moving to the fork.
That revision also restructured the ceremony API, which is the rest of this
change: Layout::token_request/token_response became free functions taking
the secret field name directly, and libid-rs no longer carries a profile
table to read it from. The field is named here instead. That is safe in a
way the old comment's premise was not -- `client_secret` is OAuth 2.0's own
parameter name (RFC 6749 2.3.1), fixed by the protocol GitHub implements,
not a libID fact that could drift. Every caller in libid-rs and the
TypeScript verifier passes the same literal.
Measured after: the session now completes an MPC-TLS handshake with
github.com, sends the POST, receives 994 bytes, and commits the transcript.
Assisted-by: Claude Fable 5.1
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`foreign_subtree` and the other-script check sliced `&str` at fixed byte indices. A multi-byte character landing on one of them panics: `<p>é` puts `é` at bytes 3..5, and `rest[1..4]` splits it. The scan runs at startup on the configured artifact, so the process aborted with a backtrace where it should have refused the file and named the setting. Reachable with any artifact that carries non-ASCII after a short tag -- `<p>é`, `<b>—`, `<em>é`, `<h1>é`, `<p>abcé` all reproduce. The current Distribution callback happens to be pure ASCII, which is the only reason this never fired; a localised one would take the deployment down. Compared as bytes now, which has no boundary to split. The test asserts only that scanning returns rather than unwinds, and it fails on the old code with `byte index 4 is not a char boundary`. Alongside it, three things the same review found: - The notary-admission test sent a fully valid body and asserted "not 403", so it passed every gate and opened a real MPC-TLS session -- spending the fixture client secret against github.com on any machine running a local notary, which LOCAL_STACK.md now tells people to run. It now sends a body the gate AFTER the notary check refuses, which proves admission without dialling. The suite drops from 8.47s to 0.01s, which is that dial. - `notary_host` is split once at startup instead of per request, removing a `map_or` fallback no input could reach. - README, `lib.rs` and `state.rs` still said the effective origin set governs the token route. It has admitted `ccdpOrigin` alone since the last commit. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A caller read two to four arguments before it learned what was being built,
and the type that came out was named only in the return position.
| Before | After |
|---|---|
| `scan(html)` | `Layout::scan(html)` |
| `compose(html, inputs, source)` | `CallbackDocument::compose(..)` |
| `callback_document(cfg, ccdp, origins)` | `CallbackDocument::for_deployment(..)` |
| `select_layouts(sent, recv)` | `Selection::of(sent, recv)` |
| `classify(recv)` | `PlatformAnswer::in_response(recv)` |
| `admit(state, headers)` | `Admitted::of(state, headers)` |
| `origins(headers)` | `Origins::of(headers)` |
| `config_record(uri, ccdp, platforms)` | `CeremonyConfig { .. }.serialized()` |
Two are worth more than the rename.
`PlatformAnswer::in_response` says WHERE it reads. `classify` named the verb
and not the thing, and this one searches the received transcript -- status
line, headers and body -- rather than a parsed field. A reader who assumed
otherwise is exactly the reader who would widen it.
`CeremonyConfig` holds a deployment together rather than passing it apart.
Its three fields were three readings of one deployment kept in step by hand
at the only call site, and the projection is of a deployment, so a value
that is not part of one now has no field to arrive in. `record` becomes its
private method and stops restating the arguments.
`bearer_range` stays a free function on purpose: it returns `Range<usize>`,
which is not this crate's to construct, and the argument for naming the type
first does not apply when the type is std's.
No behaviour changes. Every function moved with its body intact, so the
complexity and the coverage of each are what they were; the suite passes
unchanged, which is the point.
Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`secrecy::SecretString` holds the secret in the configuration, the platform table and the credentials: `Debug` redacts it, reading it is `expose_secret()` where the request body is written, and the memory is zeroed on drop. The two hand-written `Debug` impls are derived again. `GH_OAUTH_CLIENT_SECRET` is optional; an empty value is unset. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…the way out `TokenResponseBody` and `AttestationBody` borrow the session's result: `&str` for the bearer, `&[u8]` for the attested data, the signature and the opening, each written as unpadded URL-safe base64 by the field's serializer. Nothing but bytes can fill those fields, and the three copies and the encoding helper are gone. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`origin::Origin` is the canonical form every configured origin is checked into, with two constructors: `parse` folds, `listed` refuses what is not already canonical. The admission set, the CCDP origin, the artifact's inputs and the upstream retriever carry it instead of strings, its `header_value` cannot fail, and the retriever's constructor no longer has refusals for spellings an `Origin` cannot be. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
refactor: checked values carry their check
An `x` table in the configuration file is a public client: a client id, versions, no secret. `/config` publishes it as exactly `clientId` and `ceremonyVersions`. The callback document is the same bytes for X's return, its long code and `https://x.com/` as the referrer included. A deployment enabling X alone starts with no `GH_OAUTH_CLIENT_SECRET` in its environment, publishes the X entry and mounts no token route. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
GitHub's token request runs in the browser like every other platform's, with the App's client secret as a public application credential. The bridge publishes it: a github table carries `token_exchange_credential`, nonempty printable ASCII without whitespace, and the served entry carries it as `tokenExchangeCredential`. Google and X entries carry none. `POST /api/v1/ceremony/github-token` is not served: a `POST` or a preflight on that path is a `404` with no CORS header. The bridge opens no notary connection, holds no secret and reads no `NOTARY_WIRE_PORT` or `GH_OAUTH_CLIENT_SECRET`; a file naming `notary_wire_port` or `gh_oauth_client_secret` is refused like any other unknown key. The libid-rs crates, the TLSNotary fork and its mpz patches, `secrecy` and `tower-http` leave the dependency graph, and the image's builder stage installs no git and no OpenSSL headers. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The GitHub claim runs in the browser end to end; the bridge publishes the App's client secret as the public `tokenExchangeCredential` and serves two routes and a liveness probe. The exchange's request and response, the wire port, the secret variable and the per-client rate limit are gone from the README. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Wondertan
reviewed
Sep 15, 2026
`GET /api/v1/ceremony/config` with no `Origin` is admitted on exactly one `Sec-Fetch-Site: same-origin`, whatever the allowlist holds; an explicit `Origin` is judged against the allowlist as before. The bridge holds no origin of its own: `public_origin` and `PUBLIC_ORIGIN` are gone, and a file naming `public_origin` is refused like any other unknown key. The application derives the redirect URI from the bridge origin it knows. This is wider than libid#13's text, which conditions the same-origin read on the bridge origin being listed. The record is public, and the origin's own pages are the operator's; the listing guarded nothing. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The github entry of `/config` carries the App's client secret as `clientCredential`, the name libid#13 gives the field; the configuration file's key is `client_credential`. Value and checks are unchanged: nonempty printable ASCII without whitespace, present on the github entry only. A configuration read with forwarding headers and no `Origin` is refused like one with `Referer` or `Host`. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Wondertan
approved these changes
Sep 15, 2026
Every tag of an `<svg>` or `<math>` subtree begins with `<`, so the walk to the matching close steps from one `<` to the next and classifies it, its position never moving back. A subtree nested a hundred thousand deep is walked in one pass. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A container image sets `HOST` and `PORT` in the environment, which beats the file, so a mounted file naming `host` or `port` was read and not applied: the operator's `port = 9000` left the bridge on 8722 with no word. The file no longer has those keys, so one naming them is refused at startup like any other unknown key, and where the process listens is set with `HOST`/`PORT` or `--host`/`--port` only. The file tests parse with a command no environment variable reaches, so what a file supplies is what the test wrote whatever the machine running them exports; `tests/startup.rs` passes the loopback port to the binary in its environment. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The enabled platforms are read from the file and nowhere else, so a run without one was refused for carrying no platform and told to add a `[[platforms]]` table to a file it had never been given. It now names the missing file and how to supply it. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`ALLOWED_APP_ORIGINS[i]` counted the members it did not skip, so a list carrying a blank entry named the wrong one and sent the operator to edit an origin that was already canonical. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The effective origin set is built from the checked list without a second copy of it, the unasked-`304` refusal takes its wording from the error it reports, the test profile enables no tokio feature no test uses, and the image's builder stage copies no local configuration file and no `.env`. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A Distribution that sends no `ETag` answers every refresh with the whole document, and each answer was published and logged as a replacement: an operator read "the callback artifact was replaced" every five minutes about bytes that never changed. A refresh publishes when the document or its validator differs from what is served, and is otherwise the `304` case. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The callback document's policy names the CCDP origin as its one `frame-src` source, and a Content-Security-Policy source expression has no form for an IPv6 literal: Chrome discards the source, leaving `frame-src` with none, and the Callback's frame to the Distribution is blocked with only a console warning. A deployment naming one is refused before it binds, with the reason. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The state held a receiver beside the sender to read the published document through, where the sender reads it the same way. The route and the refresh borrow the one field; a test that waits for a replacement subscribes. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`main` spells the repositories `libID-server-rs` and `libID-rs`; this branch takes that spelling for its `repository`, its README title and the workflow comment naming where the workflow came from. The crate name, the binary and the image path stay lower case, where cargo and the registry want them. Everything else on `main` is what this branch replaces. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The walk matched `</svg` wherever the bytes fell, so a name written inside a quoted attribute value ended the subtree early: the script check then read the truncated span, and a `<script>` inside foreign content was accepted and hashed over a span the browser parses differently. It now steps one tag at a time through `end_of_tag`, which skips a quoted value whole, and classifies each tag by name with the boundary rule the outer match already used. A nested `<svg/>` closes itself, `<svg-icon>` is a custom element, and a `<script` or a CDATA section anywhere in the subtree is refused. A processing instruction and a CDATA section are refused where they are markup, in `ordinary_tag` and in that walk. Inside script text they are ordinary bytes, and a bundle carrying one in a string no longer stops the deployment. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The size bound is the retrieved artifact's; the composed document, longer by what the slot holds, is read without it. `/health` reads nothing from the request, and the route that refuses a query is the configuration one. The repository ignores the local configuration file and any `.env`, as the image already did. CI resolves the same lock the image builds with. The image runs as a user of its own. A test that ends before it stops the binary leaves none running, and the scratch configuration file both test suites write is one helper that removes it. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A browser sending a header of its own preflights the read, and the route had no `OPTIONS`: the answer was a `405` with no grant, and the read the preflight was for never happened. `OPTIONS` now applies the admission rule the `GET` applies, exactly one `Origin` in the effective set, and grants `GET`, the headers the request asked for, and no credentials, for ten minutes. An origin the read would refuse gets no grant. The record is public and read without credentials, so the headers a caller wants to send are granted as asked rather than from a list this service would keep. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The artifact contract has the Distribution serve `/ccdp/callback.html` with the hashes of the code it ships, and the bridge "combines the artifact's executable hashes with its own deployment-specific policy". This bridge computed them instead, which is why it carried a tag-level reader of arbitrary HTML: locating script text exactly is the hard part of hashing it. It now reads the artifact's own `script-src`, refuses one naming anything but hashes, and carries those into the policy it writes. No source this bridge did not write reaches a browser. Substitution cannot invalidate a hash: the slot is a non-executable data block and the only thing substitution touches. What the reader has left to find is that slot and the mount point, both byte patterns the contract fixes. The foreign-content walk, the tag walk, the script shapes and the refusals that made them sound are gone with the hashing that needed them; `scan.rs` is 252 lines where it was 670, and `sha2` and `base64` are fixture-only. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A Distribution that ships a stale hash served a document whose code the browser blocks, and the bridge said nothing. The reader takes each script element the document carries -- the data slot or a plain module, and nothing else -- and composition refuses an artifact whose policy does not name exactly the hashes of those modules. That is what the contract's own reference bridge does, and it is the one thing worth reading the document for: the hashes still travel from the artifact into the composed policy rather than being written here. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The retrieval stops a body at the artifact bound, which is where the bytes arrive, so the reader checked a length that could not be over it. The configuration route answered a refused preflight from a second refusal that differed from the first in nothing a browser reads. Two tests asserted that an unserved path is a `404` with no CORS header, one for the path the token route had and one for a path that never existed; they are one test over both. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The artifact carries the semantic equivalent of the contract's example, and the reader matched the example's bytes: a build writing the data slot's `id` and `type` the other way round, or in single quotes, stopped the deployment. The attributes of one script element are read in whatever order, quoting and case they were written, and the element is the data slot, a module, or refused. The elements are still found by their bytes and not by the element that encloses them: a document burying them in an inert one is read as carrying them, which the module says. Assisted-by: Claude Opus 5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This server is the OAuth Bridge of a libID ceremony, and only that. It publishes the configuration an application starts from and serves the one callback document the OAuth platforms redirect back to. Everything the browser runs after the callback is served by a separate static CCDP Distribution at
CCDP_ORIGIN. The bridge performs no token exchange and opens no notary connection: GitHub's exchange runs in the browser like every other platform's, with the App's client secret published here as a public application credential.The contract is
specs/oauth-bridge.mdon libid#13; the GitHub profile whose token request carries the public credential isspecs/platform-ceremonies.mdon libid#35.Routes
GET,OPTIONS/api/v1/ceremony/config{ ccdpOrigin, platforms }. OneOriginfrom the admitted set, echoed exactly, never*; or a same-originGETwithoutOriginon exactly oneSec-Fetch-Site: same-origin, whatever the allowlist holds.403otherwise,400for a query.no-store,Vary: Origin, Sec-Fetch-Site.OPTIONSanswers the preflight a caller sending its own header needs, by the same admission rule:GET, the headers asked for, no credentials, ten minutes.GET/auth/callbackCOOP: unsafe-none,no-referrer,no-store, a CSP naming the SHA-256 of each script.GET/healthHEALTHCHECK. Not a contract route.The admission set is
allowed_app_origins ∪ {ccdp_origin}; the callback document is told the same set. Any other path,POST /api/v1/ceremony/github-tokenincluded, is404with no CORS header.The published record
Each enabled platform is published as
clientIdandceremonyVersions; thegithubentry also carriesclientCredential, the App's client secret, which GitHub requires in every token request. It is nonempty printable ASCII without whitespace, checked at startup. The record carries no redirect URI (the application derives{bridgeOrigin}/auth/callbackfrom the bridge origin it knows), no allowlist, no notary setting and no user token.Configuration
A TOML file, named by
LIBID_CONFIGor--configand required: the enabled platforms are read from it and nowhere else, so a run naming none is refused saying that. Every key can be overridden by the environment variable or flag of the same name.bridge.toml.exampleis a complete starting point.Where the process listens is not a key of that file. A container image sets
HOSTandPORTin the environment, which beats a file, so a file naminghostorportwould be read and not applied; both are refused there like any other unknown key and set withHOST/PORTor--host/--port.allowed_app_originsccdp_originhttps://lib.id[[platforms]]id,client_id,versions; thegithubtable also carriesclient_credential.Origins are HTTPS, or HTTP on exactly
localhostor127.0.0.1. A refusal names the member the operator wrote, by its own index. A configuration that could not serve a ceremony stops the process: a non-canonical or duplicate application origin, no platform, agithubtable without its credential or with one carrying whitespace or a non-printable byte. There is no notary setting and no environment variable for the credential; a file namingnotary_wire_portorgh_oauth_client_secretis refused as an unknown key.The callback artifact
Retrieved from
{CCDP_ORIGIN}/ccdp/callback.htmlbefore the listener binds and revalidated every five minutes on itsETag; a refresh that fails, or returns something the bridge will not serve, leaves the served document as it is, and one that returns the document already served publishes nothing. A deployment that cannot retrieve its artifact does not start.The artifact arrives with the hashes of the code it carries in its own
script-src. The bridge refuses a policy naming anything but hashes, checks that those are the hashes of the modules the document carries, and carries them into the policy it composes: it writes no script source of its own, and a Distribution shipping a stale hash is refused rather than served as a page whose code the browser blocks. Reading the document is then the data slot, the modules and the mount point, each read by its attributes rather than their spelling.The document's policy names the CCDP origin as its one
frame-srcsource, and a policy source expression has no form for an IPv6 literal, so an origin written as one is refused before the listener binds.Breaking for deployments
Gone from
main:/auth/github/challenge,/auth/github/result/{challenge},/auth/gmail/callback, the proof verification with its countersigning key, and the settingsBASE_URL,APP_URL,ALLOWED_ORIGINS,NOTARY_URL,NOTARY_ADDRESS,CHAIN_ID,VERIFIER_CONTRACT_ADDRESS,GH_OAUTH_CLIENT_ID,CHALLENGE_TTL_SECS. Gone since earlier revisions of this branch:POST /api/v1/ceremony/github-token,notary_wire_port,GH_OAUTH_CLIENT_SECRET, thegithubtable'sclient_secret, andpublic_origin/PUBLIC_ORIGIN: the bridge holds no origin of its own.hostandportare no longer read from the configuration file; a deployment that set them there setsHOSTandPORTinstead, and the published image already does.The GitHub OAuth App's callback URL must be registered as
{bridgeOrigin}/auth/callback. A proxy in front of this server must redact the callback's query string from its access logs. The image installs no CA bundle: the roots for retrieving the artifact are compiled in. It runs as a user of its own rather than as root.Depends on
Nothing from libid-rs. The TLSNotary fork and its mpz patches are gone from the dependency graph.
Verification
83 tests: 60 unit, 19 over the router, 4 over the binary. clippy
-D warnings, rustdoc-D warnings, nightly rustfmt,--locked; the image builds and still listens on0.0.0.0:8722. The live ceremony suite stays in this repository as #12: the browser obtains the code, a prover of the suite's own runs the sessions against an in-process notary, and the bridge under test serves/configand the callback.The reader finds elements by their bytes and does not track what encloses them: a document burying them in an inert element is read as carrying them, which the module says. The configuration-file tests parse with a command no environment variable reaches, so an exported
PORTorCCDP_ORIGINcannot change what they read.Known gaps
/api/v1/ceremony/github-tokenand expectscallbackPathin/config; libid-contracts'profiles.jsonstill has githubsecretField: "client_secret"; no libid-rs change yet. All three follow libid#13/#35.