Skip to content

fix: modules authenticate the forwarded Work Context (signature, issuer, audience, window) - #6

Merged
antoinetoussaint-byte merged 8 commits into
mainfrom
fix/verify-forwarded-work-context
Oct 26, 2026
Merged

antoinetoussaint-byte merged 8 commits into
mainfrom
fix/verify-forwarded-work-context

Conversation

@antoinetoussaint-byte

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

Copy link
Copy Markdown
Collaborator

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/PROOF is one secret shared by every hop-fronted module, and internal visibility 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)

Entry point Checks
Go hop.NewVerifier(ctx, keys hop.KeySource, audience, proof string) (*hop.Verifier, error); (*Verifier).Verify(ctx, metadata.MD) (hop.Identity, error); .UnaryServerInterceptor() / .StreamServerInterceptor() proof → one token bound by the stamp's digest → Core's Inspector: canonical decode, issuer pin, Ed25519 signature under the published key, audience == this module, not-before/expiry (Core skew), structure/attenuation
Python codefly_hop.Verifier(keys, *, audience, proof); .verify(headers) -> Identity proof → digest → issuer, Ed25519 signature, audience, window, in Core's order (authority_client.work_context.authenticate)
TypeScript authenticateClaims(headers, proof, audience, trust); trustFromKeys(KeysResponse) the same as Python
  • Who the call is for comes from the authenticated claims, using the one hop.IdentityOf. The hop now stamps from the same function (decide.IdentityOf delegates 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.
  • Deleted (legacy means delete): hop.Read, hop.ErrNoStamp, the proof-only hop.UnaryServerInterceptor(proof) / StreamServerInterceptor(proof), workcontext.ReadClaims, read_claims, readClaims, and codefly_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 Keys RPC, through the generated client, over the same host.Dial the hop uses. That reaches the local authority on a laptop and the host (accounts) on a cell. Accounts' rung-1 branch serves Keys as an adapter over its key set, and the local authority already serves Keys over gRPC (and /.well-known/jwks.json over REST). No key is typed in, no file is mounted, and neither host needed a new surface.

  • Refresh: keys are re-read when a token names a key id the verifier does not hold (at most every 10 s, so a local-authority restart with a fresh key heals), and when they are older than 5 min (so a removed key stops verifying). If the keys cannot be re-read, the last ones are trusted for 15 min at most; after that every call is refused, naming the outage, just as the hop refuses a call it cannot check.
  • Rejected: having the hop or edge re-publish the keys. That would make a proxy the key-distribution point, able to substitute keys. The module trusts exactly what the hop trusts: the authority's own Keys.
  • Conformance: a new shared case, "a module authenticates the forwarded context against the published keys". It reads the host's 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 drives workcontext.Authenticate, the same code hop.Verifier runs (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.

  • What remains: a composed peer that legitimately holds a genuine, unexpired context addressed to B can call B directly with the shared proof. That skips B's method policy and revocation for at most that context's remaining lifetime (≤15 min on the local authority). It cannot change the tenant, owner or scopes.
  • Why it is not closed here: a per-module proof needs per-audience delivery from Codefly (a group is delivered whole to every service that declares it) or a hop-signed stamp. On a cell, the mesh already admits only the hop. Filed as hop/PROOF per audience: close the composed-peer bypass of method policy and live revocation #5.

Revocation

The interface's only revocation answer is Check. The module does not call it per request. Check runs Core's full Verify, 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)

  • Go: Core's workcontext.Inspector and Decode — no second verifier. hop.IdentityOf is the hop's existing derivation, moved so the hop and the module share it.
  • Python: the SaaS Python SDK's work_context.Verifier was 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), with cryptography. 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.
  • TypeScript: no TS verifier of Core's wire exists in the fleet. It uses node:crypto Ed25519 with the same checks as Python.
  • Not reused: a parallel Codex attempt on this clone moved the method-policy decision from the hop to every module: two authority RPCs per call, a re-check of permissions the hop decided, and a new Connect surface on the edge. It was discarded as outside this brief.

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/go and libraries/authority-client/go at this PR's head commit):

proof, _ := codefly.For(ctx).WorkspaceSecret("hop", "PROOF")
conn, _, err := host.Dial(ctx)                       // authority-client/go/host
verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), "<module audience>", proof)
config.GRPCServerOptions = append(config.GRPCServerOptions,
    grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()),
    grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor()))
// handlers: id, _ := hop.FromContext(ctx); id.Tenant; id.Has(kind, action); id.Claims.Allows(kind, action, resourceID)
// an HTTP face: id, err := verifier.Verify(ctx, md) with md built from the request headers

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):

from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub
from authority_client import host                     # the transport rule (Go host.Dial's), for the channel
from codefly_hop import Unauthenticated, Verifier
channel = host.dial(endpoint, local=is_local, mesh=mesh_value)
verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], proof=proof)  # once
identity = verifier.verify(request.headers)               # sync handlers
identity = await verifier.verify_async(request.headers)   # asyncio handlers: a key wait runs off the loop
identity.tenant, identity.has(kind, action), identity.claims.allows(kind, action, resource_id)

The module must declare service-dependencies: - interface: codefly.dev/authority@0.2.0 so that the mesh edge exists.

Pins: this repo merges with merge commits (PR #1), so the branch commit ead918e that libraries/hop/go and codefly-hop pin survives the merge. Move the pins to a tag once one is cut.

Also in this PR: host.Dial no longer echoes authority/endpoint

Raised by the document store fixer, whose canary test caught it. A refusal of authority/endpoint quoted 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. TestARefusalNeverEchoesTheEndpointValue fails on the previous host.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 link host through 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)

  • N1, dependency confusion: fixed. codefly-hop depended on the bare name authority-client, which is not on PyPI, and pip ignores tool.uv.sources. A dependency cannot pin its own commit, so codefly_hop now ships inside the authority-client distribution, beside the package it rests on, and libraries/hop/python is deleted. One git reference at one commit installs both, with pip or uv, and nothing is resolved by name. Proof:
    • The wheelhouse held only third-party wheels: cffi, cryptography, googleapis_common_protos, grpcio, protobuf, protovalidate, setuptools, wheel, pytest and their dependencies, with zero authority or codefly packages.
    • 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.host works, and the suite passes from that install (137 passed).
    • The deleted codefly-hop pyproject.toml, installed under the same --no-index in a fresh venv, fails with No matching distribution found for authority-client. That is the name lookup pip would have sent to PyPI.
  • m-b, asyncio: fixed. Verifier.verify_async waits 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.
  • m-a (strict base64 in Go): left to a Core follow-up; nothing filed, as asked. M2 stays tracked as hop/PROOF per audience: close the composed-peer bypass of method policy and live revocation #5.
  • 593a2ab (another agent's authority_client.host) was reviewed. It mirrors Go host.go case 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 literal mesh-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 generated authority_client.egg-info/ files are removed and now ignored.

Security review r44: what changed (head 4173075)

  • B1, caller-triggered DoS: fixed.
    • Verify checks the stamp (proof and digest) before any key work, so a call without them never reaches Keys.
    • Keys are read one at a time (singleflight), detached from every call (context.Background plus a 5 s timeout). A caller can't cancel a read, fail it, or delay the next one.
    • A call whose key is held never waits on a read. A call naming an unknown key waits only as long as its own context allows, and giving up affects only that caller.
    • Python matches: the stamp first, then a background read with timeout=.
    • Tests reproduce both probes. 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. TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne and TestAReadIsBoundedByItsOwnTimeout cover the second. Python and TypeScript have counterparts.
  • M1, Python/TS accepted tokens Go refuses: fixed, rule for rule.
    • Both now run Core's decode path: the contract's own buf.validate declarations, run by protovalidate (Python against the packaged contract.binpb, because the generated Python module was emitted without the options), plus unknown fields, canonical encoding and the 24 h lifetime.
    • Both refuse Core's public conformance key and port checkStructure (attenuation and grant shape).
    • libraries/authority-client/testdata/vectors.json is 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.
  • M2, the doc overclaimed: fixed. The package doc now says the shared proof keeps out callers that are not composed workloads, and does not show that a composed workload came through the hop. hop/PROOF per audience: close the composed-peer bypass of method policy and live revocation #5 stays the tracked gap.
  • M3, TypeScript had no Verifier: fixed. 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.
  • m1: an actor granted nothing narrows IdentityOf to nothing, as Core's EffectiveScopes does (Go, Python, TS).
  • m2: callers get a fixed reason ("the authority's published keys are unavailable"). The read's own failure goes to KeysError() / keys_error / keysError, for logs.
  • m3: libraries re-pinned onto fff5385, which contains b65ace9's host.go fix.
    • Python: codefly-hop now depends on its sibling authority-client by a uv path source. Installing codefly-hop at commit X brings authority-client at X with no override; I verified this from GitHub at 4173075.
    • Go: there is no consumer-side equivalent. hop/go@X requires authority-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' replace directives are in-repo and never reach consumers.
  • m4: the console reads its audience from hop.Audience(), which is CODEFLY__MODULE, the module the composition started this process as. NewVerifier never fails on an authority that isn't serving keys yet: it refuses calls with the reason until it is.
  • Open questions, answered:
    • Keys on both hosts: the local authority serves it on main. Accounts serves it on authority-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 for Check, so modules gain no new host dependency.
    • Check returns the token's claims unchanged: yes. A new shared conformance assertion requires Check's context to equal the token's payload byte for byte. It passes on the local authority. Accounts' branch returns workContextToWire(verified.Context()), the verified claims themselves.
    • The cell mesh: not verified here.

Evidence (no CI in this repo; this is the run, 2026-10-25)

go vet ./... and go test -race -count=1 ./... in every Go module, plus the Python and TS suites:

head 4173075b40ce2972f463752dca23d2369385bbf3
### libraries/authority-client/go
go vet ./...: ok
ok  	github.com/codefly-dev/authority/libraries/authority-client/go/host	1.060s
ok  	github.com/codefly-dev/authority/libraries/authority-client/go/workcontext	1.174s
### libraries/hop/go
go vet ./...: ok
ok  	github.com/codefly-dev/authority/libraries/hop/go	1.351s
ok  	github.com/codefly-dev/authority/libraries/hop/go/routing	1.023s
### libraries/conformance/go
go vet ./...: ok
### libraries/resources/go
go vet ./...: ok
ok  	github.com/codefly-dev/authority/libraries/resources/go	1.024s
### libraries/delivery/go
go vet ./...: ok
ok  	github.com/codefly-dev/authority/libraries/delivery/go	1.023s
### modules/authority/services/local/code
go vet ./...: ok
ok  	gateway/pkg/business/issuer	7.192s
ok  	gateway/pkg/business/state	1.626s
ok  	gateway/pkg/infra/postgres	1.123s
ok  	gateway/plugins/authority	1.231s
ok  	gateway/plugins/composition	2.943s
ok  	gateway/plugins/installation	1.125s
ok  	gateway/plugins/registry_test	35.016s
### modules/authority/services/hop/code
go vet ./...: ok
ok  	hop	1.069s
ok  	hop/pkg/business/decide	1.052s
ok  	hop/pkg/infra/proxy	1.151s
### modules/authority/services/edge/code
go vet ./...: ok
ok  	edge	1.102s
### modules/console/services/api/code
go vet ./...: ok
ok  	api	1.062s
### libraries/authority-client/python (installed from GitHub at the head, with codefly-hop)
47 passed in 0.31s
### libraries/hop/python (installed from GitHub at the head; authority-client resolved at the same commit)
14 passed in 0.24s
### libraries/authority-client/typescript
ℹ tests 38
ℹ pass 38
ℹ fail 0

libraries/conformance/go has no tests of its own; its new case ran inside modules/authority/services/local/code → gateway/plugins/registry_test (all four refusals passed by name). codefly-hop was 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 run of the playground with the console behind the hop. The conformance case has not run against accounts. surface render --online does not apply.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 5 commits October 25, 2026 18:42
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>
antoinetoussaint-byte and others added 3 commits October 25, 2026 20:08
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>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 4ebd5be into main Oct 26, 2026
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