Repository navigation
fix: modules authenticate the forwarded Work Context (signature, issuer, audience, window) - #6
Merged
Conversation
The Go, Python and TypeScript claim readers only checked that the stamp's proof matched and that sha256(token) equalled the stamp's digest. Every hop-fronted module shares the proof, so any module could forge both and a context for any tenant. Each reader now authenticates the token itself: the issuer pin, the Ed25519 signature under the authority's published key, the audience is this module, and the window; Go through Core's Inspector, Python and TypeScript in Core's order. The decode-only ReadClaims/read_claims/readClaims are deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hop.Verifier (Go) and codefly_hop.Verifier (Python) are the one module-side entry point. Built once from the authority interface's Keys through the generated client, they authenticate the forwarded token (proof, digest, issuer, Ed25519 signature, audience, window), read who the call is for from the authenticated claims with the one IdentityOf the hop also stamps from, and refuse a stamp that says otherwise. Keys are re-read on an unknown key id and when old; unreadable keys stop being trusted after 15 minutes. The decode-only hop.Read, the proof-only interceptors and codefly_hop.read are deleted. The console adopts the Verifier; the hop's proxy tests now run end-to-end over signed contexts; hoptest is a real authority for module tests; the shared conformance suite proves a forged, unsigned, misaddressed or expired context is refused by name against each host's published keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts raw value A refusal of authority/endpoint quoted the configured value, so a credential pasted into it (userinfo, a query token) reached the logs. Errors now name the endpoint's host and port only, and an unparseable value is not echoed at all. The new test fails on the previous code and passes on this one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ule for rule Review r44 M1 and M3. Python and TypeScript now apply Core's decode path (the contract's buf.validate rules through protovalidate, unknown fields, canonical encoding, lifetime), refuse Core's public conformance key, and port Core's attenuation and grant-shape rules. Go writes testdata/vectors.json, 25 tokens with its verdict on each, and all three languages must reach that verdict for the same reason. TypeScript gains the Verifier the Go and Python libraries have: the stamp checked first, then keys read through Keys, singleflight, on their own timeout, refreshed by key id and age, with identity from the claims and the stamp cross-check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is stated as it is Review r44 B1: Verify checks the stamp (proof and digest) before any key work. Keys are read one at a time, detached from every call, on their own 5 s timeout; a caller can neither cancel a read, fail it, nor delay the next one, and a call whose key is held never waits on a read. A read's failure goes to KeysError for logs; a caller gets a fixed reason (m2). NewVerifier never fails on an authority that is not serving yet (m4). Python matches: the stamp first, a background read with a timeout. M2: the package doc says what the shared proof establishes (a composed workload), not that the call came through the hop (#5). m1: an actor granted nothing narrows the identity to nothing, in Go and Python. hop.Audience reads the module the composition started (the console uses it). The conformance suite now also proves Check returns the token's own claims byte for byte. Libraries re-pinned onto fff5385; codefly-hop depends on its sibling authority-client by path, so one commit pins both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 25, 2026
authority_client.host is the Go library's host.Target / host.Endpoint, rule for rule: https is verified TLS; plaintext (http, grpc, tcp, bare host:port) only to loopback in a local run, or to an in-cluster Service address under the literal internal-transport/mesh-protected=true; anything else refused by name, naming the host and port alone. A Python module reading the verifier's Keys or calling Issue over plaintext to an arbitrary host would let anyone on the path swap the keys (forgery) or read its workload credential; this is the one rule it reuses instead of writing its own. tests/test_host.py mirrors host_test.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e loop free Review r57 N1: codefly-hop depended on the bare name authority-client, which is not on PyPI; pip ignores tool.uv.sources and would look it up on the public index, a dependency-confusion hole in a process holding the hop proof. A dependency cannot pin its own commit, so codefly_hop now ships in the authority-client distribution beside the package it rests on: one git reference at one commit installs both, with pip or uv, and nothing is resolved by name. libraries/hop/python is deleted. Review r57 m-b: Verifier.verify_async waits for the keys off the event loop (asyncio.to_thread); a call whose key is held never leaves it. Also removes the generated authority_client.egg-info committed in 593a2ab and ignores it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
What we are trying to do
Close a forgery hole. Modules behind the authority hop only decoded the forwarded Work Context. The hop library checked two things: that the shared proof matched, and that
sha256(token)equalled the stamp's digest. A forger can write both.hop/PROOFis one secret shared by every hop-fronted module, andinternalvisibility lets any module reach another directly, so any module could forge a context for any tenant. This was confirmed by two reviews (document store r36, annotations r37).Modules still re-check no permission the hop decided. What changes is that they now authenticate the token before anything reads it.
The module-side entry point (one per language)
hop.NewVerifier(ctx, keys hop.KeySource, audience, proof string) (*hop.Verifier, error);(*Verifier).Verify(ctx, metadata.MD) (hop.Identity, error);.UnaryServerInterceptor()/.StreamServerInterceptor()Inspector: canonical decode, issuer pin, Ed25519 signature under the published key, audience == this module, not-before/expiry (Core skew), structure/attenuationcodefly_hop.Verifier(keys, *, audience, proof);.verify(headers) -> Identityauthority_client.work_context.authenticate)authenticateClaims(headers, proof, audience, trust);trustFromKeys(KeysResponse)hop.IdentityOf. The hop now stamps from the same function (decide.IdentityOfdelegates to it). A stamp whose owner, tenant, actors, scopes, binding, task or expiry disagrees with the token is refused, naming the field.Identity.Claims(Go) /identity.claims(Python) answers resource-exact questions (Allows(kind, action, id)) from the same authenticated token.hop.Read,hop.ErrNoStamp, the proof-onlyhop.UnaryServerInterceptor(proof)/StreamServerInterceptor(proof),workcontext.ReadClaims,read_claims,readClaims, andcodefly_hop.read. A module that bumps its pin without adopting fails to compile; it does not silently keep decoding.Where the keys come from (the coordinator's r40 point)
The CLI has never delivered issuer keys to modules. The simplest supported path is the one the module already depends on: the authority interface's
KeysRPC, through the generated client, over the samehost.Dialthe hop uses. That reaches the local authority on a laptop and the host (accounts) on a cell. Accounts' rung-1 branch servesKeysas an adapter over its key set, and the local authority already servesKeysover gRPC (and/.well-known/jwks.jsonover REST). No key is typed in, no file is mounted, and neither host needed a new surface.Keys.Keys, issues a context, then requires the module check to accept it and to refuse a forged, unsigned, wrong-audience and expired context, naming the reason. It drivesworkcontext.Authenticate, the same codehop.Verifierruns (the suite links no generated package, so it cannot import the hop package). It passes on the local authority. Accounts runs it when its conformance run lands (not run here).hop/PROOF: kept, with one narrower claim (argued)Once the signature is verified, the proof authenticates nothing: identity is the token's. It still carries one claim, this call came through the hop, which means the method policy and the live Check (revision, seal, single use) were applied. Deleting it would let anyone holding a token addressed to a module call any of its methods directly. On a laptop, with no mesh, that includes the person's own client holding a context issued to them at that module. It would skip both the method policy and revocation. So it is not legacy.
Revocation
The interface's only revocation answer is
Check. The module does not call it per request. Check runs Core's fullVerify, which consumes a single-use capability: the hop already spent it, so the module's Check would refuse it as replayed. One consumer per capability is Core's rule. It would also double the authority round trips. Liveness stays the hop's live Check, bound to the module's call by the digest. The remaining gap is the composed-peer case above.Reuse (what was reused, what was not)
workcontext.InspectorandDecode— no second verifier.hop.IdentityOfis the hop's existing derivation, moved so the hop and the module share it.work_context.Verifierwas the first choice and was measured unfit. It refuses tokens Core accepts: several scope rows of one kind, which the authority issues, and Core's own grant fixture ("actor_chain[1] widens authority"). Filed as work_context.Verifier refuses Core-minted Work Contexts (several rows per kind, Core's grant fixture) saas-sdk-python#26. The Python reader therefore runs Core's authenticity checks directly (issuer, signature, audience, window), withcryptography. Core's structural and attenuation rules are not reproduced in Python or TS: they constrain what the authority signs, and the signature is what makes the claims the authority's.node:cryptoEd25519 with the same checks as Python.Classification: fix, at the owner of the behaviour (the hop library and the claim readers). No gate or baseline was touched.
Adoption note (for the module fixers)
Go (go-grpc module; pin
libraries/hop/goandlibraries/authority-client/goat this PR's head commit):Tests:
a := hoptest.New(t);hop.NewVerifier(ctx, a, aud, proof);md := hoptest.Forward(t, a.Issue(t, aud, scopes...), proof).Python (one distribution, one pin, pip or uv:
authority-client @ git+https://github.com/codefly-dev/authority@<head>#subdirectory=libraries/authority-client/python):The module must declare
service-dependencies: - interface: codefly.dev/authority@0.2.0so that the mesh edge exists.Pins: this repo merges with merge commits (PR #1), so the branch commit
ead918ethatlibraries/hop/goandcodefly-hoppin survives the merge. Move the pins to a tag once one is cut.Also in this PR:
host.Dialno longer echoesauthority/endpointRaised by the document store fixer, whose canary test caught it. A refusal of
authority/endpointquoted the raw value, so a credential pasted into it (userinfo, a query token) reached the logs. Errors now name the endpoint by host and port only; a value that does not parse is not echoed at all.TestARefusalNeverEchoesTheEndpointValuefails on the previoushost.go(it caught"https://user:s3cr3t-token@…" is not an origin) and passes on this one. Re-run after the change:libraries/authority-client/go, plus hop, edge and console (which linkhostthrough local replace directives), all vet and race-test green. Classification: fix at the owner; the store's redaction can go once it pins this head.Security re-review r57 (head
2d1c185)authority-client, which is not on PyPI, and pip ignorestool.uv.sources. A dependency cannot pin its own commit, socodefly_hopnow ships inside theauthority-clientdistribution, beside the package it rests on, andlibraries/hop/pythonis deleted. One git reference at one commit installs both, with pip or uv, and nothing is resolved by name. Proof:pip install --no-index --find-links <that wheelhouse> "authority-client @ git+https://github.com/codefly-dev/authority@7caa0cf…#subdirectory=libraries/authority-client/python"succeeds.import codefly_hop, authority_client.work_context, authority_client.hostworks, and the suite passes from that install (137 passed).pyproject.toml, installed under the same--no-indexin a fresh venv, fails withNo matching distribution found for authority-client. That is the name lookup pip would have sent to PyPI.Verifier.verify_asyncwaits for the keys off the event loop (asyncio.to_thread); a call whose key is held never leaves the loop. The test keeps a ticker running while a key read hangs and requires the loop to keep ticking.593a2ab(another agent'sauthority_client.host) was reviewed. It mirrors Gohost.gocase for case and never echoes the endpoint (host and port, or "(unparseable)"). Plaintext goes only to loopback in a local run, or to<svc>.<ns>.svc[.cluster.local]under the literalmesh-protected=true. It is stricter than Go on an empty host and on a non-numeric port; the caller supplies the resolved value (there is no SDK fallback). Its generatedauthority_client.egg-info/files are removed and now ignored.Security review r44: what changed (head
4173075)Verifychecks the stamp (proof and digest) before any key work, so a call without them never reachesKeys.context.Backgroundplus a 5 s timeout). A caller can't cancel a read, fail it, or delay the next one.timeout=.TestAnUnauthenticatedCallerNeitherReadsNorStarvesTheKeys: after a rotation, a proof-less call with a cancelled context every 10 s; 100/100 genuine calls admitted, 0 reads caused by unauthenticated calls.TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOneandTestAReadIsBoundedByItsOwnTimeoutcover the second. Python and TypeScript have counterparts.buf.validatedeclarations, run by protovalidate (Python against the packagedcontract.binpb, because the generated Python module was emitted without the options), plus unknown fields, canonical encoding and the 24 h lifetime.checkStructure(attenuation and grant shape).libraries/authority-client/testdata/vectors.jsonis written by Go (workcontext/vectors_test.go): 25 tokens with Go's verdict on each. Python and TypeScript must reach the same verdict and name the same reason. The cases include actor-widens, no seal, empty tenant, wrong type, fixture key, an actor without an epoch, grant-shape violations, lifetime, and every earlier refusal.new Verifier(keys, audience, proof).verify(headers)mirrors Go: stamp first, singleflight timed reads refreshed by key id and age, the stale policy, identity from the claims, and the stamp cross-check.IdentityOfto nothing, as Core'sEffectiveScopesdoes (Go, Python, TS).KeysError()/keys_error/keysError, for logs.fff5385, which containsb65ace9'shost.gofix.authority-clientby a uv path source. Installing codefly-hop at commit X brings authority-client at X with no override; I verified this from GitHub at4173075.hop/go@Xrequiresauthority-client@fff5385(a commit cannot name itself). A module that imports both requires both at X, and MVS keeps X: no extra pin beyond the packages it imports. The services'replacedirectives are in-repo and never reach consumers.hop.Audience(), which isCODEFLY__MODULE, the module the composition started this process as.NewVerifiernever fails on an authority that isn't serving keys yet: it refuses calls with the reason until it is.Keyson both hosts: the local authority serves it on main. Accounts serves it onauthority-interface(feat(accounts): serve the published host interfaces as adapters — rung 1, with the gap list module-saas-starter#1217, open), not on main. The hop already needs that same branch forCheck, so modules gain no new host dependency.Checkreturns the token's claims unchanged: yes. A new shared conformance assertion requiresCheck's context to equal the token's payload byte for byte. It passes on the local authority. Accounts' branch returnsworkContextToWire(verified.Context()), the verified claims themselves.Evidence (no CI in this repo; this is the run, 2026-10-25)
go vet ./...andgo test -race -count=1 ./...in every Go module, plus the Python and TS suites:libraries/conformance/gohas no tests of its own; its new case ran insidemodules/authority/services/local/code→gateway/plugins/registry_test(all four refusals passed by name).codefly-hopwas also installed in a clean venv from its pinned git dependency (authority-client@ead918e) and its 11 tests passed.Not verified: no end-to-end
codefly runof the playground with the console behind the hop. The conformance case has not run against accounts.surface render --onlinedoes not apply.🤖 Generated with Claude Code