Repository navigation
fix: hop registration proves possession; REST callers keep {"error"}; setup page hardened (#28, #30 follow-ups) - #32
Merged
Conversation
…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>
This was referenced Oct 26, 2026
… 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
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>
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.
Follow-ups from the reviews of #28 (round e) and #30. The owner approved them.
Per item
connect.NewErrorWriter()took every GET, and every POST with anapplication/*body, for Connectproxy/http.go) and the edge (refuseCall) now useNewErrorWriter(WithRequireConnectProtocolHeader()). Only aConnect-Protocol-Versionheader,?connect=v1or 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.forbidFramingsendsX-Frame-Options: DENYandContent-Security-Policy: frame-ancestors 'none'on every setup response and on the sign-in page.TestTheSignInAndSetupPagesRefuseToBeFramed/auth/setupwas mounted against any hostEdge.servesSetupasks the host once, with no credential (ListOffers).Unimplementedis 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.TestTheSetupPageIsMountedOnlyWhereTheHostServesOffersp.Organization != orgfromadministrator()fails this test, while the existingTestOffersAreAnAdministratorsReadstill passes.TestAnotherOrganizationsAdministratorIsRefusedTheOffersInstanceevery 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 (acnfin 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 overcodefly-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,TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredentialandTestARegistrationProofNamesTheCredentialsAudience. With the pin check disabled, the pin test and the over-the-wire test both fail.admission=****=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,lapsesincluded.registrationReportnow 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
ListOffersclient 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)
TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProofinstamp(on main since e1966db) was flaky. Its "mutated" proof replaced the last two characters withAA, 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 protoin the local service, thengenerate client --no-facadefor 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/goand 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.0accept 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.accountsstill does not implementRegisterAdmissionKey; when it does, it needs the same check.Gates (local,
GOWORK=off)go vet ./...andgo 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).Not verified
Unimplemented.🤖 Generated with Claude Code