Skip to content

fix: hop registration proves possession; REST callers keep {"error"}; setup page hardened (#28, #30 follow-ups) - #32

Merged
antoinetoussaint-byte merged 6 commits into
mainfrom
harden/review-followups
Oct 27, 2026
Merged

antoinetoussaint-byte merged 6 commits into
mainfrom
harden/review-followups

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-ups from the reviews of #28 (round e) and #30. The owner approved them.

Per item

# Finding Disposition Test (fails before the fix)
1 #30 F1: connect.NewErrorWriter() took every GET, and every POST with an application/* body, for Connect fix. The hop (proxy/http.go) and the edge (refuseCall) now use NewErrorWriter(WithRequireConnectProtocolHeader()). Only a Connect-Protocol-Version header, ?connect=v1 or a gRPC content type gets Connect's or gRPC's error. REST and form callers get {"error": reason} back, with the status mapping they had before #30: DeadlineExceeded is 503 and FailedPrecondition is 500. I restored that mapping as asked. FailedPrecondition→500 is imprecise: grpc-gateway and Connect use 400. I left it unchanged because changing it would alter behaviour for REST callers. TestARESTCallerKeepsItsErrorBodyAndStatus (hop), TestAModuleRefusalKeepsTheRESTBodyForARESTCaller (edge). Both fail without the option and show {"code",...} with 504/400.
2 #30 F2: setup and sign-in could be framed fix. forbidFraming sends X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' on every setup response and on the sign-in page. TestTheSignInAndSetupPagesRefuseToBeFramed
3 #30 F3: /auth/setup was mounted against any host fix. Edge.servesSetup asks the host once, with no credential (ListOffers). Unimplemented is a definite no, and the path then answers 404, so no install, grant or revoke is sent. Any other answer is a yes. An unreachable host gets a 503 and is asked again. With no setup clients configured the path is also a 404; before, it fell through to the frontend. TestTheSetupPageIsMountedOnlyWhereTheHostServesOffers
4 #30 F4: no test of another organization's administrator test only (the behaviour was already correct). Zoë, Globex's admin, reads Globex's offers and is refused Acme's ListOffers and ReviewOffer by name. A mutation check: removing p.Organization != org from administrator() fails this test, while the existing TestOffersAreAnAdministratorsRead still passes. TestAnotherOrganizationsAdministratorIsRefusedTheOffers
5 #28 F1+F2: registration rested on the 600 s bearer credential alone; the trust domain was not checked fix, with a stated residual risk. Revised after the review of 9442eb5 (#32 B1). The proof alone did not stop a thief: it is signed by the key being registered, which the registrant picks, and the native credential binds no key. What the authority now guarantees: it pins one admission key per hop instance, the Instance every native credential names. The first registration for an instance binds that key until its lease lapses (two minutes). The same key renews the binding. Any other key for that instance is refused by name, even one with a valid fresh proof (bounded at 64 instances). So a captured hop credential cannot replace or add a key for an instance that has registered. A new instance (a restarted or rotated hop) registers its own key, and replicas that share a seed each register it. What it does not guarantee: a holder of a captured credential who registers before that instance's own first registration, or after an authority restart (pins live in memory) and before the hop's next renewal (at most about 30 s, or 5 s retries), pins its own key for that instance. The hop's registration is then refused by name, and the hop logs it. The hop registers at boot to keep the first window small. Closing it needs the launcher to bind the key at issuance (a cnf in the native credential, which would be a core change). The proof stays (RegisterAdmissionKeyRequest.proof, interface 0.3.4, additive). It is an Ed25519 signature by the key being registered over codefly-hop-registration-v1, the authority's audience, the credential's SHA-256, the key, a 32-byte nonce and the signing second. It stops replayed (nonce kept for the window plus Skew), stale (over 30 s), retargeted (wrong audience) and credential-swapped registrations, and it proves possession of the registered key, but not that the registrant is the hop. Trust domain: the hop must be in the host's trust domain, meaning the single one the run's anchor declares at start; with several or none declared, every hop is refused. The proto comments, the stamp doc, the hop library doc and both READMEs now say exactly this. TestAHopInstanceHoldsOneAdmissionKey: the review's thief probe is refused once the hop has registered; the hop renews; a restart (new instance) works; a replica with a shared key works; the pin is released when its lease lapses. TestTheFirstRegistrationOfAnInstanceWins (the residual race, written down). TestARotationOverlapsUntilTheRetiringKeyLapses (rotation across two instances). TestARegistrationMustProveItHoldsTheKey, TestOnlyTheHopOfThisHostsTrustDomainRegisters, TestTheHostsTrustDomainIsTheOneTheAnchorDeclares, TestARegistrationProofBindsCredentialKeyAudienceAndTime. Over the wire, TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps, which now includes the thief signing with its own key. In the hop library, TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential and TestARegistrationProofNamesTheCredentialsAudience. With the pin check disabled, the pin test and the over-the-wire test both fail.
6 #28 F4: the hop logged admission=**** fix. The cause was not the field name. When the run forwards the hop's line, its text redaction reads the label before the first = or :. That label was "the authority publishes this hop's admission key admission", and "authority" plus "key" makes it look like a credential, so everything after it was masked, lapses included. registrationReport now puts the key id and lapse time (or the refusal reason) in the message, behind a label with neither word. It never logs the seed or the private key. TestTheRegistrationLogNamesTheKeyIDThroughTheRunsRedaction. A probe on main reproduced (INFO) (hop) the authority publishes this hop's admission key admission=****.

Classification

Every item is a fix at the place that owns the behaviour; none is a hack. Reused rather than rebuilt: connect-go's own protocol-header option; the stamp package's Ed25519 signer and its canonical base64url/JSON encoding (a new domain, not a new format); the issuer's existing lease and lapse loop for the nonce memory; the anchor the attester already holds for the trust domain; and the existing ListOffers client for the setup probe. Not reused, on purpose: the Postgres replay store, because the local authority's registrations live in memory, as its key set already does.

Also fixed (found while gating)

TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof in stamp (on main since e1966db) was flaky. Its "mutated" proof replaced the last two characters with AA, and about 1 run in 256 the random-nonce signature already ended that way, so the mutation changed nothing; locally, 5 of 6 batches of 20 runs failed. The test now changes the signature's first character, and 300 runs in a row pass.

Generation

Only codefly generate (v0.1.215) produced the generated code: generate proto in the local service, then generate client --no-facade for authority-client. Before I edited the proto, both commands reproduced main byte for byte. The no-proxy gate's manifest records the five regenerated Go files. The commits that change the interface and the client library (dc49cde, 4ac2e19 with the restated guarantee, 7b6569e with the stamp test fix) come first. libraries/hop/go and the local service pin 7b6569e as a pseudo-version, as before.

Compatibility

The wire change is additive, so consumers on >=0.2.0 <0.4.0 accept it. An authority at 0.3.4 refuses an unproved 0.3.3 registration, so the hop and the local authority move together, and both are in this repository. accounts still does not implement RegisterAdmissionKey; when it does, it needs the same check.

Gates (local, GOWORK=off)

  • go vet ./... and go test -race -count=1 ./... pass in all 11 Go modules, with gofmt clean. The hop library's Postgres regressions and the local store tests ran against a scratch Postgres 17 (HOP_TEST_POSTGRES, AUTHORITY_STORE_TEST_DATABASE_URL).
  • pytest: 332 passed. npm test: 178 passed.

Not verified

  • No composed playground run. The repo's playground does not load on CLI v0.1.215 (records 0.5.0); that problem predates this PR and is on main too. The registration is covered over the real gRPC wire with the generated client, but not in a live run.
  • The setup probe against a real hosted authority. It is covered by a stand-in that answers Unimplemented.
  • A handbook record of the 0.3.4 contract is still to be written, after this lands.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 5 commits October 26, 2026 16:48
…y 0.3.4)

Review of #28 (round e, F1): RegisterAdmissionKey was
authorized by the hop's 600 s bearer workload credential alone, so whoever
captured that credential could register any key for its lifetime and forge
admissions.

RegisterAdmissionKeyRequest gains proof (field 3): an Ed25519 signature by
the admission key itself over a domain-separated array of the authority's
audience, the SHA-256 of the presented credential, the public key, a fresh
32-byte nonce and the signing second. stamp.ProveRegistration signs it and
stamp.CheckRegistration verifies it (30 s window, Skew ahead); the authority
reserves the nonce. Additive on the wire: consumers' >=0.2.0 <0.4.0 admits
0.3.4. Python and TypeScript only read Keys, so they gain the field and no
code.

Regenerated only through codefly v0.1.215 (generate proto in the local
service, generate client --no-facade for authority-client; both reproduced
main byte for byte before the proto edit). The no-proxy gate's manifest
records the five regenerated Go files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tup page hardened

Follow-ups from the reviews of #28 (round e) and #30.

#28 F1 (proof of possession): hop.Registration signs each registration with
the admission key itself (stamp.ProveRegistration) over the audience its
credential names, the credential's digest and the key, fresh; the local
authority verifies it (stamp.CheckRegistration), refuses a missing,
mismatched, stale or replayed proof, and remembers each nonce while the
proof could pass as fresh (bounded). A captured hop credential alone now
registers nothing. Pins hop/go and the local service to dc49cde.
#28 F2: the authority also requires the hop's trust domain to be the
host's: the one trust domain the run's anchor declares when it starts.
#28 F4: the hop names the published key by its id in a line the run's log
redaction leaves alone ("the authority ... key admission=" was masked as
"admission=****"), and a refusal by its reason; never the seed.
#30 F1: hop and edge answer in Connect's words only a caller that says it
speaks Connect (WithRequireConnectProtocolHeader) or gRPC; REST and form
callers keep {"error": reason} and the status mapping they always had.
#30 F2: sign-in and setup send X-Frame-Options: DENY and
Content-Security-Policy: frame-ancestors 'none'.
#30 F3: /auth/setup is mounted only where the host serves OfferService
(asked once without a credential; Unimplemented is 404, unreachable 503).
#30 F4: a test that another organization's administrator is refused
ListOffers and ReviewOffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #32 (B1): the proof is signed by the key the
request registers, which the registrant chooses, and the native credential
binds no key; so it proves possession of that key, not that the registrant is
the hop. A thief holding the hop's credential can sign with a key of its own.

The interface now states the guarantee the authority gives: one admission
key is pinned per hop instance (the instance the workload credential names);
the first registration for an instance binds its key until the lease lapses,
the same key renews, any other key for that instance is refused by name. It
states what remains open: a captured credential that registers before that
instance's own first registration, or after an authority restart before the
hop's next renewal. The proof stays for replay, audience, freshness and
credential binding, and stamp's doc says so.

Comments only on the wire. Regenerated through codefly v0.1.215 (generate
proto, generate client --no-facade); the no-proxy gate's manifest records the
regenerated Go files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof replaced the
proof's last two characters with "AA". The signature covers a random nonce, so
about one run in 256 the signature already ended in "AA": the "mutated" proof
was the signed one and the test failed (5 of 6 batches of 20 failed locally).
It now changes the signature's first character to a different valid one.
Found while gating #32; pre-existing on main since e1966db.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #32 (B1): a registration's proof is signed by
the key being registered, which the registrant picks, and the native
credential binds no key, so a thief holding the hop's 600 s credential could
sign with a key of its own and register it.

The local authority now pins one admission key per hop instance (the
instance every native credential names): the first registration for an
instance binds its key until its lease lapses, the same key renews, and any
other key for that instance is refused by name (bounded at 64 instances).
A captured credential therefore cannot replace or add a key for an instance
that has registered; a new instance (restart, rotation, replica) registers
its own. What remains, stated in the docs and a test: a captured credential
used before the instance's own first registration (the hop registers at
boot), or after an authority restart before the hop's next renewal, pins its
key instead, and the hop's registration is refused by name in its log.

Hop library and local authority pin authority-client at 7b6569e. READMEs and
the hop library doc state the guarantee and its limit exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… harden/review-followups

Conflicts were only generated artefacts: the authority-client library's
contract.binpb, catalog digest and library.codefly.yaml digest. Each side
recorded the contract of its own proto tree; neither digest is right for the
merged tree, so none was hand-picked: both are regenerated.

Regenerated with codefly v0.1.216 (built from tag d3af5843, which carries
cli#1189):
- generate proto in services/local: no change, the auto-merged bindings
  (#32's authority/v1, #34's installation/v1) are what the merged protos give.
- generate contracts authority: the local grpc contract carries #32's
  RegisterAdmissionKeyRequest.proof and #34's InstallationService.Current;
  the catalog, module.package.codefly.yaml and the vendored authority.proto
  copy follow. --check is clean.
- generate client --no-facade for authority-client: contract, catalog and
  library digest; its bindings are unchanged. Its contract is byte-identical
  to the module's local grpc contract, as on main.

Kept from #33: the hop's range codefly.dev/authority@>=0.3.3 <0.4.0 (a 0.3.3
authority ignores the additive proof, so the floor still holds) and the
package version 0.1.0 (#34 moved the contract under it unreleased too).
Kept from #32: the authority interface at 0.3.4 and the module implementing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 656d23b into main Oct 27, 2026
16 checks passed
antoinetoussaint-byte added a commit that referenced this pull request Oct 27, 2026
The local authority takes main's authority-client pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antoinetoussaint-byte added a commit that referenced this pull request Oct 27, 2026
…s interface 0.3.4 requires

After main's per-instance hop admission key (#32), RegisterAdmissionKey
requires a fresh proof signed by the key it registers over the credential and
the authority's audience. The case signs each registration
(stamp.ProveRegistration) with the audience the hop's credential names, and
the library takes main's authority-client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant