Skip to content

Bind hop stamps to exact methods, audiences and single-use admissions - #28

Merged
antoinetoussaint-byte merged 23 commits into
mainfrom
fix/hop-stamp-binds-method
Oct 26, 2026
Merged

antoinetoussaint-byte merged 23 commits into
mainfrom
fix/hop-stamp-binds-method

Conversation

@antoinetoussaint-byte

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

Copy link
Copy Markdown
Collaborator

A captured hop stamp could be moved from a read procedure to a write, and composed workloads could fabricate stamps using the shared run proof. This replaces that admission contract in Go, Python and TypeScript.

The hop signs the exact procedure, audience, token digest, fresh 256-bit nonce and a maximum 30-second validity window using a service-scoped Ed25519 seed. Verifiers authenticate those bindings and the Work Context, then atomically consume the nonce through a mandatory shared durable replay store before entering the handler. HTTP, Connect, unary gRPC and streaming paths are covered. Single-use capabilities are still consumed only by the hop's live Check; downstream consumes the separate admission nonce.

This is intentionally breaking: no shared-proof or method-less admission remains. Advisory policy refusals cannot be signed, and legacy warning mode is refused. The libraries reuse Core's ReplayStore contract, with no implicit per-replica memory fallback.

Merged with main (PR #26)

Both PRs reworked the module-side verifier, and the merge keeps both behaviours rather than choosing one. #26's Connect routing, bare-token verification and typed caller-safe refusals all survive; #28's signed admission replaces the shared hop/PROOF secret everywhere. Concretely: the identity readers lose their proof argument, #26's refusal scheme now covers #28's admission failures (stamp_method, stamp_audience, stamp_window, stamp_issued, stamp_advisory, stamp_replayed, stamp_store), and NewTokenVerifier/VerifyToken remain as the bare-token path — a process that receives no hop calls supplies neither the hop public key nor a replay store, and Verify refuses with configuration. Every Go module keeps one core pin, v0.19.7-0.20261026092055-b23dc13543b2 (canonical base64url decoding).

Security review r164

An independent review of 33c436b found one blocker, two majors and six medium findings. All but the key producer are addressed here.

  • Admission window judged at call start. grpc-go decodes a unary request fully before the unary interceptor runs, so a module serving large or slow uploads refused admissions the authority had granted. Verifier.StatsHandler (with grpc.StatsHandler) records the arrival instant when gRPC tags the call and reserves the nonce then, and the window is judged against it — once, in all three languages. (It replaced TapHandle in round 4, see below.)
  • A two-second clock allowance (stamp.Skew, SKEW), applied identically in Go, Python and TypeScript. A verifier running behind no longer refuses genuine calls at second boundaries, and being issued too far ahead has its own reason rather than reading as an expiry. The signed span is still capped at 30 seconds exactly.
  • REST behind the hop is refused at startup. A REST face transcoded into a module's gRPC or Connect listener can never be admitted: the hop signs VERB /path while that listener observes the full procedure, and a module cannot verify at both layers. routing.Validate refuses such a composition (routing.ErrTranscodedREST) instead of refusing every REST call at runtime. Native HTTP faces are unaffected.
  • A deployable replay store. libraries/hop/go/replaypg reserves nonces in a module's own Postgres, and the console composes one (modules/console/services/store). hop-verification/REPLAY_DIRECTORY is gone — Codefly delivers a store, not a shared volume, and an absolute path to per-pod storage looked identical to a shared mount. Outside a local run NewVerifier now refuses a store that does not declare itself shared.
  • One identity-only reader per language. AuthenticateToken / authenticate_token / authenticateToken. The header-taking variants are gone: after the proof's removal they accepted any token plus a self-computed digest, so a caller fixing a build break by deleting the argument lost admission silently.

Security review round 3 (073a40e): B1, M1, M2, M3

B1 — the hop's admission key is published through Keys (owner's decision; code first, the handbook proposal follows).

  • codefly.dev/authority@0.3.2 (additive; consumers declaring >=0.2.0 <0.4.0 admit it): KeysResponse.admission_keys and PublicKey.purpose. Admission keys (purpose hop-admission) are a field of their own beside the Work Context keys (purpose work-context), so a verifier that reads keys alone — an earlier release, or the JSON Web Key Set — can never trust a hop key to sign a Work Context. Regenerated only through Codefly: codefly generate proto in the local service, then codefly generate client --no-facade for authority-client (CLI v0.1.215, whose Python template resolves protovalidate). tools/noproxygate/generated.json records the five regenerated Go files.
  • The local authority derives the public half from the hop's own hop-signing/SEED — replaced in round 4 by the hop's registration (below).
  • Go, Python and TypeScript verifiers take the admission keys from the Keys read they already cache and refresh. hop-verification/PUBLIC_KEY, the publicKey/public_key constructor arguments and the playground's empty hop-verification.env are deleted. Every published key is trusted, so a rotation overlaps. A stamp signed by no published key is refused as stamp_proof and starts no key read (review r44's "an unauthenticated caller neither reads nor starves the keys" still holds); a verifier holding no trusted keys reads them at most once per keyRetry, as readiness does. An authority that publishes no admission key leaves every call refused as keys_unavailable, and the key error says why (ErrNoAdmissionKeys / NoAdmissionKeys). Each key is used for its own purpose only.
  • Tests: TestAVerifierTakesTheHopsKeyFromTheAuthoritysKeysAlone, TestAStampSignedByAnUnpublishedKeyIsRefusedByName, TestARotationsOverlapAdmitsBothKeysUntilTheRetiringOneIsWithdrawn, TestAKeyIsUsedForItsOwnPurposeOnly, TestNoPublishedAdmissionKeyRefusesEveryCallAndSaysWhy (hop/go); TestSignedByNamesThePublishedKeyAProofIsSignedBy, TestAdmissionIsTheSignersStablePublishedEntry (stamp); TestTheHopsAdmissionKeyIsDerivedFromItsSeedAndPublished, TestAMalformedHopSeedRefusesToBoot (local); the console's arrival tests start a verifier from published keys alone; the same five cases in Python and TypeScript.
  • (Superseded in round 4: the local authority no longer derives the key; the hop registers it.)

M1 — the window is judged at arrival on the Connect listener too. TapHandle is replaced by Verifier.StatsHandler: gRPC calls TagRPC in handleStream on every transport, including grpc.Server.ServeHTTP, which the generated Connect listener (vanguardgrpc) uses and where no tap handle runs. The console installs it. Tests (console, the real generated server, both listeners): TestALateBodyIsJudgedAtArrivalOnBothListeners (gRPC to the gRPC and to the Connect listener), TestALateConnectBodyIsJudgedAtArrival (Connect protocol), TestOneAdmissionOnBothListenersRunsOnce. With the stats handler removed, all three late-body cases fail as stamp_window.

M2 — crashed holders leave no permanent row. replaypg.Collect deletes every reservation whose expiry plus allowance has passed by the database clock, active or not: Reserve refuses past retention on that clock, so nothing is revived. Tests: TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock (real Postgres; fails on the old statement), TestAReservationIsHeldToItsRetentionWhetherOrNotItsCallFinished, TestCollectingARunningCallsReservationReopensNothing (transport + Postgres).

M3 — Python reservation-store parity. A store is reservation-capable only with both reserve and finish (reserve_async/finish_async on the async path), as TypeScript requires. Test: test_a_store_with_reserve_but_no_finish_is_consumed_not_reserved (fails without the fix).

Merged with main twice: #30 (local setup) and #7 (installation-scoped grants), then #31 (one destination per service; its rule kept, both routing test sets kept).

A codefly run of the playground was not verified in round 3; round 4 below has a live run.

Security review round 4 (2552d5c): B1' and N1

B1' — the hop registers its admission key; the local authority derives nothing (coordinator's decision). The local authority read hop-signing/SEED for the hop, a secret no composed run delivers to it (it cannot depend on the hop, which depends on it), so the playground published no admission key and the console refused every call as keys_unavailable.

  • codefly.dev/authority@0.3.3 (additive; >=0.2.0 <0.4.0 consumers admit it) adds RegisterAdmissionKey(actor, public_key) → (key, expires_at_unix). Regenerated only through Codefly v0.1.215 (generate proto in the local service, generate client --no-facade for authority-client); tools/noproxygate/generated.json records the five regenerated Go files.
  • The hop declares module-identity, so the run vouches for it, and registers its public half under its own workload credential (hop.Registration.Keep, libraries/hop/go/register.go): at boot, then renewing at a quarter of each lease, every 5 s after a failure, logging each change of outcome. The seed never leaves the hop.
  • The local authority attests the caller and refuses any workload but authority/hop by name (the caller is console/api, not the hop (authority/hop)), then publishes the key in Keys.admission_keys (purpose hop-admission) until its lease (AdmissionLease, 2 min) lapses. Re-registration renews one entry (a restarted hop is idempotent; a restarted authority learns the key at the next renewal, ≤30 s); a rotation publishes both keys while both are renewed; a key no running hop renews is withdrawn. At most 8 keys are published at once (refused by name past that). stamp.KeyID is the one key-id derivation. hopAdmissionKey, its tests and the seed read are deleted.
  • A verifier whose last read published no admission key (it read before the hop registered) re-reads on the next call or readiness check, at most once per keyRetry, instead of waiting out keyMaxAge — Go, Python and TypeScript alike. Callers inside keyRetry still drive no read.
  • Tests: TestTheHopsRegisteredKeyIsPublishedInKeys, TestANonHopCallerIsRefusedByName, TestReRegistrationAtRestartIsIdempotent, TestARotationOverlapsUntilTheRetiringKeyLapses, TestThePublishedAdmissionKeysAreBounded (local issuer); TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps (local, over the wire: the hop's process environment as the playground run gives it — workspace authority-playground, authority/hop, its own seed, its projected credential — and none of it on the authority; Keys is empty before, publishes the hop's key after, a genuine stamp is signed by the published key and checks, an impostor's is not, documents is refused by name); TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential, TestARefusedRegistrationIsReportedAndRetried, TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry (hop/go; fails without the re-read); the same verifier case in Python and TypeScript (each fails without its fix). The module-side Go verifier links the authority-client bindings of the same protos, which cannot share a test binary with the local service's own, so the verifier half of the end to end is driven from Keys in its module and proven in the live run below.
  • Follow-up, not in this PR: SaaS accounts implementing RegisterAdmissionKey (attest the hop's workload, publish what it registers). Until it does, a hop fronting modules on a cell logs that its key is not published and those modules refuse every admission as keys_unavailable. The handbook record of the 0.3.3 interface follows the code.
  • Rotation note: a verifier learns a new key within keyMaxAge (a stamp by an unknown key starts no read), so start a hop with the new seed that long before calls are routed to it, while a hop with the retiring seed keeps renewing the retiring key (libraries/hop/README.md).

N1 — collection cannot revive a nonce near expiry. replaypg.Collect deletes only rows CollectMargin (1 min) past their retention by the database clock, so a collection committing between Reserve's clock check and its insert finds nothing to delete. Tests (real Postgres): TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock (a row 61 s past is collected, 30 s past is kept), TestConcurrentCollectionNeverRevivesANonceNearItsExpiry (20 rounds of Collect racing Reserve across expiry: never admitted, never collected inside the margin); both fail on the old statement. The transport regressions age the row by the margin before collecting.

Live run (codefly v0.1.215, --naming-scope fix28r5, 2026-10-26). The repo's playground/ does not load on this CLI, independent of this PR: records 0.5.0 ships no module.contract.codefly.yaml, so generating hop routes fails (hop routes for records: openat module.contract.codefly.yaml), and 0.6.0 renamed the module record-store. So I ran a scratch copy of the playground workspace composing authority and console from this checkout, with the hop fronting the console only and no edge routes (codefly run service console/api authority/hop --local-agents --naming-scope fix28r5); nothing about the hop was configured on the authority. Observed:

  • hop log: the authority publishes this hop's admission key; Keys on the local authority: admissionKeys: [hop-67d0eb32a0bbae3a, purpose hop-admission].
  • Maya's Work Context, WhoAmI through the hop: maya, human, acme, console.identity:read (the console's verifier accepted the hop's genuine stamp); Version: 0.1.0.
  • Maya's Work Context straight to the console: stamp_proof. No Work Context through the hop: refused.
  • RegisterAdmissionKey under the console's own projected credential: PermissionDenied: the caller is console/api, not the hop (authority/hop); with no credential: refused; Keys still publishes one key.
  • Not observed live: an authority restart (a touched file did not trigger a reload), covered by the unit tests.

Docs updated

  • libraries/hop/README.md — the wire contract: the clock allowance, the window judged at arrival (the stats handler, both listeners), the transcoded-REST refusal, the Postgres store, collection and the startup guard, "Delivering the verification key" (published in Keys, rotation overlap, the accounts follow-up), and the adoption order.
  • libraries/hop/go/README.md — the module-side example, now with replaypg, no key, and grpc.StatsHandler.
  • README.md — the 0.3.2 and 0.3.3 interface notes.
  • libraries/authority-client/python/README.md, libraries/authority-client/typescript/README.md — the single identity reader, the admission reason codes, and construction without a hop key or replay store.
  • modules/console/services/store/README.md — new: why the console composes a store.
  • playground/configurations/README.md — no hop key is configured; the hop registers it; hop-verification.env is deleted.
  • libraries/hop/README.md — the hop's registration, the re-read while no key is published, the rotation procedure, the collection margin.
  • modules/authority/services/hop/service.codefly.yaml — module-identity: true. modules/authority/services/local/service.codefly.yaml — nothing about the hop is read.
  • modules/console/services/api/service.codefly.yaml, modules/console/module.codefly.yaml — the store dependency; the hop-verification group is gone. modules/authority/services/local/service.codefly.yaml — what the local authority reads to publish the hop's key.

Validation

  • go vet and go test -race -count=1 pass in all 11 Go modules, including tools/noproxygate, with HOP_TEST_POSTGRES on a local Postgres 17 (the transport and store regressions ran); go mod tidy -diff and gofmt clean (round 4 at b9b716b; the hop service re-run at 655fe4f).
  • Python: 332 passed. TypeScript: 178 passed.
  • Regression probes cover read-to-delete retargeting, fabricated shared proofs with genuine capabilities, wrong audiences, single-use replay across verifier instances, concurrent replay, expiry, duplicate metadata, replay-store failures, persistent reservations, the arrival-instant window, the clock allowance in three languages, retention past expiry, and the transcoded-REST refusal.

Addresses the admission gap reported in module-robin #512 finding 2 and module-evaluation #91 finding 1; related authority issue #5. Downstream owners must adopt the breaking API (no key argument; install StatsHandler), have their authority implement RegisterAdmissionKey and publish what the hop registers, and supply a durable replay backend shared by every replica.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 5 commits October 26, 2026 05:09
Replace shared-proof admission with service-only Ed25519 signing, short validity windows and mandatory shared replay consumption in Go, Python and TypeScript. Refuse advisory policy bypasses and exercise the independent review probes.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antoinetoussaint-byte and others added 18 commits October 26, 2026 08:13
…ication

PR #26 and PR #28 both reworked the module-side verifier. The merge keeps
both behaviours rather than choosing one.

#28's signed admission replaces the shared `hop/PROOF` secret everywhere,
so `stamp.Context`, `workcontext.Authenticate`, Python `bound_token` /
`authenticate` and TS `boundToken` / `authenticateClaims` lose their proof
argument and remain identity-only readers. #26's typed refusals now cover
those paths too: `stamp.Check`, Python `check` and TS `checkCall` refuse
with `refusal.Reason` values, and the scheme gains `stamp_method`,
`stamp_audience`, `stamp_window`, `stamp_advisory`, `stamp_replayed` and
`stamp_store` for the admission failures #28 introduced. No reason names a
proof, token, digest, nonce or claim.

#26's `NewTokenVerifier` / `VerifyToken` survive as the bare-token path: a
process that receives no hop calls supplies neither the hop public key nor a
replay store, and `Verify` refuses with `configuration` because it has
nothing to check an admission against. #28's advisory refusal stands: an
admission carrying `x-codefly-hop-advisory` is refused by `stamp.Check`, so
`Verify` never reads that header as identity.

#26's Connect routing in the hop service and its generated-route validation
merge unchanged, and its connect-listener test now asserts the signed
admission for the procedure it arrives on instead of a shared secret.

Every Go module keeps the single core pin v0.19.7-0.20261026092055-b23dc13543b2
(canonical base64url decoding). TypeScript gains `refusal.ts`, which holds
`Reason` and `Unauthenticated` so `hop-call.ts` can refuse with the same codes
without importing `work-context.ts` back; `work-context` re-exports both.

The three client READMEs are updated for the merged construction and the new
admission reason codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libraries/hop/go, libraries/conformance/go and the local authority resolve
authority-client from the proxy rather than a path replacement, so they must
name the revision that carries both the signed-admission API and the typed
refusal reasons: 4b1c57a, the merge commit. Every module is tidied, and the
single core pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged.

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

Security review r164 on 33c436b. Each blocker and major, and the #26 conflicts
it listed.

B3, and R1/R2 with it: the admission window is judged once, at the instant the
call starts. grpc-go decodes a unary request fully before it runs the unary
interceptor, so a module serving large or slow uploads refused admissions the
authority had granted; Python's interceptors already ran before the body.
Verifier.TapHandle, installed with grpc.InTapHandle, records the arrival
instant when the stream is accepted and Verify judges against it. It reads no
header and runs no cryptography, because it runs on the connection's I/O
goroutine. A server installing no handle judges the window when Verify runs, as
before, so nothing is admitted by its absence. The redundant re-checks after
the key read and the reservation are gone in all three languages.

A two-second clock allowance (stamp.Skew, Python and TypeScript SKEW) applies
identically in Go, Python and TypeScript: an admission may be issued that far
ahead of the verifier's clock and stays admissible that far past its expiry.
Being issued further ahead has its own reason, stamp_issued, so a verifier
running behind is no longer diagnosed as an expiry. The signed span is still
capped at Lifetime exactly. Reservations are retained to expiry plus the
allowance, which is sound because the window is judged at arrival: a replay can
only be admitted if it too arrives inside the window, and inside the window the
nonce is still retained.

B2: a REST face transcoded into a module's gRPC or Connect listener cannot be
admitted. The hop signs an http route as "VERB /path" — all a published http
route tells it — while that listener's verifier observes the full procedure, and
a module cannot verify at both layers because the second would consume the nonce
again. Signing the procedure instead needs the published route to carry its
producer's template-to-procedure mapping, and the hop-routes schema has no such
field, so routing.Validate refuses the composition at hop startup
(routing.ErrTranscodedREST) rather than refusing every REST call at runtime. A
module serving HTTP natively, with no procedural face on the same service, is
unaffected.

B1, the replay half: libraries/hop/go/replaypg is the durable shared store the
contract required and nothing delivered, over a store Codefly does deliver;
INSERT ... ON CONFLICT DO NOTHING over a primary key is the atomic reservation,
shared by every replica. The console composes its own Postgres for it
(modules/console/services/store) and drops hop-verification/REPLAY_DIRECTORY,
which asked for a shared volume the platform cannot mount and in practice meant
a per-pod directory. NewVerifier now refuses a store that does not declare
itself shared outside a local run (hop.ErrReplayStoreNotShared); NewFileReplayStore
does not declare it, because an absolute path to per-pod storage is
indistinguishable from a shared mount, and NewSharedFileReplayStore is how an
operator asserts otherwise.

R4 and the #26 conflict behind it: one identity-only reader per language.
workcontext.Authenticate, Python authenticate and TS authenticateClaims are
gone; AuthenticateToken / authenticate_token / authenticateToken remain. A
reader that took a call's metadata read as though it admitted the call, and
after the shared proof's removal it accepted any token plus a self-computed
digest, so a caller fixing a build break by deleting the proof argument lost
admission silently. A caller holding metadata now extracts the bound token with
stamp.Context first: two visible calls, neither an admission.

B1, the verification key, is NOT fixed and is stated as open. Nothing produces
hop-verification/PUBLIC_KEY: `codefly config generate` makes random bytes only,
and a configuration value template assembles its producer's own primitives, so
neither can derive a public half from the hop's seed. The agreed direction is
the authority publishing the hop's admission key through its own Keys, which the
verifier already trusts, caches and re-reads on rotation, and which the Work
Context trust set ignores because it filters on algorithm. Keys is part of
interfaces/authority, so that is a contract change: proposed in the handbook
first, and implemented by accounts alongside the local authority. Until then a
run configures the key by hand, and libraries/hop/README.md, the console's
manifest and the playground group all say so rather than implying a producer
exists. The playground also gains its hop-verification group, and the README
states the documents/records adoption order the review found unsequenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libraries/hop/go, libraries/conformance/go and the local authority resolve
authority-client from the proxy, so they must name the revision carrying
stamp.Skew, the stamp_issued and stamp_* admission reasons, and the single
AuthenticateToken reader: 3d0a11c. Every module is tidied and the single core
pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment claimed single-process use by convention; NewVerifier now enforces
it, so the comment says which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… while the call runs

The follow-up security review of #28 at 8a3e953 reproduced, over the real
grpc-go transport and a real Postgres, one single-use admission running its
handler twice: two deliveries arrive inside the window, the first runs, the
second is held before its body is decoded, collection removes the reservation
after expiry plus skew, and the held delivery then consumes the nonce afresh.
The tap handle only recorded the arrival instant; the nonce was consumed at
body decode, and collection could drop a reservation a call still needed.

Go: TapHandle now starts the admission check and the reservation when the
stream is accepted (beside the call, not on the connection's I/O goroutine),
and the interceptors await it and hold the reservation until the handler
returns; a call cancelled before its handler releases its hold. A new
hop.ReservationStore (Reserve/Finish) keeps a reservation until Finish AND
expiry plus skew; replaypg implements it with an active column that Collect
never removes, and a Reserve conditioned on the retention not having passed
by the database's clock, so a delayed delivery can never revive a collected
nonce (stamp_window). A store with only Consume keeps a nonce to its retention
and a consumption that completes past it is refused on the window. Verify
releases its hold before returning; Admit is Verify for an HTTP module that
runs the call's work itself and holds the reservation until done.

Python and TypeScript reserve before the key read with the same store
protocol (reserve/finish, Expired), hold it through admit / admit_async, and
now refuse at construction, outside the environment named exactly "local", a
store that does not declare itself shared (replay_store_not_shared), as Go
does. Go's local check now matches exactly "local": an unset or empty
environment, or any other local-prefixed name, is refused.

The console's migration adds active and grants UPDATE on that column only.
CI starts the runner's Postgres for libraries/hop/go so the transport
regressions run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to fix/hop-stamp-binds-method

Conflicts kept both sides: the hop's HTTP face signs with its Signer and
answers a Connect caller in Connect's words (refuse takes the request);
both proxy tests stay; local's go.mod keeps main's new indirect deps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….3.2)

KeysResponse gains admission_keys and PublicKey gains purpose: the authority
publishes the keys a hop signs call admissions with (purpose hop-admission)
beside the Work Context keys (purpose work-context). They are a field of
their own, never entries of keys, so a verifier that reads keys alone (an
earlier release, or the JSON Web Key Set) can never trust a hop key to sign
a Work Context. More than one is published while a hop key rotates.

Regenerated only through codefly: generate proto in the local service, then
generate client --no-facade for authority-client (CLI v0.1.215, whose Python
template resolves protovalidate). noproxygate's manifest records the five
regenerated Go files. The interface moves to 0.3.2, additive, so existing
>=0.2.0 <0.4.0 consumers admit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SignedBy names which of the authority's published hop admission keys an
admission is signed by, so a verifier trusts every published key while a
hop's key rotates. Published is a seed's public half as Keys carries it in
admission_keys: Ed25519, purpose hop-admission, a key id derived from the
key. Check keeps judging one key; the proof's parsing is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host with bindings of its own (the local authority) links its own copy of
host/authority/v1, so a stamp package importing the client's bindings would
register the same proto files twice. Admission answers the key id and the
raw key, and each caller builds the entry in its own bindings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ival on every listener; collect crashed holds

Review r3 (#28 at 073a40e) B1, M1 and M2.

B1. The Go verifier reads the hop's admission keys from the authority's
Keys, the read it already caches and refreshes, and trusts every one
published, so a rotation overlaps. NewVerifier no longer takes a public key;
hop-verification/PUBLIC_KEY and the playground's empty file are deleted. A
stamp signed by no published key is refused as stamp_proof and starts no key
read; a verifier holding no trusted keys at all reads them, at most once per
keyRetry, as readiness does. An authority publishing no admission key refuses
every call as keys_unavailable and KeysError says why (ErrNoAdmissionKeys).
A key is used for its own purpose only. The local authority derives the
public half from the hop's own hop-signing/SEED in code and publishes it;
hoptest publishes the hops a test names (PublishHop, WithdrawHop).

M1. StatsHandler replaces TapHandle. gRPC tags every call with it in
handleStream, on its own listener and on the Connect listener that reaches
the same server through ServeHTTP, where no tap handle runs. The console
installs it; its regression drives the generated server on both listeners
and over the Connect protocol with a body that arrives after the window.

M2. replaypg.Collect removes every reservation past its retention by the
database clock, active or not: Reserve refuses past retention on that clock,
so nothing is revived, and a holder that stopped before Finish leaves no
permanent row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… admission key from Keys

Review r3 B1 and M3, the Go verifier's rules in both languages: no
public_key/publicKey argument; the admission keys come from the Keys read the
verifier already makes (admission_keys, purpose hop-admission), every
published one is trusted while a key rotates, a stamp signed by none is
refused as stamp_proof without starting a read, an authority publishing none
refuses every call as keys_unavailable and keys_error/keysError says why
(NoAdmissionKeys), and Work Context trust keeps only work-context keys.

M3: Python treats a store as reservation-capable only when it has both
reserve and finish (reserve_async and finish_async on the async path), as
TypeScript does.

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

The hop README's verification-key section states what is built (Keys'
admission_keys, purpose, rotation overlap, local derivation, the accounts
follow-up) and drops the open note; the Go, Python and TypeScript READMEs
construct verifiers without a key; the playground README no longer asks for
a hand-written one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p-binds-method

Kept both: #31's rule that a service's gRPC and Connect endpoints are one
destination, and this branch's refusal of a REST face transcoded into a
procedural listener; both routing test sets stay. #31's dual-publication
proxy test signs with the hop's Signer, as every proxy test here does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r4 (#28 at 2552d5c) B1': the local authority
derived the hop's admission key from hop-signing/SEED, a secret a composed
run never delivers to it (it cannot depend on the hop, which depends on the
authority), so the playground published no admission key.

AuthorityService gains RegisterAdmissionKey: the hop presents its public key
with its workload credential, the authority refuses any other workload, and
Keys publishes the key in admission_keys. A registration lapses unless
renewed, so a key no running hop signs with is withdrawn and a rotation
overlaps. Additive: consumers' >=0.2.0 <0.4.0 admits 0.3.3.

stamp.KeyID is the one derivation of the published key id, used by the
signer and by the authority it registers with.

Regenerated only through codefly v0.1.215: generate proto in the local
service, then generate client --no-facade for authority-client; the
no-proxy gate's manifest records the five regenerated Go files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rives nothing

Review r4 (#28 at 2552d5c) B1' and N1.

B1'. The local authority read hop-signing/SEED for the hop, which no
composed run delivers to it, so the playground published no admission key
and the console refused every call as keys_unavailable. That derivation is
deleted. The hop declares module-identity, so the run vouches for it, and
registers its public key over its workload credential
(hop.Registration.Keep): at boot, then renewing at a quarter of each lease
and every 5s after a failure, reporting each change of outcome in its log.
The local authority attests the caller, refuses any workload but
authority/hop by name, and publishes the key in Keys until its lease
(AdmissionLease, 2 min) lapses: re-registration renews one entry, a rotation
publishes both keys while both are renewed, and a key no running hop renews
is withdrawn. At most 8 keys are published at once.

A verifier whose last read published no admission key (it read before the
hop registered) reads again on the next call or readiness check, at most
once per keyRetry, instead of waiting out keyMaxAge: Go, Python and
TypeScript alike.

N1. replaypg.Collect removes only rows CollectMargin (1 min) past their
retention, so a collection committing between Reserve's clock check and its
insert can no longer revive a nonce near expiry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The logger masks a field named key, so the boot line said key=****; it now
names the admission key id and when its registration lapses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 49101ae into main Oct 26, 2026
15 checks passed
antoinetoussaint-byte added a commit that referenced this pull request Oct 26, 2026
…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>
antoinetoussaint-byte added a commit that referenced this pull request Oct 27, 2026
fix: hop registration proves possession; REST callers keep {"error"}; setup page hardened (#28, #30 follow-ups)
@antoinetoussaint-byte

antoinetoussaint-byte commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

lazybox has finished working on this. claude (Opus 5) held it from 2026-11-02 15:18 UTC until 2026-11-02 15:47 UTC. The working label has been removed; this comment is the record.

claim detail
Lazybox-Claim-Device: effacd542b611010656e
Lazybox-Claim-Session: e1b4aa4bd6
Lazybox-Claim-Workspace: github-codefly-dev-authority-28
Lazybox-Claim-Agent: claude
Lazybox-Claim-Model: Opus 5
Lazybox-Claim-Started: 2026-11-02T15:18:53Z
Lazybox-Claim-Heartbeat: 2026-11-02T15:47:54Z
Lazybox-Claim-Expires: 2026-11-02T16:18:53Z
Lazybox-Claim-Released: 2026-11-02T15:47:54Z

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