From ead918ed860c5b56160f61b85dbd4cc44701feb4 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:42:07 -0400 Subject: [PATCH 1/8] fix: authenticate the forwarded Work Context before any claim is read 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 --- libraries/authority-client/go/README.md | 41 ++-- .../authority-client/go/workcontext/claims.go | 90 +++++--- .../go/workcontext/claims_test.go | 92 ++++++-- libraries/authority-client/python/README.md | 63 +++--- .../python/authority_client/work_context.py | 174 +++++++++++---- .../authority-client/python/pyproject.toml | 2 +- .../python/tests/test_work_context.py | 199 +++++++++--------- .../authority-client/typescript/README.md | 35 ++- .../typescript/tests/work-context.test.mjs | 133 +++++------- .../typescript/work-context.ts | 145 ++++++++----- 10 files changed, 571 insertions(+), 403 deletions(-) diff --git a/libraries/authority-client/go/README.md b/libraries/authority-client/go/README.md index de8f7548..0290793b 100644 --- a/libraries/authority-client/go/README.md +++ b/libraries/authority-client/go/README.md @@ -1,40 +1,39 @@ # Authority Go client -Codefly generates the RPC bindings in `gen/`. The `workcontext` package adds a -non-verifying projection using Core's maintained decoder and scope containment: +Codefly generates the RPC bindings in `gen/`. The `workcontext` package +authenticates the Work Context the hop forwarded and projects its claims: ```go import claims "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" -// The reader requires incoming metadata and the run-delivered hop proof. md, _ := metadata.FromIncomingContext(ctx) -c, err := claims.ReadClaims(md, proof) -// Handle err, compare c.Audience() to this module's delivered audience, then: +c, err := claims.Authenticate(md, proof, audience, trust) +// err wraps claims.ErrUnauthenticated and names the reason. allowed := c.Allows(resourceKind, action, exactResourceID) ``` +A module does not call this directly: `hop.Verifier` +(`libraries/hop/go`) resolves `trust` from the authority's published keys and +runs it on every call. `Authenticate` is the generated-code-free check it rests +on, which the shared conformance suite also drives against both hosts. + +It checks, in order: this run's hop proof, exactly one token and the stamp's +SHA-256 digest of it (`stamp.Context`); then Core's `Inspector`: canonical +decode, the issuer pin, the Ed25519 signature under the published key the token +names, the audience is this module, not-before and expiry within Core's skew, +and structure and attenuation. It consumes nothing and asks the authority +nothing: liveness is the authority's Check the hop made, bound to this call by +the digest. Proof and digest alone authenticate nothing; the signature does. + `Scopes()` returns deep copies of the final actor's grants, or the owner's grants when there is no actor. An empty final actor grants nothing. `Allows` evaluates each candidate independently. An empty requested resource ID asks for the entire kind; a restricted grant cannot satisfy it. Kind-wide grants cover named IDs. -Actions are literal: no admin implication, wildcard expansion, prefix matching -or union of one grant's actions with another's resources. - -The reader checks a bounded canonical envelope and 64-byte signature shape, then -uses Core Decode. Go deliberately retains Core's stronger structural/schema and -attenuation checks; the Python and TypeScript readers only project readable -claims. All three agree on the shared Core-produced fixtures. None verifies a -signature, current time, live revisions or replay. Even a digest-bound context -may have been refused in warning mode. These claims alone never prove authority. -Keep audience, installed-definition, ownership, resource and live-visibility -checks at their existing owners. The exposed protobuf scopes are Core's current -public types during the authority migration; do not mutate returned copies and -expect to change the claims snapshot. +Actions are literal. `Context()` is a deep copy of the authenticated claims. From this directory, run `GOWORK=off go test -race ./...` and `go vet ./...`. -The tests consume `../testdata/claims.json`, whose gateway-side oracle verifies -Core-minted tokens at a fixed fixture clock. Synthetic mutated tokens in unit -tests exercise parsing only and are not valid credentials. +The tests re-sign the bodies of `../testdata/claims.json` with a test key of +their own (Core refuses its public fixture key outside its kit). Handwritten code lives outside `gen/`, the directory Codefly replaces. The root module scaffold is preserved by `codefly generate client`. diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index 9fccb841..c1d2cfb0 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -1,67 +1,87 @@ -// Package workcontext projects an already hop-bound Work Context. It is not a -// verifier: readable claims prove neither authentication nor live authority. +// Package workcontext authenticates the Work Context the hop forwarded to a +// module, and projects its claims. Authenticate is the one check a module's +// reading rests on: nothing here decodes a token it has not authenticated. package workcontext import ( - "encoding/base64" + "crypto/ed25519" "errors" "fmt" + "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "google.golang.org/grpc/metadata" - "strings" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" "google.golang.org/protobuf/proto" ) -// ErrUnreadableContext names a token that cannot be projected. Core's structural -// refusals are also retained in the error chain; no signature is verified. -var ErrUnreadableContext = errors.New("authority: unreadable context") +// ErrUnauthenticated is a forwarded Work Context the module must not believe. +// The error chain names the reason: Core's refusal (signature, issuer, +// audience, window, structure) or the stamp's (proof, digest). +var ErrUnauthenticated = errors.New("authority: the forwarded Work Context is not authenticated") + +// Trust is the authority a module believes: the issuer every capability must +// name and that issuer's published verification keys by key id, exactly as +// the authority interface's Keys answers them. It is resolved from the host, +// never typed in. +type Trust struct { + Issuer string + Keys map[string]ed25519.PublicKey + // Now is the clock the window is judged at; nil means time.Now. + Now func() time.Time +} -// Claims owns a snapshot of the audience and Core's effective scopes. -// It can be read concurrently. Its zero value grants nothing. +// Claims owns a snapshot of an authenticated context. It can be read +// concurrently. Its zero value grants nothing. type Claims struct { audience string scopes []*basev0.WorkScopeV1 + context *basev0.WorkContextV1 } -// ReadClaims verifies the hop proof and digest before reading the bound token, using Core's maintained decoder. -// In addition to canonical envelope checks, Go retains Core's structural checks; -// the Python/TypeScript projections do not implement that structural validator. -// None of these readers verifies signatures, time, live revisions or replay. -func ReadClaims(md metadata.MD, proof string) (*Claims, error) { +// Authenticate returns the claims of the Work Context the hop forwarded with +// md to this module (audience), after two checks, in order: +// +// 1. the stamp: md carries this run's hop proof, exactly one token, and the +// stamp's digest of that token, so the token is the one the authority's +// Check admitted for this call (stamp.Context); +// 2. the token itself, with Core's Inspector: canonical decode, the issuer +// pin, the Ed25519 signature under the published key the token names, the +// audience is this module, not-before and expiry within Core's skew, and +// the chain's structure and attenuation. +// +// It consumes nothing and asks the authority nothing: liveness (revision, +// seal, single-use) is the authority's Check the hop made, which the digest +// binds to this call. A proof alone, or a digest alone, authenticates nothing: +// whoever holds the proof can write both, but cannot sign. +func Authenticate(md metadata.MD, proof, audience string, trust Trust) (*Claims, error) { + if audience == "" { + return nil, fmt.Errorf("%w: no audience: a module authenticates a context for itself", ErrUnauthenticated) + } token, err := stamp.Context(md, proof) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnreadableContext, err) - } - - if len(token) == 0 || len(token) > core.MaxTokenSize { - return nil, fmt.Errorf("%w: expected a bounded context", ErrUnreadableContext) + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) } - payload, signature, ok := strings.Cut(token, ".") - if !ok || strings.Contains(signature, ".") { - return nil, fmt.Errorf("%w: expected payload.signature", ErrUnreadableContext) - } - for n, segment := range []string{payload, signature} { - decoded, err := base64.RawURLEncoding.DecodeString(segment) - if err != nil || base64.RawURLEncoding.EncodeToString(decoded) != segment || (n == 1 && len(decoded) != 64) { - return nil, fmt.Errorf("%w: noncanonical envelope or invalid signature length", ErrUnreadableContext) - } + inspector := core.Inspector{Issuer: trust.Issuer, Keys: trust.Keys, Now: trust.Now} + if _, err := inspector.Inspect(token, audience); err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) } decoded, err := core.Decode(token) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnreadableContext, err) + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) } context := decoded.Context() scopes := context.GetAuthorityScopes() if chain := context.GetActorChain(); len(chain) > 0 { scopes = chain[len(chain)-1].GetGrantedScopes() } - return &Claims{audience: context.GetAudience(), scopes: scopes}, nil + return &Claims{audience: context.GetAudience(), scopes: scopes, context: context}, nil } -// Audience is data to compare with this module's delivered audience. +// Audience is the module the context is addressed to: the one Authenticate +// was asked for. func (c *Claims) Audience() string { if c == nil { return "" @@ -69,6 +89,14 @@ func (c *Claims) Audience() string { return c.audience } +// Context returns a deep copy of the authenticated claims. +func (c *Claims) Context() *basev0.WorkContextV1 { + if c == nil || c.context == nil { + return nil + } + return proto.Clone(c.context).(*basev0.WorkContextV1) +} + // Scopes returns deep copies; changing them cannot change later Allows results. func (c *Claims) Scopes() []*basev0.WorkScopeV1 { if c == nil { diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index fe3ae465..108568f6 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -1,6 +1,7 @@ package workcontext_test import ( + "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -10,6 +11,7 @@ import ( "os" "strings" "testing" + "time" reader "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -54,18 +56,28 @@ func body(t *testing.T, name string) *basev0.WorkContextV1 { return nil } -// Synthetic signatures test projection only; they are not authenticated tokens. -func tokenFor(t *testing.T, c *basev0.WorkContextV1) string { +// The test authority: a key of the test's own, never Core's public fixture key. +var signer = ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + +func trustFor(c *basev0.WorkContextV1) reader.Trust { + at := time.Unix(c.GetNotBeforeUnix(), 0).Add(time.Second) + return reader.Trust{Issuer: c.GetIssuer(), Keys: map[string]ed25519.PublicKey{c.GetKeyId(): signer.Public().(ed25519.PublicKey)}, Now: func() time.Time { return at }} +} + +func signWith(t *testing.T, key ed25519.PrivateKey, c *basev0.WorkContextV1) string { t.Helper() b, err := (proto.MarshalOptions{Deterministic: true}).Marshal(c) if err != nil { t.Fatal(err) } - return base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)) + return base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(key, b)) } + +func tokenFor(t *testing.T, c *basev0.WorkContextV1) string { return signWith(t, signer, c) } + func read(t *testing.T, c *basev0.WorkContextV1) *reader.Claims { t.Helper() - out, err := readToken(tokenFor(t, c)) + out, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) if err != nil { t.Fatal(err) } @@ -74,10 +86,7 @@ func read(t *testing.T, c *basev0.WorkContextV1) *reader.Claims { func TestCoreFixtures(t *testing.T) { for _, f := range fixtures(t) { t.Run(f.Name, func(t *testing.T) { - c, err := readToken(f.Token) - if err != nil { - t.Fatal(err) - } + c := read(t, body(t, f.Name)) if c.Audience() != f.Audience { t.Fatal("audience differs") } @@ -137,11 +146,12 @@ func TestEmptyFinalActorGrantsNothing(t *testing.T) { t.Fatal("earlier scopes leaked") } } -func TestUnreadableContexts(t *testing.T) { - valid := tokenFor(t, body(t, "session")) +func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { + session := body(t, "session") + valid := tokenFor(t, session) payload, _, _ := strings.Cut(valid, ".") - for _, token := range []string{"", "abc", "a.b.c", ".", "é.x", strings.Repeat("a", 32*1024+1), valid + "=", payload + ".AA", payload + "." + strings.Repeat("A", 85) + "B"} { - if _, err := readToken(token); !errors.Is(err, reader.ErrUnreadableContext) { + for _, token := range []string{"abc", "a.b.c", ".", "é.x", strings.Repeat("a", 32*1024+1), valid + "=", payload + ".AA", payload + "." + strings.Repeat("A", 85) + "B"} { + if _, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("envelope not refused: %v", err) } } @@ -160,40 +170,76 @@ func TestUnreadableContexts(t *testing.T) { } token := tokenFor(t, c) if where == "duplicate" { - p, s, _ := strings.Cut(token, ".") + p, _, _ := strings.Cut(token, ".") b, _ := base64.RawURLEncoding.DecodeString(p) - token = base64.RawURLEncoding.EncodeToString(append(b, b...)) + "." + s + b = append(b, b...) + token = base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, b)) } - if _, err := readToken(token); !errors.Is(err, reader.ErrUnreadableContext) { + if _, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("malformed claims not refused: %v", err) } }) } } +// The defect this package exists to close: a party holding the run's proof +// writes the proof and the digest of any token it likes. Only the signature, +// the issuer, the audience and the window make the token one the authority +// issued for this module, now. +func TestAForgedOrMisaddressedContextIsRefusedByName(t *testing.T) { + session := body(t, "session") + trust := trustFor(session) + _, other, _ := ed25519.GenerateKey(nil) + payload, _, _ := strings.Cut(tokenFor(t, session), ".") + foreign := proto.Clone(session).(*basev0.WorkContextV1) + foreign.Issuer = "https://forger.example" + for name, c := range map[string]struct { + token, audience, reason string + trust reader.Trust + }{ + "forged": {signWith(t, other, session), session.GetAudience(), "signature", trust}, + "unsigned": {payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)), session.GetAudience(), "signature", trust}, + "another issuer": {tokenFor(t, foreign), session.GetAudience(), "issued by", trust}, + "wrong audience": {tokenFor(t, session), "another-module", "minted for audience", trust}, + "expired": {tokenFor(t, session), session.GetAudience(), "expired", reader.Trust{Issuer: trust.Issuer, Keys: trust.Keys, Now: func() time.Time { return time.Unix(session.GetExpiresAtUnix(), 0).Add(time.Hour) }}}, + "not yet valid": {tokenFor(t, session), session.GetAudience(), "not valid before", reader.Trust{Issuer: trust.Issuer, Keys: trust.Keys, Now: func() time.Time { return time.Unix(session.GetNotBeforeUnix(), 0).Add(-time.Hour) }}}, + "unknown key": {tokenFor(t, session), session.GetAudience(), "no verification key", reader.Trust{Issuer: trust.Issuer, Keys: map[string]ed25519.PublicKey{"rotated": trust.Keys[session.GetKeyId()]}, Now: trust.Now}}, + "no trust": {tokenFor(t, session), session.GetAudience(), "names no issuer", reader.Trust{}}, + "no audience": {tokenFor(t, session), "", "no audience", trust}, + } { + t.Run(name, func(t *testing.T) { + _, err := reader.Authenticate(stampedToken(c.token), "test-proof", c.audience, c.trust) + if !errors.Is(err, reader.ErrUnauthenticated) || !strings.Contains(err.Error(), c.reason) { + t.Fatalf("want a refusal naming %q, got %v", c.reason, err) + } + }) + } +} + func stampedToken(token string) metadata.MD { sum := sha256.Sum256([]byte(token)) return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-proof", "test-proof", "x-codefly-hop-context", hex.EncodeToString(sum[:])) } -func readToken(token string) (*reader.Claims, error) { - return reader.ReadClaims(stampedToken(token), "test-proof") -} -func TestReadClaimsRequiresTheStamp(t *testing.T) { - token := fixtures(t)[0].Token +func TestAuthenticateRequiresTheStamp(t *testing.T) { + session := body(t, "session") + token := tokenFor(t, session) for _, change := range []func(metadata.MD){ func(md metadata.MD) { md.Delete("x-codefly-hop-proof") }, func(md metadata.MD) { md.Set("x-codefly-hop-proof", "forged") }, func(md metadata.MD) { md.Delete("x-codefly-hop-context") }, - func(md metadata.MD) { md.Set("x-codefly-work-context", token+"forged") }, + func(md metadata.MD) { md.Set("x-codefly-work-context", tokenFor(t, body(t, "operation"))) }, func(md metadata.MD) { md.Append("x-codefly-work-context", token) }, } { md := stampedToken(token) change(md) - if _, err := reader.ReadClaims(md, "test-proof"); !errors.Is(err, reader.ErrUnreadableContext) { + if _, err := reader.Authenticate(md, "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("unbound claims accepted: %v", err) } } - if _, err := reader.ReadClaims(stampedToken(token), ""); err == nil { + if _, err := reader.Authenticate(stampedToken(token), "", session.GetAudience(), trustFor(session)); err == nil { t.Fatal("empty proof accepted") } + if c, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); err != nil || c.Context().GetTenantId() != session.GetTenantId() { + t.Fatalf("an authentic, bound context: %v", err) + } } diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index b320dbf7..9ed66643 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -1,52 +1,39 @@ # Authority Python client -The Codefly-generated authority bindings are accompanied by a small, non-verifying -Work Context reader. The reader verifies the incoming hop stamp itself before projecting the bound -context through the generated protobuf type: +The Codefly-generated authority bindings, and the module-side check of the Work +Context the hop forwarded: ```python -from authority_client.work_context import read_claims +from authority_client.work_context import Trust, authenticate -claims = read_claims(request.headers, proof) -# Compare claims.audience to this module's delivered audience. +trust = Trust.from_keys(stub.Keys(KeysRequest())) # the authority interface's published keys +claims = authenticate(request.headers, proof, audience, trust) # raises Unauthenticated, naming why allowed = claims.allows(resource_kind, action, installed_resource_id) ``` -The reader preserves Core's effective scopes: the final actor's grants, or the -owner's grants when there is no actor. An empty final grant stays empty. Each -scope is evaluated independently, so an action on one resource is not combined -with an unrelated resource from another scope. An omitted resource ID asks for -whole-kind authority; a resource-limited grant cannot satisfy it. A whole-kind -grant can satisfy a named-resource request. Actions are literal, following the -existing Core containment rule and Python SDK; `admin` implies no other action, -and raw token `*` actions are not expanded here. - -`read_claims` raises `UnreadableContext` for a missing/forged stamp, a substituted token, an unreadable envelope, unknown wire -fields, noncanonical payload or absent audience. It does **not** replace Core's -structural validation, authenticate a signature, check time/revocation or consume -a replay nonce. In warning mode even a stamped context may have been refused by -the authority. The advisory is diagnostic and grants nothing. Keep the module's -installed-definition, audience, expiry, ownership and visibility checks. Resolve -resource IDs from the domain object; never use a kind-wide fallback when a bound -context is missing or unreadable. - -This package directly reuses its generated `WorkContextV1` binding. Its canonical -wire and exact-resource checks follow the existing Python SDK's algorithms; -they are maintained here during migration, not imported from its private helpers. -SDK changes therefore require an explicit parity review until it adopts this seam. -It does not depend on a running SaaS service or introduce another verifier. - -Pin consumers by repository commit, together with the hop package: -`authority-client @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/authority-client/python`. +A module normally uses `codefly_hop.Verifier` (`libraries/hop/python`), which +resolves and refreshes the trust and derives who the call is for. + +`authenticate` checks the run's hop proof, exactly one token and the stamp's +digest of it; then, in Core's order: canonical decode, the issuer pin, the +Ed25519 signature under the published key the token names, the audience is this +module, and not-before and expiry within Core's 30-second skew. Core's structural +and attenuation rules constrain what the authority signs and are not reproduced. +It consumes nothing and asks the authority nothing: liveness is the hop's Check, +bound to this call by the digest. + +The SaaS Python SDK's verifier was not reused: it refuses tokens Core accepts +(several scope rows of one resource kind, and Core's own grant fixture). -Run the Python tests from this directory: +Effective scopes are the final actor's grants, or the owner's when there is no +actor. Each scope is evaluated independently; an omitted resource ID asks for the +whole kind; actions are literal. + +Pin consumers by repository commit: +`authority-client @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/authority-client/python`. ```sh uv run --no-project --with pytest --with-editable . pytest tests ``` -The shared `../testdata/claims.json` contains Core-minted tokens and effective -scope/query expectations. The gateway's `TestPythonClaimProjectionFixtures` -verifies them against Core with a fixed test clock; use its -`-update-python-claims` flag to regenerate. These public conformance tokens are -test data, never credentials for a running installation. +The tests re-sign the bodies of `../testdata/claims.json` with a test key. diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 657fc98f..6f25f744 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -1,28 +1,77 @@ -"""Non-verifying projection of the Work Context already bound by a hop stamp. - -Pass the incoming headers and this run's delivered hop proof to ``read_claims``. -This is a reader, not Core's structural validator or a verifier: it checks neither -signature, liveness, revocation nor delegation. In warning mode the hop may have -forwarded a refused context; its advisory remains diagnostic, never a grant. +"""Authenticate the Work Context the hop forwarded to a module, then project it. + +``authenticate`` is the Go library's ``workcontext.Authenticate``, check for +check: the stamp binds exactly one token to this run's hop proof by its digest, +and the token itself must carry a valid Ed25519 signature under the authority's +published key, name the pinned issuer, be addressed to this module and be inside +its window, judged in Core's order (decode, issuer, signature, audience, window). + +Core's structural and attenuation rules are not reproduced: they constrain what +the authority signs, and the signature is what makes these claims the +authority's. The SaaS Python SDK's verifier was not reused because it refuses +tokens Core accepts (several scope rows of one kind, Core's own grant fixture). +Nothing here asks the authority or consumes a nonce: liveness is the +authority's Check the hop made, which the digest binds to this call. """ from __future__ import annotations import base64 -from dataclasses import dataclass -from collections.abc import Iterable, Mapping -from codefly_hop import read, NoStamp - +import hashlib +import hmac +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from google.protobuf.message import DecodeError from ._gen.host.authority.v1.work_context_pb2 import WorkContextV1 -# The existing Core and Python SDK envelope bound, before decoding allocates. +WORK_CONTEXT_KEY = "x-codefly-work-context" +PROOF_KEY = "x-codefly-hop-proof" +CONTEXT_KEY = "x-codefly-hop-context" +ALGORITHM = "Ed25519" +# Core's bounds: MaxTokenSize, DefaultSkew and MaxTTLCeiling. MAX_TOKEN_BYTES = 32 * 1024 - - -class UnreadableContext(ValueError): - """The forwarded token cannot be projected through the published wire type.""" +SKEW = timedelta(seconds=30) +MAX_LIFETIME = timedelta(hours=24) + + +class Unauthenticated(ValueError): + """The forwarded Work Context must not be believed; the message names why.""" + + +class Trust: + """The authority a module believes: the issuer every capability must name and + its published verification keys by key id, as the authority interface's Keys + answers them. Build it with ``from_keys``; never type a key in.""" + + def __init__(self, issuer: str, keys: Mapping[str, bytes], *, now: Callable[[], datetime] | None = None) -> None: + if not issuer: + raise Unauthenticated("the authority names no issuer") + if not keys: + raise Unauthenticated("the authority publishes no verification key") + self.issuer = issuer + self.now = now or (lambda: datetime.now(timezone.utc)) + try: + self.keys = {key_id: Ed25519PublicKey.from_public_bytes(bytes(raw)) for key_id, raw in keys.items()} + except ValueError as error: + raise Unauthenticated(f"the authority's keys are unusable: {error}") from error + + @classmethod + def from_keys(cls, response, *, now: Callable[[], datetime] | None = None) -> "Trust": + """Trust built from the authority interface's KeysResponse. Only Ed25519 + keys are kept; an empty or repeated key id is refused.""" + keys: dict[str, bytes] = {} + for key in response.keys: + if key.algorithm != ALGORITHM: + continue + if not key.key_id or key.key_id in keys: + raise Unauthenticated("the authority publishes an unnamed or repeated key id") + keys[key.key_id] = bytes(key.public_key) + return cls(response.issuer, keys, now=now) @dataclass(frozen=True) @@ -37,13 +86,15 @@ class Claims: audience: str # Core's effective scopes: the last actor's grants, or the owner's if no actor. scopes: tuple[Scope, ...] + # The authenticated claims, whole. + context: WorkContextV1 | None = field(default=None, compare=False, repr=False) def allows(self, kind: str, action: str, resource_id: str = "") -> bool: """One exact action on one resource; empty ID asks for the whole kind. Each grant is considered independently. Actions are literal, as in Core's - scope containment and the Python SDK; no admin implication or wildcard - expansion happens here. A kind-wide grant includes any named resource. + scope containment; no admin implication or wildcard expansion happens + here. A kind-wide grant includes any named resource. """ if not kind or not action: return False @@ -55,47 +106,84 @@ def allows(self, kind: str, action: str, resource_id: str = "") -> bool: ) -def read_claims(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> Claims: - """Read an already hop-bound context without another verification/replay call. - - Uses the published generated protobuf type. The canonical-envelope/payload - checks follow the existing Python SDK's algorithm, maintained here during - migration rather than imported from its private helpers. Unknown fields are - refused rather than silently losing a restriction. Consumers still check audience and resolve the - exact domain resource; this projection alone is never proof of authority. - """ - try: - token = read(headers, proof).context - except (NoStamp, TypeError, ValueError) as error: - raise UnreadableContext("context requires this run's hop proof and matching stamp") from error - if not token: - raise UnreadableContext("context requires a token bound by the hop stamp") - if not token.isascii() or len(token) > MAX_TOKEN_BYTES or token.count(".") != 1: - raise UnreadableContext("expected a bounded . context") +def authenticate( + headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str, audience: str, trust: Trust +) -> Claims: + """The claims of the Work Context the hop forwarded with headers to this module + (audience), or ``Unauthenticated`` naming why it must not be believed.""" + if not audience: + raise Unauthenticated("no audience: a module authenticates a context for itself") + token = bound_token(headers, proof) + if len(token) > MAX_TOKEN_BYTES or not token.isascii() or token.count(".") != 1: + raise Unauthenticated("expected a bounded . context") payload_segment, signature_segment = token.split(".") - payload = _decode_segment(payload_segment) - if len(_decode_segment(signature_segment)) != 64: - raise UnreadableContext("expected a 64-byte signature") + payload, signature = _canonical(payload_segment), _canonical(signature_segment) context = WorkContextV1() try: context.ParseFromString(payload) except DecodeError as error: - raise UnreadableContext("payload is not a WorkContextV1") from error + raise Unauthenticated("payload is not a WorkContextV1") from error context.DiscardUnknownFields() - if context.SerializeToString(deterministic=True) != payload or not context.audience: - raise UnreadableContext("payload is noncanonical, has unknown fields or names no audience") + if context.SerializeToString(deterministic=True) != payload: + raise Unauthenticated("payload is noncanonical or carries a field this reader does not know") + if context.algorithm != ALGORITHM: + raise Unauthenticated(f"algorithm {context.algorithm!r} is not {ALGORITHM}") + if context.issuer != trust.issuer: + raise Unauthenticated(f"issued by {context.issuer!r}, not {trust.issuer!r}") + key = trust.keys.get(context.key_id) + if key is None: + raise Unauthenticated(f"no verification key {context.key_id!r}; the authority does not publish it") + try: + key.verify(signature, payload) + except InvalidSignature: + raise Unauthenticated("signature does not verify under the authority's published key") from None + if context.audience != audience: + raise Unauthenticated(f"minted for audience {context.audience!r}, presented to {audience!r}") + now = trust.now().timestamp() + if context.expires_at_unix - context.not_before_unix > MAX_LIFETIME.total_seconds(): + raise Unauthenticated(f"lifetime exceeds {MAX_LIFETIME}") + if now + SKEW.total_seconds() < context.not_before_unix: + raise Unauthenticated("not valid before its not-before instant") + if not now - SKEW.total_seconds() < context.expires_at_unix: + raise Unauthenticated("expired") scopes = context.actor_chain[-1].granted_scopes if context.actor_chain else context.authority_scopes return Claims( audience=context.audience, scopes=tuple(Scope(s.resource_kind, tuple(s.actions), tuple(s.resource_ids)) for s in scopes), + context=context, ) -def _decode_segment(segment: str) -> bytes: +def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> str: + """The one token the stamp's digest binds under this run's proof. Bound, not + authenticated: whoever holds the proof can write both.""" + values = collect(headers) + proofs, tokens, digests = values.get(PROOF_KEY, []), values.get(WORK_CONTEXT_KEY, []), values.get(CONTEXT_KEY, []) + if not proof or len(proofs) != 1 or not hmac.compare_digest(proofs[0].encode(), proof.encode()): + raise Unauthenticated("the call did not come through the hop: it carries no stamp with this run's proof") + if len(tokens) != 1 or not tokens[0] or len(digests) != 1: + raise Unauthenticated("the stamp binds no single Work Context") + digest = hashlib.sha256(tokens[0].encode(errors="surrogatepass")).hexdigest() + if not hmac.compare_digest(digest.encode(), digests[0].encode()): + raise Unauthenticated("the Work Context is not the one the hop's stamp binds") + return tokens[0] + + +def collect(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> dict[str, list[str]]: + """Headers by lower-cased name, repeats kept: a single-value mapping cannot + show a second token a caller appended.""" + pairs = headers.items() if isinstance(headers, Mapping) else headers + values: dict[str, list[str]] = {} + for key, value in pairs: + values.setdefault(key.lower(), []).append(value) + return values + + +def _canonical(segment: str) -> bytes: try: decoded = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) except ValueError as error: - raise UnreadableContext("context segment is not base64url") from error + raise Unauthenticated("context segment is not base64url") from error if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != segment: - raise UnreadableContext("context segment is not canonical base64url") + raise Unauthenticated("context segment is not canonical base64url") return decoded diff --git a/libraries/authority-client/python/pyproject.toml b/libraries/authority-client/python/pyproject.toml index 77ddd497..c8e5dcde 100644 --- a/libraries/authority-client/python/pyproject.toml +++ b/libraries/authority-client/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "authority-client" version = "0.0.1" -dependencies = ["protobuf>=5.29.3,<7", "grpcio>=1.71.0", "googleapis-common-protos", "codefly-hop @ git+https://github.com/codefly-dev/authority@cac1f6b3dcb6c9bca8a6fa7ee3f8135b8f5175ae#subdirectory=libraries/hop/python"] +dependencies = ["protobuf>=5.29.3,<7", "grpcio>=1.71.0", "googleapis-common-protos", "cryptography>=41"] [build-system] requires = ["setuptools>=68"] diff --git a/libraries/authority-client/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 1189ad72..51a575ca 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -2,37 +2,59 @@ import hashlib import json from dataclasses import FrozenInstanceError +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from authority_client._gen.host.authority.v1.authority_pb2 import KeysResponse, PublicKey from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 -from authority_client.work_context import MAX_TOKEN_BYTES, Claims, Scope, UnreadableContext, read_claims as bound_claims -from codefly_hop import CONTEXT_KEY, WORK_CONTEXT_KEY, NoStamp, read +from authority_client.work_context import ( + CONTEXT_KEY, PROOF_KEY, WORK_CONTEXT_KEY, Claims, Scope, Trust, Unauthenticated, authenticate, +) FIXTURES = json.loads((Path(__file__).parents[2] / "testdata/claims.json").read_text()) -STAMP = json.loads((Path(__file__).parents[3] / "hop/testdata/stamped.json").read_text()) +PROOF = "test-proof" +# The test authority: a key of the test's own, never Core's public fixture key. +SIGNER = Ed25519PrivateKey.from_private_bytes(bytes(32)) +PUBLIC = SIGNER.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) -def read_claims(token): - headers = dict(STAMP["headers"]) - headers[WORK_CONTEXT_KEY] = token - headers[CONTEXT_KEY] = hashlib.sha256(token.encode(errors="surrogatepass")).hexdigest() - return bound_claims(headers, STAMP["proof"]) +def encoded(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() -def encoded(payload): - return base64.urlsafe_b64encode(payload).rstrip(b"=").decode() +def body(name): + token = next(f["token"] for f in FIXTURES if f["name"] == name) + context = WorkContextV1() + context.ParseFromString(base64.urlsafe_b64decode(token.split(".")[0] + "==")) + return context -def token_for(context): - # Projection-only unit tests. These are not authenticated capabilities. - return encoded(context.SerializeToString(deterministic=True)) + "." + encoded(bytes(64)) +def sign(context, key=SIGNER): + payload = context.SerializeToString(deterministic=True) + return encoded(payload) + "." + encoded(key.sign(payload)) + + +def trust_for(context, at=None): + when = at or datetime.fromtimestamp(context.not_before_unix + 1, timezone.utc) + keys = KeysResponse(issuer=context.issuer, keys=[PublicKey(key_id=context.key_id, algorithm="Ed25519", public_key=PUBLIC)]) + return Trust.from_keys(keys, now=lambda: when) + + +def stamped(token, proof=PROOF): + return {WORK_CONTEXT_KEY: token, PROOF_KEY: proof, CONTEXT_KEY: hashlib.sha256(token.encode()).hexdigest()} + + +def read(context): + return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) @pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"]) -def test_core_minted_and_verified_contexts_match_the_owner_projection(fixture): - claims = read_claims(fixture["token"]) +def test_core_minted_contexts_authenticate_and_project_the_owner_scopes(fixture): + claims = read(body(fixture["name"])) assert claims.audience == fixture["audience"] assert claims.scopes == tuple( Scope(s["resource_kind"], tuple(s.get("actions", [])), tuple(s.get("resource_ids", []))) @@ -40,99 +62,82 @@ def test_core_minted_and_verified_contexts_match_the_owner_projection(fixture): ) for probe in fixture["probes"]: assert claims.allows(probe["kind"], probe["action"], probe["resource_id"]) == probe["allowed"] + assert claims.context.tenant_id == body(fixture["name"]).tenant_id def test_resource_ids_and_actions_are_never_combined_across_grants(): - context = WorkContextV1(audience="model") + context = body("session") + del context.authority_scopes[:] context.authority_scopes.add(resource_kind="profiles", actions=["read"], resource_ids=["a"]) context.authority_scopes.add(resource_kind="profiles", actions=["invoke"], resource_ids=["b"]) - claims = read_claims(token_for(context)) - assert claims.allows("profiles", "read", "a") - assert claims.allows("profiles", "invoke", "b") - assert not claims.allows("profiles", "invoke", "a") - assert not claims.allows("profiles", "read", "b") - assert not claims.allows("profiles", "read") - assert not claims.allows("other", "read", "a") + claims = read(context) + assert claims.allows("profiles", "read", "a") and claims.allows("profiles", "invoke", "b") + assert not claims.allows("profiles", "invoke", "a") and not claims.allows("profiles", "read", "b") + assert not claims.allows("profiles", "read") and not claims.allows("other", "read", "a") with pytest.raises(FrozenInstanceError): claims.audience = "other" -def test_last_actor_grants_are_used_even_when_empty(): - context = WorkContextV1(audience="model") - context.authority_scopes.add(resource_kind="profiles", actions=["read", "invoke"]) - first = context.actor_chain.add(principal_id="first") - first.granted_scopes.add(resource_kind="profiles", actions=["read"], resource_ids=["a"]) - claims = read_claims(token_for(context)) - assert claims.allows("profiles", "read", "a") - assert not claims.allows("profiles", "invoke", "a") - assert not claims.allows("profiles", "read", "b") - context.actor_chain.add(principal_id="last") - assert read_claims(token_for(context)).scopes == () - - def test_kind_wide_grants_include_resources_without_action_implications(): claims = Claims("model", (Scope("profiles", ("read", "admin", "*"), ()),)) - assert claims.allows("profiles", "read") - assert claims.allows("profiles", "read", "a") + assert claims.allows("profiles", "read") and claims.allows("profiles", "read", "a") assert not claims.allows("profiles", "invoke", "a") - assert not claims.allows("profiles", "") - assert not claims.allows("", "read") - - -@pytest.mark.parametrize("token", ["", "abc", "a.b.c", ".", "a.b", "a." + encoded(bytes(64)), - "é.x", "\ud800.x", "a" * (MAX_TOKEN_BYTES + 1), encoded(b"{}") + "." + encoded(bytes(64)), - token_for(WorkContextV1()), token_for(WorkContextV1(audience="model")) + "=", -]) -def test_unreadable_envelopes_are_named(token): - with pytest.raises(UnreadableContext): - read_claims(token) - - -def test_unknown_nested_fields_and_duplicate_wire_fields_are_refused(): - context = WorkContextV1(audience="model") - scope = context.authority_scopes.add(resource_kind="profiles", actions=["read"]) - scope.ParseFromString(scope.SerializeToString() + b"\x78\x01") - with pytest.raises(UnreadableContext): - read_claims(token_for(context)) - payload = WorkContextV1(audience="model").SerializeToString() - with pytest.raises(UnreadableContext): - read_claims(encoded(payload + payload) + "." + encoded(bytes(64))) - - -def test_resource_projection_uses_the_bound_token_and_never_the_flat_stamp(): - fixture = next(f for f in FIXTURES if f["name"] == "delegated") - headers = dict(STAMP["headers"]) - headers[WORK_CONTEXT_KEY] = fixture["token"] - headers[CONTEXT_KEY] = hashlib.sha256(fixture["token"].encode()).hexdigest() - identity = read(headers, STAMP["proof"]) - claims = bound_claims(headers, STAMP["proof"]) - assert claims == read_claims(fixture["token"]) - assert not claims.allows("documents.collection", "read") # advisory is not a grant - other_token = next(f["token"] for f in FIXTURES if f["token"] != fixture["token"]) - for changed in [dict(headers, **{WORK_CONTEXT_KEY: other_token}), - list(headers.items()) + [(WORK_CONTEXT_KEY, fixture["token"])]]: - with pytest.raises(NoStamp): - read(changed, STAMP["proof"]) - headers.pop(CONTEXT_KEY) - with pytest.raises(UnreadableContext): - bound_claims(headers, STAMP["proof"]) - - -def test_projection_does_not_claim_to_verify_signature(): - token = FIXTURES[0]["token"] - assert read_claims(token) == read_claims(token.split(".")[0] + "." + encoded(bytes(64))) - - -@pytest.mark.parametrize("change", ["proof", "digest", "token", "duplicate", "missing"]) -def test_reader_itself_refuses_an_unbound_token(change): - headers = dict(STAMP["headers"]) - if change == "proof": headers["x-codefly-hop-proof"] = "forged" + assert not claims.allows("profiles", "") and not claims.allows("", "read") + + +# The defect this reader exists to close: whoever holds the run's proof writes the +# proof and the digest of any token. Only the signature, the issuer, the audience +# and the window make the token one the authority issued for this module, now. +def test_a_forged_or_misaddressed_context_is_refused_by_name(): + context = body("session") + trust = trust_for(context) + payload = sign(context).split(".")[0] + foreign = WorkContextV1() + foreign.CopyFrom(context) + foreign.issuer = "https://forger.example" + expired = trust_for(context, datetime.fromtimestamp(context.expires_at_unix, timezone.utc) + timedelta(hours=1)) + for token, audience, use, reason in [ + (sign(context, Ed25519PrivateKey.generate()), context.audience, trust, "signature"), + (payload + "." + encoded(bytes(64)), context.audience, trust, "signature"), + (sign(foreign), context.audience, trust, "issued by"), + (sign(context), "another-module", trust, "minted for audience"), + (sign(context), context.audience, trust_for(context, datetime.fromtimestamp(context.not_before_unix, timezone.utc) - timedelta(hours=1)), "not valid before"), + (sign(context), context.audience, expired, "expired"), + (sign(context), "", trust, "no audience"), + ]: + with pytest.raises(Unauthenticated, match=reason): + authenticate(stamped(token), PROOF, audience, use) + + +@pytest.mark.parametrize("token", ["", "abc", "a.b.c", ".", "a." + encoded(bytes(64)), "é.x", + encoded(b"{}") + "." + encoded(bytes(64))]) +def test_unreadable_envelopes_are_refused(token): + context = body("session") + with pytest.raises(Unauthenticated): + authenticate(stamped(token), PROOF, context.audience, trust_for(context)) + + +@pytest.mark.parametrize("change", ["proof", "digest", "token", "duplicate", "missing", "empty proof"]) +def test_the_reader_refuses_a_token_the_stamp_does_not_bind(change): + context = body("session") + token = sign(context) + headers, proof = stamped(token), PROOF + if change == "proof": headers[PROOF_KEY] = "forged" if change == "digest": headers[CONTEXT_KEY] = "0" * 64 - if change == "token": headers[WORK_CONTEXT_KEY] = FIXTURES[0]["token"] - if change == "duplicate": headers = list(headers.items()) + [(WORK_CONTEXT_KEY,headers[WORK_CONTEXT_KEY])] + if change == "token": headers[WORK_CONTEXT_KEY] = sign(body("operation")) + if change == "duplicate": headers = list(headers.items()) + [(WORK_CONTEXT_KEY, token)] if change == "missing": headers.pop(CONTEXT_KEY) - with pytest.raises(UnreadableContext): bound_claims(headers,STAMP["proof"]) - - -def test_reader_refuses_an_empty_proof(): - with pytest.raises(UnreadableContext): bound_claims(STAMP["headers"], "") + if change == "empty proof": proof = "" + with pytest.raises(Unauthenticated): + authenticate(headers, proof, context.audience, trust_for(context)) + + +def test_trust_comes_only_from_published_ed25519_keys(): + with pytest.raises(Unauthenticated, match="no issuer"): + Trust.from_keys(KeysResponse(keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=PUBLIC)])) + with pytest.raises(Unauthenticated, match="no verification key"): + Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="RSA", public_key=PUBLIC)])) + with pytest.raises(Unauthenticated, match="repeated"): + Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=PUBLIC)] * 2)) + with pytest.raises(Unauthenticated, match="unusable"): + Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=b"short")])) diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index a4ad57a3..0d3350a1 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -1,37 +1,26 @@ # Authority TypeScript client Codefly generates the RPC bindings exported by `@codefly-dev/authority-client`. -The handwritten, non-verifying claims reader has a separate public export: +The handwritten module-side check has a separate public export: ```ts -import { readClaims } from '@codefly-dev/authority-client/work-context'; +import { authenticateClaims, trustFromKeys } from '@codefly-dev/authority-client/work-context'; -const claims = readClaims(requestHeaders, proof); -// Compare claims.audience to the delivered module audience. +const trust = trustFromKeys(await authority.keys({})); // the interface's published keys +const claims = authenticateClaims(requestHeaders, proof, audience, trust); // throws Unauthenticated const allowed = claims.allows(resourceKind, action, exactResourceID); ``` -The server-side Node reader takes header pairs (preserving repeats) or a header -map and the run-delivered proof. It requires exactly one proof, token and digest, -checks the proof and SHA-256 binding, then projects. Do not send the proof to a -browser. Readable claims alone are not proof of authentication. The -projection verifies neither signatures, current time, live revisions nor replay. -A warning-mode hop can bind a context the authority refused. Preserve the -integration's authentication and the module's audience, installed-definition, -ownership and live resource-visibility checks. +It requires exactly one proof, token and digest and checks the SHA-256 binding, +then, in Core's order: canonical decode, issuer, the Ed25519 signature under the +published key the token names, the audience is this module, and not-before and +expiry within Core's 30-second skew. Core's structural and attenuation rules are +not reproduced; they constrain what the authority signs. Re-fetch the keys when a +token names a key id the trust does not hold. Do not send the proof to a browser. `claims.scopes` contains frozen copies of the final actor's grants, or the owner's -when no actor exists. Empty final grants stay empty. Each scope is evaluated -independently. An omitted/empty resource ID asks for the whole kind; a restricted -grant cannot satisfy it. Whole-kind grants include named IDs. No wildcard or -admin implication, prefix/pattern match or cross-scope action/ID merging occurs. - -`UnreadableContext` names malformed/oversized envelopes, noncanonical base64url, -wrong signature length, unreadable protobuf, absent audience, unknown fields and -noncanonical protobuf bytes. This is a projection through the generated schema, -not Core's structural validator. Go also applies Core Decode's stronger schema -and attenuation checks. Shared Core-produced fixtures prove equal audience, -effective scope and exact-resource answers across the three language readers. +when no actor exists. Each scope is evaluated independently; an omitted resource +ID asks for the whole kind; no wildcard or admin implication occurs. Run `npm ci --ignore-scripts` and `npm test`. Tests import the built public package exports, including the generated schema. No running SaaS, service or provider is diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 779b665b..6756e77f 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -1,101 +1,80 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:crypto'; import { test } from 'node:test'; import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; -import { WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { readClaims as boundClaims, UnreadableContext } from '@codefly-dev/authority-client/work-context'; - -const stamped = token => ({'x-codefly-work-context':token,'x-codefly-hop-proof':'test-proof','x-codefly-hop-context':createHash('sha256').update(token).digest('hex')}); -const readClaims = token => boundClaims(stamped(token),'test-proof'); +import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; +import { authenticateClaims, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); const encode = bytes => Buffer.from(bytes).toString('base64url'); -// Projection-only inputs: synthetic signatures never represent authenticated calls. -const tokenFor = context => encode(toBinary(WorkContextV1Schema,context))+'.'+encode(new Uint8Array(64)); +// The test authority: a key of the test's own, never Core's public fixture key. +const signer = createPrivateKey({key: Buffer.concat([Buffer.from('302e020100300506032b657004220420','hex'), Buffer.alloc(32)]), format: 'der', type: 'pkcs8'}); +const publicKey = Buffer.from(signer.export({format:'jwk'}).x,'base64url'); +const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f=>f.name===name).token.split('.')[0],'base64url')); +const signed = (context, key=signer) => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,key)); }; +const trustFor = (context, at=(Number(context.notBeforeUnix)+1)*1000) => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>at); +const stamped = (token, proof='test-proof') => ({'x-codefly-work-context':token,'x-codefly-hop-proof':proof,'x-codefly-hop-context':createHash('sha256').update(token).digest('hex')}); +const read = context => authenticateClaims(stamped(signed(context)),'test-proof',context.audience,trustFor(context)); const scope = (actions, resourceIds=[]) => create(WorkScopeV1Schema,{resourceKind:'profiles',actions,resourceIds}); for (const fixture of fixtures) { - test(`Core-minted fixture: ${fixture.name}`, () => { - const claims = readClaims(fixture.token); + test(`Core-minted fixture authenticates: ${fixture.name}`, () => { + const claims = read(body(fixture.name)); assert.equal(claims.audience,fixture.audience); assert.deepEqual(claims.scopes, fixture.scopes.map(s=>({resourceKind:s.resource_kind,actions:s.actions??[],resourceIds:s.resource_ids??[]}))); - for (const probe of fixture.probes) { - assert.equal(claims.allows(probe.kind,probe.action,probe.resource_id),probe.allowed); - } + for (const probe of fixture.probes) assert.equal(claims.allows(probe.kind,probe.action,probe.resource_id),probe.allowed); }); } -test('later matching grant works without merging actions and resource IDs',()=>{ - const context = create(WorkContextV1Schema,{audience:'model',authorityScopes:[scope(['read'],['a']),scope(['invoke'],['b'])]}); - const claims = readClaims(tokenFor(context)); - assert.equal(claims.allows('profiles','read','a'),true); - assert.equal(claims.allows('profiles','invoke','b'),true); - assert.equal(claims.allows('profiles','invoke','a'),false); - assert.equal(claims.allows('profiles','read','b'),false); - assert.equal(claims.allows('profiles','read'),false); - assert.equal(claims.allows('other','read','a'),false); - assert.throws(()=>{claims.audience='other';},TypeError); - assert.throws(()=>{claims.scopes[0].actions.push('invoke');},TypeError); - assert.throws(()=>{claims.scopes[0].resourceIds.push('b');},TypeError); -}); - -test('final actor grants including an empty list override earlier scopes',()=>{ - const fixture=fixtures.find(f=>f.name==='delegated'); - const context=fromBinary(WorkContextV1Schema,Buffer.from(fixture.token.split('.')[0],'base64url')); - context.actorChain.at(-1).grantedScopes=[]; - const claims=readClaims(tokenFor(context)); - assert.deepEqual(claims.scopes,[]); - assert.equal(claims.allows('record','read','record-conformance'),false); -}); - -test('whole-kind grants include exact resources, with no action implication',()=>{ - const claims=readClaims(tokenFor(create(WorkContextV1Schema,{audience:'model',authorityScopes:[scope(['read','admin','*'])]}))); - assert.equal(claims.allows('profiles','read'),true); - assert.equal(claims.allows('profiles','read','a'),true); - assert.equal(claims.allows('profiles','invoke','a'),false); - assert.equal(claims.allows('profiles',''),false); - assert.equal(claims.allows('','read'),false); +test('grants are evaluated one at a time and literally', () => { + const context = body('session'); + context.authorityScopes = [scope(['read'],['a']), scope(['invoke'],['b'])]; + const claims = read(context); + assert.ok(claims.allows('profiles','read','a') && claims.allows('profiles','invoke','b')); + assert.ok(!claims.allows('profiles','invoke','a') && !claims.allows('profiles','read','b') && !claims.allows('profiles','read')); + assert.throws(()=>{ claims.scopes[0].actions.push('admin'); }); }); -test('malformed, oversized and noncanonical envelopes refuse by name',()=>{ - const valid=fixtures[0].token, payload=valid.split('.')[0]; - for(const token of ['', 'abc','a.b.c','.','a.b','é.x','a'.repeat(32769),valid+'=',payload+'.AA',payload+'.'+'A'.repeat(85)+'B']) { - assert.throws(()=>readClaims(token),UnreadableContext); +// The defect this reader closes: whoever holds the proof writes the proof and any +// token's digest. Only signature, issuer, audience and window make it authentic. +test('a forged, unsigned, misaddressed or expired context is refused by name', () => { + const context = body('session'), trust = trustFor(context); + const foreign = create(WorkContextV1Schema,{...context, issuer:'https://forger.example'}); + const payload = signed(context).split('.')[0]; + for (const [token, audience, use, reason] of [ + [signed(context, generateKeyPairSync('ed25519').privateKey), context.audience, trust, /signature/], + [payload+'.'+encode(new Uint8Array(64)), context.audience, trust, /signature/], + [signed(foreign), context.audience, trust, /issued by/], + [signed(context), 'another-module', trust, /minted for audience/], + [signed(context), context.audience, trustFor(context,(Number(context.expiresAtUnix)+3600)*1000), /expired/], + [signed(context), context.audience, trustFor(context,(Number(context.notBeforeUnix)-3600)*1000), /not valid before/], + [signed(create(WorkContextV1Schema,{...context, keyId:'rotated'})), context.audience, trust, /no verification key/], + [signed(context), '', trust, /no audience/], + ]) { + assert.throws(()=>authenticateClaims(stamped(token),'test-proof',audience,use), e => e instanceof Unauthenticated && reason.test(e.message)); } }); -test('unknown root/nested fields, duplicate payload and absent audience refuse',()=>{ - const context=create(WorkContextV1Schema,{audience:'model',authorityScopes:[scope(['read'])]}); - const payload=toBinary(WorkContextV1Schema,context), signature=encode(new Uint8Array(64)); - assert.throws(()=>readClaims(encode(new Uint8Array([...payload,0xf8,7,1]))+'.'+signature),UnreadableContext); - assert.throws(()=>readClaims(encode(new Uint8Array([...payload,...payload]))+'.'+signature),UnreadableContext); - const original=toBinary(WorkScopeV1Schema,context.authorityScopes[0]); - context.authorityScopes[0]=fromBinary(WorkScopeV1Schema,new Uint8Array([...original,0x78,1])); - assert.throws(()=>readClaims(tokenFor(context)),UnreadableContext); - assert.throws(()=>readClaims(tokenFor(create(WorkContextV1Schema))),UnreadableContext); +test('the stamp must bind the token under this run\'s proof', () => { + const context = body('session'), token = signed(context), trust = trustFor(context); + for (const headers of [ + {...stamped(token), 'x-codefly-hop-proof':'forged'}, + {...stamped(token), 'x-codefly-hop-context':'0'.repeat(64)}, + {...stamped(token), 'x-codefly-work-context':signed(body('operation'))}, + [...Object.entries(stamped(token)), ['x-codefly-work-context',token]], + ]) assert.throws(()=>authenticateClaims(headers,'test-proof',context.audience,trust),Unauthenticated); + assert.throws(()=>authenticateClaims(stamped(token),'',context.audience,trust),Unauthenticated); + for (const bad of ['abc','a.b.c','é.x', payloadOnly(token)+'.AA']) { + assert.throws(()=>authenticateClaims(stamped(bad),'test-proof',context.audience,trust),Unauthenticated); + } }); -test('projection is not authentication or Core structural validation',()=>{ - const payload=fixtures[0].token.split('.')[0]; - assert.deepEqual(readClaims(payload+'.'+encode(new Uint8Array(64))).scopes,readClaims(fixtures[0].token).scopes); - // Like Python, this projection accepts readable but structurally incomplete - // claims. Go deliberately retains Core Decode's stronger structural refusal. - assert.equal(readClaims(tokenFor(create(WorkContextV1Schema,{audience:'model'}))).audience,'model'); +test('trust comes only from published Ed25519 keys', () => { + const key = create(PublicKeySchema,{keyId:'k',algorithm:'Ed25519',publicKey}); + assert.throws(()=>trustFromKeys(create(KeysResponseSchema,{keys:[key]})),Unauthenticated); + assert.throws(()=>trustFromKeys(create(KeysResponseSchema,{issuer:'i',keys:[key,key]})),Unauthenticated); + assert.throws(()=>trustFromKeys(create(KeysResponseSchema,{issuer:'i',keys:[create(PublicKeySchema,{keyId:'k',algorithm:'Ed25519',publicKey:new Uint8Array(3)})]})),Unauthenticated); }); - -test('the reader refuses missing, forged and substituted stamps',()=>{ - const token=fixtures[0].token; - for (const change of [ - h=>delete h['x-codefly-hop-proof'],h=>h['x-codefly-hop-proof']='forged', - h=>delete h['x-codefly-hop-context'],h=>h['x-codefly-hop-context']='0'.repeat(64), - h=>h['x-codefly-work-context']=fixtures[1].token, - h=>h['x-codefly-work-context']=[token,token], - ]) { - const headers=stamped(token);change(headers); - assert.throws(()=>boundClaims(headers,'test-proof'),UnreadableContext); - } - assert.throws(()=>boundClaims(stamped(token),''),UnreadableContext); - assert.throws(()=>boundClaims([...Object.entries(stamped(token)),['X-Codefly-Work-Context',token]],'test-proof'),UnreadableContext); -}); +function payloadOnly(token) { return token.split('.')[0]; } diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index fbcf2698..8e92cc5e 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,10 +1,45 @@ -/** Non-verifying projection of an already hop-bound Work Context. */ -import { createHash, timingSafeEqual } from "node:crypto"; +/** Authenticate the Work Context the hop forwarded to a Node module, then project it. + * + * `authenticateClaims` is the Go library's `workcontext.Authenticate`, check for + * check where it matters: the stamp binds exactly one token to this run's proof by + * its digest, and the token itself must carry a valid Ed25519 signature under the + * authority's published key, name the pinned issuer, be addressed to this module and + * be inside its window, judged in Core's order. Core's structural and attenuation + * rules constrain what the authority signs and are not reproduced. Nothing here asks + * the authority or consumes a nonce: liveness is the hop's Check, which the digest + * binds to this call. */ +import { createHash, createPublicKey, timingSafeEqual, verify, type KeyObject } from "node:crypto"; import { fromBinary, toBinary } from "@bufbuild/protobuf"; -import { WorkContextV1Schema } from "./src/gen/host/authority/v1/work_context_pb.js"; +import type { KeysResponse } from "./src/gen/host/authority/v1/authority_pb.js"; +import { WorkContextV1Schema, type WorkContextV1 } from "./src/gen/host/authority/v1/work_context_pb.js"; -export class UnreadableContext extends Error { - override name = "UnreadableContext"; +/** Core's bounds: MaxTokenSize, DefaultSkew, MaxTTLCeiling. */ +const MAX_TOKEN_BYTES = 32 * 1024, SKEW_SECONDS = 30, MAX_LIFETIME_SECONDS = 24 * 3600; + +export class Unauthenticated extends Error { + override name = "Unauthenticated"; +} + +/** The authority a module believes: its issuer and published keys, from Keys. */ +export interface Trust { + readonly issuer: string; + readonly keys: ReadonlyMap; + /** Unix milliseconds; Date.now when omitted. */ + readonly now?: () => number; +} + +/** Trust from the authority interface's KeysResponse: Ed25519 keys only. */ +export function trustFromKeys(response: KeysResponse, now?: () => number): Trust { + const keys = new Map(); + for (const key of response.keys) { + if (key.algorithm !== "Ed25519") continue; + if (!key.keyId || keys.has(key.keyId) || key.publicKey.length !== 32) { + throw new Unauthenticated("the authority publishes an unnamed, repeated or malformed key"); + } + keys.set(key.keyId, createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(key.publicKey).toString("base64url") }, format: "jwk" })); + } + if (!response.issuer || keys.size === 0) throw new Unauthenticated("the authority publishes no issuer or no Ed25519 key"); + return { issuer: response.issuer, keys, now }; } export interface Scope { @@ -16,6 +51,8 @@ export interface Scope { export interface Claims { readonly audience: string; readonly scopes: readonly Scope[]; + /** The authenticated claims, whole. */ + readonly context: WorkContextV1; /** Empty resourceID asks for the entire kind. Actions and IDs are literal. */ allows(kind: string, action: string, resourceID?: string): boolean; } @@ -23,47 +60,38 @@ export interface Claims { /** Server-side request headers; repeated values must not be discarded. */ export type StampHeaders = Readonly> | Iterable; -/** Verify the run proof and token digest before projecting. This is not another - * signature, liveness or replay check. In warn mode bound claims may be refused. */ -export function readClaims(headers: StampHeaders, proof: string): Claims { - if (headers === null || typeof headers !== "object" || typeof proof !== "string") { - throw new UnreadableContext("context requires request headers and a run proof"); - } - const values = new Map(); - const pairs = Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers); - for (const [key,value] of pairs) { - const name=key.toLowerCase(); - values.set(name,[...(values.get(name) ?? []),...(typeof value === "string" ? [value] : value)]); - } - const one=(key:string):string => { const v=values.get(key); return v?.length === 1 ? v[0] : ""; }; - const equal=(a:string,b:string):boolean => { - const left=new TextEncoder().encode(a),right=new TextEncoder().encode(b); - return left.length === right.length && timingSafeEqual(left,right); - }; - const token=one("x-codefly-work-context"); - if (!proof || !equal(proof,one("x-codefly-hop-proof")) || !token || token.length > 32*1024 || - !equal(createHash("sha256").update(token).digest("hex"),one("x-codefly-hop-context"))) { - throw new UnreadableContext("context requires this run's hop proof and matching stamp"); - } - if (!token || token.length > 32 * 1024 || token.split(".").length !== 2) { - throw new UnreadableContext("expected a bounded payload.signature context"); +/** The claims of the Work Context the hop forwarded to this module (audience), or + * Unauthenticated naming why it must not be believed. */ +export function authenticateClaims(headers: StampHeaders, proof: string, audience: string, trust: Trust): Claims { + if (!audience) throw new Unauthenticated("no audience: a module authenticates a context for itself"); + const token = boundToken(headers, proof); + if (token.length > MAX_TOKEN_BYTES || token.split(".").length !== 2) { + throw new Unauthenticated("expected a bounded payload.signature context"); } const [payloadSegment, signatureSegment] = token.split("."); - const payload = decodeSegment(payloadSegment); - if (decodeSegment(signatureSegment).length !== 64) { - throw new UnreadableContext("expected a 64-byte signature"); - } - let context; + const payload = decodeSegment(payloadSegment), signature = decodeSegment(signatureSegment); + let context: WorkContextV1; try { context = fromBinary(WorkContextV1Schema, payload, { readUnknownFields: false }); } catch { - throw new UnreadableContext("payload is not a WorkContextV1"); + throw new Unauthenticated("payload is not a WorkContextV1"); } const canonical = toBinary(WorkContextV1Schema, context); - if (!context.audience || canonical.length !== payload.length || - canonical.some((byte, index) => byte !== payload[index])) { - throw new UnreadableContext("payload is noncanonical, has unknown fields or names no audience"); + if (canonical.length !== payload.length || canonical.some((byte, index) => byte !== payload[index])) { + throw new Unauthenticated("payload is noncanonical or carries a field this reader does not know"); + } + if (context.algorithm !== "Ed25519") throw new Unauthenticated(`algorithm ${context.algorithm} is not Ed25519`); + if (context.issuer !== trust.issuer) throw new Unauthenticated(`issued by ${context.issuer}, not ${trust.issuer}`); + const key = trust.keys.get(context.keyId); + if (!key) throw new Unauthenticated(`no verification key ${context.keyId}; the authority does not publish it`); + if (signature.length !== 64 || !verify(null, payload, key, signature)) { + throw new Unauthenticated("signature does not verify under the authority's published key"); } + if (context.audience !== audience) throw new Unauthenticated(`minted for audience ${context.audience}, presented to ${audience}`); + const now = (trust.now ?? Date.now)() / 1000, notBefore = Number(context.notBeforeUnix), expires = Number(context.expiresAtUnix); + if (expires - notBefore > MAX_LIFETIME_SECONDS) throw new Unauthenticated("lifetime exceeds 24h"); + if (now + SKEW_SECONDS < notBefore) throw new Unauthenticated("not valid before its not-before instant"); + if (!(now - SKEW_SECONDS < expires)) throw new Unauthenticated("expired"); const effective = context.actorChain.at(-1)?.grantedScopes ?? context.authorityScopes; const scopes = Object.freeze(effective.map(scope => Object.freeze({ resourceKind: scope.resourceKind, @@ -73,6 +101,7 @@ export function readClaims(headers: StampHeaders, proof: string): Claims { return Object.freeze({ audience: context.audience, scopes, + context, allows(kind: string, action: string, resourceID = ""): boolean { if (!kind || !action) return false; return scopes.some(scope => scope.resourceKind === kind && scope.actions.includes(action) && @@ -81,18 +110,36 @@ export function readClaims(headers: StampHeaders, proof: string): Claims { }); } -function decodeSegment(segment: string): Uint8Array { - if (!/^[A-Za-z0-9_-]+$/.test(segment)) { - throw new UnreadableContext("context segment is not base64url"); +/** The one token the stamp's digest binds under this run's proof. Bound, not + * authenticated: whoever holds the proof can write both. */ +function boundToken(headers: StampHeaders, proof: string): string { + if (headers === null || typeof headers !== "object" || typeof proof !== "string") { + throw new Unauthenticated("context requires request headers and a run proof"); } - let decoded: string; - try { - decoded = atob(segment.replace(/-/g, "+").replace(/_/g, "/")); - } catch { - throw new UnreadableContext("context segment is not base64url"); + const values = new Map(); + const pairs = Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers); + for (const [key, value] of pairs) { + const name = key.toLowerCase(); + values.set(name, [...(values.get(name) ?? []), ...(typeof value === "string" ? [value] : value)]); + } + const one = (key: string): string => { const v = values.get(key); return v?.length === 1 ? v[0] : ""; }; + const equal = (a: string, b: string): boolean => { + const left = new TextEncoder().encode(a), right = new TextEncoder().encode(b); + return left.length === right.length && timingSafeEqual(left, right); + }; + const token = one("x-codefly-work-context"); + if (!proof || !equal(proof, one("x-codefly-hop-proof"))) { + throw new Unauthenticated("the call did not come through the hop: it carries no stamp with this run's proof"); } - if (btoa(decoded).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") !== segment) { - throw new UnreadableContext("context segment is not canonical base64url"); + if (!token || !equal(createHash("sha256").update(token).digest("hex"), one("x-codefly-hop-context"))) { + throw new Unauthenticated("the Work Context is not the one the hop's stamp binds"); } - return Uint8Array.from(decoded, character => character.charCodeAt(0)); + return token; +} + +function decodeSegment(segment: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(segment)) throw new Unauthenticated("context segment is not base64url"); + const decoded = Buffer.from(segment, "base64url"); + if (decoded.toString("base64url") !== segment) throw new Unauthenticated("context segment is not canonical base64url"); + return new Uint8Array(decoded); } From 8a2fe5f0b01fe7f66920999617a2bb2399c0238b Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:53:48 -0400 Subject: [PATCH 2/8] fix: modules authenticate every forwarded call with hop.Verifier 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 --- libraries/conformance/go/conformance.go | 1 + libraries/conformance/go/forwarded_context.go | 78 +++++ libraries/conformance/go/go.mod | 5 +- libraries/conformance/go/go.sum | 10 +- libraries/hop/go/README.md | 50 +++- libraries/hop/go/fixture_test.go | 192 +++++++++++-- libraries/hop/go/go.mod | 10 +- libraries/hop/go/go.sum | 26 +- libraries/hop/go/hop.go | 272 ++++++++++++++---- libraries/hop/go/hop_test.go | 150 +++++++--- libraries/hop/go/hoptest/hoptest.go | 90 ++++++ libraries/hop/go/outbound_test.go | 8 +- libraries/hop/python/README.md | 30 +- libraries/hop/python/pyproject.toml | 9 +- .../hop/python/src/codefly_hop/__init__.py | 211 ++++++++------ libraries/hop/python/tests/test_read.py | 156 ++++++---- libraries/hop/testdata/stamped.json | 46 ++- .../hop/code/pkg/business/decide/decide.go | 47 +-- .../pkg/business/decide/permissive_test.go | 4 +- .../pkg/infra/proxy/generated_routing_test.go | 4 +- .../hop/code/pkg/infra/proxy/http_test.go | 13 +- .../hop/code/pkg/infra/proxy/proxy_test.go | 78 +++-- modules/authority/services/local/code/go.mod | 1 + modules/authority/services/local/code/go.sum | 2 + modules/console/services/api/code/go.mod | 2 +- modules/console/services/api/code/work.go | 29 +- 26 files changed, 1149 insertions(+), 375 deletions(-) create mode 100644 libraries/conformance/go/forwarded_context.go create mode 100644 libraries/hop/go/hoptest/hoptest.go diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index b52d92e0..09d124dd 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -264,6 +264,7 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt c.installationIdentity(t, ctx, f) c.sessionDisplayNames(t, ctx, f) c.bindingRefusals(t, ctx, f) + c.forwardedContext(t, ctx, f) t.Run("parent per-kind limits survive audience changes", func(t *testing.T) { if f.ParentLimits == nil { t.Fatal("fixture supplies no parent-limit round trip") diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go new file mode 100644 index 00000000..f6fd0641 --- /dev/null +++ b/libraries/conformance/go/forwarded_context.go @@ -0,0 +1,78 @@ +package conformance + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "testing" + "time" + + forwarded "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + "google.golang.org/grpc/metadata" +) + +// forwardedContext is the module side of the hop, against this host: what the +// host publishes through Keys is exactly what authenticates what it issues, so +// a module that pins those keys accepts the host's context and refuses a +// forged, unsigned, misaddressed or expired one, naming the reason. It drives +// the same check every module runs behind the hop (workcontext.Authenticate, +// under hop.Verifier), never a copy of it. +func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) { + t.Run("a module authenticates the forwarded context against the published keys", func(t *testing.T) { + published := c.must(t, ctx, "host.authority.v1.AuthorityService", "Keys", map[string]any{}) + trust := forwarded.Trust{Issuer: str(published, "issuer"), Keys: map[string]ed25519.PublicKey{}} + for _, key := range listMsgs(published, "keys") { + if str(key, "algorithm") == "Ed25519" { + trust.Keys[str(key, "key_id")] = ed25519.PublicKey(field(key, "public_key").Bytes()) + } + } + sess := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken}) + read := []any{map[string]any{"resource_kind": f.ReadKind, "actions": []any{"read"}}} + issued := c.must(t, as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.Audience, "scopes": read}) + token := str(issued, "token") + // The window is judged at the host's own clock, which a fixture may fix. + at := time.Unix(i64(msg(issued, "context"), "issued_at_unix"), 0).Add(time.Second) + trust.Now = func() time.Time { return at } + + const proof = "conformance-proof" + if claims, err := forwarded.Authenticate(stamped(token, proof), proof, f.Audience, trust); err != nil || claims.Audience() != f.Audience { + t.Fatalf("the host's own context, under its published keys, is accepted: %v", err) + } + payload, _, _ := strings.Cut(token, ".") + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + t.Fatal(err) + } + _, forger, _ := ed25519.GenerateKey(rand.Reader) + later := trust + later.Now = func() time.Time { return at.Add(25 * time.Hour) } + for name, probe := range map[string]struct { + token, audience, reason string + trust forwarded.Trust + }{ + "forged": {payload + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(forger, raw)), f.Audience, "signature", trust}, + "unsigned": {payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), f.Audience, "signature", trust}, + "wrong audience": {token, f.Elsewhere, "minted for audience", trust}, + "expired": {token, f.Audience, "expired", later}, + } { + t.Run(name+" is refused by name", func(t *testing.T) { + _, err := forwarded.Authenticate(stamped(probe.token, proof), proof, probe.audience, probe.trust) + if !errors.Is(err, forwarded.ErrUnauthenticated) || !strings.Contains(err.Error(), probe.reason) { + t.Fatalf("want a refusal naming %q: %v", probe.reason, err) + } + }) + } + }) +} + +// stamped is the incoming metadata of a call the hop let through carrying +// token: the token, this run's proof and the stamp's digest of the token. +func stamped(token, proof string) metadata.MD { + sum := sha256.Sum256([]byte(token)) + return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-proof", proof, "x-codefly-hop-context", hex.EncodeToString(sum[:])) +} diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 192d01c5..040adb27 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -3,6 +3,7 @@ module github.com/codefly-dev/authority/libraries/conformance/go go 1.27.3 require ( + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 google.golang.org/grpc v1.84.0 @@ -50,7 +51,7 @@ require ( golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 1efbb0ca..fdbca607 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,6 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 h1:7vdTj6aLQVyFf0MWWgUnR/otX1v9xN6AbFKC2cHrQjE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= @@ -207,10 +209,10 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 59a82297..14048c54 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -1,5 +1,48 @@ # Go hop library +## The module side: `Verifier` + +Every module behind the hop authenticates each call with one `Verifier`, built +once at startup from the authority interface it already depends on: + +```go +import ( + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/host" + hop "github.com/codefly-dev/authority/libraries/hop/go" +) + +proof, _ := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") +conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell +verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), "", proof) +config.GRPCServerOptions = append(config.GRPCServerOptions, + grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), + grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) +// in a handler: id, _ := hop.FromContext(ctx); id.Tenant, id.Has(kind, action), id.Claims.Allows(kind, action, resourceID) +// an HTTP module: id, err := verifier.Verify(ctx, metadataFromHeaders) +``` + +`Verify` checks this run's proof, that the stamp's digest binds the one +forwarded token, and then the token itself (Core's `Inspector`): the issuer pin, +the Ed25519 signature under the authority's published key, the audience is this +module, not-before and expiry, structure and attenuation. Who the call is for is +read from the authenticated claims (`IdentityOf`); a stamp that says otherwise +is refused, naming the field. It re-checks no permission the hop decided and +consumes nothing: liveness (revision, seal, single use) is the authority's Check +the hop made, bound to this call by the digest. + +The keys come from the interface's `Keys`, through the generated client: never +typed in, never mounted. They are re-read when a token names a key the verifier +does not hold (at most every 10 s) and when older than 5 minutes; keys that +cannot be re-read are trusted for 15 minutes at most, then every call is +refused naming the outage. + +`hoptest` is a real authority for a module's own tests: `hoptest.New(t)` signs +with a fresh key and answers `Keys`; `Issue` mints a Work Context and +`hoptest.Forward` stamps it exactly as the hop does. + +## Calling out: `Outbound` + `Outbound.ForModule(ctx, organization, binding)` names the organization of the work item. The local authority accepts an empty organization only when exactly one organization has an active installation of the calling module. Zero or @@ -26,6 +69,7 @@ Validate this library with `go test -race ./...` and `go vet ./...` from this directory. Issuer unit tests in the local authority exercise the actual installation-based tenant resolution. -The Go dependency now pins the shared hop-stamp validation introduced in -`9f04fc4`; standalone library tests therefore use the same digest check as the -service without needing an external workspace replacement. +The Go dependency pins authority-client at the commit that introduced +`workcontext.Authenticate`; standalone library tests need no workspace replacement. +`../testdata/stamped.json` is a genuinely signed call as the hop stamps it; the +Python verifier reads it back. Regenerate with `go test -run Fixture -update-fixture`. diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index d18a4e41..cf6835b9 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -1,40 +1,200 @@ package hop import ( + "context" + "crypto/ed25519" + "crypto/sha256" "encoding/json" + "errors" "flag" "os" "path/filepath" + "reflect" + "strings" "testing" + "time" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/encoding/protojson" ) var updateFixture = flag.Bool("update-fixture", false, "rewrite ../testdata/stamped.json") -// The stamp's one contract, for every language a module is written in: the -// headers this hop writes for one call, which the Python reader -// (../python) reads back in its own tests. -func TestTheStampFixtureIsWhatTheHopWrites(t *testing.T) { - md := metadata.Pairs(WorkContextKey, "eyJ3YyI6ImZpeHR1cmUifQ.c2lnbmF0dXJl") - Stamp(md, Identity{Owner: "remi", OwnerKind: "human", Tenant: "acme", Actors: []string{"robin"}, Scopes: []string{"modelservice.profiles:read", "modelservice.profiles:invoke"}, Binding: "acme/robin/model", Task: "task-7", Expires: 1792818000, Advisory: "documents.collection:read is not held"}, "fixture-proof") - headers := map[string]string{} - for key, values := range md { - headers[key] = values[0] - } - want, err := json.MarshalIndent(map[string]any{"proof": "fixture-proof", "headers": headers}, "", " ") +// signer is a test authority: a key derived from a seed in this file, so it is +// public and signs fixtures only. It is not Core's fixture key, which Core refuses. +type signer struct { + id string + key ed25519.PrivateKey + issuer string +} + +func newSigner(seed string) signer { + sum := sha256.Sum256([]byte(seed)) + return signer{id: "hop-" + seed, key: ed25519.NewKeyFromSeed(sum[:]), issuer: "https://authority.hop.test"} +} + +func (s signer) published() *authorityv1.PublicKey { + return &authorityv1.PublicKey{KeyId: s.id, Algorithm: core.Algorithm, PublicKey: s.key.Public().(ed25519.PublicKey)} +} + +func (s signer) issue(t *testing.T, at time.Time, audience string, scopes ...*basev0.WorkScopeV1) string { + t.Helper() + a := &core.Authority{Issuer: s.issuer, KeyID: s.id, Key: s.key, Revisions: core.FixtureRevisions(), Seals: core.FixtureSeals(), Now: func() time.Time { return at }} + token, _, err := a.Start(context.Background(), core.StartInput{ + TenantID: core.FixtureTenant, OwnerPrincipalID: core.FixturePrincipal, OwnerPrincipalKind: "human", + OrganizationID: core.FixtureOrganization, TaskID: core.FixtureTask, Audience: audience, + AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, + }) if err != nil { t.Fatal(err) } - want = append(want, '\n') + return token +} + +func forwarded(t *testing.T, token, proof, advisory string) metadata.MD { + t.Helper() + decoded, err := core.Decode(token) + if err != nil { + t.Fatal(err) + } + id := IdentityOf(decoded.Context()) + id.Advisory = advisory + md := metadata.Pairs(WorkContextKey, token) + Stamp(md, id, proof) + return md +} + +// keys is a switchable Keys answer that counts the reads. +type keys struct { + response *authorityv1.KeysResponse + err error + reads int +} + +func (k *keys) Keys(context.Context, *authorityv1.KeysRequest, ...grpc.CallOption) (*authorityv1.KeysResponse, error) { + k.reads++ + return k.response, k.err +} + +type stampFixture struct { + Proof string `json:"proof"` + Audience string `json:"audience"` + Now int64 `json:"now"` + Keys json.RawMessage `json:"keys"` + Headers map[string]string `json:"headers"` + Identity map[string]any `json:"identity"` +} + +// The stamp's one contract, for every language a module is written in: the +// headers this hop writes for one call carrying a genuinely signed Work +// Context, the published keys that authenticate it at a fixed clock, and the +// identity read from it. The Python verifier (../python) reads it back. +func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { path := filepath.Join("..", "testdata", "stamped.json") + s := newSigner("fixture") if *updateFixture { - if err := os.WriteFile(path, want, 0o644); err != nil { + at := time.Unix(1792818000, 0) + token := s.issue(t, at, "documents", &basev0.WorkScopeV1{ResourceKind: "documents.collection", Actions: []string{"read"}}, &basev0.WorkScopeV1{ResourceKind: "documents.collection", Actions: []string{"write"}, ResourceIds: []string{"c-1"}}) + md := forwarded(t, token, "fixture-proof", "documents.collection:write is not held") + published, _ := protojson.Marshal(&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}) + f := stampFixture{Proof: "fixture-proof", Audience: "documents", Now: at.Add(time.Minute).Unix(), Keys: published, Headers: map[string]string{}} + for key, values := range md { + f.Headers[key] = values[0] + } + id := IdentityOf(mustDecode(t, token)) + f.Identity = map[string]any{"owner": id.Owner, "owner_kind": id.OwnerKind, "tenant": id.Tenant, "actors": id.Actors, "scopes": id.Scopes, "binding": id.Binding, "task": id.Task, "expires": id.Expires, "advisory": "documents.collection:write is not held"} + out, _ := json.MarshalIndent(f, "", " ") + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { t.Fatal(err) } } - got, err := os.ReadFile(path) - if err != nil || string(got) != string(want) { - t.Fatalf("%s is not what the hop writes; regenerate with -update-fixture: %v", path, err) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var f stampFixture + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + published := &authorityv1.KeysResponse{} + if err := protojson.Unmarshal(f.Keys, published); err != nil { + t.Fatal(err) + } + v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, f.Proof) + if err != nil { + t.Fatal(err) + } + v.now = func() time.Time { return time.Unix(f.Now, 0) } + md := metadata.MD{} + for key, value := range f.Headers { + md.Set(key, value) + } + id, err := v.Verify(context.Background(), md) + if err != nil { + t.Fatalf("%s no longer authenticates; regenerate with -update-fixture: %v", path, err) + } + if want := forwarded(t, id.Context, f.Proof, id.Advisory); !reflect.DeepEqual(want, md) { + t.Fatalf("%s is not what the hop writes; regenerate with -update-fixture", path) + } + if id.Owner != f.Identity["owner"] || id.Tenant != f.Identity["tenant"] || strings.Join(id.Scopes, ",") != "documents.collection:read" || id.Advisory != f.Identity["advisory"] || !id.Claims.Allows("documents.collection", "write", "c-1") { + t.Fatalf("identity: %+v", id) + } +} + +func mustDecode(t *testing.T, token string) *basev0.WorkContextV1 { + t.Helper() + d, err := core.Decode(token) + if err != nil { + t.Fatal(err) + } + return d.Context() +} + +// A rotated key is fetched when a token first names it, at most once per +// keyRetry; a removed key stops verifying within keyMaxAge; keys that cannot +// be re-read are trusted no longer than keyStale. +func TestPublishedKeysAreRereadOnRotationAndExpireWhenUnreadable(t *testing.T) { + first, second := newSigner("first"), newSigner("second") + source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} + start := time.Unix(1792818000, 0) + clock := start + v, err := NewVerifier(context.Background(), source, "documents", "proof") + if err != nil { + t.Fatal(err) + } + v.now = func() time.Time { return clock } + v.tried, v.fetched = start, start + verify := func(token string) error { + _, err := v.Verify(context.Background(), forwarded(t, token, "proof", "")) + return err + } + if err := verify(first.issue(t, clock, "documents")); err != nil { + t.Fatal(err) + } + source.response = &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{second.published()}} + clock = start.Add(time.Second) + if err := verify(second.issue(t, clock, "documents")); err == nil || !strings.Contains(err.Error(), "no verification key") || source.reads != 1 { + t.Fatalf("within keyRetry the keys are not re-read: %v (reads %d)", err, source.reads) + } + clock = start.Add(keyRetry + time.Second) + if err := verify(second.issue(t, clock, "documents")); err != nil || source.reads != 2 { + t.Fatalf("a rotated key is fetched: %v (reads %d)", err, source.reads) + } + if err := verify(first.issue(t, clock, "documents")); err == nil { + t.Fatal("a key the authority no longer publishes still verifies") + } + source.err = errors.New("authority down") + clock = clock.Add(keyMaxAge + time.Second) + if err := verify(second.issue(t, clock, "documents")); err != nil { + t.Fatalf("a failed re-read keeps the last keys until keyStale: %v", err) + } + clock = clock.Add(keyStale) + if err := verify(second.issue(t, clock, "documents")); !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), "cannot be read") { + t.Fatalf("stale keys are refused, naming the outage: %v", err) } } diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index c9e80b00..f13c0b10 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,13 +3,21 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025201406-9f04fc47f69b + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c + github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/google/uuid v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 7981711e..4d44d9bb 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -1,11 +1,33 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025201406-9f04fc47f69b h1:u4PdyGVoJZ5VM0J18Oehc3dxqkbeYmhZ7o3j4bECEys= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025201406-9f04fc47f69b/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= +github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 88f2e36e..d8da9d87 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -1,24 +1,36 @@ // Package hop is the stamp's contract: what the hop in front of a module writes -// onto a call it let through, and how the module reads it. +// onto a call it let through, and how the module authenticates it. // -// The hop has checked the call's Work Context with the authority. The module -// believes the stamp only when it carries this run's proof; any other call did -// not come through the hop, and the module's interceptor refuses it by name. A -// module that reads the stamp verifies no token and asks no authority: the hop -// did both. In a cell the mesh is why nothing reaches the module around the hop; -// on a laptop, with no mesh, the proof is. +// The hop asked the authority's Check, which verified the caller's Work Context +// live (revision, seal, single use) and applied the method's policy, then wrote +// who the call is for, this run's proof and the digest of the checked token. +// The module re-checks no permission the hop decided, but believes nothing it +// has not authenticated: Verifier checks the token's signature under the +// authority's published keys, its issuer, that it is addressed to this module, +// its window, and that it is the token the stamp's digest binds; who the call +// is for is read from the authenticated claims, never from a header. The proof +// says the call came through the hop, so the method policy and the live check +// were applied; it authenticates no identity, because every hop-fronted module +// holds it. package hop import ( "context" + "crypto/ed25519" "crypto/sha256" - "crypto/subtle" "encoding/hex" "errors" - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "fmt" + "slices" "strconv" "strings" + "sync" + "time" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -70,10 +82,12 @@ type Identity struct { // (unix seconds), when the hop stamped them. Task string Expires int64 - // Context is the Work Context the authority checked, as forwarded. A module - // that needs its full claims decodes them from it without verifying again: - // the stamp's proof and digest are what vouch for it. + // Context is the authenticated Work Context, as forwarded: the parent a + // module asks a child of when it acts onward. Context string + // Claims are the authenticated claims, for resource-exact questions + // (Claims.Allows) the flat Scopes cannot answer. + Claims *workcontext.Claims // Advisory is what the authority would have refused, when the hop let the // call through anyway (permissive mode); "" when the call was admitted. Advisory string @@ -89,9 +103,6 @@ func (i Identity) Has(kind, action string) bool { return false } -// ErrNoStamp is a call that did not come through this run's hop. -var ErrNoStamp = errors.New("the call did not come through the hop: it carries no stamp with this run's proof") - // Strip removes every stamp key a caller may have sent: a stamp is the hop's to // write and no one else's. func Strip(md metadata.MD) { @@ -129,38 +140,194 @@ func Stamp(md metadata.MD, id Identity, proof string) { } } -// Read returns the identity the hop stamped, or ErrNoStamp when md does not -// carry this run's proof. An empty proof is never accepted: a module with no -// proof delivered reads every call as one that bypassed the hop. -func Read(md metadata.MD, proof string) (Identity, error) { - got := first(md, ProofKey) - if proof == "" || got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(proof)) != 1 { - return Identity{}, ErrNoStamp - } - id := Identity{Owner: first(md, OwnerKey), OwnerKind: first(md, OwnerKindKey), Tenant: first(md, TenantKey), Binding: first(md, BindingKey), Task: first(md, TaskKey)} - if id.Owner == "" || id.Tenant == "" { - return Identity{}, ErrNoStamp +// IdentityOf reads who an authenticated Work Context is for. Its scopes are the +// effective ones: the owner's authority narrowed by every actor's grant. A +// scope limited to named resources is left out rather than widened to its whole +// kind; Claims.Allows answers those. It is the one derivation: the hop stamps +// it from the authority's checked context and the module derives it from the +// token it authenticated, so the two agree or the call is refused. +func IdentityOf(wc *basev0.WorkContextV1) Identity { + id := Identity{ + Owner: wc.GetOwnerPrincipalId(), + OwnerKind: wc.GetOwnerPrincipalKind(), + Tenant: wc.GetTenantId(), + Binding: wc.GetOperationBinding().GetBindingId(), + Task: wc.GetTaskId(), + Expires: wc.GetExpiresAtUnix(), } - if expires := first(md, ExpiresKey); expires != "" { - at, err := strconv.ParseInt(expires, 10, 64) - if err != nil || at <= 0 { - return Identity{}, ErrNoStamp + scopes := flatten(wc.GetAuthorityScopes()) + for _, actor := range wc.GetActorChain() { + id.Actors = append(id.Actors, actor.GetPrincipalId()) + if granted := actor.GetGrantedScopes(); len(granted) > 0 { + held := flatten(granted) + scopes = slices.DeleteFunc(scopes, func(s string) bool { return !slices.Contains(held, s) }) } - id.Expires = at } - id.Actors = split(first(md, ActorsKey)) - id.Scopes = split(first(md, ScopesKey)) - id.Advisory = first(md, AdvisoryKey) - if bound := first(md, ContextKey); bound != "" { - token, err := stamp.Context(md, proof) - if err != nil { - return Identity{}, ErrNoStamp + id.Scopes = scopes + return id +} + +func flatten(scopes []*basev0.WorkScopeV1) []string { + var out []string + for _, s := range scopes { + if len(s.GetResourceIds()) > 0 { + continue + } + for _, action := range s.GetActions() { + if scope := s.GetResourceKind() + ":" + action; !slices.Contains(out, scope) { + out = append(out, scope) + } } - id.Context = token } + return out +} + +// KeySource is the authority interface's Keys, as its generated client serves +// it: the issuer and the public keys every Work Context is signed with. +type KeySource interface { + Keys(ctx context.Context, in *authorityv1.KeysRequest, opts ...grpc.CallOption) (*authorityv1.KeysResponse, error) +} + +// How the published keys are kept: re-read when a token names a key the +// verifier does not hold (at most once per keyRetry), and when they are older +// than keyMaxAge, so a removed key stops verifying. A failed re-read keeps the +// last keys until they are keyStale old; then every call is refused, naming +// the outage, as the hop refuses a call it cannot check. +const ( + keyRetry = 10 * time.Second + keyMaxAge = 5 * time.Minute + keyStale = 15 * time.Minute +) + +// Verifier is the one module-side entry point: it authenticates every call the +// hop forwards before anything reads it. Build it once, at startup. +type Verifier struct { + source KeySource + audience string + proof string + now func() time.Time + + mu sync.Mutex + trust workcontext.Trust + fetched time.Time // when trust was read + tried time.Time // the last read attempt +} + +// NewVerifier pins this module's audience and the run's hop proof, and reads +// the authority's published keys through the interface's Keys. keys is the +// generated client of the authority the module already depends on +// (authorityv1.NewAuthorityServiceClient over host.Dial): the local authority +// on a laptop, the host on a cell. A key is never typed in or mounted. +func NewVerifier(ctx context.Context, keys KeySource, audience, proof string) (*Verifier, error) { + switch { + case keys == nil: + return nil, errors.New("hop: a verifier needs the authority's Keys") + case audience == "": + return nil, errors.New("hop: a verifier needs this module's audience") + case proof == "": + return nil, errors.New("hop: a verifier needs the run's hop proof (codefly config generate hop PROOF)") + } + v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now} + v.mu.Lock() + defer v.mu.Unlock() + if err := v.fetch(ctx); err != nil { + return nil, err + } + return v, nil +} + +// Verify authenticates the call md carries and returns who it is for, from the +// authenticated claims. The error wraps workcontext.ErrUnauthenticated and +// names the reason. +func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) { + trust, err := v.trustFor(ctx, first(md, WorkContextKey)) + if err != nil { + return Identity{}, err + } + claims, err := workcontext.Authenticate(md, v.proof, v.audience, trust) + if err != nil { + return Identity{}, err + } + id := IdentityOf(claims.Context()) + if err := sameAsStamped(md, id); err != nil { + return Identity{}, err + } + id.Context, id.Claims, id.Advisory = first(md, WorkContextKey), claims, first(md, AdvisoryKey) return id, nil } +// trustFor returns the keys to judge token by, re-reading them when the token +// names a key not held or the keys are old. +func (v *Verifier) trustFor(ctx context.Context, token string) (workcontext.Trust, error) { + v.mu.Lock() + defer v.mu.Unlock() + now := v.now() + _, held := v.trust.Keys[keyIDOf(token)] + if (!held || now.Sub(v.fetched) > keyMaxAge) && now.Sub(v.tried) >= keyRetry { + if err := v.fetch(ctx); err != nil && now.Sub(v.fetched) > keyStale { + return workcontext.Trust{}, err + } + } + if now.Sub(v.fetched) > keyStale { + return workcontext.Trust{}, fmt.Errorf("%w: the authority's keys were last read %s ago", workcontext.ErrUnauthenticated, now.Sub(v.fetched).Round(time.Second)) + } + return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil +} + +// fetch reads the published keys; v.mu is held. +func (v *Verifier) fetch(ctx context.Context) error { + v.tried = v.now() + response, err := v.source.Keys(ctx, &authorityv1.KeysRequest{}) + if err != nil { + return fmt.Errorf("%w: the authority's published keys cannot be read: %w", workcontext.ErrUnauthenticated, err) + } + trust := workcontext.Trust{Issuer: response.GetIssuer(), Keys: map[string]ed25519.PublicKey{}} + for _, key := range response.GetKeys() { + if key.GetAlgorithm() != core.Algorithm { + continue + } + if key.GetKeyId() == "" || len(key.GetPublicKey()) != ed25519.PublicKeySize || trust.Keys[key.GetKeyId()] != nil { + return fmt.Errorf("%w: the authority publishes an unnamed, repeated or malformed key", workcontext.ErrUnauthenticated) + } + trust.Keys[key.GetKeyId()] = ed25519.PublicKey(slices.Clone(key.GetPublicKey())) + } + if trust.Issuer == "" || len(trust.Keys) == 0 { + return fmt.Errorf("%w: the authority publishes no issuer or no Ed25519 key", workcontext.ErrUnauthenticated) + } + v.trust, v.fetched = trust, v.tried + return nil +} + +// keyIDOf is the key a token names, read only to choose the keys it is judged +// by; "" when it does not decode, which Authenticate then refuses. +func keyIDOf(token string) string { + decoded, err := core.Decode(token) + if err != nil { + return "" + } + return decoded.Context().GetKeyId() +} + +// sameAsStamped refuses a stamp that says anything the authenticated claims do +// not: the identity is the token's, and a disagreeing header is a forgery or a +// broken hop, either way not a call to serve. +func sameAsStamped(md metadata.MD, id Identity) error { + expires := "" + if id.Expires > 0 { + expires = strconv.FormatInt(id.Expires, 10) + } + for _, field := range []struct{ key, want string }{ + {OwnerKey, id.Owner}, {OwnerKindKey, id.OwnerKind}, {TenantKey, id.Tenant}, + {ActorsKey, strings.Join(id.Actors, ",")}, {ScopesKey, strings.Join(id.Scopes, ",")}, + {BindingKey, id.Binding}, {TaskKey, id.Task}, {ExpiresKey, expires}, + } { + if got := md.Get(field.key); len(got) > 1 || first(md, field.key) != field.want { + return fmt.Errorf("%w: the stamp's %s does not match the authenticated Work Context", workcontext.ErrUnauthenticated, strings.TrimPrefix(field.key, StampPrefix)) + } + } + return nil +} + func digest(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) @@ -168,18 +335,18 @@ func digest(token string) string { type identityKey struct{} -// FromContext returns the identity the module's interceptor read off the call. +// FromContext returns the identity the module's interceptor authenticated. func FromContext(ctx context.Context) (Identity, bool) { id, ok := ctx.Value(identityKey{}).(Identity) return id, ok } -// UnaryServerInterceptor gives each call the identity the hop stamped, and -// refuses a call that bypassed the hop. Health checks pass unstamped: the run -// probes the module directly, and they read no data. -func UnaryServerInterceptor(proof string) grpc.UnaryServerInterceptor { +// UnaryServerInterceptor gives each call the identity Verify authenticated, +// and refuses, naming the reason, a call it does not. Health checks pass +// unstamped: the run probes the module directly, and they read no data. +func (v *Verifier) UnaryServerInterceptor() grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - ctx, err := admit(ctx, info.FullMethod, proof) + ctx, err := v.admit(ctx, info.FullMethod) if err != nil { return nil, err } @@ -188,9 +355,9 @@ func UnaryServerInterceptor(proof string) grpc.UnaryServerInterceptor { } // StreamServerInterceptor is UnaryServerInterceptor for streams. -func StreamServerInterceptor(proof string) grpc.StreamServerInterceptor { +func (v *Verifier) StreamServerInterceptor() grpc.StreamServerInterceptor { return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - ctx, err := admit(ss.Context(), info.FullMethod, proof) + ctx, err := v.admit(ss.Context(), info.FullMethod) if err != nil { return err } @@ -198,12 +365,12 @@ func StreamServerInterceptor(proof string) grpc.StreamServerInterceptor { } } -func admit(ctx context.Context, method, proof string) (context.Context, error) { +func (v *Verifier) admit(ctx context.Context, method string) (context.Context, error) { if strings.HasPrefix(method, "/grpc.health.v1.Health/") { return ctx, nil } md, _ := metadata.FromIncomingContext(ctx) - id, err := Read(md, proof) + id, err := v.Verify(ctx, md) if err != nil { return nil, status.Error(codes.Unauthenticated, err.Error()) } @@ -223,10 +390,3 @@ func first(md metadata.MD, key string) string { } return "" } - -func split(s string) []string { - if s == "" { - return nil - } - return strings.Split(s, ",") -} diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index c5a8bfb8..e2ffbdad 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -1,74 +1,144 @@ -package hop +package hop_test import ( "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "strings" "testing" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) -func TestAStampIsBelievedOnlyWithThisRunsProof(t *testing.T) { - md := metadata.Pairs(OwnerKey, "mallory", ScopesKey, "documents:admin") // a caller's forged stamp - Stamp(md, Identity{Owner: "maya", OwnerKind: "human", Tenant: "acme", Actors: []string{"org-directory"}, Scopes: []string{"documents:read"}}, "run-proof") - id, err := Read(md, "run-proof") - if err != nil || id.Owner != "maya" || len(id.Actors) != 1 || !id.Has("documents", "read") || id.Has("documents", "admin") { - t.Fatalf("stamp: %+v %v", id, err) +const proof = "run-proof" + +var read = &basev0.WorkScopeV1{ResourceKind: "documents", Actions: []string{"read"}} + +func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier { + t.Helper() + v, err := hop.NewVerifier(context.Background(), a, audience, proof) + if err != nil { + t.Fatal(err) } - if _, err := Read(md, "another-run"); err != ErrNoStamp { - t.Fatalf("another run's proof: %v", err) + return v +} + +func TestTheModuleReadsWhoTheCallIsForFromTheAuthenticatedToken(t *testing.T) { + a := hoptest.New(t) + token := a.Issue(t, "documents", read) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof)) + if err != nil { + t.Fatal(err) } - if _, err := Read(md, ""); err != ErrNoStamp { - t.Fatalf("a module with no proof reads every call as bypassing the hop: %v", err) + if id.Owner != "principal-owner" || id.Tenant != "tenant-conformance" || !id.Has("documents", "read") || id.Has("documents", "write") || id.Context != token || !id.Claims.Allows("documents", "read", "any") { + t.Fatalf("identity: %+v", id) } - if _, err := Read(metadata.Pairs(OwnerKey, "mallory", TenantKey, "acme"), "run-proof"); err != ErrNoStamp { - t.Fatalf("a stamp without the proof: %v", err) +} + +// The defect: every hop-fronted module holds the proof, so it can write the +// proof and the digest of any token. Nothing but the authority's signature, +// addressed to this module and current, is believed. +func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { + a := hoptest.New(t) + v := verifier(t, a, "documents") + _, otherKey, _ := ed25519.GenerateKey(rand.Reader) + genuine := a.Issue(t, "documents", read) + payload, _, _ := strings.Cut(genuine, ".") + raw, _ := base64.RawURLEncoding.DecodeString(payload) + forged := payload + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(otherKey, raw)) + unsigned := payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)) + elsewhere := a.Issue(t, "records", read) + + cases := map[string]struct { + md metadata.MD + reason string + }{ + "forged signature": {stampedAs(t, a, forged, genuine), "signature"}, + "unsigned": {stampedAs(t, a, unsigned, genuine), "signature"}, + "another audience": {hoptest.Forward(t, elsewhere, proof), "minted for audience"}, + "no stamp": {metadata.Pairs(hop.WorkContextKey, genuine), "proof"}, + "another run's hop": {hoptest.Forward(t, genuine, "another-run"), "proof"}, + "swapped token": {swapped(hoptest.Forward(t, genuine, proof), a.Issue(t, "documents", read)), "does not match the hop stamp"}, + "stamped tenant lie": {with(hoptest.Forward(t, genuine, proof), hop.TenantKey, "globex"), "tenant"}, + "stamped scope lie": {with(hoptest.Forward(t, genuine, proof), hop.ScopesKey, "documents:read,documents:admin"), "scopes"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + _, err := v.Verify(context.Background(), c.md) + if !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), c.reason) { + t.Fatalf("want a refusal naming %q, got %v", c.reason, err) + } + }) } } -func TestTheModuleRefusesACallThatBypassedTheHop(t *testing.T) { - intercept := UnaryServerInterceptor("run-proof") +// stampedAs forwards token under the stamp the hop wrote for genuine, with the +// digest rewritten, as any holder of the proof can. +func stampedAs(t *testing.T, _ *hoptest.Authority, token, genuine string) metadata.MD { + md := hoptest.Forward(t, genuine, proof) + sum := sha256.Sum256([]byte(token)) + md.Set(hop.WorkContextKey, token) + md.Set(hop.ContextKey, hex.EncodeToString(sum[:])) + return md +} + +// swapped replaces the token and keeps the digest of the one the hop checked. +func swapped(md metadata.MD, token string) metadata.MD { + md.Set(hop.WorkContextKey, token) + return md +} + +func with(md metadata.MD, key, value string) metadata.MD { + md.Set(key, value) + return md +} + +func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { + a := hoptest.New(t) + intercept := verifier(t, a, "console").UnaryServerInterceptor() handler := func(ctx context.Context, _ any) (any, error) { - id, _ := FromContext(ctx) + id, _ := hop.FromContext(ctx) return id.Owner, nil } call := func(md metadata.MD, method string) (any, error) { return intercept(metadata.NewIncomingContext(context.Background(), md), nil, &grpc.UnaryServerInfo{FullMethod: method}, handler) } - if _, err := call(metadata.MD{}, "/api.ApiService/WhoAmI"); status.Code(err) != codes.Unauthenticated { + if _, err := call(metadata.MD{}, "/api.ApiService/WhoAmI"); status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "proof") { t.Fatalf("no stamp: %v", err) } - md := metadata.MD{} - Stamp(md, Identity{Owner: "maya", Tenant: "acme"}, "run-proof") - if got, err := call(md, "/api.ApiService/WhoAmI"); err != nil || got != "maya" { - t.Fatalf("stamped: %v %v", got, err) + if got, err := call(hoptest.Forward(t, a.Issue(t, "console", read), proof), "/api.ApiService/WhoAmI"); err != nil || got != "principal-owner" { + t.Fatalf("forwarded: %v %v", got, err) } if _, err := call(metadata.MD{}, "/grpc.health.v1.Health/Check"); err != nil { t.Fatalf("the run probes health directly: %v", err) } - if _, err := call(metadata.MD{}, "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo"); status.Code(err) != codes.Unauthenticated { - t.Fatalf("nothing but health passes unstamped: %v", err) - } - if _, err := call(metadata.MD{}, "/grpc.health.v1.HealthX/Check"); status.Code(err) != codes.Unauthenticated { - t.Fatalf("only the named services pass: %v", err) + for _, method := range []string{"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", "/grpc.health.v1.HealthX/Check"} { + if _, err := call(metadata.MD{}, method); status.Code(err) != codes.Unauthenticated { + t.Fatalf("%s passed unstamped: %v", method, err) + } } } -func TestTheStampBindsTheWorkContextTheAuthorityChecked(t *testing.T) { - md := metadata.Pairs(WorkContextKey, "checked-token") - Stamp(md, Identity{Owner: "maya", Tenant: "acme"}, "run-proof") - id, err := Read(md, "run-proof") - if err != nil || id.Context != "checked-token" { - t.Fatalf("bound: %+v %v", id, err) - } - md.Set(WorkContextKey, "swapped-token") - if _, err := Read(md, "run-proof"); err != ErrNoStamp { - t.Fatalf("a token swapped after the check: %v", err) - } - md.Append(WorkContextKey, "checked-token") - if _, err := Read(md, "run-proof"); err != ErrNoStamp { - t.Fatalf("two tokens: %v", err) +func TestAVerifierNeedsPublishedKeysAnAudienceAndAProof(t *testing.T) { + a := hoptest.New(t) + for name, build := range map[string]func() (*hop.Verifier, error){ + "no keys": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), nil, "documents", proof) }, + "no audience": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), a, "", proof) }, + "no proof": func() (*hop.Verifier, error) { return hop.NewVerifier(context.Background(), a, "documents", "") }, + } { + if _, err := build(); err == nil { + t.Fatalf("%s: accepted", name) + } } } diff --git a/libraries/hop/go/hoptest/hoptest.go b/libraries/hop/go/hoptest/hoptest.go new file mode 100644 index 00000000..365565d6 --- /dev/null +++ b/libraries/hop/go/hoptest/hoptest.go @@ -0,0 +1,90 @@ +// Package hoptest is a real authority for a module's tests: it signs Work +// Contexts with a key of its own, answers Keys for them, and forwards a call +// exactly as the hop stamps it. A module's interceptor tests then exercise the +// same Verifier production runs, with no hand-built header standing in. +package hoptest + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + hop "github.com/codefly-dev/authority/libraries/hop/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// Issuer is the issuer every Work Context this authority signs names. +const Issuer = "https://authority.hoptest" + +// Authority signs with a fresh key; Rotate replaces it. +type Authority struct { + signer *core.Authority + keys *authorityv1.KeysResponse + // Now is the signing clock; nil means time.Now. + Now func() time.Time +} + +// New returns an authority with a fresh signing key. +func New(t testing.TB) *Authority { + a := &Authority{} + a.Rotate(t) + return a +} + +// Rotate signs from now on with a fresh key, and publishes only that key. +func (a *Authority) Rotate(t testing.TB) { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("hoptest-%x", public[:4]) + a.signer = &core.Authority{Issuer: Issuer, KeyID: id, Key: private, Revisions: core.FixtureRevisions(), Seals: core.FixtureSeals(), + Now: func() time.Time { + if a.Now != nil { + return a.Now() + } + return time.Now() + }} + a.keys = &authorityv1.KeysResponse{Issuer: Issuer, Keys: []*authorityv1.PublicKey{{KeyId: id, Algorithm: core.Algorithm, PublicKey: public}}} +} + +// Keys is the authority interface's Keys, as a Verifier reads it. +func (a *Authority) Keys(context.Context, *authorityv1.KeysRequest, ...grpc.CallOption) (*authorityv1.KeysResponse, error) { + return a.keys, nil +} + +// Issue signs a ten-minute Work Context for Core's fixture owner, addressed to +// audience, carrying scopes. +func (a *Authority) Issue(t testing.TB, audience string, scopes ...*basev0.WorkScopeV1) string { + t.Helper() + token, _, err := a.signer.Start(context.Background(), core.StartInput{ + TenantID: core.FixtureTenant, OwnerPrincipalID: core.FixturePrincipal, OwnerPrincipalKind: "human", + OrganizationID: core.FixtureOrganization, TaskID: core.FixtureTask, Audience: audience, + AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + return token +} + +// Forward is the incoming metadata of a call the hop let through carrying +// token: the token, and the stamp the hop writes for it under proof. +func Forward(t testing.TB, token, proof string) metadata.MD { + t.Helper() + decoded, err := core.Decode(token) + if err != nil { + t.Fatal(err) + } + md := metadata.Pairs(hop.WorkContextKey, token) + hop.Stamp(md, hop.IdentityOf(decoded.Context()), proof) + return md +} diff --git a/libraries/hop/go/outbound_test.go b/libraries/hop/go/outbound_test.go index 148ffb0a..8744fb2b 100644 --- a/libraries/hop/go/outbound_test.go +++ b/libraries/hop/go/outbound_test.go @@ -27,12 +27,8 @@ func (a *authority) Issue(_ context.Context, in *authorityv1.IssueRequest, _ ... func TestACallOutActsForTheCallerOrTheModuleThroughABinding(t *testing.T) { a := &authority{} o := Outbound{Authority: a, Credential: func() (string, error) { return "workload-credential", nil }} - md := metadata.Pairs(WorkContextKey, "caller-token") - Stamp(md, Identity{Owner: "maya", Tenant: "acme"}, "proof") - in, err := admit(metadata.NewIncomingContext(context.Background(), md), "/m.S/Do", "proof") - if err != nil { - t.Fatal(err) - } + // The identity the module's Verifier authenticated (hop_test.go proves that path). + in := context.WithValue(context.Background(), identityKey{}, Identity{Owner: "maya", Tenant: "acme", Context: "caller-token"}) out, err := o.ForCaller(in, "runtime-admission") if err != nil { t.Fatal(err) diff --git a/libraries/hop/python/README.md b/libraries/hop/python/README.md index 4d2caa60..ef88bef5 100644 --- a/libraries/hop/python/README.md +++ b/libraries/hop/python/README.md @@ -1,20 +1,30 @@ # codefly-hop (Python) -The authority hop's stamp, read by a module served in Python. The Go library -(`../go`) is the same contract; both read the fixture the hop writes -(`../testdata/stamped.json`, regenerated by the Go test with `-update-fixture`). +The authority hop's stamp, authenticated by a module served in Python. The Go +library (`../go`, `hop.Verifier`) is the same contract; both read the fixture the +Go hop writes (`../testdata/stamped.json`, regenerated by the Go test with +`-update-fixture`). ```python -from codefly_hop import NoStamp, read +from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub +from codefly_hop import Unauthenticated, Verifier -identity = read(request.headers, proof) # proof: the run's hop/PROOF -identity.tenant, identity.owner, identity.has("modelservice.profiles", "read") +verifier = Verifier(AuthorityServiceStub(channel), audience="documents", proof=proof) # once, at startup +identity = verifier.verify(request.headers) # raises Unauthenticated, naming why +identity.tenant, identity.owner, identity.has("documents.collection", "read") +identity.claims.allows("documents.collection", "write", collection_id) ``` -`identity.advisory` preserves the hop's reason for admitting a call in warning -mode; it is empty when no reason was stamped. It is diagnostic information: -reading it grants no scopes and leaves proof and Work Context binding checks -unchanged. Consumers decide how to report it alongside their own domain checks. +`channel` reaches the authority the module already depends on (the interface +`codefly.dev/authority`): the local authority on a laptop, the host on a cell. +The verifier reads the published keys through its `Keys` and re-reads them when a +token names a key it does not hold (at most every 10 s) and when they are older +than 5 minutes; keys that cannot be re-read are trusted for 15 minutes at most. + +`verify` authenticates the token (`authority_client.work_context.authenticate`: +proof, digest, issuer, Ed25519 signature, audience, window), reads who the call +is for from the authenticated claims, and refuses a stamp that says otherwise. +`identity.advisory` is the hop's warning-mode reason; it grants nothing. A module depends on it by its git coordinates at a pinned commit: `codefly-hop @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/hop/python`. diff --git a/libraries/hop/python/pyproject.toml b/libraries/hop/python/pyproject.toml index 3fdbec43..7912a93c 100644 --- a/libraries/hop/python/pyproject.toml +++ b/libraries/hop/python/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "codefly-hop" -version = "0.1.0" -description = "The authority hop's stamp, read by a module served in Python: who a call is for, as the hop established it." +version = "0.2.0" +description = "The authority hop's stamp, authenticated by a module served in Python: who a call is for, read from the verified Work Context." requires-python = ">=3.11" -dependencies = [] +dependencies = ["authority-client @ git+https://github.com/codefly-dev/authority@ead918ed860c5b56160f61b85dbd4cc44701feb4#subdirectory=libraries/authority-client/python"] [project.optional-dependencies] test = ["pytest>=8"] @@ -12,5 +12,8 @@ test = ["pytest>=8"] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["src/codefly_hop"] diff --git a/libraries/hop/python/src/codefly_hop/__init__.py b/libraries/hop/python/src/codefly_hop/__init__.py index f66c9960..1b8eb972 100644 --- a/libraries/hop/python/src/codefly_hop/__init__.py +++ b/libraries/hop/python/src/codefly_hop/__init__.py @@ -1,20 +1,30 @@ -"""The authority hop's stamp, as a module served in Python reads it. +"""The authority hop's stamp, as a module served in Python authenticates it. The hop in front of a module asked the authority's Check, which verified the -caller's Work Context for the module and the method and applied what the method -declares, then wrote who the call is for onto the request: the stamp. A module -reads the stamp and verifies nothing itself. This is the Go library's ``Read`` -(libraries/hop/go), rule for rule: the run's proof must match, the owner and the -tenant must be named, and a stamp bound to a Work Context is held to that exact -token. Both read the one fixture the hop writes (libraries/hop/testdata). +caller's Work Context live and applied what the method declares, then wrote who +the call is for, this run's proof and the digest of the checked token: the +stamp. The module re-checks no permission the hop decided, but believes nothing +it has not authenticated. ``Verifier`` is the Go library's ``hop.Verifier`` +(libraries/hop/go), rule for rule: the token is authenticated against the +authority's published keys (``authority_client.work_context.authenticate``), who +the call is for is read from its claims, and a stamp that says otherwise is +refused. Both read the one fixture the Go hop writes (libraries/hop/testdata). """ from __future__ import annotations -import hashlib -import hmac -from collections.abc import Iterable, Mapping +import base64 +import threading +import time +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field +from datetime import datetime, timezone + +from authority_client._gen.host.authority.v1.authority_pb2 import KeysRequest +from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 +from authority_client.work_context import Claims, Trust, Unauthenticated, authenticate, collect + +__all__ = ["Identity", "Verifier", "Unauthenticated", "identity_of"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -25,103 +35,142 @@ ACTORS_KEY = STAMP_PREFIX + "actors" SCOPES_KEY = STAMP_PREFIX + "scopes" BINDING_KEY = STAMP_PREFIX + "binding" -# The checked context's task, and the instant it expires (unix seconds): what a -# module keeps state under, and what it must not hold a session past. TASK_KEY = STAMP_PREFIX + "task" EXPIRES_KEY = STAMP_PREFIX + "expires" CONTEXT_KEY = STAMP_PREFIX + "context" ADVISORY_KEY = STAMP_PREFIX + "advisory" - -class NoStamp(Exception): - """The call did not come through the hop: it carries no stamp with this run's proof.""" - - def __init__(self) -> None: - super().__init__( - "the call did not come through the hop: it carries no stamp with this run's proof" - ) +# The Go verifier's key policy: re-read on an unknown key at most every 10 s, +# and when older than 5 min; unreadable keys are trusted no longer than 15 min. +KEY_RETRY, KEY_MAX_AGE, KEY_STALE = 10.0, 300.0, 900.0 @dataclass(frozen=True) class Identity: - """Who a call is for, as the hop established it.""" + """Who a call is for, read from the authenticated Work Context.""" owner: str owner_kind: str tenant: str actors: tuple[str, ...] = () - # What the call may do, each ":". + # Kind-wide effective scopes, each ":"; claims.allows + # answers resource-exact questions. scopes: tuple[str, ...] = () - # The operation binding the last actor exercises, when it names one. binding: str = "" - # The checked context's task, and when it expires (unix seconds; 0 when unstamped). task: str = "" expires: int = 0 - # The Work Context the authority checked, as forwarded. + # The authenticated Work Context, as forwarded. context: str = field(default="", repr=False) - # The refusal the hop allowed in warning mode. + # The refusal the hop allowed in warning mode; diagnostic, grants nothing. advisory: str = "" + claims: Claims | None = field(default=None, compare=False, repr=False) def has(self, kind: str, action: str) -> bool: - """Whether the call may do action on kind.""" + """Whether the call may do action on the whole of kind.""" return f"{kind}:{action}" in self.scopes -def read(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> Identity: - """The identity the hop stamped on headers, or NoStamp when they do not carry this - run's proof. An empty proof is never accepted: a module with no proof delivered - reads every call as one that bypassed the hop.""" - values = _collect(headers) - got = _first(values, PROOF_KEY) - if not proof or not got or not hmac.compare_digest(got.encode(), proof.encode()): - raise NoStamp() - owner, tenant = _first(values, OWNER_KEY), _first(values, TENANT_KEY) - if not owner or not tenant: - raise NoStamp() - expires = 0 - if raw := _first(values, EXPIRES_KEY): - if not raw.isdigit() or int(raw) <= 0: - raise NoStamp() - expires = int(raw) - context = "" - bound = _first(values, CONTEXT_KEY) - if bound: - tokens = values.get(WORK_CONTEXT_KEY, []) - if len(tokens) != 1 or not hmac.compare_digest(_digest(tokens[0]).encode(), bound.encode()): - raise NoStamp() - context = tokens[0] +def identity_of(context) -> Identity: + """The Go library's IdentityOf: owner authority narrowed by every actor's + grant; resource-limited scopes are left out rather than widened.""" + scopes = _flatten(context.authority_scopes) + actors = [] + for actor in context.actor_chain: + actors.append(actor.principal_id) + if actor.granted_scopes: + held = _flatten(actor.granted_scopes) + scopes = [s for s in scopes if s in held] return Identity( - owner=owner, - owner_kind=_first(values, OWNER_KIND_KEY), - tenant=tenant, - actors=_split(_first(values, ACTORS_KEY)), - scopes=_split(_first(values, SCOPES_KEY)), - binding=_first(values, BINDING_KEY), - task=_first(values, TASK_KEY), - expires=expires, - context=context, - advisory=_first(values, ADVISORY_KEY), + owner=context.owner_principal_id, + owner_kind=context.owner_principal_kind, + tenant=context.tenant_id, + actors=tuple(actors), + scopes=tuple(scopes), + binding=context.operation_binding.binding_id, + task=context.task_id, + expires=int(context.expires_at_unix), ) -def _collect(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> dict[str, list[str]]: - # A header name is case-insensitive; a Starlette Headers or a WSGI environ - # converted to pairs keeps repeats, which a single-value mapping cannot. - pairs = headers.items() if isinstance(headers, Mapping) else headers - values: dict[str, list[str]] = {} - for key, value in pairs: - values.setdefault(key.lower(), []).append(value) - return values - - -def _first(values: dict[str, list[str]], key: str) -> str: - found = values.get(key) - return found[0] if found else "" - - -def _split(value: str) -> tuple[str, ...]: - return tuple(value.split(",")) if value else () - - -def _digest(token: str) -> str: - return hashlib.sha256(token.encode()).hexdigest() +class Verifier: + """The one module-side entry point: authenticates every call the hop forwards. + + ``keys`` is the authority interface's generated ``AuthorityServiceStub`` (or + anything with its ``Keys``): the published keys are read through it, never + typed in. Build it once at startup. + """ + + def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] | None = None) -> None: + if keys is None or not audience or not proof: + raise Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof") + self._source, self._audience, self._proof = keys, audience, proof + self._now = now or time.time + self._lock = threading.Lock() + self._trust: Trust | None = None + self._fetched = self._tried = 0.0 + with self._lock: + self._fetch() + + def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + """Who the call is for, or ``Unauthenticated`` naming why it is refused.""" + pairs = list(headers.items() if isinstance(headers, Mapping) else headers) + values = collect(pairs) + token = (values.get(WORK_CONTEXT_KEY) or [""])[0] + claims = authenticate(pairs, self._proof, self._audience, self._trust_for(token)) + identity = identity_of(claims.context) + stamped = { + OWNER_KEY: identity.owner, OWNER_KIND_KEY: identity.owner_kind, TENANT_KEY: identity.tenant, + ACTORS_KEY: ",".join(identity.actors), SCOPES_KEY: ",".join(identity.scopes), + BINDING_KEY: identity.binding, TASK_KEY: identity.task, + EXPIRES_KEY: str(identity.expires) if identity.expires > 0 else "", + } + for key, want in stamped.items(): + got = values.get(key, []) + if len(got) > 1 or (got[0] if got else "") != want: + raise Unauthenticated(f"the stamp's {key.removeprefix(STAMP_PREFIX)} does not match the authenticated Work Context") + advisory = (values.get(ADVISORY_KEY) or [""])[0] + return Identity(**{**identity.__dict__, "context": token, "advisory": advisory, "claims": claims}) + + def _trust_for(self, token: str) -> Trust: + with self._lock: + now = self._now() + held = self._trust is not None and _key_id(token) in self._trust.keys + if (not held or now - self._fetched > KEY_MAX_AGE) and now - self._tried >= KEY_RETRY: + try: + self._fetch() + except Unauthenticated: + if now - self._fetched > KEY_STALE: + raise + if now - self._fetched > KEY_STALE: + raise Unauthenticated(f"the authority's keys were last read {int(now - self._fetched)}s ago") + return self._trust + + def _fetch(self) -> None: + self._tried = self._now() + try: + response = self._source.Keys(KeysRequest()) + except Exception as error: # a gRPC error, or the stub's transport + raise Unauthenticated(f"the authority's published keys cannot be read: {error}") from error + self._trust = Trust.from_keys(response, now=lambda: datetime.fromtimestamp(self._now(), timezone.utc)) + self._fetched = self._tried + + +def _key_id(token: str) -> str: + """The key a token names, read only to choose the keys it is judged by.""" + try: + context = WorkContextV1() + context.ParseFromString(base64.urlsafe_b64decode(token.split(".")[0] + "==")) + return context.key_id + except Exception: + return "" + + +def _flatten(scopes) -> list[str]: + out: list[str] = [] + for scope in scopes: + if scope.resource_ids: + continue + for action in scope.actions: + if f"{scope.resource_kind}:{action}" not in out: + out.append(f"{scope.resource_kind}:{action}") + return out diff --git a/libraries/hop/python/tests/test_read.py b/libraries/hop/python/tests/test_read.py index 54ca3545..d9e02c4f 100644 --- a/libraries/hop/python/tests/test_read.py +++ b/libraries/hop/python/tests/test_read.py @@ -1,76 +1,110 @@ +import base64 import json from pathlib import Path import pytest +from google.protobuf import json_format +from authority_client._gen.host.authority.v1.authority_pb2 import KeysResponse +from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 from codefly_hop import ( - ADVISORY_KEY, - CONTEXT_KEY, - EXPIRES_KEY, - PROOF_KEY, - TENANT_KEY, - WORK_CONTEXT_KEY, - NoStamp, - read, + ACTORS_KEY, CONTEXT_KEY, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, Verifier, identity_of, ) -FIXTURE = json.loads((Path(__file__).parents[2] / "testdata" / "stamped.json").read_text()) +FIXTURE = json.loads((Path(__file__).parents[2] / "testdata/stamped.json").read_text()) -def stamped(**changes): - headers = dict(FIXTURE["headers"]) - for key, value in changes.items(): - if value is None: - headers.pop(key, None) - else: - headers[key] = value - return headers +class Keys: + """The authority interface's Keys, as the generated stub answers it.""" + def __init__(self, response=None, error=None): + self.response, self.error, self.reads = response, error, 0 + + def Keys(self, _request): + self.reads += 1 + if self.error: + raise self.error + return self.response + + +def published(): + return json_format.ParseDict(FIXTURE["keys"], KeysResponse()) -def test_the_stamp_the_hop_writes_reads_back_as_its_identity(): - identity = read(FIXTURE["headers"], FIXTURE["proof"]) - assert (identity.owner, identity.owner_kind, identity.tenant) == ("remi", "human", "acme") - assert identity.actors == ("robin",) - assert identity.binding == "acme/robin/model" - assert (identity.task, identity.expires) == ("task-7", 1792818000) - assert identity.context == FIXTURE["headers"][WORK_CONTEXT_KEY] - assert identity.advisory == "documents.collection:read is not held" - assert identity.has("modelservice.profiles", "invoke") - assert not identity.has("modelservice.profiles", "admin") - - -def test_header_names_are_case_insensitive_and_repeats_are_kept(): - pairs = [(key.title(), value) for key, value in FIXTURE["headers"].items()] - assert read(pairs, FIXTURE["proof"]).tenant == "acme" - twice = pairs + [(WORK_CONTEXT_KEY, "another-token")] - with pytest.raises(NoStamp): - read(twice, FIXTURE["proof"]) - - -@pytest.mark.parametrize( - "headers,proof", - [ - (stamped(), ""), # no proof delivered: every call bypassed the hop - (stamped(), "another-run"), # another run's hop - (stamped(**{PROOF_KEY: None}), "fixture-proof"), # no stamp at all - (stamped(**{TENANT_KEY: None}), "fixture-proof"), # a stamp naming no tenant - (stamped(**{WORK_CONTEXT_KEY: "swapped-after-the-check"}), "fixture-proof"), - (stamped(**{CONTEXT_KEY: "0" * 64}), "fixture-proof"), - (stamped(**{EXPIRES_KEY: "soon"}), "fixture-proof"), - ], -) -def test_a_call_without_this_runs_stamp_acts_for_no_one(headers, proof): - with pytest.raises(NoStamp): - read(headers, proof) +def verifier(clock=lambda: FIXTURE["now"], source=None): + return Verifier(source or Keys(published()), audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=clock) -def test_advisory_is_preserved_without_changing_scope_or_stamp_checks(): - reason = "documents.collection:read is not held" - headers = stamped(**{ADVISORY_KEY: reason}) - identity = read(headers, FIXTURE["proof"]) - assert identity.advisory == reason - assert not identity.has("documents.collection", "read") + +def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): + identity = verifier().verify(FIXTURE["headers"]) + want = FIXTURE["identity"] + assert (identity.owner, identity.owner_kind, identity.tenant, identity.task, identity.expires) == ( + want["owner"], want["owner_kind"], want["tenant"], want["task"], want["expires"]) + assert identity.scopes == tuple(want["scopes"]) and identity.actors == tuple(want["actors"] or ()) + assert identity.advisory == want["advisory"] assert identity.context == FIXTURE["headers"][WORK_CONTEXT_KEY] - assert read(stamped(**{ADVISORY_KEY: None}), FIXTURE["proof"]).advisory == "" - with pytest.raises(NoStamp): - read(headers, "wrong-proof") + assert identity.has("documents.collection", "read") and not identity.has("documents.collection", "write") + assert identity.claims.allows("documents.collection", "write", "c-1") # resource-exact, from the claims + + +# The defect: a module holding the proof writes the proof and any token's digest. +@pytest.mark.parametrize("change, reason", [ + ({TENANT_KEY: "globex"}, "tenant"), + ({SCOPES_KEY: "documents.collection:read,documents.collection:admin"}, "scopes"), + ({ACTORS_KEY: "robin"}, "actors"), + ({"x-codefly-hop-proof": "another-run"}, "proof"), + ({CONTEXT_KEY: "0" * 64}, "stamp binds"), +]) +def test_a_stamp_the_token_does_not_bear_out_is_refused(change, reason): + with pytest.raises(Unauthenticated, match=reason): + verifier().verify({**FIXTURE["headers"], **change}) + + +def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_digest(): + import hashlib + token = FIXTURE["headers"][WORK_CONTEXT_KEY] + forged = token.split(".")[0] + "." + base64.urlsafe_b64encode(bytes(64)).rstrip(b"=").decode() + headers = {**FIXTURE["headers"], WORK_CONTEXT_KEY: forged, CONTEXT_KEY: hashlib.sha256(forged.encode()).hexdigest()} + with pytest.raises(Unauthenticated, match="signature"): + verifier().verify(headers) + + +def test_another_audience_and_an_expired_context_are_refused(): + with pytest.raises(Unauthenticated, match="minted for audience"): + Verifier(Keys(published()), audience="records", proof=FIXTURE["proof"], now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"]) + with pytest.raises(Unauthenticated, match="expired"): + verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"]) + + +def test_keys_are_reread_for_an_unknown_key_and_trusted_no_longer_than_stale(): + clock = [FIXTURE["now"]] + source = Keys(published()) + v = verifier(clock=lambda: clock[0], source=source) + rotated = KeysResponse(issuer=published().issuer) + rotated.keys.add(key_id="rotated", algorithm="Ed25519", public_key=bytes(32)) + source.response = rotated + clock[0] += 400 # older than KEY_MAX_AGE: re-read, and the fixture key is gone + with pytest.raises(Unauthenticated, match="no verification key"): + v.verify(FIXTURE["headers"]) + assert source.reads == 2 + source.response, source.error = published(), RuntimeError("authority down") + clock[0] += 1000 + with pytest.raises(Unauthenticated, match="cannot be read"): + v.verify(FIXTURE["headers"]) + + +def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): + context = WorkContextV1(owner_principal_id="maya", tenant_id="acme") + context.authority_scopes.add(resource_kind="documents", actions=["read", "write"]) + context.authority_scopes.add(resource_kind="records", actions=["read"], resource_ids=["r-1"]) + robin = context.actor_chain.add(principal_id="robin") + robin.granted_scopes.add(resource_kind="documents", actions=["read"]) + context.actor_chain.add(principal_id="model") + identity = identity_of(context) + assert identity.actors == ("robin", "model") and identity.scopes == ("documents:read",) + + +def test_a_verifier_needs_keys_an_audience_and_a_proof(): + for args in [(None, "documents", "p"), (Keys(published()), "", "p"), (Keys(published()), "documents", "")]: + with pytest.raises(Unauthenticated): + Verifier(args[0], audience=args[1], proof=args[2]) diff --git a/libraries/hop/testdata/stamped.json b/libraries/hop/testdata/stamped.json index 3a410fea..55219c1d 100644 --- a/libraries/hop/testdata/stamped.json +++ b/libraries/hop/testdata/stamped.json @@ -1,17 +1,41 @@ { + "proof": "fixture-proof", + "audience": "documents", + "now": 1792818060, + "keys": { + "issuer": "https://authority.hop.test", + "keys": [ + { + "keyId": "hop-fixture", + "algorithm": "Ed25519", + "publicKey": "CViKnk6aO0k+3rS1SPdYggLCjBPmMG6s4UU/Y95Smqc=" + } + ] + }, "headers": { - "x-codefly-hop-actors": "robin", - "x-codefly-hop-advisory": "documents.collection:read is not held", - "x-codefly-hop-binding": "acme/robin/model", - "x-codefly-hop-context": "cde194a7f3368961e48f1937c63fc67e058b2717d9c0581ac0eb08874ca8b714", - "x-codefly-hop-expires": "1792818000", - "x-codefly-hop-owner": "remi", + "x-codefly-hop-actors": "", + "x-codefly-hop-advisory": "documents.collection:write is not held", + "x-codefly-hop-context": "cfaa9f0a79b969b344d63c29cded67e41791cee66339a9e59881708433b90c1b", + "x-codefly-hop-expires": "1792818600", + "x-codefly-hop-owner": "principal-owner", "x-codefly-hop-owner-kind": "human", "x-codefly-hop-proof": "fixture-proof", - "x-codefly-hop-scopes": "modelservice.profiles:read,modelservice.profiles:invoke", - "x-codefly-hop-task": "task-7", - "x-codefly-hop-tenant": "acme", - "x-codefly-work-context": "eyJ3YyI6ImZpeHR1cmUifQ.c2lnbmF0dXJl" + "x-codefly-hop-scopes": "documents.collection:read", + "x-codefly-hop-task": "task-conformance", + "x-codefly-hop-tenant": "tenant-conformance", + "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGJmZDRhNmM3LTcyNDAtNGUxNy1hZThhLTA4YjMxMWIyZDNhMVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYzQ1N2YxM2UtMzM3Ny00ODYyLThiMjEtODliZmU3YmQ5ZWJmigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.lRL9AGY2xuV56DtmS9vOXkaCPgtaVHlrnkzmdg9GBOvRjuQt8j8adscKdUGrsmX3vxCPmhCHi0vWp0t6noilAw" }, - "proof": "fixture-proof" + "identity": { + "actors": null, + "advisory": "documents.collection:write is not held", + "binding": "", + "expires": 1792818600, + "owner": "principal-owner", + "owner_kind": "human", + "scopes": [ + "documents.collection:read" + ], + "task": "task-conformance", + "tenant": "tenant-conformance" + } } diff --git a/modules/authority/services/hop/code/pkg/business/decide/decide.go b/modules/authority/services/hop/code/pkg/business/decide/decide.go index fba752c1..f86f0259 100644 --- a/modules/authority/services/hop/code/pkg/business/decide/decide.go +++ b/modules/authority/services/hop/code/pkg/business/decide/decide.go @@ -7,16 +7,17 @@ package decide import ( "context" "github.com/codefly-dev/authority/libraries/hop/go/routing" - "slices" "strings" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/hop/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) // Checker is the authority's Check, as the interface's generated client serves it. @@ -118,41 +119,17 @@ func (d Decider) decide(ctx context.Context, method string, md metadata.MD) (Dec return Decision{Module: module, Identity: id, Route: route}, nil } -// IdentityOf reads who a checked Work Context is for. Its scopes are the -// effective ones: the owner's authority narrowed by every actor's grant. A scope -// limited to named resources is left out rather than widened to its whole kind. +// IdentityOf reads who the authority's checked context is for, with the one +// derivation the module's Verifier also applies to the token it authenticates +// (hop.IdentityOf); the two must agree or the module refuses the call. func IdentityOf(wc *authorityv1.WorkContextV1) hop.Identity { - id := hop.Identity{ - Owner: wc.GetOwnerPrincipalId(), - OwnerKind: wc.GetOwnerPrincipalKind(), - Tenant: wc.GetTenantId(), - Binding: wc.GetOperationBinding().GetBindingId(), - Task: wc.GetTaskId(), - Expires: wc.GetExpiresAtUnix(), - } - scopes := flatten(wc.GetAuthorityScopes()) - for _, actor := range wc.GetActorChain() { - id.Actors = append(id.Actors, actor.GetPrincipalId()) - if granted := actor.GetGrantedScopes(); len(granted) > 0 { - held := flatten(granted) - scopes = slices.DeleteFunc(scopes, func(s string) bool { return !slices.Contains(held, s) }) - } + wire, err := proto.Marshal(wc) + if err != nil { + return hop.Identity{} } - id.Scopes = scopes - return id -} - -func flatten(scopes []*authorityv1.WorkScopeV1) []string { - var out []string - for _, s := range scopes { - if len(s.GetResourceIds()) > 0 { - continue - } - for _, action := range s.GetActions() { - if scope := s.GetResourceKind() + ":" + action; !slices.Contains(out, scope) { - out = append(out, scope) - } - } + claims := &basev0.WorkContextV1{} + if err := proto.Unmarshal(wire, claims); err != nil { + return hop.Identity{} } - return out + return hop.IdentityOf(claims) } diff --git a/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go b/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go index 02d54e31..742fcdec 100644 --- a/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go +++ b/modules/authority/services/hop/code/pkg/business/decide/permissive_test.go @@ -106,8 +106,8 @@ func TestPermissivePolicyUsesTheAuthorityAdvisoryIdentity(t *testing.T) { t.Fatalf("lost advisory or effective scopes: %+v", id) } hop.Stamp(stamp, id, "proof") - if read, err := hop.Read(stamp, "proof"); err != nil || read.Advisory != id.Advisory { - t.Fatalf("the advisory rides the stamp: %+v %v", read, err) + if got := stamp.Get(hop.AdvisoryKey); len(got) != 1 || got[0] != id.Advisory { + t.Fatalf("the advisory rides the stamp: %v", got) } strict := Decider{Routes: testRoutes("documents", "POST /documents/write"), Authority: advisoryChecker{}, Modules: map[string]bool{"documents": true}, AudienceOf: func(string) (string, error) { return "documents", nil }} diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go index 801020f9..603480f1 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/generated_routing_test.go @@ -176,7 +176,9 @@ func TestGeneratedGRPCRoutesKeepSiblingAndAudienceSeparate(t *testing.T) { func routedBackend(t *testing.T, register func(*grpc.Server)) *grpc.ClientConn { t.Helper() listener := bufconn.Listen(1 << 20) - server := grpc.NewServer(grpc.UnaryInterceptor(hop.UnaryServerInterceptor(proof))) + // Routing only: which backend is reached. Module-side authentication is + // proxy_test.go's, over signed contexts. + server := grpc.NewServer() register(server) go func() { _ = server.Serve(listener) }() t.Cleanup(server.Stop) diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go index 8c52424c..0345af00 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/http_test.go @@ -56,9 +56,16 @@ func TestAnHTTPRequestIsDecidedThenStampedAndForwarded(t *testing.T) { for key, values := range seen.Header { md[strings.ToLower(key)] = values } - id, err := hop.Read(md, "run-proof") - if err != nil || id.Tenant != "acme" || id.Owner != "remi" || id.Context != "wc-model" { - t.Fatalf("the module read %+v, %v", id, err) + // The stamp as the hop wrote it (the caller's tenant replaced); the module's + // Verifier authenticates it against the token, proven in libraries/hop/go. + if first := func(k string) string { + v := md.Get(k) + if len(v) != 1 { + return "" + } + return v[0] + }; first(hop.TenantKey) != "acme" || first(hop.OwnerKey) != "remi" || first(hop.WorkContextKey) != "wc-model" || first(hop.ProofKey) != "run-proof" || first(hop.ContextKey) == "" { + t.Fatalf("the hop stamped %v", md) } } diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go index 66574d43..37366356 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go @@ -8,13 +8,17 @@ import ( authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "github.com/codefly-dev/authority/libraries/hop/go/routing" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" "hop/pkg/business/decide" gen "hop/pkg/gen" @@ -23,34 +27,40 @@ import ( const proof = "run-proof" -var human = "human" - -// authority answers Check the way the interface does: maya's token is -// addressed to the module, anything else is refused; a method in refuse is -// refused as the module's delivered policy would have it. +// authority answers Check the way the interface does, over genuinely signed +// Work Contexts: a token addressed to the audience asked about is answered +// with its claims; a revoked one, or one addressed elsewhere, is refused; a +// method in refuse is refused as the module's delivered policy would have it. type authority struct { + *hoptest.Authority asked []string methods []string refuse map[string]string + revoked map[string]bool } func (a *authority) Check(_ context.Context, in *authorityv1.CheckRequest, _ ...grpc.CallOption) (*authorityv1.CheckResponse, error) { a.asked = append(a.asked, in.GetAudience()) a.methods = append(a.methods, in.GetMethod()) - if in.GetToken() != "maya-at-module" { + decoded, err := core.Decode(in.GetToken()) + if err != nil || a.revoked[in.GetToken()] || decoded.Context().GetAudience() != in.GetAudience() { return nil, status.Error(codes.PermissionDenied, "work context: revoked: maya no longer holds the role") } if reason, refused := a.refuse[in.GetMethod()]; refused { return nil, status.Error(codes.PermissionDenied, reason) } - return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{ - OwnerPrincipalId: "maya", OwnerPrincipalKind: &human, TenantId: "acme", - AuthorityScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "console.identity", Actions: []string{"read"}}}, - }}, nil + wire, _ := proto.Marshal(decoded.Context()) + wc := &authorityv1.WorkContextV1{} + if err := proto.Unmarshal(wire, wc); err != nil { + return nil, err + } + return &authorityv1.CheckResponse{Context: wc}, nil } -// module answers Version with who the hop said the call is for, read through -// the same interceptor a module runs. +var identityRead = &basev0.WorkScopeV1{ResourceKind: "console.identity", Actions: []string{"read"}} + +// module answers Version with who the call is for, authenticated by the same +// Verifier a module runs. type module struct { gen.UnimplementedHopServiceServer reached int @@ -85,19 +95,18 @@ func dial(t *testing.T, listener *bufconn.Listener, opts ...grpc.DialOption) *gr } func setup(t *testing.T) (through gen.HopServiceClient, direct gen.HopServiceClient, m *module, a *authority, refused *[]string) { - m, a, refused = &module{}, &authority{}, &[]string{} + m, a, refused = &module{}, &authority{Authority: hoptest.New(t), revoked: map[string]bool{}}, &[]string{} + verifier, err := hop.NewVerifier(context.Background(), a, "console", proof) + if err != nil { + t.Fatal(err) + } moduleListener := bufconn.Listen(1 << 20) - moduleServer := grpc.NewServer(grpc.UnaryInterceptor(hop.UnaryServerInterceptor(proof))) + moduleServer := grpc.NewServer(grpc.UnaryInterceptor(verifier.UnaryServerInterceptor())) gen.RegisterHopServiceServer(moduleServer, m) go func() { _ = moduleServer.Serve(moduleListener) }() t.Cleanup(moduleServer.Stop) p := &proxy.Proxy{ - Decide: decide.Decider{Routes: &routing.Routing{Endpoints: []routing.RouteEndpoint{{Audience: "console", Transport: "grpc", Address: "console", Methods: []string{"/api.HopService/Version"}}}}, Authority: a, Modules: map[string]bool{"console": true}, AudienceOf: func(token string) (string, error) { - if token == "for-records" { - return "records", nil - } - return "console", nil - }}.Decide, + Decide: decide.Decider{Routes: &routing.Routing{Endpoints: []routing.RouteEndpoint{{Audience: "console", Transport: "grpc", Address: "console", Methods: []string{"/api.HopService/Version"}}}}, Authority: a, Modules: map[string]bool{"console": true}}.Decide, Upstreams: map[string]*grpc.ClientConn{"console": dial(t, moduleListener, proxy.DialOptions()...)}, Proof: proof, Refused: func(method string, err error) { *refused = append(*refused, method+": "+err.Error()) }, @@ -108,13 +117,14 @@ func setup(t *testing.T) (through gen.HopServiceClient, direct gen.HopServiceCli func TestTheHopLetsThroughOnlyACallTheAuthorityChecked(t *testing.T) { through, _, m, a, refused := setup(t) - ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, "maya-at-module", + token := a.Issue(t, "console", identityRead) + ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, token, hop.OwnerKey, "mallory", hop.ScopesKey, "console.identity:admin") // a forged stamp, stripped got, err := through.Version(ctx, &gen.VersionRequest{}) if err != nil { t.Fatal(err) } - if want := "maya@acme console.identity:read token=maya-at-module"; got.GetVersion() != want { + if want := "principal-owner@tenant-conformance console.identity:read token=" + token; got.GetVersion() != want { t.Fatalf("the module read %q, want %q", got.GetVersion(), want) } if len(a.asked) != 1 || a.asked[0] != "console" || m.reached != 1 || len(*refused) != 0 { @@ -123,12 +133,14 @@ func TestTheHopLetsThroughOnlyACallTheAuthorityChecked(t *testing.T) { } func TestTheHopRefusesByNameAndTheModuleIsNeverReached(t *testing.T) { - through, _, m, _, refused := setup(t) + through, _, m, a, refused := setup(t) _, err := through.Version(context.Background(), &gen.VersionRequest{}) if status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "carries no Work Context") { t.Fatalf("no token: %v", err) } - ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, "revoked") + revoked := a.Issue(t, "console", identityRead) + a.revoked[revoked] = true + ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, revoked) _, err = through.Version(ctx, &gen.VersionRequest{}) if status.Code(err) != codes.PermissionDenied || !strings.Contains(err.Error(), "the authority refused the call to console: work context: revoked") { t.Fatalf("refused token: %v", err) @@ -139,13 +151,21 @@ func TestTheHopRefusesByNameAndTheModuleIsNeverReached(t *testing.T) { } func TestACallAroundTheHopIsRefusedByTheModule(t *testing.T) { - _, direct, m, _, _ := setup(t) - ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, "maya-at-module", + _, direct, m, a, _ := setup(t) + genuine := a.Issue(t, "console", identityRead) + ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, genuine, hop.OwnerKey, "maya", hop.TenantKey, "acme", hop.ProofKey, "a-guess") _, err := direct.Version(ctx, &gen.VersionRequest{}) - if status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "did not come through the hop") || m.reached != 0 { + if status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "proof") || m.reached != 0 { t.Fatalf("around the hop: %v reached %d", err, m.reached) } + // A peer that holds the proof stamps a context it forged: the module + // authenticates the token itself, so the stamp alone is never enough. + forged := hoptest.New(t).Issue(t, "console", identityRead) + _, err = direct.Version(metadata.NewOutgoingContext(context.Background(), hoptest.Forward(t, forged, proof)), &gen.VersionRequest{}) + if status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "no verification key") || m.reached != 0 { + t.Fatalf("a forged context with the proof: %v reached %d", err, m.reached) + } } func TestEffectiveScopesAreTheOwnersNarrowedByEveryActor(t *testing.T) { @@ -167,7 +187,7 @@ func TestEffectiveScopesAreTheOwnersNarrowedByEveryActor(t *testing.T) { func TestTheHopHandsTheMethodToTheAuthorityAndPassesItsRefusalOn(t *testing.T) { through, _, m, a, refused := setup(t) - ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, "maya-at-module") + ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, a.Issue(t, "console", identityRead)) if _, err := through.Version(ctx, &gen.VersionRequest{}); err != nil || m.reached != 1 { t.Fatalf("allowed: %v reached %d", err, m.reached) } @@ -183,7 +203,7 @@ func TestTheHopHandsTheMethodToTheAuthorityAndPassesItsRefusalOn(t *testing.T) { func TestAContextForAModuleTheHopDoesNotFrontIsRefusedBeforeTheAuthority(t *testing.T) { through, _, m, a, _ := setup(t) - ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, "for-records") + ctx := metadata.AppendToOutgoingContext(context.Background(), hop.WorkContextKey, a.Issue(t, "records", identityRead)) _, err := through.Version(ctx, &gen.VersionRequest{}) if status.Code(err) != codes.PermissionDenied || !strings.Contains(err.Error(), `addressed to "records", a module this hop does not stand in front of`) { t.Fatalf("another module: %v", err) diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 4cfb7408..1c474eda 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -30,6 +30,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index 18e5680f..325f1b65 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,6 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/console/services/api/code/go.mod b/modules/console/services/api/code/go.mod index 7126f0e5..3fcf3a30 100644 --- a/modules/console/services/api/code/go.mod +++ b/modules/console/services/api/code/go.mod @@ -6,6 +6,7 @@ require ( buf.build/go/protovalidate v1.4.0 connectrpc.com/connect v1.21.0 connectrpc.com/vanguard v0.4.0 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 github.com/codefly-dev/sdk-go v0.4.0 @@ -24,7 +25,6 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index d20ac3db..e71b1528 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/host" "github.com/codefly-dev/authority/libraries/hop/go" codefly "github.com/codefly-dev/sdk-go" "google.golang.org/grpc" @@ -18,19 +20,30 @@ func init() { WithConfigure(answerFromTheStamp) } -// answerFromTheStamp is all the authority code the console carries: it reads -// who a call is for off the stamp the hop wrote, and refuses a call that did -// not come through the hop. It verifies no token and asks no authority. +// answerFromTheStamp is all the authority code the console carries: the hop's +// Verifier authenticates each forwarded Work Context against the authority's +// published keys and reads who the call is for from it, and refuses, by name, +// a call that did not come through the hop or carries a context the authority +// did not issue for the console. It re-checks no permission the hop decided. func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Clean, error) { proof, err := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") if err != nil || proof == "" { return nil, fmt.Errorf("console: no run proof delivered (codefly config generate hop PROOF): %v", err) } + conn, _, err := host.Dial(ctx) + if err != nil { + return nil, fmt.Errorf("console: %w", err) + } + verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), "console", proof) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("console: %w", err) + } config.GRPCServerOptions = append(config.GRPCServerOptions, - grpc.ChainUnaryInterceptor(hop.UnaryServerInterceptor(proof)), - grpc.ChainStreamInterceptor(hop.StreamServerInterceptor(proof))) + grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), + grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) config.Service = &console{} - return nil, nil + return func() { _ = conn.Close() }, nil } type console struct { @@ -41,11 +54,11 @@ func (*console) Version(context.Context, *gen.VersionRequest) (*gen.VersionRespo return &gen.VersionResponse{Version: "0.1.0"}, nil } -// WhoAmI answers who the call is for, from the stamp alone. +// WhoAmI answers who the call is for, from the authenticated Work Context. func (*console) WhoAmI(ctx context.Context, _ *gen.WhoAmIRequest) (*gen.WhoAmIResponse, error) { id, ok := hop.FromContext(ctx) if !ok { - return nil, status.Error(codes.Unauthenticated, hop.ErrNoStamp.Error()) + return nil, status.Error(codes.Unauthenticated, "the call carries no authenticated Work Context") } return &gen.WhoAmIResponse{Owner: id.Owner, OwnerKind: id.OwnerKind, Tenant: id.Tenant, Actors: id.Actors, Scopes: id.Scopes}, nil } From b65ace93551598a268dca078ef0253b985fc0928 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:04:24 -0400 Subject: [PATCH 3/8] fix: host.Dial names the authority endpoint by host and port, never its 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 --- libraries/authority-client/go/host/host.go | 15 ++++++++--- .../authority-client/go/host/host_test.go | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/libraries/authority-client/go/host/host.go b/libraries/authority-client/go/host/host.go index d8532ecb..491a1841 100644 --- a/libraries/authority-client/go/host/host.go +++ b/libraries/authority-client/go/host/host.go @@ -71,13 +71,13 @@ func targetFor(ctx context.Context, endpoint string, fallback func() (string, er } if !strings.Contains(endpoint, "://") { if _, _, err := net.SplitHostPort(endpoint); err != nil { - return "", nil, fmt.Errorf("authority/endpoint %q is neither host:port nor a URL", endpoint) + return "", nil, fmt.Errorf("authority/endpoint is neither host:port nor a URL (value not shown: it may carry a credential)") } return plaintext(endpoint, codefly.IsLocal(), mesh) } u, err := url.Parse(endpoint) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { - return "", nil, fmt.Errorf("authority/endpoint %q is not an origin", endpoint) + return "", nil, fmt.Errorf("authority/endpoint %s is not an origin: it must carry no credential, path, query or fragment", hostOnly(u)) } switch u.Scheme { case "https": @@ -85,10 +85,19 @@ func targetFor(ctx context.Context, endpoint string, fallback func() (string, er case "http", "grpc", "tcp": return plaintext(withPort(u, "80"), codefly.IsLocal(), mesh) default: - return "", nil, fmt.Errorf("authority/endpoint %q names scheme %q; the host is reached over http:// (h2c) or https://", endpoint, u.Scheme) + return "", nil, fmt.Errorf("authority/endpoint %s names scheme %q; the host is reached over http:// (h2c) or https://", hostOnly(u), u.Scheme) } } +// hostOnly names an endpoint in an error by its host and port alone: never the +// raw value, its userinfo, path or query, which may carry a credential. +func hostOnly(u *url.URL) string { + if u == nil || u.Host == "" { + return "(unparseable)" + } + return u.Host +} + func withPort(u *url.URL, port string) string { if u.Port() != "" { return u.Host diff --git a/libraries/authority-client/go/host/host_test.go b/libraries/authority-client/go/host/host_test.go index 32ee24a9..e747dbcb 100644 --- a/libraries/authority-client/go/host/host_test.go +++ b/libraries/authority-client/go/host/host_test.go @@ -107,3 +107,30 @@ func TestUpstreamEndpointUsesTheSharedTransportGate(t *testing.T) { } } } + +// A credential pasted into authority/endpoint never reaches an error: a +// refusal names the host and port alone. +func TestARefusalNeverEchoesTheEndpointValue(t *testing.T) { + const secret = "s3cr3t-token" + for _, endpoint := range []string{ + "https://user:" + secret + "@authority.example:8443", + "https://authority.example:8443/?token=" + secret, + "https://authority.example:8443/" + secret, + "https://authority.example:8443/#" + secret, + "ftp://authority.example:21?k=" + secret, + "ftp://" + secret + "@authority.example:21", + secret + "-not-an-address", + "https://[" + secret, + } { + _, _, err := Target(endpoint, nil) + if err == nil { + t.Fatalf("%q accepted", endpoint) + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("the refusal echoes the endpoint's credential: %v", err) + } + } + if _, _, err := Target("https://user:"+secret+"@authority.example:8443", nil); err == nil || !strings.Contains(err.Error(), "authority.example:8443") { + t.Fatalf("the refusal names the host and port: %v", err) + } +} From fff5385f9203d3bb49df07971214602fdd9e7a5b Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:46:44 -0400 Subject: [PATCH 4/8] fix: Python and TypeScript hold forwarded contexts to Go's verdict, rule 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 --- libraries/authority-client/go/README.md | 4 + .../go/workcontext/vectors_test.go | 133 ++++++++ libraries/authority-client/python/README.md | 19 +- .../python/authority_client/contract.binpb | 1 + .../python/authority_client/work_context.py | 117 ++++++- .../authority-client/python/pyproject.toml | 5 +- .../python/tests/test_work_context.py | 18 ++ .../authority-client/testdata/vectors.json | 295 ++++++++++++++++++ .../authority-client/typescript/README.md | 29 +- .../typescript/package-lock.json | 42 +++ .../authority-client/typescript/package.json | 1 + .../typescript/tests/work-context.test.mjs | 76 ++++- .../typescript/work-context.ts | 236 ++++++++++++-- 13 files changed, 924 insertions(+), 52 deletions(-) create mode 100644 libraries/authority-client/go/workcontext/vectors_test.go create mode 120000 libraries/authority-client/python/authority_client/contract.binpb create mode 100644 libraries/authority-client/testdata/vectors.json diff --git a/libraries/authority-client/go/README.md b/libraries/authority-client/go/README.md index 0290793b..edc68cda 100644 --- a/libraries/authority-client/go/README.md +++ b/libraries/authority-client/go/README.md @@ -31,6 +31,10 @@ each candidate independently. An empty requested resource ID asks for the entire kind; a restricted grant cannot satisfy it. Kind-wide grants cover named IDs. Actions are literal. `Context()` is a deep copy of the authenticated claims. +`../testdata/vectors.json` is written by `workcontext/vectors_test.go` +(`go test ./workcontext -args -update-vectors`): tokens and Go's verdict on +each, which the Python and TypeScript readers must reach too. + From this directory, run `GOWORK=off go test -race ./...` and `go vet ./...`. The tests re-sign the bodies of `../testdata/claims.json` with a test key of their own (Core refuses its public fixture key outside its kit). diff --git a/libraries/authority-client/go/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go new file mode 100644 index 00000000..991f472b --- /dev/null +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -0,0 +1,133 @@ +package workcontext_test + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "os" + "strings" + "testing" + "time" + + reader "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + core "github.com/codefly-dev/core/workcontext" +) + +var updateVectors = flag.Bool("update-vectors", false, "rewrite ../testdata/vectors.json") + +// vector is one token and the verdict Go reaches on it. Python and TypeScript +// run the same file and must reach the same verdict: rule for rule, one source. +type vector struct { + Name string `json:"name"` + Token string `json:"token"` + Audience string `json:"audience"` + Issuer string `json:"issuer"` + Now int64 `json:"now"` + Keys map[string]string `json:"keys"` + Accept bool `json:"accept"` + Reason string `json:"reason,omitempty"` +} + +func vectors(t *testing.T) []vector { + fixturePublic, fixturePrivate := core.FixtureKeyPair() + own := signer.Public().(ed25519.PublicKey) + build := func(name, from string, change func(*basev0.WorkContextV1), key ed25519.PrivateKey, reason string) vector { + c := body(t, from) + if change != nil { + change(c) + } + if key == nil { + key = signer + } + public := own + if key.Equal(fixturePrivate) { + public = fixturePublic + } + return vector{Name: name, Token: signWith(t, key, c), Audience: body(t, from).GetAudience(), Issuer: c.GetIssuer(), + Now: c.GetNotBeforeUnix() + 1, Keys: map[string]string{c.GetKeyId(): base64.StdEncoding.EncodeToString(public)}, + Accept: reason == "", Reason: reason} + } + widen := func(c *basev0.WorkContextV1) { + c.ActorChain[0].GrantedScopes = []*basev0.WorkScopeV1{{ResourceKind: "record", Actions: []string{"admin"}}} + } + out := []vector{ + build("session", "session", nil, nil, ""), + build("delegated", "delegated", nil, nil, ""), + build("delegated operation", "delegated-operation", nil, nil, ""), + build("operation", "operation", nil, nil, ""), + build("grant", "grant", nil, nil, ""), + build("several rows of one kind", "session", func(c *basev0.WorkContextV1) { + c.AuthorityScopes = []*basev0.WorkScopeV1{{ResourceKind: "record", Actions: []string{"read"}, ResourceIds: []string{"a"}}, {ResourceKind: "record", Actions: []string{"write"}, ResourceIds: []string{"b"}}} + }, nil, ""), + build("an actor granted nothing", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].GrantedScopes = nil }, nil, ""), + build("an actor widens the owner", "delegated", widen, nil, "widens authority"), + build("no seal", "session", func(c *basev0.WorkContextV1) { c.Seal = nil }, nil, "seal"), + build("seal epoch zero", "session", func(c *basev0.WorkContextV1) { c.Seal.PrincipalEpoch = 0 }, nil, "principal_epoch"), + build("empty tenant", "session", func(c *basev0.WorkContextV1) { c.TenantId = "" }, nil, "tenant_id"), + build("wrong type", "session", func(c *basev0.WorkContextV1) { c.Typ = "codefly.work-context/v0" }, nil, "typ"), + build("unknown replay policy", "session", func(c *basev0.WorkContextV1) { c.ReplayPolicy = "forever" }, nil, "replay_policy"), + build("actor without an epoch", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].PrincipalEpoch = 0 }, nil, "principal_epoch"), + build("lifetime beyond a day", "session", func(c *basev0.WorkContextV1) { c.ExpiresAtUnix = c.NotBeforeUnix + 25*3600 }, nil, "lifetime"), + build("grant without single use", "grant", func(c *basev0.WorkContextV1) { c.ReplayPolicy = core.ReplayIdempotent }, nil, "replayable"), + build("grant to another hop", "grant", func(c *basev0.WorkContextV1) { c.GrantHop.GrantId = "another-grant" }, nil, "grant"), + build("signed with Core's public conformance key", "session", nil, fixturePrivate, "conformance fixture key"), + } + forged := build("forged signature", "session", nil, nil, "signature") + other := ed25519.NewKeyFromSeed([]byte("a forger, not the authority, 32b")) + payload, _, _ := strings.Cut(forged.Token, ".") + raw, _ := base64.RawURLEncoding.DecodeString(payload) + forged.Token = payload + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(other, raw)) + unsigned := build("unsigned", "session", nil, nil, "signature") + unsigned.Token = payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)) + elsewhere := build("another audience", "session", nil, nil, "minted for audience") + elsewhere.Audience = "another-module" + expired := build("expired", "session", nil, nil, "expired") + expired.Now = body(t, "session").GetExpiresAtUnix() + 3600 + early := build("not yet valid", "session", nil, nil, "not valid before") + early.Now = body(t, "session").GetNotBeforeUnix() - 3600 + issuer := build("another issuer", "session", nil, nil, "issued by") + issuer.Issuer = "https://forger.example" + unknown := build("a key the authority does not publish", "session", nil, nil, "no verification key") + unknown.Keys = map[string]string{"rotated": unknown.Keys[body(t, "session").GetKeyId()]} + return append(out, forged, unsigned, elsewhere, expired, early, issuer, unknown) +} + +// Every verdict in ../testdata/vectors.json is Go's, reached by the one check a +// module runs; the file is regenerated from these cases, never edited. +func TestTheSharedVectorsAreGosVerdicts(t *testing.T) { + cases := vectors(t) + want, err := json.MarshalIndent(cases, "", " ") + if err != nil { + t.Fatal(err) + } + want = append(want, '\n') + const path = "../../testdata/vectors.json" + if *updateVectors { + if err := os.WriteFile(path, want, 0o644); err != nil { + t.Fatal(err) + } + } + if got, err := os.ReadFile(path); err != nil || string(got) != string(want) { + t.Fatalf("%s is not what these cases produce; regenerate with -update-vectors: %v", path, err) + } + for _, v := range cases { + t.Run(v.Name, func(t *testing.T) { + keys := map[string]ed25519.PublicKey{} + for id, encoded := range v.Keys { + raw, _ := base64.StdEncoding.DecodeString(encoded) + keys[id] = raw + } + at := time.Unix(v.Now, 0) + _, err := reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, reader.Trust{Issuer: v.Issuer, Keys: keys, Now: func() time.Time { return at }}) + if v.Accept && err != nil { + t.Fatalf("Go refuses a vector it should accept: %v", err) + } + if !v.Accept && (!errors.Is(err, reader.ErrUnauthenticated) || !strings.Contains(err.Error(), v.Reason)) { + t.Fatalf("want a refusal naming %q: %v", v.Reason, err) + } + }) + } +} diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index 9ed66643..e7b48342 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -15,12 +15,19 @@ A module normally uses `codefly_hop.Verifier` (`libraries/hop/python`), which resolves and refreshes the trust and derives who the call is for. `authenticate` checks the run's hop proof, exactly one token and the stamp's -digest of it; then, in Core's order: canonical decode, the issuer pin, the -Ed25519 signature under the published key the token names, the audience is this -module, and not-before and expiry within Core's 30-second skew. Core's structural -and attenuation rules constrain what the authority signs and are not reproduced. -It consumes nothing and asks the authority nothing: liveness is the hop's Check, -bound to this call by the digest. +digest of it; then Core's rules in Core's order: the decode path (size, the +contract's `buf.validate` schema rules run by protovalidate against the packaged +contract descriptor, unknown fields, canonical encoding, a lifetime of at most a +day), the issuer pin, the key (never Core's public conformance key), the Ed25519 +signature, the audience is this module, not-before and expiry within Core's +30-second skew, and the chain's attenuation and grant shape. Go writes +`../testdata/vectors.json` with its verdict on each case; this reader's tests +must reach the same verdict, for the same reason. It consumes nothing and asks +the authority nothing: liveness is the hop's Check, bound by the digest. + +`authority_client/contract.binpb` links to the published `../contract` +descriptor because the generated Python module was emitted without the +`buf.validate` options; it is packaged with the wheel. The SaaS Python SDK's verifier was not reused: it refuses tokens Core accepts (several scope rows of one resource kind, and Core's own grant fixture). diff --git a/libraries/authority-client/python/authority_client/contract.binpb b/libraries/authority-client/python/authority_client/contract.binpb new file mode 120000 index 00000000..acffc303 --- /dev/null +++ b/libraries/authority-client/python/authority_client/contract.binpb @@ -0,0 +1 @@ +../../contract/contract.binpb \ No newline at end of file diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 6f25f744..9fff90a5 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -4,12 +4,16 @@ check: the stamp binds exactly one token to this run's hop proof by its digest, and the token itself must carry a valid Ed25519 signature under the authority's published key, name the pinned issuer, be addressed to this module and be inside -its window, judged in Core's order (decode, issuer, signature, audience, window). +its window, judged in Core's order and by Core's rules: the decode path (size, +schema, unknown fields, canonical encoding, lifetime), the issuer pin, the key +(never Core's public conformance key), the signature, the audience, the window, +and the chain's attenuation and grant shape. The schema rules are the contract's +own ``buf.validate`` declarations, run by protovalidate against the published +contract descriptor, not restated here. ``testdata/vectors.json``, written by the +Go test, holds every rule to Go's verdict. -Core's structural and attenuation rules are not reproduced: they constrain what -the authority signs, and the signature is what makes these claims the -authority's. The SaaS Python SDK's verifier was not reused because it refuses -tokens Core accepts (several scope rows of one kind, Core's own grant fixture). +The SaaS Python SDK's verifier was not reused because it refuses tokens Core +accepts (several scope rows of one kind, Core's own grant fixture). Nothing here asks the authority or consumes a nonce: liveness is the authority's Check the hop made, which the digest binds to this call. """ @@ -23,8 +27,13 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone +from importlib import resources + +import protovalidate from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from google.protobuf import descriptor_pb2, descriptor_pool, message_factory from google.protobuf.message import DecodeError from ._gen.host.authority.v1.work_context_pb2 import WorkContextV1 @@ -37,6 +46,26 @@ MAX_TOKEN_BYTES = 32 * 1024 SKEW = timedelta(seconds=30) MAX_LIFETIME = timedelta(hours=24) +REPLAY_SINGLE_USE = "single-use" +# Core's conformance key: its private half is derivable from Core's public source, +# so a key equal to it is refused whatever an authority publishes (Core's resolveKey). +_FIXTURE_PUBLIC = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"codefly work context conformance fixtures").digest() +).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + + +def _contract_class(): + """WorkContextV1 as the published contract declares it, buf.validate rules + included (the generated Python module was emitted without them).""" + files = descriptor_pb2.FileDescriptorSet() + files.ParseFromString(resources.files("authority_client").joinpath("contract.binpb").read_bytes()) + pool = descriptor_pool.DescriptorPool() + for file in files.file: + pool.Add(file) + return message_factory.GetMessageClass(pool.FindMessageTypeByName("host.authority.v1.WorkContextV1")) + + +_CONTRACT = _contract_class() class Unauthenticated(ValueError): @@ -55,8 +84,9 @@ def __init__(self, issuer: str, keys: Mapping[str, bytes], *, now: Callable[[], raise Unauthenticated("the authority publishes no verification key") self.issuer = issuer self.now = now or (lambda: datetime.now(timezone.utc)) + self.raw = {key_id: bytes(raw) for key_id, raw in keys.items()} try: - self.keys = {key_id: Ed25519PublicKey.from_public_bytes(bytes(raw)) for key_id, raw in keys.items()} + self.keys = {key_id: Ed25519PublicKey.from_public_bytes(raw) for key_id, raw in self.raw.items()} except ValueError as error: raise Unauthenticated(f"the authority's keys are unusable: {error}") from error @@ -123,16 +153,22 @@ def authenticate( context.ParseFromString(payload) except DecodeError as error: raise Unauthenticated("payload is not a WorkContextV1") from error + try: + protovalidate.validate(_parsed(_CONTRACT, payload)) + except protovalidate.ValidationError as error: + raise Unauthenticated(f"invalid claims: {error}") from error context.DiscardUnknownFields() if context.SerializeToString(deterministic=True) != payload: raise Unauthenticated("payload is noncanonical or carries a field this reader does not know") - if context.algorithm != ALGORITHM: - raise Unauthenticated(f"algorithm {context.algorithm!r} is not {ALGORITHM}") + if context.expires_at_unix - context.not_before_unix > MAX_LIFETIME.total_seconds(): + raise Unauthenticated(f"the capability's lifetime exceeds {MAX_LIFETIME}") if context.issuer != trust.issuer: raise Unauthenticated(f"issued by {context.issuer!r}, not {trust.issuer!r}") key = trust.keys.get(context.key_id) if key is None: raise Unauthenticated(f"no verification key {context.key_id!r}; the authority does not publish it") + if trust.raw[context.key_id] == _FIXTURE_PUBLIC: + raise Unauthenticated(f"key {context.key_id!r} is the conformance fixture key, which anyone can derive from Core's source") try: key.verify(signature, payload) except InvalidSignature: @@ -140,12 +176,11 @@ def authenticate( if context.audience != audience: raise Unauthenticated(f"minted for audience {context.audience!r}, presented to {audience!r}") now = trust.now().timestamp() - if context.expires_at_unix - context.not_before_unix > MAX_LIFETIME.total_seconds(): - raise Unauthenticated(f"lifetime exceeds {MAX_LIFETIME}") if now + SKEW.total_seconds() < context.not_before_unix: raise Unauthenticated("not valid before its not-before instant") if not now - SKEW.total_seconds() < context.expires_at_unix: raise Unauthenticated("expired") + _check_structure(context) scopes = context.actor_chain[-1].granted_scopes if context.actor_chain else context.authority_scopes return Claims( audience=context.audience, @@ -154,6 +189,66 @@ def authenticate( ) +def _parsed(cls, payload: bytes): + message = cls() + message.ParseFromString(payload) + return message + + +def scope_contained(child, parents) -> bool: + """Core's ScopeContained: per action, a whole-kind row covers it, or the + child's ids all lie within the rows of that kind holding the action.""" + if not any(parent.resource_kind == child.resource_kind for parent in parents): + return False + for action in child.actions: + rows = [p for p in parents if p.resource_kind == child.resource_kind and action in p.actions] + if any(not p.resource_ids for p in rows): + continue + ids = {i for p in rows for i in p.resource_ids} + if not child.resource_ids or any(i not in ids for i in child.resource_ids): + return False + return True + + +def _identity(context, hop=None) -> tuple: + if hop is None: + return (context.owner_principal_id, context.owner_principal_kind, context.owner_agent_id, context.organization_id) + return (hop.principal_id, hop.principal_kind, hop.agent_id, hop.organization_id or context.organization_id) + + +def _check_structure(context) -> None: + """Core's checkStructure: every hop attenuates the one before it, and a grant + hop elevates exactly the hop it names, for one action on one resource.""" + grant = context.grant_hop if context.HasField("grant_hop") else None + chain = context.actor_chain + effective = context.authority_scopes + for index, hop in enumerate(chain): + elevated = grant is not None and index == len(chain) - 1 + if not elevated and not all(scope_contained(s, effective) for s in hop.granted_scopes): + raise Unauthenticated(f"actor hop {index} ({hop.principal_id}) widens authority beyond the hop before it") + effective = hop.granted_scopes + if grant is None: + return + name = grant.grant_id + if not chain: + raise Unauthenticated(f"grant {name!r} has no actor hop to elevate") + if not context.HasField("parent_session_id"): + raise Unauthenticated(f"grant {name!r} is not a child session") + if context.replay_policy != REPLAY_SINGLE_USE: + raise Unauthenticated(f"grant {name!r} is replayable; a grant authorizes one call") + scope = grant.granted_scope + if len(scope.actions) != 1 or len(scope.resource_ids) != 1: + raise Unauthenticated(f"grant {name!r} must name exactly one action on one resource") + last = chain[-1] + if len(last.granted_scopes) != 1 or last.granted_scopes[0] != scope: + raise Unauthenticated(f"grant {name!r} elevates a hop holding something other than the granted scope") + if last.delegation_id != name: + raise Unauthenticated(f"grant {name!r} elevates a hop delegated by {last.delegation_id!r}") + actor = _identity(context, chain[-2]) if len(chain) > 1 else _identity(context) + if _identity(context, last) != actor: + raise Unauthenticated(f"grant {name!r} hands authority to another principal rather than elevating it") + + def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> str: """The one token the stamp's digest binds under this run's proof. Bound, not authenticated: whoever holds the proof can write both.""" diff --git a/libraries/authority-client/python/pyproject.toml b/libraries/authority-client/python/pyproject.toml index c8e5dcde..f27daa26 100644 --- a/libraries/authority-client/python/pyproject.toml +++ b/libraries/authority-client/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "authority-client" version = "0.0.1" -dependencies = ["protobuf>=5.29.3,<7", "grpcio>=1.71.0", "googleapis-common-protos", "cryptography>=41"] +dependencies = ["protobuf>=5.29.3,<7", "grpcio>=1.71.0", "googleapis-common-protos", "cryptography>=41", "protovalidate>=1.0"] [build-system] requires = ["setuptools>=68"] @@ -9,3 +9,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["authority_client", "authority_client.*"] + +[tool.setuptools.package-data] +authority_client = ["contract.binpb"] diff --git a/libraries/authority-client/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 51a575ca..38e3a16f 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -141,3 +141,21 @@ def test_trust_comes_only_from_published_ed25519_keys(): Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=PUBLIC)] * 2)) with pytest.raises(Unauthenticated, match="unusable"): Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=b"short")])) + + +VECTORS = json.loads((Path(__file__).parents[2] / "testdata/vectors.json").read_text()) + + +# Go writes the vectors and its verdict on each (workcontext/vectors_test.go): +# this reader must reach the same verdict on every one, rule for rule. +@pytest.mark.parametrize("vector", VECTORS, ids=lambda v: v["name"]) +def test_python_reaches_gos_verdict_on_every_shared_vector(vector): + keys = {kid: base64.b64decode(raw) for kid, raw in vector["keys"].items()} + trust = Trust(vector["issuer"], keys, now=lambda: datetime.fromtimestamp(vector["now"], timezone.utc)) + headers = stamped(vector["token"]) + if vector["accept"]: + authenticate(headers, PROOF, vector["audience"], trust) + else: + with pytest.raises(Unauthenticated) as refused: + authenticate(headers, PROOF, vector["audience"], trust) + assert vector["reason"] in str(refused.value) diff --git a/libraries/authority-client/testdata/vectors.json b/libraries/authority-client/testdata/vectors.json new file mode 100644 index 00000000..32656c1c --- /dev/null +++ b/libraries/authority-client/testdata/vectors.json @@ -0,0 +1,295 @@ +[ + { + "name": "session", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "delegated", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGZmNTI5NTQ0LTFjMzItNDM1MS1hOGEwLWI4ODA1MDQzMGYzNFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkggEkZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSSAXAKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFsgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.-ihKlNTTQKBJOSno3gEAvsBAjbDbTSDML3oti2yHO4q54RhB1IkR9Xxw2sB6rsm95TtefmsLTx_L8WqiRU3BDA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "delegated operation", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGY0Y2M3MDc2LThlYTItNDI3Ny1iYjBlLTkwZDE0ZjYzZjAxZFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZmIwOTg1ZmMtMjcxNi00NWE0LTlhZDUtMTg1Y2UwZDA4MGIyggEkZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSSAXAKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFsgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAPaAR8KGWJpbmRpbmctY29uZm9ybWFuY2UtYWN0b3IQBBgB.p5Z1I-b0I0_nEQMjyrelsq-sLkO7f82Ub7bBTiCZGdBmLvnUSJ1xNqO_9k-Q00cQwo7u4xuOCptQ9gIfyQnQBw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "operation", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGRiYzBmYzgyLWQ0YzQtNGQ1NC1iN2RmLThjZTRhODZkYjlhMlAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokNDYzY2FkNjgtOGQxYi00ZmJkLWFkOTctZmUzZjI5NjdkMGFhigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYA9oBGQoTYmluZGluZy1jb25mb3JtYW5jZRAEGAE.lVhXsqKNP3enq3XfdiSlsq9AXbd_6tvnx_GGOL4JumMyGvjEtmBLGH7VIjkmMYQ-HMP2uJh0yV54NF45VEMVBw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "grant", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZA3MX11gZKJGIzNzgwZDEwLWFlYzAtNGJlYS05YTdiLTk4NzU1ODAxYzc0MFAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYWEwYzdmMTQtY2ViYy00MDY1LThmYTEtYmNlMzMyZjE0YmY1ggEkZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkigEOCgZyZWNvcmQSBHJlYWSSAXAKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFkgFuCg9wcmluY2lwYWwtYWN0b3ISBWFnZW50GhFncmFudC1jb25mb3JtYW5jZSIlCgZyZWNvcmQSB2FwcHJvdmUaEnJlY29yZC1jb25mb3JtYW5jZSoYY29kZWZseS50ZXN0L2FjdG9yOjEuMC4wOAWyAQVodW1hbsIBgQEKEWdyYW50LWNvbmZvcm1hbmNlEhsKEnByaW5jaXBhbC1hcHByb3ZlchIFaHVtYW4aJQoGcmVjb3JkEgdhcHByb3ZlGhJyZWNvcmQtY29uZm9ybWFuY2UiFHJlc291cmNlLWNvbmZvcm1hbmNlKhJzaGEyNTY6Y29uZm9ybWFuY2XKARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.gPe9HqW3MGi2ovQWUmYrl_C9_pLiFZZwnrhOgUmhehi6QF1vMzIPVOHomVNhcRbJ5q8UqzMZoW11EK60dzbjCg", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "several rows of one kind", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigERCgZyZWNvcmQSBHJlYWQaAWGKARIKBnJlY29yZBIFd3JpdGUaAWKyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.hSLAdlKrvNvVlBAVkKZUZxMp8EgBHF_TpyrCIxXTqfE-kE4HBWMQ30onZlP9fSePdd9yrTeEqUa7kKASEqTcCg", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "an actor granted nothing", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGZmNTI5NTQ0LTFjMzItNDM1MS1hOGEwLWI4ODA1MDQzMGYzNFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkggEkZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSSAUwKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFsgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.rZeeK-lMrN-Avjmd473VWl5vaCp6odNs4UF5VBtdXwp2Sihphw9fD041r9rTQtGICRrOoh1vOthIP2BXXZZzDw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": true + }, + { + "name": "an actor widens the owner", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGZmNTI5NTQ0LTFjMzItNDM1MS1hOGEwLWI4ODA1MDQzMGYzNFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkggEkZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSSAV0KD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiDwoGcmVjb3JkEgVhZG1pbioYY29kZWZseS50ZXN0L2FjdG9yOjEuMC4wOAWyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.uaWIJJdFsWUswYdTmcgeIxdI5UxwaAneohW07Rmm367S-rPh_TecJIeW5UhGWybuBw3sB9LDyVnMsM5rWyccBA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "widens authority" + }, + { + "name": "no seal", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZQ.4SKuuhHZ84ajXf6FR4oyES83co-4POLPEVqvRfm8VqQov95Emo5vW5begRYDD0vxmFJpjsfAMwtLWFEX4QR0CA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "seal" + }, + { + "name": "seal epoch zero", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHBIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.tpvckcFkYWYEMf9IfDChQvG-HerR3NWDFwurk-pb37PiipewWOHMNP6VkmGIELObj332AWjMaobpgWiPEB63AA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "principal_epoch" + }, + { + "name": "empty tenant", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50ag9wcmluY2lwYWwtb3duZXJyEHRhc2stY29uZm9ybWFuY2V6JGY3ZDIzZmFkLWUyNTEtNDNjMy1hZjMyLTNjNTNlOWNjMGVjMooBDgoGcmVjb3JkEgRyZWFksgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.kR_KwzPU7XEH0AOLk-dtfYupGUngJNHsE3c8_W9dnd6fCoHVQ8WLRxXY6zCnialuvw2xX-UbhtNU-PhsFbpFBA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "tenant_id" + }, + { + "name": "wrong type", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MBIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw._DrrK-YEZTHYT4MOtIw9beGuYtIK4Stzeo_hbnBSeRuL_3yP2ViLTC7pZgjn7DDaVVGmC8ofenSCGoFYBWg2CA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "typ" + }, + { + "name": "unknown replay policy", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgdmb3JldmVyYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.J8ZjyZI30fxtmjbkyLiP--oGbCyb07YWT3iv7os-52M1FZZF2IkOfemghKG8cQj_kQYFEwH6Y5YwtffNOcPVDA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "replay_policy" + }, + { + "name": "actor without an epoch", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGZmNTI5NTQ0LTFjMzItNDM1MS1hOGEwLWI4ODA1MDQzMGYzNFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkggEkZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSSAW4KD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMLIBBWh1bWFuygEYb3JnYW5pemF0aW9uLWNvbmZvcm1hbmNl0gEeCAISGGluc3RhbGxhdGlvbi1jb25mb3JtYW5jZRgD.we9eiLflwPJtfEdouyMWNl9ArW9wCB2K9MbhZI-NHnTnFYbcKVm-4b9kEfwr86FibezxDEdjG2BSWw6Gx1q3CA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "principal_epoch" + }, + { + "name": "lifetime beyond a day", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsIT71gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.MviZtbhVoecLZDNfkQ36fKrNvJWLmk_3MbDCXWGHWog7BCc7PLDT6nGKlmGsOsC65GmD8fKzFnEW1PHsO1oDBA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "lifetime" + }, + { + "name": "grant without single use", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZA3MX11gZKJGIzNzgwZDEwLWFlYzAtNGJlYS05YTdiLTk4NzU1ODAxYzc0MFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYWEwYzdmMTQtY2ViYy00MDY1LThmYTEtYmNlMzMyZjE0YmY1ggEkZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkigEOCgZyZWNvcmQSBHJlYWSSAXAKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFkgFuCg9wcmluY2lwYWwtYWN0b3ISBWFnZW50GhFncmFudC1jb25mb3JtYW5jZSIlCgZyZWNvcmQSB2FwcHJvdmUaEnJlY29yZC1jb25mb3JtYW5jZSoYY29kZWZseS50ZXN0L2FjdG9yOjEuMC4wOAWyAQVodW1hbsIBgQEKEWdyYW50LWNvbmZvcm1hbmNlEhsKEnByaW5jaXBhbC1hcHByb3ZlchIFaHVtYW4aJQoGcmVjb3JkEgdhcHByb3ZlGhJyZWNvcmQtY29uZm9ybWFuY2UiFHJlc291cmNlLWNvbmZvcm1hbmNlKhJzaGEyNTY6Y29uZm9ybWFuY2XKARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.REcWnKFlYx2PWZlXJEdHgnb5HqbQP0D0CWIUce70--QG25kWnfGVVCCKh4Vmp7X_UoLDq7k0ZAXJ9dYjfrceBQ", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "replayable" + }, + { + "name": "grant to another hop", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZA3MX11gZKJGIzNzgwZDEwLWFlYzAtNGJlYS05YTdiLTk4NzU1ODAxYzc0MFAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYWEwYzdmMTQtY2ViYy00MDY1LThmYTEtYmNlMzMyZjE0YmY1ggEkZDczMGEwNjYtYWFjYy00ZDE4LTk2ZGItODdhNTU1MmQ1NWZkigEOCgZyZWNvcmQSBHJlYWSSAXAKD3ByaW5jaXBhbC1hY3RvchIFYWdlbnQaFmRlbGVnYXRpb24tY29uZm9ybWFuY2UiIgoGcmVjb3JkEgRyZWFkGhJyZWNvcmQtY29uZm9ybWFuY2UqGGNvZGVmbHkudGVzdC9hY3RvcjoxLjAuMDgFkgFuCg9wcmluY2lwYWwtYWN0b3ISBWFnZW50GhFncmFudC1jb25mb3JtYW5jZSIlCgZyZWNvcmQSB2FwcHJvdmUaEnJlY29yZC1jb25mb3JtYW5jZSoYY29kZWZseS50ZXN0L2FjdG9yOjEuMC4wOAWyAQVodW1hbsIBfQoNYW5vdGhlci1ncmFudBIbChJwcmluY2lwYWwtYXBwcm92ZXISBWh1bWFuGiUKBnJlY29yZBIHYXBwcm92ZRoScmVjb3JkLWNvbmZvcm1hbmNlIhRyZXNvdXJjZS1jb25mb3JtYW5jZSoSc2hhMjU2OmNvbmZvcm1hbmNlygEYb3JnYW5pemF0aW9uLWNvbmZvcm1hbmNl0gEeCAISGGluc3RhbGxhdGlvbi1jb25mb3JtYW5jZRgD.ePYWEMm7-i5Oeia4nNt1pQFRY9qmZYa7WhiXe-1pdd-ET98GtVT5SpQrvzA9sfDhj90hv1ndGaD1C1FEnzCBAA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "grant" + }, + { + "name": "signed with Core's public conformance key", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.QfE84kjvNVYJlmI60DaVgZZQQJebFu7uXjdzOkZh6MgVcpeZNM6bkdn9vCBNxWNtkdVp6N7wBrWHEuuHWradDQ", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "zUk2IHGYpgzQIg62XX3M2E4ljwn34sgdGreGOMl9Efs=" + }, + "accept": false, + "reason": "conformance fixture key" + }, + { + "name": "forged signature", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.x5OX2hwNlojApaH9acVikJ6-oD5Us7aJ1iALlW1ENeMiP7cWF5fBiEUlvdG0E6F1zuyX-aSwGEn8zyoVlWLOAA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "signature" + }, + { + "name": "unsigned", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "signature" + }, + { + "name": "another audience", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "another-module", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "minted for audience" + }, + { + "name": "expired", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792900800, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "expired" + }, + { + "name": "not yet valid", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792890000, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "not valid before" + }, + { + "name": "another issuer", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://forger.example", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "issued by" + }, + { + "name": "a key the authority does not publish", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "rotated": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "no verification key" + } +] diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index 0d3350a1..4168dfdc 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -1,22 +1,29 @@ # Authority TypeScript client Codefly generates the RPC bindings exported by `@codefly-dev/authority-client`. -The handwritten module-side check has a separate public export: +The handwritten module side has a separate public export. A module builds one +`Verifier` at startup from the generated client of the authority interface it +depends on: ```ts -import { authenticateClaims, trustFromKeys } from '@codefly-dev/authority-client/work-context'; +import { Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; -const trust = trustFromKeys(await authority.keys({})); // the interface's published keys -const claims = authenticateClaims(requestHeaders, proof, audience, trust); // throws Unauthenticated -const allowed = claims.allows(resourceKind, action, exactResourceID); +const verifier = new Verifier(createClient(AuthorityService, transport), audience, proof); +const id = await verifier.verify(requestHeaders); // throws Unauthenticated, naming why +id.tenant; id.scopes; id.claims.allows(resourceKind, action, exactResourceID); ``` -It requires exactly one proof, token and digest and checks the SHA-256 binding, -then, in Core's order: canonical decode, issuer, the Ed25519 signature under the -published key the token names, the audience is this module, and not-before and -expiry within Core's 30-second skew. Core's structural and attenuation rules are -not reproduced; they constrain what the authority signs. Re-fetch the keys when a -token names a key id the trust does not hold. Do not send the proof to a browser. +It is the Go library's `hop.Verifier`: the stamp first (proof and digest), then +the keys read through `Keys` (re-read on an unknown key at most every 10 s and +when older than 5 minutes, on a read of its own bounded by 5 s; stale after 15 +minutes), then `authenticateClaims`: Core's decode path (size, the contract's +`buf.validate` rules run by protovalidate, unknown fields, canonical encoding, +lifetime), the issuer, the key (never Core's public conformance key), the Ed25519 +signature, the audience, the window, and attenuation and grant shape. Who the +call is for comes from the claims; a stamp that disagrees is refused. The shared +`../testdata/vectors.json`, written by Go, holds every rule to Go's verdict. A +read's own failure is kept in `verifier.keysError` for logs, never sent to a +caller. Do not send the proof to a browser. `claims.scopes` contains frozen copies of the final actor's grants, or the owner's when no actor exists. Each scope is evaluated independently; an omitted resource diff --git a/libraries/authority-client/typescript/package-lock.json b/libraries/authority-client/typescript/package-lock.json index a55157a0..2b74beb6 100644 --- a/libraries/authority-client/typescript/package-lock.json +++ b/libraries/authority-client/typescript/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@bufbuild/protobuf": "^2.0.0", + "@bufbuild/protovalidate": "^1.3.0", "@connectrpc/connect": "^2.0.0" }, "devDependencies": { @@ -16,12 +17,53 @@ "typescript": "^5.9.3" } }, + "node_modules/@bufbuild/cel": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/cel/-/cel-0.6.1.tgz", + "integrity": "sha512-tW7cvf5byb4zrVKsdkVk/UhD3dH6Q0hnfmeRyKaAGXx0pwebk7NjUgdR2GSwf74GXxkC7StMfgRLA2Ea/+zPKw==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/cel-spec": "0.6.1", + "@bufbuild/re2": "0.6.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.6.2" + } + }, + "node_modules/@bufbuild/cel-spec": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/cel-spec/-/cel-spec-0.6.1.tgz", + "integrity": "sha512-m0Ac9qA669yxxC0Hy45qGsUZemjImVfxPl6r9jLbkJviEvv3R9hSYyH2ItGRbDguekX6eId0cb62ulNBK97ezg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.6.2" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@bufbuild/protovalidate": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protovalidate/-/protovalidate-1.3.0.tgz", + "integrity": "sha512-3GU0+0J/+vCk9/7Zply9XWjGLk08DcJsVNI9/e2FiPl+dIv6shBaRimkXouMs1GnPt7PueNW1vPJFGfh7O6VCA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/cel": "0.6.1", + "@bufbuild/re2": "0.6.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.8.0" + } + }, + "node_modules/@bufbuild/re2": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/re2/-/re2-0.6.1.tgz", + "integrity": "sha512-VY7zV7bWKdAsf46epowVKidolEfMKw/FoHo2xYVDVzLj/hiP5qDL3nXqJt6seZClRYfG+uzAPFQBZBlj07wO5A==", + "license": "MIT" + }, "node_modules/@connectrpc/connect": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.2.0.tgz", diff --git a/libraries/authority-client/typescript/package.json b/libraries/authority-client/typescript/package.json index c5b6dfee..4fbef10d 100644 --- a/libraries/authority-client/typescript/package.json +++ b/libraries/authority-client/typescript/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@bufbuild/protobuf": "^2.0.0", + "@bufbuild/protovalidate": "^1.3.0", "@connectrpc/connect": "^2.0.0" }, "devDependencies": { diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 6756e77f..597290d2 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:crypto'; import { test } from 'node:test'; -import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; +import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); const encode = bytes => Buffer.from(bytes).toString('base64url'); @@ -78,3 +78,75 @@ test('trust comes only from published Ed25519 keys', () => { }); function payloadOnly(token) { return token.split('.')[0]; } + +const vectors = JSON.parse(readFileSync(new URL('../../testdata/vectors.json',import.meta.url),'utf8')); +// Go writes the vectors and its verdict on each (workcontext/vectors_test.go): +// this reader must reach the same verdict, for the same reason, on every one. +for (const v of vectors) { + test(`shared vector, Go's verdict: ${v.name}`, () => { + const trust = {issuer: v.issuer, keys: new Map(Object.entries(v.keys).map(([kid, raw]) => [kid, new Uint8Array(Buffer.from(raw,'base64'))])), now: () => v.now*1000}; + const run = () => authenticateClaims(stamped(v.token),'test-proof',v.audience,trust); + if (v.accept) run(); + else assert.throws(run, e => e instanceof Unauthenticated && e.message.includes(v.reason), v.reason); + }); +} + +const fixture = JSON.parse(readFileSync(new URL('../../../hop/testdata/stamped.json',import.meta.url),'utf8')); +const published = fromJson(KeysResponseSchema, fixture.keys); +const source = (answer = () => Promise.resolve(published)) => { const s = {reads: 0, keys: () => { s.reads++; return answer(); }}; return s; }; +const settle = () => new Promise(resolve => setImmediate(resolve)); + +test('the Verifier reads the hop fixture as Go reads it', async () => { + const keys = source(); + const v = new Verifier(keys, fixture.audience, fixture.proof, () => fixture.now*1000); + const id = await v.verify(fixture.headers); + assert.equal(id.owner, fixture.identity.owner); + assert.equal(id.tenant, fixture.identity.tenant); + assert.deepEqual([...id.scopes], fixture.identity.scopes); + assert.equal(id.advisory, fixture.identity.advisory); + assert.ok(id.claims.allows('documents.collection','write','c-1')); + for (const [key, value, reason] of [['x-codefly-hop-tenant','globex',/tenant/],['x-codefly-hop-scopes','documents.collection:admin',/scopes/],['x-codefly-hop-proof','another-run',/proof/]]) { + await assert.rejects(v.verify({...fixture.headers, [key]: value}), e => e instanceof Unauthenticated && reason.test(e.message)); + } +}); + +// Review r44 B1: nothing unauthenticated reaches the key read, and a hung read +// never stalls a call whose key is already held. +test('a call without the proof never reads the keys, and a hung read stalls no held key', async () => { + let release; + let hang = false; + const keys = source(() => hang ? new Promise(resolve => { release = () => resolve(published); }) : Promise.resolve(published)); + let clock = fixture.now*1000; + const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + await settle(); + assert.equal(keys.reads, 1); + for (let i = 0; i < 20; i++) { + clock += 11_000; + await assert.rejects(v.verify({'x-codefly-work-context': 'garbage'}), Unauthenticated); + } + assert.equal(keys.reads, 1, 'a proof-less call read the keys'); + hang = true; + clock = fixture.now*1000 + 400_000; // keys older than max age: a background re-read starts and hangs + const id = await v.verify(fixture.headers); + assert.equal(id.owner, fixture.identity.owner); + assert.equal(keys.reads, 2); + release(); +}); + +test('a Verifier starts before the authority serves keys and refuses with a reason until it does', async () => { + let up = false; + const keys = source(() => up ? Promise.resolve(published) : Promise.reject(new Error('dial authority:4000: connection refused'))); + let clock = fixture.now*1000; + const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + await settle(); + await assert.rejects(v.verify(fixture.headers), e => e instanceof Unauthenticated && /unavailable/.test(e.message) && !/4000/.test(e.message)); + up = true; + clock += 11_000; + assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); +}); + +test('an actor granted nothing narrows the identity to nothing', () => { + const context = body('delegated'); + context.actorChain[0].grantedScopes = []; + assert.deepEqual(identityOf(context).scopes, []); +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 8e92cc5e..f90103fd 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,20 +1,30 @@ -/** Authenticate the Work Context the hop forwarded to a Node module, then project it. +/** Authenticate the Work Context the hop forwarded to a Node module, then read it. * - * `authenticateClaims` is the Go library's `workcontext.Authenticate`, check for - * check where it matters: the stamp binds exactly one token to this run's proof by - * its digest, and the token itself must carry a valid Ed25519 signature under the - * authority's published key, name the pinned issuer, be addressed to this module and - * be inside its window, judged in Core's order. Core's structural and attenuation - * rules constrain what the authority signs and are not reproduced. Nothing here asks - * the authority or consumes a nonce: liveness is the hop's Check, which the digest - * binds to this call. */ -import { createHash, createPublicKey, timingSafeEqual, verify, type KeyObject } from "node:crypto"; -import { fromBinary, toBinary } from "@bufbuild/protobuf"; + * `Verifier` is the Go library's `hop.Verifier`, and `authenticateClaims` is + * `workcontext.Authenticate`, rule for rule: the stamp binds exactly one token to + * this run's proof by its digest; the token passes Core's decode path (size, + * schema, unknown fields, canonical encoding, lifetime), the issuer pin, the key + * (never Core's public conformance key), the Ed25519 signature, the audience, the + * window, and the chain's attenuation and grant shape. The schema rules are the + * contract's own buf.validate declarations, run by protovalidate. The shared + * `testdata/vectors.json`, written by the Go test, holds every rule to Go's + * verdict. Nothing here consumes a nonce: liveness is the hop's Check, which the + * digest binds to this call. */ +import { createHash, createPrivateKey, createPublicKey, timingSafeEqual, verify } from "node:crypto"; +import { equals, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { createValidator } from "@bufbuild/protovalidate"; import type { KeysResponse } from "./src/gen/host/authority/v1/authority_pb.js"; -import { WorkContextV1Schema, type WorkContextV1 } from "./src/gen/host/authority/v1/work_context_pb.js"; +import { WorkContextV1Schema, WorkScopeV1Schema, type WorkActorV1, type WorkContextV1, type WorkScopeV1 } from "./src/gen/host/authority/v1/work_context_pb.js"; /** Core's bounds: MaxTokenSize, DefaultSkew, MaxTTLCeiling. */ const MAX_TOKEN_BYTES = 32 * 1024, SKEW_SECONDS = 30, MAX_LIFETIME_SECONDS = 24 * 3600; +const validator = createValidator(); +/** Core's conformance key: its private half is derivable from Core's public + * source, so a key equal to it is refused whatever an authority publishes. */ +const FIXTURE_PUBLIC = Buffer.from(createPrivateKey({ + key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), createHash("sha256").update("codefly work context conformance fixtures").digest()]), + format: "der", type: "pkcs8", +}).export({ format: "jwk" }).x as string, "base64url"); export class Unauthenticated extends Error { override name = "Unauthenticated"; @@ -23,20 +33,21 @@ export class Unauthenticated extends Error { /** The authority a module believes: its issuer and published keys, from Keys. */ export interface Trust { readonly issuer: string; - readonly keys: ReadonlyMap; + /** Raw 32-byte Ed25519 public keys by key id. */ + readonly keys: ReadonlyMap; /** Unix milliseconds; Date.now when omitted. */ readonly now?: () => number; } /** Trust from the authority interface's KeysResponse: Ed25519 keys only. */ export function trustFromKeys(response: KeysResponse, now?: () => number): Trust { - const keys = new Map(); + const keys = new Map(); for (const key of response.keys) { if (key.algorithm !== "Ed25519") continue; if (!key.keyId || keys.has(key.keyId) || key.publicKey.length !== 32) { throw new Unauthenticated("the authority publishes an unnamed, repeated or malformed key"); } - keys.set(key.keyId, createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(key.publicKey).toString("base64url") }, format: "jwk" })); + keys.set(key.keyId, Uint8Array.from(key.publicKey)); } if (!response.issuer || keys.size === 0) throw new Unauthenticated("the authority publishes no issuer or no Ed25519 key"); return { issuer: response.issuer, keys, now }; @@ -76,22 +87,31 @@ export function authenticateClaims(headers: StampHeaders, proof: string, audienc } catch { throw new Unauthenticated("payload is not a WorkContextV1"); } + const checked = validator.validate(WorkContextV1Schema, context); + if (checked.kind !== "valid") { + throw new Unauthenticated(`invalid claims: ${checked.kind === "invalid" ? checked.violations.map(v => v.toString()).join("; ") : checked.error.message}`); + } const canonical = toBinary(WorkContextV1Schema, context); if (canonical.length !== payload.length || canonical.some((byte, index) => byte !== payload[index])) { throw new Unauthenticated("payload is noncanonical or carries a field this reader does not know"); } - if (context.algorithm !== "Ed25519") throw new Unauthenticated(`algorithm ${context.algorithm} is not Ed25519`); + const notBefore = Number(context.notBeforeUnix), expires = Number(context.expiresAtUnix); + if (expires - notBefore > MAX_LIFETIME_SECONDS) throw new Unauthenticated("the capability's lifetime exceeds 24h"); if (context.issuer !== trust.issuer) throw new Unauthenticated(`issued by ${context.issuer}, not ${trust.issuer}`); - const key = trust.keys.get(context.keyId); - if (!key) throw new Unauthenticated(`no verification key ${context.keyId}; the authority does not publish it`); + const raw = trust.keys.get(context.keyId); + if (!raw || raw.length !== 32) throw new Unauthenticated(`no verification key ${context.keyId}; the authority does not publish it`); + if (Buffer.from(raw).equals(FIXTURE_PUBLIC)) { + throw new Unauthenticated(`key ${context.keyId} is the conformance fixture key, which anyone can derive from Core's source`); + } + const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(raw).toString("base64url") }, format: "jwk" }); if (signature.length !== 64 || !verify(null, payload, key, signature)) { throw new Unauthenticated("signature does not verify under the authority's published key"); } if (context.audience !== audience) throw new Unauthenticated(`minted for audience ${context.audience}, presented to ${audience}`); - const now = (trust.now ?? Date.now)() / 1000, notBefore = Number(context.notBeforeUnix), expires = Number(context.expiresAtUnix); - if (expires - notBefore > MAX_LIFETIME_SECONDS) throw new Unauthenticated("lifetime exceeds 24h"); + const now = (trust.now ?? Date.now)() / 1000; if (now + SKEW_SECONDS < notBefore) throw new Unauthenticated("not valid before its not-before instant"); if (!(now - SKEW_SECONDS < expires)) throw new Unauthenticated("expired"); + checkStructure(context); const effective = context.actorChain.at(-1)?.grantedScopes ?? context.authorityScopes; const scopes = Object.freeze(effective.map(scope => Object.freeze({ resourceKind: scope.resourceKind, @@ -110,9 +130,58 @@ export function authenticateClaims(headers: StampHeaders, proof: string, audienc }); } +/** Core's ScopeContained: per action, a whole-kind row covers it, or the child's + * ids all lie within the rows of that kind holding the action. */ +export function scopeContained(child: WorkScopeV1, parents: readonly WorkScopeV1[]): boolean { + if (!parents.some(p => p.resourceKind === child.resourceKind)) return false; + for (const action of child.actions) { + const rows = parents.filter(p => p.resourceKind === child.resourceKind && p.actions.includes(action)); + if (rows.some(p => p.resourceIds.length === 0)) continue; + const ids = new Set(rows.flatMap(p => p.resourceIds)); + if (child.resourceIds.length === 0 || child.resourceIds.some(id => !ids.has(id))) return false; + } + return true; +} + +function identityKey(context: WorkContextV1, hop?: WorkActorV1): string { + return JSON.stringify(hop + ? [hop.principalId, hop.principalKind, hop.agentId ?? "", hop.organizationId || context.organizationId || ""] + : [context.ownerPrincipalId, context.ownerPrincipalKind ?? "", context.ownerAgentId ?? "", context.organizationId ?? ""]); +} + +/** Core's checkStructure: every hop attenuates the one before it, and a grant + * hop elevates exactly the hop it names, for one action on one resource. */ +function checkStructure(context: WorkContextV1): void { + const grant = context.grantHop, chain = context.actorChain; + let effective = context.authorityScopes; + chain.forEach((hop, index) => { + const elevated = grant !== undefined && index === chain.length - 1; + if (!elevated && !hop.grantedScopes.every(scope => scopeContained(scope, effective))) { + throw new Unauthenticated(`actor hop ${index} (${hop.principalId}) widens authority beyond the hop before it`); + } + effective = hop.grantedScopes; + }); + if (grant === undefined) return; + const name = grant.grantId; + if (chain.length === 0) throw new Unauthenticated(`grant ${name} has no actor hop to elevate`); + if (context.parentSessionId === undefined) throw new Unauthenticated(`grant ${name} is not a child session`); + if (context.replayPolicy !== "single-use") throw new Unauthenticated(`grant ${name} is replayable; a grant authorizes one call`); + const scope = grant.grantedScope; + if (!scope || scope.actions.length !== 1 || scope.resourceIds.length !== 1) { + throw new Unauthenticated(`grant ${name} must name exactly one action on one resource`); + } + const last = chain[chain.length - 1]; + if (last.grantedScopes.length !== 1 || !equals(WorkScopeV1Schema, last.grantedScopes[0], scope)) { + throw new Unauthenticated(`grant ${name} elevates a hop holding something other than the granted scope`); + } + if (last.delegationId !== name) throw new Unauthenticated(`grant ${name} elevates a hop delegated by ${last.delegationId}`); + const actor = chain.length > 1 ? identityKey(context, chain[chain.length - 2]) : identityKey(context); + if (identityKey(context, last) !== actor) throw new Unauthenticated(`grant ${name} hands authority to another principal rather than elevating it`); +} + /** The one token the stamp's digest binds under this run's proof. Bound, not * authenticated: whoever holds the proof can write both. */ -function boundToken(headers: StampHeaders, proof: string): string { +export function boundToken(headers: StampHeaders, proof: string): string { if (headers === null || typeof headers !== "object" || typeof proof !== "string") { throw new Unauthenticated("context requires request headers and a run proof"); } @@ -143,3 +212,128 @@ function decodeSegment(segment: string): Uint8Array { if (decoded.toString("base64url") !== segment) throw new Unauthenticated("context segment is not canonical base64url"); return new Uint8Array(decoded); } + +/** Who a call is for, read from the authenticated Work Context. */ +export interface Identity { + readonly owner: string; + readonly ownerKind: string; + readonly tenant: string; + readonly actors: readonly string[]; + /** Kind-wide effective scopes, each ":"; claims.allows answers resource-exact questions. */ + readonly scopes: readonly string[]; + readonly binding: string; + readonly task: string; + readonly expires: number; + /** The authenticated Work Context, as forwarded. */ + readonly context: string; + /** The refusal the hop allowed in warning mode; diagnostic, grants nothing. */ + readonly advisory: string; + readonly claims: Claims; +} + +function flatten(scopes: readonly WorkScopeV1[]): string[] { + const out: string[] = []; + for (const scope of scopes) { + if (scope.resourceIds.length > 0) continue; + for (const action of scope.actions) if (!out.includes(`${scope.resourceKind}:${action}`)) out.push(`${scope.resourceKind}:${action}`); + } + return out; +} + +/** The Go library's IdentityOf: the owner's authority narrowed by every actor's + * grant (an actor granted nothing narrows to nothing); resource-limited scopes + * are left out rather than widened. */ +export function identityOf(context: WorkContextV1): Omit { + let scopes = flatten(context.authorityScopes); + for (const actor of context.actorChain) { + const held = flatten(actor.grantedScopes); + scopes = scopes.filter(scope => held.includes(scope)); + } + return { + owner: context.ownerPrincipalId, ownerKind: context.ownerPrincipalKind ?? "", tenant: context.tenantId, + actors: context.actorChain.map(actor => actor.principalId), scopes, + binding: context.operationBinding?.bindingId ?? "", task: context.taskId, expires: Number(context.expiresAtUnix), + }; +} + +/** The authority interface's Keys, as its generated Connect client serves it. */ +export interface KeySource { + keys(request: object, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise; +} + +/** The Go verifier's key policy: re-read on an unknown key at most every 10 s and + * when older than 5 min, on a read of its own bounded by 5 s; keys that cannot be + * re-read are trusted no longer than 15 min. */ +const KEY_RETRY_MS = 10_000, KEY_MAX_AGE_MS = 300_000, KEY_STALE_MS = 900_000, KEY_TIMEOUT_MS = 5_000; + +/** The one module-side entry point: authenticates every call the hop forwards. + * Build it once at startup with the generated client of the authority interface + * the module depends on; the keys are read through its Keys, never typed in. + * Construction never fails on an authority that is not serving yet: calls are + * refused, with the reason, until its keys are read. */ +export class Verifier { + private trust?: Trust; + private fetched = 0; + private tried = Number.NEGATIVE_INFINITY; + private inflight?: Promise; + /** The last key-read failure, for the module's own logs; never sent to a caller. */ + keysError?: unknown; + + constructor(private readonly source: KeySource, private readonly audience: string, private readonly proof: string, + private readonly now: () => number = Date.now) { + if (!source || !audience || !proof) throw new Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof"); + void this.refresh(); + } + + /** Who the call is for, or Unauthenticated naming why it is refused. */ + async verify(headers: StampHeaders): Promise { + const pairs = [...(Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers))] + .flatMap(([key, value]) => (typeof value === "string" ? [value] : [...value]).map(v => [key.toLowerCase(), v] as const)); + const token = boundToken(pairs, this.proof); // the stamp first: nothing unbound reaches the key read + const claims = authenticateClaims(pairs, this.proof, this.audience, await this.trustFor(keyIdOf(token))); + const id = identityOf(claims.context); + const stamped: Record = { + "x-codefly-hop-owner": id.owner, "x-codefly-hop-owner-kind": id.ownerKind, "x-codefly-hop-tenant": id.tenant, + "x-codefly-hop-actors": id.actors.join(","), "x-codefly-hop-scopes": id.scopes.join(","), + "x-codefly-hop-binding": id.binding, "x-codefly-hop-task": id.task, "x-codefly-hop-expires": id.expires > 0 ? String(id.expires) : "", + }; + for (const [key, want] of Object.entries(stamped)) { + const got = pairs.filter(([name]) => name === key).map(([, value]) => value); + if (got.length > 1 || (got[0] ?? "") !== want) { + throw new Unauthenticated(`the stamp's ${key.slice("x-codefly-hop-".length)} does not match the authenticated Work Context`); + } + } + const advisory = pairs.find(([name]) => name === "x-codefly-hop-advisory")?.[1] ?? ""; + return Object.freeze({ ...id, context: token, advisory, claims }); + } + + private async trustFor(keyId: string): Promise { + const now = this.now(); + const held = this.trust?.keys.has(keyId) ?? false; + if ((!held || now - this.fetched > KEY_MAX_AGE_MS) && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + if (!held && this.inflight) await this.inflight; + if (!this.trust || this.now() - this.fetched > KEY_STALE_MS) { + throw new Unauthenticated("the authority's published keys are unavailable"); + } + return { ...this.trust, now: this.now }; + } + + /** One read at a time, on its own timeout: no caller's signal or failure delays the next. */ + private refresh(): Promise { + if (this.inflight) return this.inflight; + this.tried = this.now(); + this.inflight = this.source.keys({}, { timeoutMs: KEY_TIMEOUT_MS }) + .then(response => { this.trust = trustFromKeys(response); this.fetched = this.now(); this.keysError = undefined; }) + .catch(error => { this.keysError = error; }) + .finally(() => { this.inflight = undefined; }); + return this.inflight; + } +} + +function keyIdOf(token: string): string { + try { + return fromBinary(WorkContextV1Schema, Buffer.from(token.split(".")[0], "base64url")).keyId; + } catch { + return ""; + } +} From 4173075b40ce2972f463752dca23d2369385bbf3 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:50:21 -0400 Subject: [PATCH 5/8] fix: no unauthenticated call reaches the key read; the proof's claim 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 (codefly-dev/authority#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 --- libraries/conformance/go/forwarded_context.go | 10 + libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 4 +- libraries/hop/go/README.md | 24 ++- libraries/hop/go/fixture_test.go | 168 ++++++++++++++-- libraries/hop/go/go.mod | 28 ++- libraries/hop/go/go.sum | 186 +++++++++++++++++- libraries/hop/go/hop.go | 173 +++++++++++----- libraries/hop/go/hop_test.go | 11 ++ libraries/hop/python/README.md | 15 +- libraries/hop/python/pyproject.toml | 11 +- .../hop/python/src/codefly_hop/__init__.py | 85 +++++--- libraries/hop/python/tests/test_read.py | 73 ++++++- .../hop/code/pkg/infra/proxy/proxy_test.go | 6 +- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 +- modules/console/services/api/code/work.go | 8 +- 17 files changed, 685 insertions(+), 125 deletions(-) diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go index f6fd0641..9af2575e 100644 --- a/libraries/conformance/go/forwarded_context.go +++ b/libraries/conformance/go/forwarded_context.go @@ -1,6 +1,7 @@ package conformance import ( + "bytes" "context" "crypto/ed25519" "crypto/rand" @@ -14,6 +15,7 @@ import ( forwarded "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" ) // forwardedContext is the module side of the hop, against this host: what the @@ -48,6 +50,14 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) if err != nil { t.Fatal(err) } + // The hop stamps who the call is for from Check's context and the module + // derives it from the token; they agree only if Check answers the token's + // own claims, byte for byte, never a re-derivation. + checked := c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": token, "audience": f.Audience}) + answered, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg(checked, "context").Interface()) + if err != nil || !bytes.Equal(answered, raw) { + t.Fatalf("Check answers the token's own claims unchanged: %v", err) + } _, forger, _ := ed25519.GenerateKey(rand.Reader) later := trust later.Now = func() time.Time { return at.Add(25 * time.Hour) } diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 040adb27..19ffbe29 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/conformance/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index fdbca607..dce3e94b 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 h1:7vdTj6aLQVyFf0MWWgUnR/otX1v9xN6AbFKC2cHrQjE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 14048c54..009b19c0 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -14,7 +14,8 @@ import ( proof, _ := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell -verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), "", proof) +audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) +verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, proof) config.GRPCServerOptions = append(config.GRPCServerOptions, grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) @@ -22,8 +23,8 @@ config.GRPCServerOptions = append(config.GRPCServerOptions, // an HTTP module: id, err := verifier.Verify(ctx, metadataFromHeaders) ``` -`Verify` checks this run's proof, that the stamp's digest binds the one -forwarded token, and then the token itself (Core's `Inspector`): the issuer pin, +`Verify` checks this run's proof and that the stamp's digest binds the one +forwarded token first (a call without them never reaches the keys), and then the token itself (Core's `Inspector`): the issuer pin, the Ed25519 signature under the authority's published key, the audience is this module, not-before and expiry, structure and attenuation. Who the call is for is read from the authenticated claims (`IdentityOf`); a stamp that says otherwise @@ -32,10 +33,19 @@ consumes nothing: liveness (revision, seal, single use) is the authority's Check the hop made, bound to this call by the digest. The keys come from the interface's `Keys`, through the generated client: never -typed in, never mounted. They are re-read when a token names a key the verifier -does not hold (at most every 10 s) and when older than 5 minutes; keys that -cannot be re-read are trusted for 15 minutes at most, then every call is -refused naming the outage. +typed in, never mounted. A read runs on its own, detached from every call and +bounded by 5 s, one at a time: no caller can cancel it, fail it or delay the +next one. Keys are re-read when a token names a key the verifier does not hold +(at most every 10 s; that call waits for the read while it lasts) and when older +than 5 minutes (the held keys serve meanwhile); keys that cannot be re-read are +trusted for 15 minutes at most. `NewVerifier` never fails on an authority that +is not serving yet: calls are refused with "the authority's published keys are +unavailable" until it is, and `KeysError()` gives the read's own failure to the +module's logs, never to a caller. + +The proof is shared by every hop-fronted module: it keeps out a caller that is +not a composed workload, and it does not show that a composed workload came +through the hop (codefly-dev/authority#5). `hoptest` is a real authority for a module's own tests: `hoptest.New(t)` signs with a fresh key and answers `Keys`; `Issue` mints a Work Context and diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index cf6835b9..2ff66fc7 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" "time" @@ -69,16 +70,51 @@ func forwarded(t *testing.T, token, proof, advisory string) metadata.MD { return md } -// keys is a switchable Keys answer that counts the reads. +// keys is a switchable Keys answer that counts the reads; with hold set, a +// read waits for it to be closed (or for its own context to end). type keys struct { + mu sync.Mutex response *authorityv1.KeysResponse err error reads int + hold chan struct{} } -func (k *keys) Keys(context.Context, *authorityv1.KeysRequest, ...grpc.CallOption) (*authorityv1.KeysResponse, error) { +func (k *keys) Keys(ctx context.Context, _ *authorityv1.KeysRequest, _ ...grpc.CallOption) (*authorityv1.KeysResponse, error) { + k.mu.Lock() k.reads++ - return k.response, k.err + hold, response, err := k.hold, k.response, k.err + k.mu.Unlock() + if hold != nil { + select { + case <-hold: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return response, err +} + +func (k *keys) set(response *authorityv1.KeysResponse, err error, hold chan struct{}) { + k.mu.Lock() + defer k.mu.Unlock() + k.response, k.err, k.hold = response, err, hold +} + +func (k *keys) count() int { + k.mu.Lock() + defer k.mu.Unlock() + return k.reads +} + +// settled waits for the verifier's running read, if any, to end. +func settled(v *Verifier) { + v.mu.Lock() + wait := v.inflight + v.mu.Unlock() + if wait != nil { + <-wait + } } type stampFixture struct { @@ -171,30 +207,140 @@ func TestPublishedKeysAreRereadOnRotationAndExpireWhenUnreadable(t *testing.T) { v.tried, v.fetched = start, start verify := func(token string) error { _, err := v.Verify(context.Background(), forwarded(t, token, "proof", "")) + settled(v) return err } if err := verify(first.issue(t, clock, "documents")); err != nil { t.Fatal(err) } - source.response = &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{second.published()}} + source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{second.published()}}, nil, nil) clock = start.Add(time.Second) - if err := verify(second.issue(t, clock, "documents")); err == nil || !strings.Contains(err.Error(), "no verification key") || source.reads != 1 { - t.Fatalf("within keyRetry the keys are not re-read: %v (reads %d)", err, source.reads) + if err := verify(second.issue(t, clock, "documents")); err == nil || !strings.Contains(err.Error(), "no verification key") || source.count() != 1 { + t.Fatalf("within keyRetry the keys are not re-read: %v (reads %d)", err, source.count()) } clock = start.Add(keyRetry + time.Second) - if err := verify(second.issue(t, clock, "documents")); err != nil || source.reads != 2 { - t.Fatalf("a rotated key is fetched: %v (reads %d)", err, source.reads) + if err := verify(second.issue(t, clock, "documents")); err != nil || source.count() != 2 { + t.Fatalf("a rotated key is fetched: %v (reads %d)", err, source.count()) } if err := verify(first.issue(t, clock, "documents")); err == nil { t.Fatal("a key the authority no longer publishes still verifies") } - source.err = errors.New("authority down") + source.set(nil, errors.New("dial authority.internal:4000: connection refused"), nil) clock = clock.Add(keyMaxAge + time.Second) if err := verify(second.issue(t, clock, "documents")); err != nil { t.Fatalf("a failed re-read keeps the last keys until keyStale: %v", err) } clock = clock.Add(keyStale) - if err := verify(second.issue(t, clock, "documents")); !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), "cannot be read") { - t.Fatalf("stale keys are refused, naming the outage: %v", err) + err = verify(second.issue(t, clock, "documents")) + if !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), "unavailable") || strings.Contains(err.Error(), "4000") { + t.Fatalf("stale keys are refused with a fixed reason that names no address: %v", err) + } + if v.KeysError() == nil || !strings.Contains(v.KeysError().Error(), "4000") { + t.Fatalf("the read's own failure is kept for the module's logs: %v", v.KeysError()) + } +} + +// Review r44 B1, first probe: after a rotation, a caller without the proof, +// with a cancelled context, every keyRetry, must neither read the keys nor +// stop the genuine call carrying the new key. +func TestAnUnauthenticatedCallerNeitherReadsNorStarvesTheKeys(t *testing.T) { + first, second := newSigner("first"), newSigner("second") + source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} + clock := time.Unix(1792818000, 0) + v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v.now = func() time.Time { return clock } + v.fetched, v.tried = clock, clock + source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{second.published()}}, nil, nil) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + genuine, unauthenticatedReads := 0, 0 + for i := 0; i < 100; i++ { + clock = clock.Add(keyRetry) + before := source.count() + for _, md := range []metadata.MD{metadata.Pairs(WorkContextKey, "garbage"), forwarded(t, second.issue(t, clock, "documents"), "another-run", "")} { + if _, err := v.Verify(cancelled, md); err == nil { + t.Fatal("a call without this run's proof was admitted") + } + } + settled(v) + unauthenticatedReads += source.count() - before + if _, err := v.Verify(context.Background(), forwarded(t, second.issue(t, clock, "documents"), "proof", "")); err == nil { + genuine++ + } + settled(v) + } + if genuine != 100 || unauthenticatedReads != 0 { + t.Fatalf("admitted %d/100 genuine calls; %d key reads caused by calls without the proof", genuine, unauthenticatedReads) + } +} + +// Review r44 B1, second probe: a hung read stalls no call whose key is held, +// and a caller that gives up waiting delays nobody's next read. +func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { + first, second := newSigner("first"), newSigner("second") + source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published(), second.published()}}} + start := time.Unix(1792818000, 0) + clock := start + v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v.now = func() time.Time { return clock } + v.fetched, v.tried = start, start + hold := make(chan struct{}) + source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published(), second.published()}}, nil, hold) + clock = start.Add(keyMaxAge + time.Second) // old keys: a background read starts, and hangs + done := make(chan error, 1) + go func() { + _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("a held key: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("a call with a held key stalled behind a hung read") + } + // A key not held waits for the read; a caller who gives up is refused alone. + third := newSigner("third") + short, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err := v.Verify(short, forwarded(t, third.issue(t, clock, "documents"), "proof", "")); err == nil || !strings.Contains(err.Error(), "ended while") { + t.Fatalf("a caller giving up: %v", err) + } + source.set(&authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{third.published()}}, nil, nil) + close(hold) + settled(v) + clock = clock.Add(keyRetry) + if _, err := v.Verify(context.Background(), forwarded(t, third.issue(t, clock, "documents"), "proof", "")); err != nil { + t.Fatalf("the next read is not delayed by the caller who gave up: %v", err) + } +} + +// A read that never answers is bounded by the verifier's own timeout. +func TestAReadIsBoundedByItsOwnTimeout(t *testing.T) { + source := &keys{hold: make(chan struct{})} + v := &Verifier{source: source, audience: "documents", proof: "proof", now: time.Now, timeout: 20 * time.Millisecond} + v.mu.Lock() + done := v.refresh() + v.mu.Unlock() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("a hung read was not bounded") + } + if !errors.Is(v.KeysError(), context.DeadlineExceeded) { + t.Fatalf("the timeout is recorded: %v", v.KeysError()) + } +} + +// An actor granted nothing narrows the identity to nothing, as Core's +// EffectiveScopes does (review r44 m1). +func TestAnActorGrantedNothingNarrowsToNothing(t *testing.T) { + id := IdentityOf(&basev0.WorkContextV1{ + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, + ActorChain: []*basev0.WorkActorV1{{PrincipalId: "robin"}}, + }) + if len(id.Scopes) != 0 || id.Has("documents", "read") { + t.Fatalf("identity: %+v", id) } } diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index f13c0b10..c0fcb301 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -14,13 +14,39 @@ require ( buf.build/go/protovalidate v1.4.0 // indirect cel.dev/cel-go v0.32.0 // indirect cel.dev/expr v0.25.3 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/go-openapi/analysis v1.0.0 // indirect + github.com/go-openapi/errors v0.22.9 // indirect + github.com/go-openapi/jsonpointer v1.0.1 // indirect + github.com/go-openapi/jsonreference v1.0.2 // indirect + github.com/go-openapi/loads v0.25.3 // indirect + github.com/go-openapi/spec v1.0.1 // indirect + github.com/go-openapi/strfmt v0.27.2 // indirect + github.com/go-openapi/swag/conv v0.29.2 // indirect + github.com/go-openapi/swag/jsonutils v0.29.2 // indirect + github.com/go-openapi/swag/loading v0.29.2 // indirect + github.com/go-openapi/swag/mangling v0.29.2 // indirect + github.com/go-openapi/swag/pools v0.29.2 // indirect + github.com/go-openapi/swag/stringutils v0.29.2 // indirect + github.com/go-openapi/swag/typeutils v0.29.2 // indirect + github.com/go-openapi/swag/yamlutils v0.29.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 4d44d9bb..1edc0d92 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -6,24 +6,190 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= +github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= +github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= +github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= +github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= +github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= +github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= +github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= +github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= +github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= +github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= +github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= +github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= +github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= +github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= +github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= +github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= +github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= +github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= +github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= +github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= +github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= +github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= +github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= +github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= +github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= +github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= +github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= +github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= +github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= +github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= +github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= +github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= +github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= +github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= +github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= +github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= +github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= +github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= +github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= @@ -32,6 +198,8 @@ golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -44,3 +212,17 @@ google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index d8da9d87..07522923 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -7,11 +7,17 @@ // The module re-checks no permission the hop decided, but believes nothing it // has not authenticated: Verifier checks the token's signature under the // authority's published keys, its issuer, that it is addressed to this module, -// its window, and that it is the token the stamp's digest binds; who the call -// is for is read from the authenticated claims, never from a header. The proof -// says the call came through the hop, so the method policy and the live check -// were applied; it authenticates no identity, because every hop-fronted module -// holds it. +// its window and structure, and that it is the token the stamp's digest binds; +// who the call is for is read from the authenticated claims, never a header. +// +// What the proof does NOT establish: it is one secret every hop-fronted module +// in the run holds. It keeps out a caller that is not a composed workload (a +// person's client on a laptop, where no mesh stands in front of the module). +// It does not show that a composed workload came through the hop: a module +// holding a genuine context addressed to another can call it directly, with +// the proof, past the method policy and the live check, until the context +// expires. Closing that needs a proof per audience or a stamp the hop signs: +// codefly-dev/authority#5. On a cell, the mesh admits only the hop. package hop import ( @@ -21,6 +27,7 @@ import ( "encoding/hex" "errors" "fmt" + "os" "slices" "strconv" "strings" @@ -28,8 +35,10 @@ import ( "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -158,10 +167,8 @@ func IdentityOf(wc *basev0.WorkContextV1) Identity { scopes := flatten(wc.GetAuthorityScopes()) for _, actor := range wc.GetActorChain() { id.Actors = append(id.Actors, actor.GetPrincipalId()) - if granted := actor.GetGrantedScopes(); len(granted) > 0 { - held := flatten(granted) - scopes = slices.DeleteFunc(scopes, func(s string) bool { return !slices.Contains(held, s) }) - } + held := flatten(actor.GetGrantedScopes()) // an actor granted nothing narrows to nothing, as Core's EffectiveScopes + scopes = slices.DeleteFunc(scopes, func(s string) bool { return !slices.Contains(held, s) }) } id.Scopes = scopes return id @@ -182,6 +189,18 @@ func flatten(scopes []*basev0.WorkScopeV1) []string { return out } +// Audience is this module's audience: the module the composition started this +// process as (CODEFLY__MODULE, the identity a manifest names), which is the +// name a Work Context is addressed to and the hop routes by. Read it rather +// than typing the module's name. +func Audience() (string, error) { + module := strings.TrimSpace(os.Getenv(resources.ModulePrefix)) + if module == "" { + return "", fmt.Errorf("hop: %s is not set: the composition names no module for this process", resources.ModulePrefix) + } + return module, nil +} + // KeySource is the authority interface's Keys, as its generated client serves // it: the issuer and the public keys every Work Context is signed with. type KeySource interface { @@ -190,15 +209,23 @@ type KeySource interface { // How the published keys are kept: re-read when a token names a key the // verifier does not hold (at most once per keyRetry), and when they are older -// than keyMaxAge, so a removed key stops verifying. A failed re-read keeps the -// last keys until they are keyStale old; then every call is refused, naming -// the outage, as the hop refuses a call it cannot check. +// than keyMaxAge, so a removed key stops verifying. A read runs on its own, +// detached from any call, bounded by keyTimeout, one at a time; no caller can +// cancel it, fail it or make it wait. A failed read keeps the last keys until +// they are keyStale old; then every call is refused, as the hop refuses a call +// it cannot check. const ( - keyRetry = 10 * time.Second - keyMaxAge = 5 * time.Minute - keyStale = 15 * time.Minute + keyRetry = 10 * time.Second + keyMaxAge = 5 * time.Minute + keyStale = 15 * time.Minute + keyTimeout = 5 * time.Second ) +// errKeysUnavailable is the one reason a caller is given when keys are +// missing or stale: the read's own failure may name the authority's address, +// so it goes to KeysError, for the module's logs, never to a caller. +var errKeysUnavailable = fmt.Errorf("%w: the authority's published keys are unavailable", workcontext.ErrUnauthenticated) + // Verifier is the one module-side entry point: it authenticates every call the // hop forwards before anything reads it. Build it once, at startup. type Verifier struct { @@ -206,18 +233,25 @@ type Verifier struct { audience string proof string now func() time.Time + timeout time.Duration - mu sync.Mutex - trust workcontext.Trust - fetched time.Time // when trust was read - tried time.Time // the last read attempt + mu sync.Mutex + trust workcontext.Trust + fetched time.Time // when trust was read + tried time.Time // when the last read started + inflight chan struct{} // closed when the running read ends; nil when none runs + failure error // the last read's failure, for KeysError } -// NewVerifier pins this module's audience and the run's hop proof, and reads -// the authority's published keys through the interface's Keys. keys is the -// generated client of the authority the module already depends on +// NewVerifier pins this module's audience and the run's hop proof, and starts +// reading the authority's published keys through the interface's Keys. keys is +// the generated client of the authority the module already depends on // (authorityv1.NewAuthorityServiceClient over host.Dial): the local authority // on a laptop, the host on a cell. A key is never typed in or mounted. +// +// It waits for that first read while ctx allows, and never fails on it: a +// module starts before its authority serves keys, and refuses calls, with the +// reason, until they are read. func NewVerifier(ctx context.Context, keys KeySource, audience, proof string) (*Verifier, error) { switch { case keys == nil: @@ -227,20 +261,35 @@ func NewVerifier(ctx context.Context, keys KeySource, audience, proof string) (* case proof == "": return nil, errors.New("hop: a verifier needs the run's hop proof (codefly config generate hop PROOF)") } - v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now} + v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now, timeout: keyTimeout} v.mu.Lock() - defer v.mu.Unlock() - if err := v.fetch(ctx); err != nil { - return nil, err + done := v.refresh() + v.mu.Unlock() + select { + case <-done: + case <-ctx.Done(): } return v, nil } +// KeysError is the last failure to read the published keys, nil after a +// successful read: for the module's logs and readiness, never for a caller. +func (v *Verifier) KeysError() error { + v.mu.Lock() + defer v.mu.Unlock() + return v.failure +} + // Verify authenticates the call md carries and returns who it is for, from the // authenticated claims. The error wraps workcontext.ErrUnauthenticated and -// names the reason. +// names the reason. The stamp is checked first: a call that does not carry this +// run's proof and a bound token never reaches the keys. func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) { - trust, err := v.trustFor(ctx, first(md, WorkContextKey)) + token, err := stamp.Context(md, v.proof) + if err != nil { + return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) + } + trust, err := v.trustFor(ctx, keyIDOf(token)) if err != nil { return Identity{}, err } @@ -252,34 +301,65 @@ func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) if err := sameAsStamped(md, id); err != nil { return Identity{}, err } - id.Context, id.Claims, id.Advisory = first(md, WorkContextKey), claims, first(md, AdvisoryKey) + id.Context, id.Claims, id.Advisory = token, claims, first(md, AdvisoryKey) return id, nil } -// trustFor returns the keys to judge token by, re-reading them when the token -// names a key not held or the keys are old. -func (v *Verifier) trustFor(ctx context.Context, token string) (workcontext.Trust, error) { +// trustFor returns the keys to judge a token naming keyID by. A key not held +// starts a read (at most once per keyRetry) and waits for it, as long as the +// caller does; old keys start a read and are used meanwhile. +func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trust, error) { v.mu.Lock() - defer v.mu.Unlock() now := v.now() - _, held := v.trust.Keys[keyIDOf(token)] - if (!held || now.Sub(v.fetched) > keyMaxAge) && now.Sub(v.tried) >= keyRetry { - if err := v.fetch(ctx); err != nil && now.Sub(v.fetched) > keyStale { - return workcontext.Trust{}, err + _, held := v.trust.Keys[keyID] + if (!held || now.Sub(v.fetched) > keyMaxAge) && v.inflight == nil && now.Sub(v.tried) >= keyRetry { + v.refresh() + } + wait := v.inflight + v.mu.Unlock() + if !held && wait != nil { + select { + case <-wait: + case <-ctx.Done(): + return workcontext.Trust{}, fmt.Errorf("%w: the call ended while the authority's keys were being read", workcontext.ErrUnauthenticated) } } - if now.Sub(v.fetched) > keyStale { - return workcontext.Trust{}, fmt.Errorf("%w: the authority's keys were last read %s ago", workcontext.ErrUnauthenticated, now.Sub(v.fetched).Round(time.Second)) + v.mu.Lock() + defer v.mu.Unlock() + if v.fetched.IsZero() || v.now().Sub(v.fetched) > keyStale { + return workcontext.Trust{}, errKeysUnavailable } return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil } -// fetch reads the published keys; v.mu is held. -func (v *Verifier) fetch(ctx context.Context) error { - v.tried = v.now() +// refresh starts the one read, detached from every call; v.mu is held. It +// returns the channel closed when the read ends. +func (v *Verifier) refresh() chan struct{} { + if v.inflight != nil { + return v.inflight + } + done := make(chan struct{}) + v.inflight, v.tried = done, v.now() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), v.timeout) + defer cancel() + trust, err := v.read(ctx) + v.mu.Lock() + defer v.mu.Unlock() + if err == nil { + v.trust, v.fetched = trust, v.now() + } + v.failure, v.inflight = err, nil + close(done) + }() + return done +} + +// read asks the authority for its published keys. +func (v *Verifier) read(ctx context.Context) (workcontext.Trust, error) { response, err := v.source.Keys(ctx, &authorityv1.KeysRequest{}) if err != nil { - return fmt.Errorf("%w: the authority's published keys cannot be read: %w", workcontext.ErrUnauthenticated, err) + return workcontext.Trust{}, fmt.Errorf("hop: the authority's published keys cannot be read: %w", err) } trust := workcontext.Trust{Issuer: response.GetIssuer(), Keys: map[string]ed25519.PublicKey{}} for _, key := range response.GetKeys() { @@ -287,15 +367,14 @@ func (v *Verifier) fetch(ctx context.Context) error { continue } if key.GetKeyId() == "" || len(key.GetPublicKey()) != ed25519.PublicKeySize || trust.Keys[key.GetKeyId()] != nil { - return fmt.Errorf("%w: the authority publishes an unnamed, repeated or malformed key", workcontext.ErrUnauthenticated) + return workcontext.Trust{}, errors.New("hop: the authority publishes an unnamed, repeated or malformed key") } trust.Keys[key.GetKeyId()] = ed25519.PublicKey(slices.Clone(key.GetPublicKey())) } if trust.Issuer == "" || len(trust.Keys) == 0 { - return fmt.Errorf("%w: the authority publishes no issuer or no Ed25519 key", workcontext.ErrUnauthenticated) + return workcontext.Trust{}, errors.New("hop: the authority publishes no issuer or no Ed25519 key") } - v.trust, v.fetched = trust, v.tried - return nil + return trust, nil } // keyIDOf is the key a token names, read only to choose the keys it is judged diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index e2ffbdad..bba7f65c 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -142,3 +142,14 @@ func TestAVerifierNeedsPublishedKeysAnAudienceAndAProof(t *testing.T) { } } } + +func TestTheAudienceIsTheModuleTheCompositionStarted(t *testing.T) { + t.Setenv("CODEFLY__MODULE", "documents") + if got, err := hop.Audience(); err != nil || got != "documents" { + t.Fatalf("audience: %q %v", got, err) + } + t.Setenv("CODEFLY__MODULE", "") + if _, err := hop.Audience(); err == nil { + t.Fatal("a process the composition names no module for has no audience") + } +} diff --git a/libraries/hop/python/README.md b/libraries/hop/python/README.md index ef88bef5..85e62dc4 100644 --- a/libraries/hop/python/README.md +++ b/libraries/hop/python/README.md @@ -9,7 +9,7 @@ Go hop writes (`../testdata/stamped.json`, regenerated by the Go test with from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub from codefly_hop import Unauthenticated, Verifier -verifier = Verifier(AuthorityServiceStub(channel), audience="documents", proof=proof) # once, at startup +verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], proof=proof) # once, at startup identity = verifier.verify(request.headers) # raises Unauthenticated, naming why identity.tenant, identity.owner, identity.has("documents.collection", "read") identity.claims.allows("documents.collection", "write", collection_id) @@ -17,9 +17,13 @@ identity.claims.allows("documents.collection", "write", collection_id) `channel` reaches the authority the module already depends on (the interface `codefly.dev/authority`): the local authority on a laptop, the host on a cell. -The verifier reads the published keys through its `Keys` and re-reads them when a -token names a key it does not hold (at most every 10 s) and when they are older -than 5 minutes; keys that cannot be re-read are trusted for 15 minutes at most. +The verifier checks the stamp first, then reads the published keys through `Keys` +on a thread of its own with a 5 s timeout, one read at a time; it re-reads them +when a token names a key it does not hold (at most every 10 s) and when they are +older than 5 minutes; keys that cannot be re-read are trusted for 15 minutes at +most. It never fails to construct on an authority that is not serving yet, and +refuses calls with a fixed reason until it is; `verifier.keys_error` holds the +read's own failure for logs. `verify` authenticates the token (`authority_client.work_context.authenticate`: proof, digest, issuer, Ed25519 signature, audience, window), reads who the call @@ -28,6 +32,9 @@ is for from the authenticated claims, and refuses a stamp that says otherwise. A module depends on it by its git coordinates at a pinned commit: `codefly-hop @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/hop/python`. +Install it with uv: its `authority-client` dependency is a path source to the +sibling library, so uv resolves it at the same commit; no second pin or +override is needed. (pip does not read `tool.uv.sources`.) ```sh uv run --with pytest --with-editable . pytest diff --git a/libraries/hop/python/pyproject.toml b/libraries/hop/python/pyproject.toml index 7912a93c..9b2c7205 100644 --- a/libraries/hop/python/pyproject.toml +++ b/libraries/hop/python/pyproject.toml @@ -3,7 +3,7 @@ name = "codefly-hop" version = "0.2.0" description = "The authority hop's stamp, authenticated by a module served in Python: who a call is for, read from the verified Work Context." requires-python = ">=3.11" -dependencies = ["authority-client @ git+https://github.com/codefly-dev/authority@ead918ed860c5b56160f61b85dbd4cc44701feb4#subdirectory=libraries/authority-client/python"] +dependencies = ["authority-client"] [project.optional-dependencies] test = ["pytest>=8"] @@ -12,8 +12,11 @@ test = ["pytest>=8"] requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["src/codefly_hop"] + +# authority-client is this repository's sibling library, at the same commit: +# installing codefly-hop at commit X with uv (git+…@X#subdirectory=…) brings +# authority-client at X, with no pin of its own to keep in step. +[tool.uv.sources] +authority-client = { path = "../../authority-client/python" } diff --git a/libraries/hop/python/src/codefly_hop/__init__.py b/libraries/hop/python/src/codefly_hop/__init__.py index 1b8eb972..ca79ffdb 100644 --- a/libraries/hop/python/src/codefly_hop/__init__.py +++ b/libraries/hop/python/src/codefly_hop/__init__.py @@ -22,7 +22,7 @@ from authority_client._gen.host.authority.v1.authority_pb2 import KeysRequest from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 -from authority_client.work_context import Claims, Trust, Unauthenticated, authenticate, collect +from authority_client.work_context import Claims, Trust, Unauthenticated, authenticate, bound_token, collect __all__ = ["Identity", "Verifier", "Unauthenticated", "identity_of"] @@ -41,8 +41,9 @@ ADVISORY_KEY = STAMP_PREFIX + "advisory" # The Go verifier's key policy: re-read on an unknown key at most every 10 s, -# and when older than 5 min; unreadable keys are trusted no longer than 15 min. -KEY_RETRY, KEY_MAX_AGE, KEY_STALE = 10.0, 300.0, 900.0 +# and when older than 5 min, on a read of its own bounded by 5 s; unreadable +# keys are trusted no longer than 15 min. +KEY_RETRY, KEY_MAX_AGE, KEY_STALE, KEY_TIMEOUT = 10.0, 300.0, 900.0, 5.0 @dataclass(frozen=True) @@ -77,9 +78,8 @@ def identity_of(context) -> Identity: actors = [] for actor in context.actor_chain: actors.append(actor.principal_id) - if actor.granted_scopes: - held = _flatten(actor.granted_scopes) - scopes = [s for s in scopes if s in held] + held = _flatten(actor.granted_scopes) # an actor granted nothing narrows to nothing + scopes = [s for s in scopes if s in held] return Identity( owner=context.owner_principal_id, owner_kind=context.owner_principal_kind, @@ -97,26 +97,33 @@ class Verifier: ``keys`` is the authority interface's generated ``AuthorityServiceStub`` (or anything with its ``Keys``): the published keys are read through it, never - typed in. Build it once at startup. + typed in. Build it once at startup; it never fails on an authority that is not + serving yet, and refuses calls, with the reason, until the keys are read. """ - def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] | None = None) -> None: + def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] | None = None, + timeout: float = KEY_TIMEOUT) -> None: if keys is None or not audience or not proof: raise Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof") - self._source, self._audience, self._proof = keys, audience, proof + self._source, self._audience, self._proof, self._timeout = keys, audience, proof, timeout self._now = now or time.time self._lock = threading.Lock() self._trust: Trust | None = None - self._fetched = self._tried = 0.0 + self._fetched = 0.0 + self._tried = float("-inf") + self._inflight: threading.Event | None = None + self.keys_error: Exception | None = None # the last read's failure, for logs; never sent to a caller with self._lock: - self._fetch() + started = self._refresh() + started.wait(timeout) def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: - """Who the call is for, or ``Unauthenticated`` naming why it is refused.""" + """Who the call is for, or ``Unauthenticated`` naming why it is refused. + The stamp is checked first: nothing unbound reaches the key read.""" pairs = list(headers.items() if isinstance(headers, Mapping) else headers) + token = bound_token(pairs, self._proof) + claims = authenticate(pairs, self._proof, self._audience, self._trust_for(_key_id(token))) values = collect(pairs) - token = (values.get(WORK_CONTEXT_KEY) or [""])[0] - claims = authenticate(pairs, self._proof, self._audience, self._trust_for(token)) identity = identity_of(claims.context) stamped = { OWNER_KEY: identity.owner, OWNER_KIND_KEY: identity.owner_kind, TENANT_KEY: identity.tenant, @@ -131,28 +138,42 @@ def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Iden advisory = (values.get(ADVISORY_KEY) or [""])[0] return Identity(**{**identity.__dict__, "context": token, "advisory": advisory, "claims": claims}) - def _trust_for(self, token: str) -> Trust: + def _trust_for(self, key_id: str) -> Trust: with self._lock: now = self._now() - held = self._trust is not None and _key_id(token) in self._trust.keys - if (not held or now - self._fetched > KEY_MAX_AGE) and now - self._tried >= KEY_RETRY: - try: - self._fetch() - except Unauthenticated: - if now - self._fetched > KEY_STALE: - raise - if now - self._fetched > KEY_STALE: - raise Unauthenticated(f"the authority's keys were last read {int(now - self._fetched)}s ago") + held = self._trust is not None and key_id in self._trust.keys + if (not held or now - self._fetched > KEY_MAX_AGE) and self._inflight is None and now - self._tried >= KEY_RETRY: + self._refresh() + wait = self._inflight + if not held and wait is not None: + wait.wait(self._timeout) + with self._lock: + if self._trust is None or self._now() - self._fetched > KEY_STALE: + raise Unauthenticated("the authority's published keys are unavailable") return self._trust - def _fetch(self) -> None: - self._tried = self._now() - try: - response = self._source.Keys(KeysRequest()) - except Exception as error: # a gRPC error, or the stub's transport - raise Unauthenticated(f"the authority's published keys cannot be read: {error}") from error - self._trust = Trust.from_keys(response, now=lambda: datetime.fromtimestamp(self._now(), timezone.utc)) - self._fetched = self._tried + def _refresh(self) -> threading.Event: + """Start the one read, on its own thread and timeout; the lock is held.""" + if self._inflight is not None: + return self._inflight + done = threading.Event() + self._inflight, self._tried = done, self._now() + + def read() -> None: + trust, error = None, None + try: + response = self._source.Keys(KeysRequest(), timeout=self._timeout) + trust = Trust.from_keys(response, now=lambda: datetime.fromtimestamp(self._now(), timezone.utc)) + except Exception as failure: # a gRPC error, a timeout, or keys that are unusable + error = failure + with self._lock: + if trust is not None: + self._trust, self._fetched = trust, self._now() + self.keys_error, self._inflight = error, None + done.set() + + threading.Thread(target=read, name="codefly-hop-keys", daemon=True).start() + return done def _key_id(token: str) -> str: diff --git a/libraries/hop/python/tests/test_read.py b/libraries/hop/python/tests/test_read.py index d9e02c4f..3aff34f0 100644 --- a/libraries/hop/python/tests/test_read.py +++ b/libraries/hop/python/tests/test_read.py @@ -18,10 +18,13 @@ class Keys: """The authority interface's Keys, as the generated stub answers it.""" def __init__(self, response=None, error=None): - self.response, self.error, self.reads = response, error, 0 + self.response, self.error, self.reads, self.hold = response, error, 0, None - def Keys(self, _request): + def Keys(self, _request, timeout=None): + assert timeout is not None, "a key read always carries a timeout" self.reads += 1 + if self.hold is not None and not self.hold.wait(timeout): + raise TimeoutError("deadline exceeded") if self.error: raise self.error return self.response @@ -76,21 +79,70 @@ def test_another_audience_and_an_expired_context_are_refused(): verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"]) -def test_keys_are_reread_for_an_unknown_key_and_trusted_no_longer_than_stale(): +def settle(v): + wait = v._inflight + if wait is not None: + wait.wait(5) + + +def test_keys_are_reread_when_old_and_trusted_no_longer_than_stale(): clock = [FIXTURE["now"]] source = Keys(published()) v = verifier(clock=lambda: clock[0], source=source) rotated = KeysResponse(issuer=published().issuer) rotated.keys.add(key_id="rotated", algorithm="Ed25519", public_key=bytes(32)) source.response = rotated - clock[0] += 400 # older than KEY_MAX_AGE: re-read, and the fixture key is gone - with pytest.raises(Unauthenticated, match="no verification key"): - v.verify(FIXTURE["headers"]) + clock[0] += 400 # older than KEY_MAX_AGE: a re-read starts; the held key serves this call + v.verify(FIXTURE["headers"]) + settle(v) assert source.reads == 2 - source.response, source.error = published(), RuntimeError("authority down") + clock[0] += 11 + with pytest.raises(Unauthenticated, match="no verification key"): + v.verify(FIXTURE["headers"]) # the authority no longer publishes it + source.response, source.error = published(), RuntimeError("dial authority.internal:4000: connection refused") clock[0] += 1000 - with pytest.raises(Unauthenticated, match="cannot be read"): + with pytest.raises(Unauthenticated, match="unavailable") as refused: + v.verify(FIXTURE["headers"]) # an unheld key waits for the read, which fails; the keys are stale + assert "4000" not in str(refused.value) and "4000" in str(v.keys_error) + + +# Review r44 B1: nothing without the proof reaches the key read, and a hung read +# stalls no call whose key is held. +def test_a_call_without_the_proof_never_reads_the_keys(): + clock = [FIXTURE["now"]] + source = Keys(published()) + v = verifier(clock=lambda: clock[0], source=source) + for _ in range(20): + clock[0] += 11 + for headers in ({WORK_CONTEXT_KEY: "garbage"}, {**FIXTURE["headers"], "x-codefly-hop-proof": "another-run"}): + with pytest.raises(Unauthenticated): + v.verify(headers) + assert source.reads == 1 + + +def test_a_hung_read_stalls_no_held_key(): + import threading + import time as clock_time + clock = [FIXTURE["now"]] + source = Keys(published()) + v = verifier(clock=lambda: clock[0], source=source) + source.hold = threading.Event() + clock[0] += 400 # old keys: a background read starts and hangs + started = clock_time.monotonic() + assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert clock_time.monotonic() - started < 1 + source.hold.set() + + +def test_a_verifier_starts_before_the_authority_serves_keys(): + clock = [FIXTURE["now"]] + source = Keys(error=RuntimeError("connection refused")) + v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0]) + with pytest.raises(Unauthenticated, match="unavailable"): v.verify(FIXTURE["headers"]) + source.error, source.response = None, published() + clock[0] += 11 + assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): @@ -99,9 +151,12 @@ def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): context.authority_scopes.add(resource_kind="records", actions=["read"], resource_ids=["r-1"]) robin = context.actor_chain.add(principal_id="robin") robin.granted_scopes.add(resource_kind="documents", actions=["read"]) - context.actor_chain.add(principal_id="model") + model = context.actor_chain.add(principal_id="model") + model.granted_scopes.add(resource_kind="documents", actions=["read"]) identity = identity_of(context) assert identity.actors == ("robin", "model") and identity.scopes == ("documents:read",) + context.actor_chain.add(principal_id="granted-nothing") + assert identity_of(context).scopes == () # Core's EffectiveScopes: the last actor's grants def test_a_verifier_needs_keys_an_audience_and_a_proof(): diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go index 37366356..c7941749 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/proxy_test.go @@ -176,13 +176,17 @@ func TestEffectiveScopesAreTheOwnersNarrowedByEveryActor(t *testing.T) { {ResourceKind: "records", Actions: []string{"read"}, ResourceIds: []string{"r1"}}, }, ActorChain: []*authorityv1.WorkActorV1{ - {PrincipalId: "org-directory"}, + {PrincipalId: "org-directory", GrantedScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read", "write"}}}}, {PrincipalId: "documents-reader", GrantedScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}}, }, }) if strings.Join(id.Scopes, ",") != "documents:read" || strings.Join(id.Actors, ",") != "org-directory,documents-reader" { t.Fatalf("identity: %+v", id) } + // An actor granted nothing narrows to nothing, as Core's EffectiveScopes. + if empty := decide.IdentityOf(&authorityv1.WorkContextV1{AuthorityScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, ActorChain: []*authorityv1.WorkActorV1{{PrincipalId: "robin"}}}); len(empty.Scopes) != 0 { + t.Fatalf("an actor granted nothing: %+v", empty) + } } func TestTheHopHandsTheMethodToTheAuthorityAndPassesItsRefusalOn(t *testing.T) { diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 1c474eda..30d96dc6 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -30,7 +30,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index 325f1b65..c373f102 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,8 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c h1:c3/JxuWdBlHh49QEj12ucTCaYMVDiKyk1BxV6LOtsHE= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025224207-ead918ed860c/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index e71b1528..ebe90ba4 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -30,11 +30,17 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl if err != nil || proof == "" { return nil, fmt.Errorf("console: no run proof delivered (codefly config generate hop PROOF): %v", err) } + audience, err := hop.Audience() + if err != nil { + return nil, fmt.Errorf("console: %w", err) + } conn, _, err := host.Dial(ctx) if err != nil { return nil, fmt.Errorf("console: %w", err) } - verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), "console", proof) + // It starts whether or not the authority serves its keys yet, and refuses + // calls with the reason until it does. + verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, proof) if err != nil { _ = conn.Close() return nil, fmt.Errorf("console: %w", err) From 593a2abea64847ed370e80a858be6bce49efb408 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:08:04 -0400 Subject: [PATCH 6/8] fix: Python modules dial the authority under host.Dial's transport rule 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 --- libraries/authority-client/python/README.md | 16 ++ .../python/authority_client.egg-info/PKG-INFO | 8 + .../authority_client.egg-info/SOURCES.txt | 39 +++++ .../dependency_links.txt | 1 + .../authority_client.egg-info/requires.txt | 5 + .../authority_client.egg-info/top_level.txt | 1 + .../python/authority_client/host.py | 145 ++++++++++++++++++ .../python/tests/test_host.py | 96 ++++++++++++ 8 files changed, 311 insertions(+) create mode 100644 libraries/authority-client/python/authority_client.egg-info/PKG-INFO create mode 100644 libraries/authority-client/python/authority_client.egg-info/SOURCES.txt create mode 100644 libraries/authority-client/python/authority_client.egg-info/dependency_links.txt create mode 100644 libraries/authority-client/python/authority_client.egg-info/requires.txt create mode 100644 libraries/authority-client/python/authority_client.egg-info/top_level.txt create mode 100644 libraries/authority-client/python/authority_client/host.py create mode 100644 libraries/authority-client/python/tests/test_host.py diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index e7b48342..8f5b8e7e 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -44,3 +44,19 @@ uv run --no-project --with pytest --with-editable . pytest tests ``` The tests re-sign the bodies of `../testdata/claims.json` with a test key. + +## Dialling the host + +`authority_client.host` is the Go library's `host.Target` / `host.Endpoint` rule +(`../go/host`), case for case (`tests/test_host.py` mirrors `host_test.go`): +`https://` is verified TLS; plaintext only to loopback in a local run, or to an +in-cluster Service address under `internal-transport/mesh-protected=true`; anything +else is refused by name, never echoing the value. The caller passes what its SDK +resolved (whether the run is local, the raw assertion): + +```python +from authority_client import host + +channel = host.dial(endpoint, local=is_local, mesh=mesh_protected_value) +stub = AuthorityServiceStub(channel) # Keys for codefly_hop.Verifier, Issue +``` diff --git a/libraries/authority-client/python/authority_client.egg-info/PKG-INFO b/libraries/authority-client/python/authority_client.egg-info/PKG-INFO new file mode 100644 index 00000000..3b296097 --- /dev/null +++ b/libraries/authority-client/python/authority_client.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: authority-client +Version: 0.0.1 +Requires-Dist: protobuf<7,>=5.29.3 +Requires-Dist: grpcio>=1.71.0 +Requires-Dist: googleapis-common-protos +Requires-Dist: cryptography>=41 +Requires-Dist: protovalidate>=1.0 diff --git a/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt b/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt new file mode 100644 index 00000000..33e5ac4f --- /dev/null +++ b/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt @@ -0,0 +1,39 @@ +README.md +pyproject.toml +authority_client/__init__.py +authority_client/contract.binpb +authority_client/host.py +authority_client/work_context.py +authority_client.egg-info/PKG-INFO +authority_client.egg-info/SOURCES.txt +authority_client.egg-info/dependency_links.txt +authority_client.egg-info/requires.txt +authority_client.egg-info/top_level.txt +authority_client/_gen/__init__.py +authority_client/_gen/api_pb2.py +authority_client/_gen/api_pb2_grpc.py +authority_client/_gen/host/__init__.py +authority_client/_gen/host/artifact/__init__.py +authority_client/_gen/host/artifact/v1/__init__.py +authority_client/_gen/host/artifact/v1/artifact_pb2.py +authority_client/_gen/host/artifact/v1/artifact_pb2_grpc.py +authority_client/_gen/host/authority/__init__.py +authority_client/_gen/host/authority/v1/__init__.py +authority_client/_gen/host/authority/v1/authority_pb2.py +authority_client/_gen/host/authority/v1/authority_pb2_grpc.py +authority_client/_gen/host/authority/v1/work_context_pb2.py +authority_client/_gen/host/authority/v1/work_context_pb2_grpc.py +authority_client/_gen/host/composition/__init__.py +authority_client/_gen/host/composition/v1/__init__.py +authority_client/_gen/host/composition/v1/composition_pb2.py +authority_client/_gen/host/composition/v1/composition_pb2_grpc.py +authority_client/_gen/host/installation/__init__.py +authority_client/_gen/host/installation/v1/__init__.py +authority_client/_gen/host/installation/v1/installation_pb2.py +authority_client/_gen/host/installation/v1/installation_pb2_grpc.py +authority_client/_gen/host/session/__init__.py +authority_client/_gen/host/session/v1/__init__.py +authority_client/_gen/host/session/v1/session_pb2.py +authority_client/_gen/host/session/v1/session_pb2_grpc.py +tests/test_host.py +tests/test_work_context.py \ No newline at end of file diff --git a/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt b/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/libraries/authority-client/python/authority_client.egg-info/requires.txt b/libraries/authority-client/python/authority_client.egg-info/requires.txt new file mode 100644 index 00000000..02657566 --- /dev/null +++ b/libraries/authority-client/python/authority_client.egg-info/requires.txt @@ -0,0 +1,5 @@ +protobuf<7,>=5.29.3 +grpcio>=1.71.0 +googleapis-common-protos +cryptography>=41 +protovalidate>=1.0 diff --git a/libraries/authority-client/python/authority_client.egg-info/top_level.txt b/libraries/authority-client/python/authority_client.egg-info/top_level.txt new file mode 100644 index 00000000..765dbc00 --- /dev/null +++ b/libraries/authority-client/python/authority_client.egg-info/top_level.txt @@ -0,0 +1 @@ +authority_client diff --git a/libraries/authority-client/python/authority_client/host.py b/libraries/authority-client/python/authority_client/host.py new file mode 100644 index 00000000..62074011 --- /dev/null +++ b/libraries/authority-client/python/authority_client/host.py @@ -0,0 +1,145 @@ +"""The transport rule for dialling the authority host, as a module served in Python +applies it: the Go library's ``host.Target`` / ``host.Endpoint`` +(``libraries/authority-client/go/host``), rule for rule. + +``https://`` is verified TLS. Plaintext (``http://``, ``grpc://``, ``tcp://`` or a bare +``host:port``) is admitted only to a loopback address in a local run, or to an +in-cluster Service address (``..svc[.cluster.local]``) under the +composition's literal assertion that a mutually authenticated mesh carries the hop +(``internal-transport/mesh-protected=true``). Anything else is refused, by name, and a +refusal names the endpoint by its host and port alone: never the raw value, which may +carry a credential. + +The caller supplies what its SDK resolved, as the Go library reads it from its own: +whether the run is local, and the raw ``internal-transport/mesh-protected`` value. +""" + +from __future__ import annotations + +import ipaddress +import re +from dataclasses import dataclass +from urllib.parse import urlsplit + +import grpc + +__all__ = ["Refused", "Target", "endpoint", "dial", "mesh_protected"] + +PLAINTEXT_SCHEMES = ("http", "grpc", "tcp") +_LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") + + +class Refused(ValueError): + """The endpoint may not be dialled; the message names the rule, never the value.""" + + +@dataclass(frozen=True) +class Target: + """A gRPC target and whether it is reached over TLS.""" + + address: str + tls: bool + + +def mesh_protected(value: str | None) -> bool: + """The composition's ``internal-transport/mesh-protected``: unset, empty or + ``false`` is no assertion; exactly ``true`` is one; anything else is refused.""" + if value is None or value == "" or value == "false": + return False + if value != "true": + raise Refused("internal-transport/mesh-protected must be exactly true or false") + return True + + +def endpoint(value: str, *, local: bool, mesh: str | None) -> Target: + """The target for ``value`` (authority/endpoint, or an explicit upstream) under the + transport rule, or ``Refused`` naming why.""" + asserted = mesh_protected(mesh) + value = (value or "").strip() + if not value: + raise Refused("transport endpoint is empty; supply a resolved endpoint") + if "://" not in value: + host = _split_host_port(value) + if host is None: + raise Refused( + "authority/endpoint is neither host:port nor a URL " + "(value not shown: it may carry a credential)" + ) + return _plaintext(value, host, local, asserted) + try: + parts = urlsplit(value) + port = parts.port + hostname = parts.hostname + except ValueError: + raise Refused("authority/endpoint (unparseable) is not an origin") from None + named = _host_only(parts.netloc) + if ( + not hostname + or parts.username is not None + or parts.password is not None + or parts.query + or parts.fragment + or parts.path not in ("", "/") + ): + raise Refused( + f"authority/endpoint {named} is not an origin: it must carry no credential, " + "path, query or fragment" + ) + host = f"[{hostname}]" if ":" in hostname else hostname + if parts.scheme == "https": + return Target(f"{host}:{port or 443}", True) + if parts.scheme in PLAINTEXT_SCHEMES: + return _plaintext(f"{host}:{port or 80}", hostname, local, asserted) + raise Refused( + f"authority/endpoint {named} names scheme {parts.scheme!r}; " + "the host is reached over http:// (h2c) or https://" + ) + + +def dial(value: str, *, local: bool, mesh: str | None, options=()) -> grpc.Channel: + """A channel to ``value`` under the transport rule (``endpoint``).""" + target = endpoint(value, local=local, mesh=mesh) + if target.tls: + return grpc.secure_channel(target.address, grpc.ssl_channel_credentials(), options=options) + return grpc.insecure_channel(target.address, options=options) + + +def _plaintext(target: str, hostname: str, local: bool, mesh: bool) -> Target: + if not (local and _loopback(hostname)) and not (mesh and _cluster_service(hostname)): + raise Refused( + "authority endpoint requires HTTPS, local loopback, or an in-cluster Service " + "address with internal-transport/mesh-protected=true" + ) + return Target(target, False) + + +def _split_host_port(value: str) -> str | None: + match = re.fullmatch(r"\[([^\]]+)\]:(\d{1,5})|([^:\[\]/@?#\s]+):(\d{1,5})", value) + if match is None: + return None + return match.group(1) or match.group(3) + + +def _loopback(hostname: str) -> bool: + if hostname.lower() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _cluster_service(hostname: str) -> bool: + """The mesh assertion covers Service DNS names, never arbitrary hosts or IPs.""" + labels = hostname.lower().removesuffix(".").split(".") + if len(labels) not in (3, 5) or labels[2] != "svc": + return False + if len(labels) == 5 and labels[3:] != ["cluster", "local"]: + return False + return all(_LABEL.match(label) for label in labels[:2]) + + +def _host_only(netloc: str) -> str: + """The host and port of a netloc, without userinfo.""" + host = netloc.rsplit("@", 1)[-1] + return host or "(unparseable)" diff --git a/libraries/authority-client/python/tests/test_host.py b/libraries/authority-client/python/tests/test_host.py new file mode 100644 index 00000000..45c30fea --- /dev/null +++ b/libraries/authority-client/python/tests/test_host.py @@ -0,0 +1,96 @@ +"""authority_client.host holds the Go library's host.Target rule, case for case +(libraries/authority-client/go/host/host_test.go).""" + +import pytest + +from authority_client.host import Refused, Target, endpoint, mesh_protected + + +@pytest.mark.parametrize( + "value, want", + [ + ("localhost:9000", Target("localhost:9000", False)), + ("http://localhost:8080", Target("localhost:8080", False)), + ("http://localhost", Target("localhost:80", False)), + ("grpc://localhost:9000", Target("localhost:9000", False)), + ("http://127.0.0.1", Target("127.0.0.1:80", False)), + ("http://[::1]", Target("[::1]:80", False)), + ("https://accounts.example.test", Target("accounts.example.test:443", True)), + ("https://accounts.example.test:8443/", Target("accounts.example.test:8443", True)), + ], +) +def test_the_configured_endpoint_in_a_local_run(value, want): + assert endpoint(value, local=True, mesh=None) == want + + +@pytest.mark.parametrize( + "value", + ["", " ", "accounts", "ftp://accounts:21", "https://accounts/v1", "https://u:p@accounts", + "http://accounts:1?x=1", "http://"], +) +def test_what_is_not_an_origin_is_refused(value): + with pytest.raises(Refused): + endpoint(value, local=True, mesh=None) + + +@pytest.mark.parametrize( + "value", + ["remote.test:8080", "http://remote.test", "grpc://10.0.0.2:443", "tcp://[2001:db8::1]:8080", + "http://10.0.0.5:9000", "http://authority.attacker.example:80"], +) +def test_plaintext_to_a_routable_host_is_refused(value): + for mesh in (None, "true"): + with pytest.raises(Refused, match="requires HTTPS"): + endpoint(value, local=True, mesh=mesh) + + +def test_loopback_plaintext_is_local_only(): + with pytest.raises(Refused): + endpoint("http://127.0.0.1:8080", local=False, mesh=None) + + +VALID = ("accounts.platform.svc", "accounts.platform.svc.cluster.local", "accounts.platform.svc.cluster.local.") +NAMES = VALID + ("10.0.0.2", "remote.test", "localhost", "accounts.platform.svc.evil.test", "accounts..svc", + "-accounts.platform.svc", "accounts.plat_form.svc") + + +@pytest.mark.parametrize("assertion", [None, "", "false", "true"]) +@pytest.mark.parametrize("name", NAMES) +def test_an_in_cluster_service_is_plaintext_only_under_the_mesh_assertion(assertion, name): + allowed = assertion == "true" and name in VALID + try: + target = endpoint(f"http://{name}:8080", local=False, mesh=assertion) + except Refused: + assert not allowed + else: + assert allowed and target == Target(f"{name}:8080", False) + + +@pytest.mark.parametrize("assertion", ["TRUE", " true", "true ", "yes"]) +def test_a_misspelt_assertion_is_refused_even_over_tls(assertion): + with pytest.raises(Refused, match="exactly true or false"): + mesh_protected(assertion) + with pytest.raises(Refused): + endpoint("https://accounts.example.test", local=False, mesh=assertion) + + +SECRET = "s3cr3t-token" + + +@pytest.mark.parametrize( + "value", + [ + f"https://user:{SECRET}@authority.example:8443", + f"https://authority.example:8443/?token={SECRET}", + f"https://authority.example:8443/{SECRET}", + f"https://authority.example:8443/#{SECRET}", + f"ftp://authority.example:21?k={SECRET}", + f"ftp://{SECRET}@authority.example:21", + f"{SECRET}-not-an-address", + f"https://[{SECRET}", + ], +) +def test_a_refusal_never_echoes_the_endpoint_value(value): + with pytest.raises(Refused) as refused: + endpoint(value, local=True, mesh=None) + assert SECRET not in str(refused.value) From 7caa0cf8cad91c77f5d26f899a05e820064b46e1 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:22:37 -0400 Subject: [PATCH 7/8] fix: codefly_hop ships inside authority-client; verify_async keeps the 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 --- libraries/authority-client/python/.gitignore | 2 + libraries/authority-client/python/README.md | 30 +++++++++- .../python/authority_client.egg-info/PKG-INFO | 8 --- .../authority_client.egg-info/SOURCES.txt | 39 ------------- .../dependency_links.txt | 1 - .../authority_client.egg-info/requires.txt | 5 -- .../authority_client.egg-info/top_level.txt | 1 - .../python}/codefly_hop/__init__.py | 58 +++++++++++++------ .../authority-client/python/pyproject.toml | 2 +- .../python/tests/test_hop.py} | 35 ++++++++++- libraries/hop/python/README.md | 41 ------------- libraries/hop/python/pyproject.toml | 22 ------- 12 files changed, 105 insertions(+), 139 deletions(-) create mode 100644 libraries/authority-client/python/.gitignore delete mode 100644 libraries/authority-client/python/authority_client.egg-info/PKG-INFO delete mode 100644 libraries/authority-client/python/authority_client.egg-info/SOURCES.txt delete mode 100644 libraries/authority-client/python/authority_client.egg-info/dependency_links.txt delete mode 100644 libraries/authority-client/python/authority_client.egg-info/requires.txt delete mode 100644 libraries/authority-client/python/authority_client.egg-info/top_level.txt rename libraries/{hop/python/src => authority-client/python}/codefly_hop/__init__.py (85%) rename libraries/{hop/python/tests/test_read.py => authority-client/python/tests/test_hop.py} (85%) delete mode 100644 libraries/hop/python/README.md delete mode 100644 libraries/hop/python/pyproject.toml diff --git a/libraries/authority-client/python/.gitignore b/libraries/authority-client/python/.gitignore new file mode 100644 index 00000000..aadc86cf --- /dev/null +++ b/libraries/authority-client/python/.gitignore @@ -0,0 +1,2 @@ +*.egg-info/ +__pycache__/ diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index 8f5b8e7e..2b4598f4 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -11,8 +11,32 @@ claims = authenticate(request.headers, proof, audience, trust) # raises Unauthe allowed = claims.allows(resource_kind, action, installed_resource_id) ``` -A module normally uses `codefly_hop.Verifier` (`libraries/hop/python`), which -resolves and refreshes the trust and derives who the call is for. +A module normally uses `codefly_hop.Verifier`, shipped in this same +distribution, which resolves and refreshes the trust and derives who the call is +for: + +```python +from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub +from codefly_hop import Unauthenticated, Verifier + +verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], proof=proof) # once +identity = verifier.verify(request.headers) # a sync handler +identity = await verifier.verify_async(request.headers) # an asyncio handler: a key wait runs off the loop +identity.tenant, identity.has(kind, action), identity.claims.allows(kind, action, resource_id) +``` + +The verifier checks the stamp first, then reads the published keys through `Keys` +on a thread of its own with a 5 s timeout, one read at a time; it re-reads them +on an unknown key (at most every 10 s) and when older than 5 minutes; keys that +cannot be re-read are trusted for 15 minutes at most. It never fails to +construct on an authority that is not serving yet, and refuses calls with a +fixed reason until it is; `verifier.keys_error` holds the read's failure for +logs. `codefly_hop` reads the hop's signed fixture (`../../hop/testdata/stamped.json`) +as the Go library writes it. + +`codefly_hop` and `authority_client` are one distribution so that one pinned +commit installs both, and no dependency is ever resolved by name from a public +index. `authenticate` checks the run's hop proof, exactly one token and the stamp's digest of it; then Core's rules in Core's order: the decode path (size, the @@ -36,7 +60,7 @@ Effective scopes are the final actor's grants, or the owner's when there is no actor. Each scope is evaluated independently; an omitted resource ID asks for the whole kind; actions are literal. -Pin consumers by repository commit: +Pin consumers by repository commit (pip and uv alike): `authority-client @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/authority-client/python`. ```sh diff --git a/libraries/authority-client/python/authority_client.egg-info/PKG-INFO b/libraries/authority-client/python/authority_client.egg-info/PKG-INFO deleted file mode 100644 index 3b296097..00000000 --- a/libraries/authority-client/python/authority_client.egg-info/PKG-INFO +++ /dev/null @@ -1,8 +0,0 @@ -Metadata-Version: 2.4 -Name: authority-client -Version: 0.0.1 -Requires-Dist: protobuf<7,>=5.29.3 -Requires-Dist: grpcio>=1.71.0 -Requires-Dist: googleapis-common-protos -Requires-Dist: cryptography>=41 -Requires-Dist: protovalidate>=1.0 diff --git a/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt b/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt deleted file mode 100644 index 33e5ac4f..00000000 --- a/libraries/authority-client/python/authority_client.egg-info/SOURCES.txt +++ /dev/null @@ -1,39 +0,0 @@ -README.md -pyproject.toml -authority_client/__init__.py -authority_client/contract.binpb -authority_client/host.py -authority_client/work_context.py -authority_client.egg-info/PKG-INFO -authority_client.egg-info/SOURCES.txt -authority_client.egg-info/dependency_links.txt -authority_client.egg-info/requires.txt -authority_client.egg-info/top_level.txt -authority_client/_gen/__init__.py -authority_client/_gen/api_pb2.py -authority_client/_gen/api_pb2_grpc.py -authority_client/_gen/host/__init__.py -authority_client/_gen/host/artifact/__init__.py -authority_client/_gen/host/artifact/v1/__init__.py -authority_client/_gen/host/artifact/v1/artifact_pb2.py -authority_client/_gen/host/artifact/v1/artifact_pb2_grpc.py -authority_client/_gen/host/authority/__init__.py -authority_client/_gen/host/authority/v1/__init__.py -authority_client/_gen/host/authority/v1/authority_pb2.py -authority_client/_gen/host/authority/v1/authority_pb2_grpc.py -authority_client/_gen/host/authority/v1/work_context_pb2.py -authority_client/_gen/host/authority/v1/work_context_pb2_grpc.py -authority_client/_gen/host/composition/__init__.py -authority_client/_gen/host/composition/v1/__init__.py -authority_client/_gen/host/composition/v1/composition_pb2.py -authority_client/_gen/host/composition/v1/composition_pb2_grpc.py -authority_client/_gen/host/installation/__init__.py -authority_client/_gen/host/installation/v1/__init__.py -authority_client/_gen/host/installation/v1/installation_pb2.py -authority_client/_gen/host/installation/v1/installation_pb2_grpc.py -authority_client/_gen/host/session/__init__.py -authority_client/_gen/host/session/v1/__init__.py -authority_client/_gen/host/session/v1/session_pb2.py -authority_client/_gen/host/session/v1/session_pb2_grpc.py -tests/test_host.py -tests/test_work_context.py \ No newline at end of file diff --git a/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt b/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt deleted file mode 100644 index 8b137891..00000000 --- a/libraries/authority-client/python/authority_client.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libraries/authority-client/python/authority_client.egg-info/requires.txt b/libraries/authority-client/python/authority_client.egg-info/requires.txt deleted file mode 100644 index 02657566..00000000 --- a/libraries/authority-client/python/authority_client.egg-info/requires.txt +++ /dev/null @@ -1,5 +0,0 @@ -protobuf<7,>=5.29.3 -grpcio>=1.71.0 -googleapis-common-protos -cryptography>=41 -protovalidate>=1.0 diff --git a/libraries/authority-client/python/authority_client.egg-info/top_level.txt b/libraries/authority-client/python/authority_client.egg-info/top_level.txt deleted file mode 100644 index 765dbc00..00000000 --- a/libraries/authority-client/python/authority_client.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -authority_client diff --git a/libraries/hop/python/src/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py similarity index 85% rename from libraries/hop/python/src/codefly_hop/__init__.py rename to libraries/authority-client/python/codefly_hop/__init__.py index ca79ffdb..a1711525 100644 --- a/libraries/hop/python/src/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -9,10 +9,15 @@ authority's published keys (``authority_client.work_context.authenticate``), who the call is for is read from its claims, and a stamp that says otherwise is refused. Both read the one fixture the Go hop writes (libraries/hop/testdata). + +It ships in the authority-client distribution, beside the ``authority_client`` +package it rests on, so one pinned commit installs both and no dependency is +ever looked up by name on a public index. """ from __future__ import annotations +import asyncio import base64 import threading import time @@ -119,10 +124,43 @@ def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: """Who the call is for, or ``Unauthenticated`` naming why it is refused. - The stamp is checked first: nothing unbound reaches the key read.""" + The stamp is checked first: nothing unbound reaches the key read. It may + wait up to the read's timeout for a key it does not hold yet; an asyncio + handler uses ``verify_async``.""" + pairs, token = self._bound(headers) + wait = self._start(_key_id(token)) + if wait is not None: + wait.wait(self._timeout) + return self._finish(pairs, token) + + async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + """``verify`` for an asyncio handler: a wait for the keys runs off the event + loop, and a call whose key is held never leaves it.""" + pairs, token = self._bound(headers) + wait = self._start(_key_id(token)) + if wait is not None: + await asyncio.to_thread(wait.wait, self._timeout) + return self._finish(pairs, token) + + def _bound(self, headers) -> tuple[list[tuple[str, str]], str]: pairs = list(headers.items() if isinstance(headers, Mapping) else headers) - token = bound_token(pairs, self._proof) - claims = authenticate(pairs, self._proof, self._audience, self._trust_for(_key_id(token))) + return pairs, bound_token(pairs, self._proof) + + def _start(self, key_id: str) -> threading.Event | None: + """Start a read when one is due; return it when this call must wait for it.""" + with self._lock: + now = self._now() + held = self._trust is not None and key_id in self._trust.keys + if (not held or now - self._fetched > KEY_MAX_AGE) and self._inflight is None and now - self._tried >= KEY_RETRY: + self._refresh() + return None if held else self._inflight + + def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: + with self._lock: + if self._trust is None or self._now() - self._fetched > KEY_STALE: + raise Unauthenticated("the authority's published keys are unavailable") + trust = self._trust + claims = authenticate(pairs, self._proof, self._audience, trust) values = collect(pairs) identity = identity_of(claims.context) stamped = { @@ -138,20 +176,6 @@ def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Iden advisory = (values.get(ADVISORY_KEY) or [""])[0] return Identity(**{**identity.__dict__, "context": token, "advisory": advisory, "claims": claims}) - def _trust_for(self, key_id: str) -> Trust: - with self._lock: - now = self._now() - held = self._trust is not None and key_id in self._trust.keys - if (not held or now - self._fetched > KEY_MAX_AGE) and self._inflight is None and now - self._tried >= KEY_RETRY: - self._refresh() - wait = self._inflight - if not held and wait is not None: - wait.wait(self._timeout) - with self._lock: - if self._trust is None or self._now() - self._fetched > KEY_STALE: - raise Unauthenticated("the authority's published keys are unavailable") - return self._trust - def _refresh(self) -> threading.Event: """Start the one read, on its own thread and timeout; the lock is held.""" if self._inflight is not None: diff --git a/libraries/authority-client/python/pyproject.toml b/libraries/authority-client/python/pyproject.toml index f27daa26..d07d3406 100644 --- a/libraries/authority-client/python/pyproject.toml +++ b/libraries/authority-client/python/pyproject.toml @@ -8,7 +8,7 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["authority_client", "authority_client.*"] +include = ["authority_client", "authority_client.*", "codefly_hop"] [tool.setuptools.package-data] authority_client = ["contract.binpb"] diff --git a/libraries/hop/python/tests/test_read.py b/libraries/authority-client/python/tests/test_hop.py similarity index 85% rename from libraries/hop/python/tests/test_read.py rename to libraries/authority-client/python/tests/test_hop.py index 3aff34f0..c381bdca 100644 --- a/libraries/hop/python/tests/test_read.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -11,7 +11,7 @@ ACTORS_KEY, CONTEXT_KEY, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, Verifier, identity_of, ) -FIXTURE = json.loads((Path(__file__).parents[2] / "testdata/stamped.json").read_text()) +FIXTURE = json.loads((Path(__file__).parents[3] / "hop/testdata/stamped.json").read_text()) class Keys: @@ -163,3 +163,36 @@ def test_a_verifier_needs_keys_an_audience_and_a_proof(): for args in [(None, "documents", "p"), (Keys(published()), "", "p"), (Keys(published()), "documents", "")]: with pytest.raises(Unauthenticated): Verifier(args[0], audience=args[1], proof=args[2]) + + +# Review r57 m-b: an asyncio handler's wait for the keys never blocks the loop. +def test_verify_async_waits_for_keys_off_the_event_loop(): + import asyncio + import threading + + clock = [FIXTURE["now"]] + source = Keys(error=RuntimeError("not serving yet")) + v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=1.0) + source.error, source.response, source.hold = None, published(), threading.Event() + clock[0] += 11 # no key held: this call starts a read, which hangs, and waits for it + + async def scenario(): + ticks = 0 + + async def ticker(): + nonlocal ticks + while True: + await asyncio.sleep(0.01) + ticks += 1 + + running = asyncio.create_task(ticker()) + waiting = asyncio.create_task(v.verify_async(FIXTURE["headers"])) + await asyncio.sleep(0.3) + source.hold.set() + identity = await waiting + running.cancel() + return ticks, identity + + ticks, identity = asyncio.run(scenario()) + assert ticks >= 10, "the event loop was blocked while the keys were read" + assert identity.owner == FIXTURE["identity"]["owner"] diff --git a/libraries/hop/python/README.md b/libraries/hop/python/README.md deleted file mode 100644 index 85e62dc4..00000000 --- a/libraries/hop/python/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# codefly-hop (Python) - -The authority hop's stamp, authenticated by a module served in Python. The Go -library (`../go`, `hop.Verifier`) is the same contract; both read the fixture the -Go hop writes (`../testdata/stamped.json`, regenerated by the Go test with -`-update-fixture`). - -```python -from authority_client._gen.host.authority.v1.authority_pb2_grpc import AuthorityServiceStub -from codefly_hop import Unauthenticated, Verifier - -verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], proof=proof) # once, at startup -identity = verifier.verify(request.headers) # raises Unauthenticated, naming why -identity.tenant, identity.owner, identity.has("documents.collection", "read") -identity.claims.allows("documents.collection", "write", collection_id) -``` - -`channel` reaches the authority the module already depends on (the interface -`codefly.dev/authority`): the local authority on a laptop, the host on a cell. -The verifier checks the stamp first, then reads the published keys through `Keys` -on a thread of its own with a 5 s timeout, one read at a time; it re-reads them -when a token names a key it does not hold (at most every 10 s) and when they are -older than 5 minutes; keys that cannot be re-read are trusted for 15 minutes at -most. It never fails to construct on an authority that is not serving yet, and -refuses calls with a fixed reason until it is; `verifier.keys_error` holds the -read's own failure for logs. - -`verify` authenticates the token (`authority_client.work_context.authenticate`: -proof, digest, issuer, Ed25519 signature, audience, window), reads who the call -is for from the authenticated claims, and refuses a stamp that says otherwise. -`identity.advisory` is the hop's warning-mode reason; it grants nothing. - -A module depends on it by its git coordinates at a pinned commit: -`codefly-hop @ git+https://github.com/codefly-dev/authority@#subdirectory=libraries/hop/python`. -Install it with uv: its `authority-client` dependency is a path source to the -sibling library, so uv resolves it at the same commit; no second pin or -override is needed. (pip does not read `tool.uv.sources`.) - -```sh -uv run --with pytest --with-editable . pytest -``` diff --git a/libraries/hop/python/pyproject.toml b/libraries/hop/python/pyproject.toml deleted file mode 100644 index 9b2c7205..00000000 --- a/libraries/hop/python/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[project] -name = "codefly-hop" -version = "0.2.0" -description = "The authority hop's stamp, authenticated by a module served in Python: who a call is for, read from the verified Work Context." -requires-python = ">=3.11" -dependencies = ["authority-client"] - -[project.optional-dependencies] -test = ["pytest>=8"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/codefly_hop"] - -# authority-client is this repository's sibling library, at the same commit: -# installing codefly-hop at commit X with uv (git+…@X#subdirectory=…) brings -# authority-client at X, with no pin of its own to keep in step. -[tool.uv.sources] -authority-client = { path = "../../authority-client/python" } From 2d1c185b80c16fe8ea033e3b072ba7d458b782f9 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:26:34 -0400 Subject: [PATCH 8/8] docs: the hop fixture's Python reader is codefly_hop in authority-client Co-Authored-By: Claude Opus 5.5 --- libraries/hop/go/README.md | 2 +- libraries/hop/go/fixture_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 009b19c0..00a2144f 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -82,4 +82,4 @@ the actual installation-based tenant resolution. The Go dependency pins authority-client at the commit that introduced `workcontext.Authenticate`; standalone library tests need no workspace replacement. `../testdata/stamped.json` is a genuinely signed call as the hop stamps it; the -Python verifier reads it back. Regenerate with `go test -run Fixture -update-fixture`. +Python verifier (`codefly_hop`, shipped in `libraries/authority-client/python`) reads it back. Regenerate with `go test -run Fixture -update-fixture`. diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index 2ff66fc7..5d2591cb 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -129,7 +129,7 @@ type stampFixture struct { // The stamp's one contract, for every language a module is written in: the // headers this hop writes for one call carrying a genuinely signed Work // Context, the published keys that authenticate it at a fixed clock, and the -// identity read from it. The Python verifier (../python) reads it back. +// identity read from it. The Python verifier (codefly_hop, in libraries/authority-client/python) reads it back. func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { path := filepath.Join("..", "testdata", "stamped.json") s := newSigner("fixture")