From d3d0729ecafd337f49d72496cb988378c4fac578 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:09:27 -0400 Subject: [PATCH 01/19] Bind hop admissions to exact methods, audiences and single-use nonces Replace shared-proof admission with service-only Ed25519 signing, short validity windows and mandatory shared replay consumption in Go, Python and TypeScript. Refuse advisory policy bypasses and exercise the independent review probes. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 ++-- libraries/authority-client/go/README.md | 14 +- libraries/authority-client/go/stamp/call.go | 136 ++++++++++++++++ .../authority-client/go/stamp/call_test.go | 50 ++++++ .../authority-client/go/stamp/context.go | 13 +- .../authority-client/go/workcontext/claims.go | 22 +-- .../go/workcontext/claims_test.go | 18 +-- .../go/workcontext/owner_test.go | 2 +- .../go/workcontext/vectors_test.go | 2 +- libraries/authority-client/python/README.md | 15 +- .../python/authority_client/work_context.py | 13 +- .../python/codefly_hop/__init__.py | 76 +++++---- .../python/codefly_hop/call.py | 80 ++++++++++ .../authority-client/python/tests/test_hop.py | 148 ++++++++++++++---- .../python/tests/test_owner.py | 2 +- .../python/tests/test_work_context.py | 16 +- .../authority-client/typescript/README.md | 10 +- .../authority-client/typescript/hop-call.ts | 37 +++++ .../typescript/tests/owner.test.mjs | 2 +- .../typescript/tests/work-context.test.mjs | 95 +++++++---- .../typescript/work-context.ts | 53 +++---- libraries/hop/README.md | 109 +++++++++++++ libraries/hop/go/README.md | 24 ++- libraries/hop/go/binding_test.go | 121 ++++++++++++++ libraries/hop/go/fixture_test.go | 77 ++++----- libraries/hop/go/go.mod | 2 + libraries/hop/go/hop.go | 88 ++++++----- libraries/hop/go/hop_test.go | 36 +++-- libraries/hop/go/hoptest/hoptest.go | 31 +++- libraries/hop/go/owner_test.go | 2 +- libraries/hop/go/replay.go | 65 ++++++++ libraries/hop/go/replay_test.go | 42 +++++ libraries/hop/testdata/stamped.json | 13 +- modules/authority/services/hop/README.md | 45 ++---- .../services/hop/code/enforcement_test.go | 12 +- .../hop/code/pkg/business/decide/decide.go | 24 +-- .../pkg/business/decide/permissive_test.go | 45 +----- .../pkg/infra/proxy/canonical_path_test.go | 6 +- .../pkg/infra/proxy/environment_proxy_test.go | 5 +- .../pkg/infra/proxy/generated_routing_test.go | 19 +-- .../services/hop/code/pkg/infra/proxy/http.go | 14 +- .../hop/code/pkg/infra/proxy/http_test.go | 64 +++++++- .../infra/proxy/permissive_routing_test.go | 7 +- .../hop/code/pkg/infra/proxy/proxy.go | 8 +- .../hop/code/pkg/infra/proxy/proxy_test.go | 6 +- modules/authority/services/hop/code/work.go | 44 ++---- .../authority/services/hop/code/work_test.go | 8 +- .../services/hop/service.codefly.yaml | 5 +- modules/console/services/api/code/work.go | 16 +- .../console/services/api/service.codefly.yaml | 5 +- playground/configurations/README.md | 8 +- 51 files changed, 1300 insertions(+), 482 deletions(-) create mode 100644 libraries/authority-client/go/stamp/call.go create mode 100644 libraries/authority-client/go/stamp/call_test.go create mode 100644 libraries/authority-client/python/codefly_hop/call.py create mode 100644 libraries/authority-client/typescript/hop-call.ts create mode 100644 libraries/hop/README.md create mode 100644 libraries/hop/go/binding_test.go create mode 100644 libraries/hop/go/replay.go create mode 100644 libraries/hop/go/replay_test.go diff --git a/README.md b/README.md index 05d94399..64398590 100644 --- a/README.md +++ b/README.md @@ -497,24 +497,20 @@ gRPC-only composition. An empty HTTP routing table does not remove that endpoint from Codefly's readiness checks. Requests still pass through the authority decision and are refused when no corresponding HTTP upstream exists. -The module behind it carries one interceptor from `libraries/hop/go` and no -other authority code. The stamp binds the digest of the Work Context the hop -checked, so a module that needs the full claims decodes that exact token -without verifying it again. The interceptor gives each call the identity the hop -stamped, and refuses a call that did not come through the hop. It verifies no -token and asks no authority. The stamp is believed only with the run's proof, -which Codefly generates and delivers to the hop and the module alone. In a cell -the mesh plays that part. +The module behind it carries the interceptor from `libraries/hop/go`. It +verifies a short-lived Ed25519 admission bound to the exact method, audience and +token digest, authenticates the identity, and atomically consumes the call nonce +in a durable store shared by replicas. The hop alone receives the Codefly-generated +signing seed; modules receive its public key. See the breaking +[admission protocol and delivery migration](libraries/hop/README.md). The hop and the edge ask the host the composition names in the `authority` group's `endpoint`, the key every module reads for the same host, so pointing that key at saas accounts' Connect listener moves them with the modules: `host:port` and `http://` are the run's plaintext transport (h2c), `https://` is TLS. A composition that names none reaches the authority module's local service. -Under `hop/ENFORCEMENT=warn` the hop lets through a call the authority verified -and the method's policy would have refused, stamped with that refusal as its -advisory; a call with a missing, unsound or misaddressed Work Context, or one -the authority could not be asked about, is refused in either mode. +Signed admissions require enforced method policy in every environment. +Legacy `hop/ENFORCEMENT=warn` configuration is refused with a reason. This run's hop fronts the console, whose one operation answers who the call is for. Run it from the playground workspace, `playground/`, which composes this @@ -522,7 +518,7 @@ repository's modules with the platform's stores and the directory's page: ```bash cd playground -codefly config generate hop PROOF +codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN codefly run service console/api authority/hop --local-agents ``` @@ -587,7 +583,8 @@ resource IDs does not satisfy that requirement, even with a wildcard action; several restricted scopes do not become a whole-kind grant. A whole-kind grant alongside restricted scopes still satisfies its declared action. Refusals mark resource-limited holdings in the diagnostic. `Advise` reports the same refusal -without widening or discarding the checked capability, so warn mode can log it. +without widening or discarding the checked capability. An advisory result is +not sufficient to issue a hop admission. Checking a capability without a method and reading its resource scope remain supported. Modules still own resource visibility, ownership and installation checks; method admission does not replace them. @@ -936,7 +933,7 @@ interface evolution rules require a minor bump below 1.0. Installation Issue requires its installed caller's workload credential; an installation ID alone is never authority. Workloads name the work item's organization; the local sole-installation convenience is not required of other hosts. Advisory Check -is a local development extension, and the hop rejects warn outside local runs. +is a local diagnostic extension; the hop requires enforcing Check in every environment. `codefly.dev/session@0.2.0` adds person and organization display names to Current and SignIn (fields 7 and 8). Missing fields fail conformance. These implement the diff --git a/libraries/authority-client/go/README.md b/libraries/authority-client/go/README.md index 49d5236d..507e41aa 100644 --- a/libraries/authority-client/go/README.md +++ b/libraries/authority-client/go/README.md @@ -7,7 +7,7 @@ authenticates the Work Context the hop forwarded and projects its claims: import claims "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" md, _ := metadata.FromIncomingContext(ctx) -c, err := claims.Authenticate(md, proof, audience, trust) +c, err := claims.Authenticate(md, audience, trust) // err wraps claims.ErrUnauthenticated and names the reason. allowed := c.Allows(resourceKind, action, exactResourceID) ``` @@ -17,13 +17,11 @@ A module does not call this directly: `hop.Verifier` 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. +It checks one digest-bound token with Core's Inspector: canonical decode, +issuer, signature, audience, window and attenuation. This authenticates identity +only. Method authorization and replay protection require `hop.Verifier` and the +[signed admission protocol](../../hop/README.md). A token digest alone does not +prove that the hop made a live Check. `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 diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go new file mode 100644 index 00000000..e709ef65 --- /dev/null +++ b/libraries/authority-client/go/stamp/call.go @@ -0,0 +1,136 @@ +package stamp + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "google.golang.org/grpc/metadata" +) + +// Lifetime bounds how long an admitted call may start. It does not limit a stream's duration. +const Lifetime = 30 * time.Second + +// Call is a signed, single-use admission, independent of the Work Context's replay policy. +// The array encoding and domain separate this protocol from Work Context signatures. +type Call struct { + Method string + Audience string + Digest string + Nonce string + Issued int64 + Expires int64 +} + +// Signer holds a service-scoped seed generated by Codefly. Never deliver it to a verifier. +type Signer struct{ key ed25519.PrivateKey } + +func NewSigner(seed string) (*Signer, error) { + raw, err := hex.DecodeString(seed) + if err != nil || len(raw) != ed25519.SeedSize { + return nil, errors.New("hop: signing seed must be 32 Codefly-generated bytes in hex") + } + return &Signer{key: ed25519.NewKeyFromSeed(raw)}, nil +} + +func (s *Signer) PublicKey() string { return hex.EncodeToString(s.key.Public().(ed25519.PublicKey)) } + +// Sign is called only after the authority admits the exact method and audience. +func (s *Signer) Sign(token, method, audience string, now time.Time, tokenExpiry int64) (string, error) { + if s == nil || len(s.key) != ed25519.PrivateKeySize || token == "" || method == "" || audience == "" { + return "", errors.New("hop: signing needs a token, exact method, audience and signing key") + } + expires := now.Add(Lifetime).Unix() + if tokenExpiry < expires { + expires = tokenExpiry + } + if expires <= now.Unix() { + return "", errors.New("hop: cannot stamp an expired context") + } + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return "", errors.New("hop: cannot generate call nonce") + } + digest := sha256.Sum256([]byte(token)) + payload, err := json.Marshal([]any{"codefly-hop-v1", method, audience, hex.EncodeToString(digest[:]), hex.EncodeToString(nonce), now.Unix(), expires}) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(s.key, payload)), nil +} + +// Check authenticates the binding before any keys lookup or replay-store write. +// publicKey is trusted delivery configuration, never read from incoming metadata. +func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (Call, error) { + fail := func(reason string) (Call, error) { return Call{}, fmt.Errorf("hop: %s", reason) } + one := func(key string) string { + v := md.Get(key) + if len(v) != 1 { + return "" + } + return v[0] + } + encoded := one("x-codefly-hop-proof") + parts := strings.Split(encoded, ".") + pub, err := hex.DecodeString(publicKey) + if err != nil || len(pub) != ed25519.PublicKeySize || len(parts) != 2 || len(encoded) > 8192 { + return fail("invalid signed hop proof") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { + return fail("noncanonical hop proof") + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] || !ed25519.Verify(pub, payload, signature) { + return fail("hop proof signature does not verify") + } + var fields []json.RawMessage + if json.Unmarshal(payload, &fields) != nil || len(fields) != 7 { + return fail("malformed hop proof") + } + var domain string + var call Call + for n, dst := range []any{&domain, &call.Method, &call.Audience, &call.Digest, &call.Nonce, &call.Issued, &call.Expires} { + if string(fields[n]) == "null" || json.Unmarshal(fields[n], dst) != nil { + return fail("malformed hop proof field") + } + } + if domain != "codefly-hop-v1" { + return fail("unknown hop proof version") + } + if method == "" || call.Method != method { + return fail("hop proof method mismatch") + } + if audience == "" || call.Audience != audience { + return fail("hop proof audience mismatch") + } + if call.Issued < 0 || call.Expires < 0 || call.Issued > now.Unix() || call.Expires <= now.Unix() || call.Expires <= call.Issued || call.Expires-call.Issued > int64(Lifetime/time.Second) { + return fail("hop proof expired or outside its validity window") + } + nonce, err := hex.DecodeString(call.Nonce) + if err != nil || len(nonce) != 32 || hex.EncodeToString(nonce) != call.Nonce { + return fail("invalid hop proof nonce") + } + token := one("x-codefly-work-context") + digest := sha256.Sum256([]byte(token)) + if token == "" || call.Digest != hex.EncodeToString(digest[:]) || one("x-codefly-hop-context") != call.Digest { + return fail("hop proof token digest mismatch") + } + if len(md.Get("x-codefly-hop-advisory")) != 0 { + return fail("hop proof cannot authorize an advisory refusal") + } + return call, nil +} + +// ReplayKey is globally namespaced by signing key and random nonce, not by method: +// even an accidental nonce reuse on another method must not authorize another call. +func (c Call) ReplayKey(publicKey string) string { + return "codefly-hop-v1:" + publicKey + ":" + c.Nonce +} diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go new file mode 100644 index 00000000..d225d21e --- /dev/null +++ b/libraries/authority-client/go/stamp/call_test.go @@ -0,0 +1,50 @@ +package stamp + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + "time" + + "google.golang.org/grpc/metadata" +) + +func TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof(t *testing.T) { + signer, err := NewSigner(strings.Repeat("01", 32)) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1700000000, 0) + const token = "synthetic-context" + proof, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Add(time.Minute).Unix()) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(token)) + md := metadata.Pairs("x-codefly-hop-proof", proof, "x-codefly-work-context", token, "x-codefly-hop-context", hex.EncodeToString(digest[:])) + if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", now); err != nil { + t.Fatal(err) + } + for _, mutation := range []func(metadata.MD){ + func(m metadata.MD) { m.Set("x-codefly-hop-proof", proof[:len(proof)-2]+"AA") }, + func(m metadata.MD) { m.Append("x-codefly-hop-proof", proof) }, + func(m metadata.MD) { m.Append("x-codefly-work-context", token) }, + func(m metadata.MD) { m.Append("x-codefly-hop-context", hex.EncodeToString(digest[:])) }, + func(m metadata.MD) { m.Set("x-codefly-hop-advisory", "refused") }, + } { + changed := md.Copy() + mutation(changed) + if _, err := Check(changed, signer.PublicKey(), "/pkg.Service/Read", "module", now); err == nil { + t.Fatal("mutated proof accepted") + } + } + for _, at := range []time.Time{now.Add(-time.Second), now.Add(Lifetime)} { + if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", at); err == nil { + t.Fatal("invalid admission window accepted") + } + } + if _, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Unix()); err == nil { + t.Fatal("signed expired capability") + } +} diff --git a/libraries/authority-client/go/stamp/context.go b/libraries/authority-client/go/stamp/context.go index 6df35234..173ea7e1 100644 --- a/libraries/authority-client/go/stamp/context.go +++ b/libraries/authority-client/go/stamp/context.go @@ -1,4 +1,4 @@ -// Package stamp binds the forwarded Work Context to the trusted hop's proof. +// Package stamp authenticates call attestations and extracts token bindings. package stamp import ( @@ -9,11 +9,12 @@ import ( "google.golang.org/grpc/metadata" ) -// Context returns only the token whose digest the authenticated hop stamped. -func Context(md metadata.MD, proof string) (string, error) { - proofs, tokens, digests := md.Get("x-codefly-hop-proof"), md.Get("x-codefly-work-context"), md.Get("x-codefly-hop-context") - if proof == "" || len(proofs) != 1 || subtle.ConstantTimeCompare([]byte(proofs[0]), []byte(proof)) != 1 || len(tokens) != 1 || tokens[0] == "" || len(digests) != 1 { - return "", errors.New("authority: context requires this run's hop proof and a single stamped token") +// Context extracts one digest-bound token. It does NOT authenticate hop admission; +// use Check plus a shared ReplayStore (normally through hop.Verifier). +func Context(md metadata.MD) (string, error) { + tokens, digests := md.Get("x-codefly-work-context"), md.Get("x-codefly-hop-context") + if len(tokens) != 1 || tokens[0] == "" || len(digests) != 1 { + return "", errors.New("authority: context requires a single digest-bound token") } sum := sha256.Sum256([]byte(tokens[0])) if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(digests[0])) != 1 { diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index 6af5ebcb..dcaa8938 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -41,26 +41,14 @@ type Claims struct { context *basev0.WorkContextV1 } -// 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) { +// Authenticate verifies a digest-bound token's signature, issuer, audience, +// validity and attenuation. This is identity authentication only, not method +// authorization or replay protection. Services must use hop.Verifier for admission. +func Authenticate(md metadata.MD, 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) + token, err := stamp.Context(md) if err != nil { return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) } diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index fea07c19..75da7336 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -79,7 +79,7 @@ func tokenFor(t *testing.T, c *basev0.WorkContextV1) string { return signWith(t, func read(t *testing.T, c *basev0.WorkContextV1) *reader.Claims { t.Helper() - out, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + out, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) if err != nil { t.Fatal(err) } @@ -157,7 +157,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { 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 := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.Authenticate(stampedToken(token), session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("envelope not refused: %v", err) } } @@ -181,7 +181,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { b = append(b, b...) token = base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, b)) } - if _, err := reader.Authenticate(stampedToken(token), "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.Authenticate(stampedToken(token), session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("malformed claims not refused: %v", err) } }) @@ -214,7 +214,7 @@ func TestAForgedOrMisaddressedContextIsRefusedByName(t *testing.T) { "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) + _, err := reader.Authenticate(stampedToken(c.token), 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) } @@ -230,22 +230,18 @@ 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", tokenFor(t, body(t, "operation"))) }, func(md metadata.MD) { md.Append("x-codefly-work-context", token) }, } { md := stampedToken(token) change(md) - if _, err := reader.Authenticate(md, "test-proof", session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.Authenticate(md, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("unbound claims accepted: %v", err) } } - 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() { + + if c, err := reader.Authenticate(stampedToken(token), 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/go/workcontext/owner_test.go b/libraries/authority-client/go/workcontext/owner_test.go index 66311bc0..b33b58b7 100644 --- a/libraries/authority-client/go/workcontext/owner_test.go +++ b/libraries/authority-client/go/workcontext/owner_test.go @@ -85,7 +85,7 @@ func TestAChainThatDropsOrChangesTheOwnerIsRefused(t *testing.T) { actor.GrantedScopes = []*basev0.WorkScopeV1{owned} } c.ActorChain[len(c.ActorChain)-1].GrantedScopes = []*basev0.WorkScopeV1{tc.last} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) if (err == nil) != tc.ok { t.Fatalf("authenticate: %v", err) } diff --git a/libraries/authority-client/go/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go index 991f472b..11e7f748 100644 --- a/libraries/authority-client/go/workcontext/vectors_test.go +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -121,7 +121,7 @@ func TestTheSharedVectorsAreGosVerdicts(t *testing.T) { 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 }}) + _, err := reader.Authenticate(stampedToken(v.Token), 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) } diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index fa524119..010df323 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -7,7 +7,7 @@ Context the hop forwarded: from authority_client.work_context import Trust, authenticate trust = Trust.from_keys(stub.Keys(KeysRequest())) # the authority interface's published keys -claims = authenticate(request.headers, proof, audience, trust) # raises Unauthenticated, naming why +claims = authenticate(request.headers, audience, trust) # raises Unauthenticated, naming why allowed = claims.allows(resource_kind, action, installed_resource_id) ``` @@ -19,13 +19,14 @@ for: 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 +verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], public_key=public_key, replay=shared_replay_store) # once +identity = verifier.verify(request.headers, full_procedure) # a sync handler +identity = await verifier.verify_async(request.headers, full_procedure) # 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` +The verifier requires the [signed admission protocol](../../hop/README.md), +including an atomic durable replay store shared across replicas. It 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, and a call on keys older than @@ -43,8 +44,8 @@ as the Go library writes it. 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 +`authenticate` is an identity reader, not admission. It checks one token and its +digest; 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 diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index f045f31d..f05a2325 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -149,13 +149,13 @@ def allows_owned(self, kind: str, action: str, resource_id: str, owner_id: str) def authenticate( - headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str, audience: str, trust: Trust + headers: Mapping[str, str] | Iterable[tuple[str, 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) + token = bound_token(headers) 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(".") @@ -263,13 +263,10 @@ def _check_structure(context) -> None: 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.""" +def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> str: + """Extract a digest-bound token; this does not authenticate hop admission.""" 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") + tokens, digests = values.get(WORK_CONTEXT_KEY, []), values.get(CONTEXT_KEY, []) 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() diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 793087be..2030db16 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -1,24 +1,15 @@ -"""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 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). - -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. +"""Method-bound, single-use hop admission for Python services. + +The hop alone holds a signing seed. Verifiers pin the delivered public key, +authenticate the Work Context and consume each signed nonce in a durable store +shared by all replicas. See libraries/hop/README.md for the wire contract. """ from __future__ import annotations import asyncio import base64 +import re import threading import time from collections.abc import Callable, Iterable, Mapping @@ -26,11 +17,12 @@ from dataclasses import dataclass, field from datetime import datetime, timezone +from .call import Replayed, ReplayStore, check 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, bound_token, collect -__all__ = ["Identity", "Verifier", "Unauthenticated", "identity_of"] +__all__ = ["Identity", "Verifier", "Unauthenticated", "identity_of", "ReplayStore", "Replayed"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -107,11 +99,12 @@ class Verifier: 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, + def __init__(self, keys, *, audience: str, public_key: str, replay: ReplayStore, 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, self._timeout = keys, audience, proof, timeout + if keys is None or not audience or not re.fullmatch(r"[0-9a-f]{64}", public_key) or replay is None: + raise Unauthenticated("a verifier needs the authority's Keys, this module's audience and the hop public key and shared replay store") + self._source, self._audience, self._public_key, self._timeout = keys, audience, public_key, timeout + self._replay = replay self._now = now or time.time self._lock = threading.Lock() self._trust: Trust | None = None @@ -138,33 +131,43 @@ def ready(self) -> bool: self._refresh() return self._trust is not None and now - self._fetched <= KEY_STALE - def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: 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. It may wait up to the read's timeout for a key it does not hold yet, or for keys past the stale window; an asyncio handler uses ``verify_async``.""" - pairs, token = self._bound(headers) + pairs, token, call = self._bound(headers, method) wait = self._start(_key_id(token)) if wait is not None: futures.wait((wait,), self._timeout) - return self._finish(pairs, token) + return self._finish(pairs, token, call) - async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Identity: + async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """``verify`` for an asyncio handler: a wait for the keys is awaited on the event loop and holds no thread, so any number of waiting calls leave the loop's executor free; a call whose key is held never waits.""" - pairs, token = self._bound(headers) + pairs, token, call = self._bound(headers, method) wait = self._start(_key_id(token)) if wait is not None: try: await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) except asyncio.TimeoutError: pass # judged below on the keys held: stale ones are refused with the reason - return self._finish(pairs, token) + identity = self._finish(pairs, token, call, consume=False) + try: + await self._replay.consume_async(call.nonce, call.expires) + except Replayed as error: + raise Unauthenticated("hop admission replayed") from error + except Exception as error: + raise Unauthenticated("hop replay store unavailable") from error + if call.expires <= int(self._now()): + raise Unauthenticated("hop proof expired during replay reservation") + return identity - def _bound(self, headers) -> tuple[list[tuple[str, str]], str]: + def _bound(self, headers, method): pairs = list(headers.items() if isinstance(headers, Mapping) else headers) - return pairs, bound_token(pairs, self._proof) + call = check(pairs, self._public_key, method, self._audience, self._now()) + return pairs, bound_token(pairs), call def _start(self, key_id: str) -> futures.Future[None] | None: """Start a read when one is due; return it when this call must wait for it: @@ -180,12 +183,12 @@ def _start(self, key_id: str) -> futures.Future[None] | None: judgeable = held and now - self._fetched <= KEY_STALE return None if judgeable else self._inflight - def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: + def _finish(self, pairs: list[tuple[str, str]], token: str, call, consume=True) -> 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) + claims = authenticate(pairs, self._audience, trust) values = collect(pairs) identity = identity_of(claims.context) stamped = { @@ -198,7 +201,18 @@ def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: 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] + if call.expires <= int(self._now()): + raise Unauthenticated("hop proof expired while authenticating") + try: + if consume: + self._replay.consume(call.nonce, call.expires) + except Replayed as error: + raise Unauthenticated("hop admission replayed") from error + except Exception as error: + raise Unauthenticated("hop replay store unavailable") from error + if call.expires <= int(self._now()): + raise Unauthenticated("hop proof expired during replay reservation") + advisory = "" return Identity(**{**identity.__dict__, "context": token, "advisory": advisory, "claims": claims}) def _refresh(self) -> futures.Future[None]: diff --git a/libraries/authority-client/python/codefly_hop/call.py b/libraries/authority-client/python/codefly_hop/call.py new file mode 100644 index 00000000..5b45124f --- /dev/null +++ b/libraries/authority-client/python/codefly_hop/call.py @@ -0,0 +1,80 @@ +"""Signed hop admission v1. Replay consumption is supplied by the host, shared across replicas.""" +import base64 +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Protocol + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from authority_client.work_context import Unauthenticated, collect + + +class Replayed(Exception): + """The shared store has already consumed this nonce.""" + + +class ReplayStore(Protocol): + def consume(self, nonce: str, retain_until: int) -> None: + """Atomically reserve nonce until Unix seconds; raise Replayed on reuse, another exception on unavailability. + + Every replica must use the same durable store. No process-local default. + """ + ... + + async def consume_async(self, nonce: str, retain_until: int) -> None: + """The same atomic operation without blocking the event loop.""" + ... + + +@dataclass(frozen=True) +class Call: + nonce: str + expires: int + + +def check(headers, public_key: str, method: str, audience: str, now: float) -> Call: + values = collect(headers) + + def one(key): + items = values.get(key, []) + return items[0] if len(items) == 1 else "" + + def segment(value): + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise ValueError("encoding") + raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != value: + raise ValueError("encoding") + return raw + + try: + encoded = one("x-codefly-hop-proof") + if len(encoded) > 8192: + raise ValueError("size") + payload, signature = (segment(part) for part in encoded.split(".")) + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key)).verify(signature, payload) + fields = json.loads(payload) + if not isinstance(fields, list) or len(fields) != 7: + raise ValueError("fields") + domain, bound_method, bound_audience, digest, nonce, issued, expires = fields + if any(not isinstance(v, str) for v in fields[:5]) or type(issued) is not int or type(expires) is not int: + raise ValueError("types") + except Exception as error: + raise Unauthenticated("invalid hop proof signature or encoding") from error + if domain != "codefly-hop-v1": + raise Unauthenticated("unknown hop proof version") + if not method or method != bound_method: + raise Unauthenticated("hop proof method mismatch") + if not audience or audience != bound_audience: + raise Unauthenticated("hop proof audience mismatch") + if issued < 0 or expires < 0 or issued > int(now) or expires <= int(now) or not 0 < expires - issued <= 30: + raise Unauthenticated("hop proof expired or outside its validity window") + if not re.fullmatch(r"[0-9a-f]{64}", nonce): + raise Unauthenticated("invalid hop proof nonce") + token = one("x-codefly-work-context") + if not token or digest != hashlib.sha256(token.encode()).hexdigest() or one("x-codefly-hop-context") != digest: + raise Unauthenticated("hop proof token digest mismatch") + if "x-codefly-hop-advisory" in values: + raise Unauthenticated("hop proof cannot authorize an advisory refusal") + return Call("codefly-hop-v1:" + public_key + ":" + nonce, expires) diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 0184c04d..aa60dcac 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -1,5 +1,9 @@ import base64 import json +import hashlib +import secrets +import threading +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from pathlib import Path import pytest @@ -9,11 +13,34 @@ from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 from codefly_hop import ( ACTORS_KEY, CONTEXT_KEY, KEY_RETRY, KEY_STALE, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, - Verifier, identity_of, + Verifier, Replayed, identity_of, ) FIXTURE = json.loads((Path(__file__).parents[3] / "hop/testdata/stamped.json").read_text()) +METHOD = "/test.Service/Read" + +class Replay: + def __init__(self): + self.seen, self.lock = set(), threading.Lock() + def consume(self, nonce, until): + with self.lock: + if nonce in self.seen: + raise Replayed() + self.seen.add(nonce) + async def consume_async(self, nonce, until): + self.consume(nonce, until) + +def fresh(now, *, method=METHOD, audience=FIXTURE["audience"], headers=None, issued=None, expires=None): + headers = dict(FIXTURE["headers"] if headers is None else headers) + key = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(b"fixture-proof").digest()) + payload = json.dumps(["codefly-hop-v1", method, audience, + hashlib.sha256(headers[WORK_CONTEXT_KEY].encode()).hexdigest(), secrets.token_hex(32), + int(now) if issued is None else issued, int(now)+30 if expires is None else expires], separators=(",", ":")).encode() + encode = lambda raw: base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + headers["x-codefly-hop-proof"] = encode(payload) + "." + encode(key.sign(payload)) + return headers + class Keys: """The authority interface's Keys, as the generated stub answers it.""" @@ -36,11 +63,11 @@ def published(): def verifier(clock=lambda: FIXTURE["now"], source=None): - return Verifier(source or Keys(published()), audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=clock) + return Verifier(source or Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=clock) def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): - identity = verifier().verify(FIXTURE["headers"]) + identity = verifier().verify(FIXTURE["headers"], METHOD) 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"]) @@ -57,11 +84,11 @@ def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): ({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"), + ({CONTEXT_KEY: "0" * 64}, "digest mismatch"), ]) 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}) + verifier().verify({**FIXTURE["headers"], **change}, METHOD) def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_digest(): @@ -69,15 +96,15 @@ def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_dige 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) + with pytest.raises(Unauthenticated, match="digest mismatch"): + verifier().verify(headers, METHOD) 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="audience mismatch"): + Verifier(Keys(published()), audience="records", public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"], METHOD) with pytest.raises(Unauthenticated, match="expired"): - verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"]) + verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"], METHOD) def settle(v): @@ -94,16 +121,16 @@ def test_keys_are_reread_when_old_and_trusted_no_longer_than_stale(): rotated.keys.add(key_id="rotated", algorithm="Ed25519", public_key=bytes(32)) source.response = rotated clock[0] += 400 # older than KEY_MAX_AGE: a re-read starts; the held key serves this call - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) settle(v) assert source.reads == 2 clock[0] += 11 with pytest.raises(Unauthenticated, match="no verification key"): - v.verify(FIXTURE["headers"]) # the authority no longer publishes it + v.verify(fresh(v._now()), METHOD) # 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="unavailable") as refused: - v.verify(FIXTURE["headers"]) # an unheld key waits for the read, which fails; the keys are stale + v.verify(fresh(v._now()), METHOD) # 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) @@ -117,7 +144,7 @@ def test_a_call_on_keys_past_stale_waits_for_the_reread(): source = Keys(published()) v = verifier(clock=lambda: clock[0], source=source) clock[0] = FIXTURE["now"] - assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] assert source.reads == 2 @@ -129,7 +156,7 @@ def test_a_call_on_keys_past_stale_is_refused_when_the_reread_fails(): import time as clock_time clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=0.3) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0], timeout=0.3) source.error, source.hold = RuntimeError("dial authority.internal:4000: connection refused"), threading.Event() clock[0] = FIXTURE["now"] outcome = [] @@ -152,7 +179,7 @@ def test_a_call_on_keys_past_stale_is_refused_when_the_reread_fails(): def _refusal(v): try: - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) except Unauthenticated as refused: return str(refused) return "admitted" @@ -168,7 +195,7 @@ def test_a_call_without_the_proof_never_reads_the_keys(): 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) + v.verify(headers, METHOD) assert source.reads == 1 @@ -181,7 +208,7 @@ def test_a_hung_read_stalls_no_held_key(): 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 v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] assert clock_time.monotonic() - started < 1 source.hold.set() @@ -189,12 +216,12 @@ def test_a_hung_read_stalls_no_held_key(): 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]) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0]) with pytest.raises(Unauthenticated, match="unavailable"): - v.verify(FIXTURE["headers"]) + v.verify(fresh(v._now()), METHOD) source.error, source.response = None, published() clock[0] += 11 - assert v.verify(FIXTURE["headers"]).owner == FIXTURE["identity"]["owner"] + assert v.verify(fresh(v._now()), METHOD).owner == FIXTURE["identity"]["owner"] def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): @@ -214,7 +241,7 @@ def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): 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]) + Verifier(args[0], audience=args[1], public_key=args[2], replay=Replay()) # Review r57 m-b: an asyncio handler's wait for the keys never blocks the loop. @@ -224,7 +251,7 @@ def test_verify_async_waits_for_keys_off_the_event_loop(): 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) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), 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 @@ -238,7 +265,7 @@ async def ticker(): ticks += 1 running = asyncio.create_task(ticker()) - waiting = asyncio.create_task(v.verify_async(FIXTURE["headers"])) + waiting = asyncio.create_task(v.verify_async(fresh(v._now()), METHOD)) await asyncio.sleep(0.3) source.hold.set() identity = await waiting @@ -265,13 +292,13 @@ def submit(self, *args, **kwargs): clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0], timeout=1.0) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0], timeout=1.0) source.hold = threading.Event() clock[0] = FIXTURE["now"] async def scenario(): asyncio.get_running_loop().set_default_executor(NoThreads()) - calls = [asyncio.create_task(v.verify_async(FIXTURE["headers"])) for _ in range(64)] + calls = [asyncio.create_task(v.verify_async(fresh(v._now()), METHOD)) for _ in range(64)] await asyncio.sleep(0.1) assert not any(call.done() for call in calls), "a call was judged before its re-read ended" source.hold.set() @@ -279,7 +306,7 @@ async def scenario(): source.hold = threading.Event() # the next re-read hangs past the timeout clock[0] += KEY_STALE + KEY_RETRY # stale keys are refused before the token's own expiry is read try: - await v.verify_async(FIXTURE["headers"]) + await v.verify_async(fresh(v._now()), METHOD) except Unauthenticated as refused: return identities, str(refused) return identities, "admitted" @@ -310,7 +337,7 @@ def test_ready_holds_while_keys_are_trusted(): def test_an_unready_replica_becomes_ready_by_polling_alone(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("not serving yet")) - v = Verifier(source, audience=FIXTURE["audience"], proof=FIXTURE["proof"], now=lambda: clock[0]) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0]) ready = False for poll in range(30): if poll == 3: @@ -336,3 +363,68 @@ def test_an_idle_replica_polled_for_readiness_stays_ready(): settle(v) assert v.ready(), f"an idle replica went unready at minute {minute + 1}" assert 10 <= source.reads <= 14 + + +# Independent review probes: method retargeting, cross-audience use, replay on +# another replica, and a workload constructing the old shared-proof stamp. +def test_review_retargeting_and_replay_across_replicas(): + store = Replay() + first = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) + second = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) + headers = FIXTURE["headers"] + for method in ("/documents.v1.Service/Delete", "/datasets.v1.Service/Register", "Read", "", "POST " + METHOD): + with pytest.raises(Unauthenticated, match="method mismatch"): + first.verify(headers, method) + other = Verifier(Keys(published()), audience="records", public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated, match="audience mismatch"): + other.verify(headers, METHOD) + assert first.verify(headers, METHOD).claims.context.replay_policy == "single-use" + for replica in (first, second): + with pytest.raises(Unauthenticated, match="replay"): + replica.verify(headers, METHOD) + with pytest.raises(Unauthenticated, match="proof"): + second.verify({**headers, "x-codefly-hop-proof": "fixture-proof"}, METHOD) + + +def test_review_concurrent_replay_and_store_failure(): + from concurrent.futures import ThreadPoolExecutor + v = verifier() + def attempt(_): + try: + v.verify(FIXTURE["headers"], METHOD) + return 1 + except Unauthenticated: + return 0 + with ThreadPoolExecutor(max_workers=16) as pool: + assert sum(pool.map(attempt, range(32))) == 1 + class Down(Replay): + def consume(self, nonce, until): + raise OSError("store down") + v._replay = Down() + with pytest.raises(Unauthenticated, match="store unavailable"): + v.verify(fresh(FIXTURE["now"]), METHOD) + + +@pytest.mark.parametrize("issued,expires", [(0, 30), (FIXTURE["now"] + 1, FIXTURE["now"] + 30), (FIXTURE["now"], FIXTURE["now"] + 31)]) +def test_signed_admission_window_is_bounded(issued, expires): + with pytest.raises(Unauthenticated, match="validity window"): + verifier().verify(fresh(FIXTURE["now"], issued=issued, expires=expires), METHOD) + + +def test_duplicate_proof_and_token_are_refused_before_consumption(): + for key in ("x-codefly-hop-proof", WORK_CONTEXT_KEY, CONTEXT_KEY): + v = verifier() + with pytest.raises(Unauthenticated): + v.verify(list(FIXTURE["headers"].items()) + [(key, FIXTURE["headers"][key])], METHOD) + v.verify(FIXTURE["headers"], METHOD) + + +def test_async_replay_has_one_winner_across_replicas(): + import asyncio + store = Replay() + replicas = [Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) for _ in range(2)] + async def run(): + return await asyncio.gather(*(replicas[n % 2].verify_async(FIXTURE["headers"], METHOD) for n in range(32)), return_exceptions=True) + results = asyncio.run(run()) + assert sum(isinstance(result, Identity) for result in results) == 1 + assert sum(isinstance(result, Unauthenticated) for result in results) == 31 diff --git a/libraries/authority-client/python/tests/test_owner.py b/libraries/authority-client/python/tests/test_owner.py index 12f599e1..2d8ef048 100644 --- a/libraries/authority-client/python/tests/test_owner.py +++ b/libraries/authority-client/python/tests/test_owner.py @@ -17,7 +17,7 @@ def claims_of(context): - return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + return authenticate(stamped(sign(context)), context.audience, trust_for(context)) # The owner-selected rows (allows refusing, allows_owned admitting only the diff --git a/libraries/authority-client/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 24f6fea5..ea2099af 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -49,7 +49,7 @@ def stamped(token, proof=PROOF): def read(context): - return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + return authenticate(stamped(sign(context)), context.audience, trust_for(context)) @pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"]) @@ -110,7 +110,7 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): (sign(context), "", trust, "no audience"), ]: with pytest.raises(Unauthenticated, match=reason): - authenticate(stamped(token), PROOF, audience, use) + authenticate(stamped(token), audience, use) @pytest.mark.parametrize("token", ["", "abc", "a.b.c", ".", "a." + encoded(bytes(64)), "é.x", @@ -118,22 +118,20 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): def test_unreadable_envelopes_are_refused(token): context = body("session") with pytest.raises(Unauthenticated): - authenticate(stamped(token), PROOF, context.audience, trust_for(context)) + authenticate(stamped(token), context.audience, trust_for(context)) -@pytest.mark.parametrize("change", ["proof", "digest", "token", "duplicate", "missing", "empty proof"]) +@pytest.mark.parametrize("change", ["digest", "token", "duplicate", "missing"]) 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] = sign(body("operation")) if change == "duplicate": headers = list(headers.items()) + [(WORK_CONTEXT_KEY, token)] if change == "missing": headers.pop(CONTEXT_KEY) - if change == "empty proof": proof = "" with pytest.raises(Unauthenticated): - authenticate(headers, proof, context.audience, trust_for(context)) + authenticate(headers, context.audience, trust_for(context)) def test_trust_comes_only_from_published_ed25519_keys(): @@ -158,8 +156,8 @@ def test_python_reaches_gos_verdict_on_every_shared_vector(vector): 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) + authenticate(headers, vector["audience"], trust) else: with pytest.raises(Unauthenticated) as refused: - authenticate(headers, PROOF, vector["audience"], trust) + authenticate(headers, vector["audience"], trust) assert vector["reason"] in str(refused.value) diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index cd543a2a..f3213a4c 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -8,12 +8,12 @@ depends on: ```ts import { Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; -const verifier = new Verifier(createClient(AuthorityService, transport), audience, proof); -const id = await verifier.verify(requestHeaders); // throws Unauthenticated, naming why +const verifier = new Verifier(createClient(AuthorityService, transport), audience, publicKey, sharedReplayStore); +const id = await verifier.verify(requestHeaders, fullProcedure); // throws Unauthenticated, naming why id.tenant; id.scopes; id.claims.allows(resourceKind, action, exactResourceID); ``` -It is the Go library's `hop.Verifier`: the stamp first (proof and digest), then +It is the Go library's `hop.Verifier`: the signed method, audience, token digest and admission window first, 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, when a call waits for the re-read as it does on an unknown key), then @@ -28,7 +28,9 @@ caller. **Readiness uses `verifier.ready()`**: true while trusted keys are held, through failed re-reads inside the 15-minute window; false before the first read or once they are stale. It also starts the same rate-limited, non-blocking re-read a call would, so an unready replica recovers and an idle one never goes -stale. Never gate readiness on `keysError`. Do not send the proof to a browser. +stale. Never gate readiness on `keysError`. After authenticating the identity, +it consumes the nonce through the required durable replay store shared across +replicas. See the [admission protocol](../../hop/README.md). `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/hop-call.ts b/libraries/authority-client/typescript/hop-call.ts new file mode 100644 index 00000000..3c839ae0 --- /dev/null +++ b/libraries/authority-client/typescript/hop-call.ts @@ -0,0 +1,37 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; + +/** Durable atomic consumption, shared by all replicas. Throw Replayed on reuse, another error on outage. + * retainUntil is Unix seconds. Successful reservations must survive restart. */ +export class Replayed extends Error { override name = "Replayed"; } + +export interface ReplayStore { consume(nonce: string, retainUntil: number): Promise; } + +export function checkCall(pairs: readonly (readonly [string, string])[], publicKey: string, method: string, audience: string, now: number): { nonce: string; expires: number } { + const fail = (reason: string): never => { throw new Error(`hop: ${reason}`); }; + const one = (key: string): string => { const values = pairs.filter(([k]) => k === key); return values.length === 1 ? values[0][1] : ""; }; + const encoded = one("x-codefly-hop-proof"), parts = encoded.split("."); + if (!/^[0-9a-f]{64}$/.test(publicKey) || parts.length !== 2 || encoded.length > 8192) fail("invalid signed hop proof"); + const decode = (part: string): Buffer => { + const raw = Buffer.from(part, "base64url"); + if (!/^[A-Za-z0-9_-]+$/.test(part) || raw.toString("base64url") !== part) fail("noncanonical hop proof"); + return raw; + }; + const payload = decode(parts[0]), signature = decode(parts[1]); + const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(publicKey, "hex").toString("base64url") }, format: "jwk" }); + if (!verify(null, payload, key, signature)) fail("hop proof signature does not verify"); + let fields: unknown; + try { fields = JSON.parse(payload.toString("utf8")); } catch { fail("malformed hop proof"); } + if (!Array.isArray(fields) || fields.length !== 7) return fail("malformed hop proof"); + const [domain, boundMethod, boundAudience, digest, nonce, issued, expires] = fields; + if (!fields.slice(0, 5).every(v => typeof v === "string") || !Number.isSafeInteger(issued) || !Number.isSafeInteger(expires)) fail("malformed hop proof fields"); + if (domain !== "codefly-hop-v1") fail("unknown hop proof version"); + if (!method || method !== boundMethod) fail("hop proof method mismatch"); + if (!audience || audience !== boundAudience) fail("hop proof audience mismatch"); + const seconds = Math.floor(now / 1000); + if (issued < 0 || expires < 0 || issued > seconds || expires <= seconds || expires <= issued || expires - issued > 30) fail("hop proof expired or outside its validity window"); + if (!/^[0-9a-f]{64}$/.test(nonce)) fail("invalid hop proof nonce"); + const token = one("x-codefly-work-context"); + if (!token || digest !== createHash("sha256").update(token).digest("hex") || digest !== one("x-codefly-hop-context")) fail("hop proof token digest mismatch"); + if (pairs.some(([key]) => key === "x-codefly-hop-advisory")) fail("hop proof cannot authorize an advisory refusal"); + return { nonce: `codefly-hop-v1:${publicKey}:${nonce}`, expires }; +} diff --git a/libraries/authority-client/typescript/tests/owner.test.mjs b/libraries/authority-client/typescript/tests/owner.test.mjs index e490b79d..86bedbc3 100644 --- a/libraries/authority-client/typescript/tests/owner.test.mjs +++ b/libraries/authority-client/typescript/tests/owner.test.mjs @@ -18,7 +18,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f const signed = context => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,signer)); }; const trustFor = context => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>(Number(context.notBeforeUnix)+1)*1000); 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 read = context => authenticateClaims(stamped(signed(context)),'test-proof',context.audience,trustFor(context)); +const read = context => authenticateClaims(stamped(signed(context)), context.audience, trustFor(context)); const scope = fields => create(WorkScopeV1Schema, fields); // The owner-selected rows (allows refusing, allowsOwned admitting only the diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 3ede50e3..a2112cac 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -1,10 +1,11 @@ +import { createPrivateKey as hopPrivateKey, randomBytes as hopRandom, sign as hopSign } from "node:crypto"; 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, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; +import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed } 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'); @@ -15,7 +16,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f 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 read = context => authenticateClaims(stamped(signed(context)), context.audience, trustFor(context)); const scope = (actions, resourceIds=[]) => create(WorkScopeV1Schema,{resourceKind:'profiles',actions,resourceIds}); for (const fixture of fixtures) { @@ -55,21 +56,19 @@ test('a forged, unsigned, misaddressed or expired context is refused by name', ( [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)); + assert.throws(()=>authenticateClaims(stamped(token), audience, use), e => e instanceof Unauthenticated && reason.test(e.message)); } }); 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); + ]) assert.throws(()=>authenticateClaims(headers, 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); + assert.throws(()=>authenticateClaims(stamped(bad), context.audience, trust),Unauthenticated); } }); @@ -88,28 +87,36 @@ const vectors = JSON.parse(readFileSync(new URL('../../testdata/vectors.json',im 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); + const run = () => authenticateClaims(stamped(v.token), 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 METHOD = "/test.Service/Read"; +const replay = () => { const seen = new Set(); return { async consume(nonce) { if (seen.has(nonce)) throw new Replayed(); seen.add(nonce); } }; }; +const fresh = (now, method = METHOD, audience = fixture.audience) => { + const key = hopPrivateKey({key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), createHash("sha256").update("fixture-proof").digest()]), format: "der", type: "pkcs8"}); + const payload = Buffer.from(JSON.stringify(["codefly-hop-v1", method, audience, fixture.headers["x-codefly-hop-context"], hopRandom(32).toString("hex"), Math.floor(now/1000), Math.floor(now/1000)+30])); + return {...fixture.headers, "x-codefly-hop-proof": payload.toString("base64url")+"."+hopSign(null,payload,key).toString("base64url")}; +}; + 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); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => fixture.now*1000); + const id = await v.verify(fresh(v.now()), METHOD); 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)); + await assert.rejects(v.verify({...fixture.headers, [key]: value}, METHOD), e => e instanceof Unauthenticated && reason.test(e.message)); } }); @@ -120,17 +127,17 @@ test('a call without the proof never reads the keys, and a hung read stalls no h 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); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => 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); + await assert.rejects(v.verify({'x-codefly-work-context': 'garbage'}, METHOD), 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); + const id = await v.verify(fresh(v.now()), METHOD); assert.equal(id.owner, fixture.identity.owner); assert.equal(keys.reads, 2); release(); @@ -140,12 +147,12 @@ test('a Verifier starts before the authority serves keys and refuses with a reas 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); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); - await assert.rejects(v.verify(fixture.headers), e => e instanceof Unauthenticated && /unavailable/.test(e.message) && !/4000/.test(e.message)); + await assert.rejects(v.verify(fresh(v.now()), METHOD), 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); + assert.equal((await v.verify(fresh(v.now()), METHOD)).owner, fixture.identity.owner); }); // The robin playground's model gateway, live: its first call after 39 idle @@ -156,10 +163,10 @@ test('a Verifier starts before the authority serves keys and refuses with a reas test('a call on keys past the stale window waits for the re-read', async () => { const keys = source(); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); clock = fixture.now*1000; - assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); + assert.equal((await v.verify(fresh(v.now()), METHOD)).owner, fixture.identity.owner); assert.equal(keys.reads, 2); }); @@ -169,11 +176,11 @@ test('a call on keys past the stale window is refused when the re-read fails', a let fail; const keys = source(() => keys.reads === 1 ? Promise.resolve(published) : new Promise((_, reject) => { fail = reject; })); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); clock = fixture.now*1000; let judged = false; - const call = v.verify(fixture.headers).finally(() => { judged = true; }); + const call = v.verify(fresh(v.now()), METHOD).finally(() => { judged = true; }); for (let i = 0; i < 5; i++) await settle(); assert.equal(judged, false, 'the call was judged before its re-read ended'); fail(new Error('dial authority.internal:4000: connection refused')); @@ -188,17 +195,17 @@ test('a call on keys past the stale window is refused when the re-read hangs', { let hang = false; const keys = source(() => hang ? new Promise(() => {}) : Promise.resolve(published)); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock, 100); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock, 100); await settle(); hang = true; clock = fixture.now*1000; const started = performance.now(); - await assert.rejects(v.verify(fixture.headers), e => e instanceof Unauthenticated && /unavailable/.test(e.message)); + await assert.rejects(v.verify(fresh(v.now()), METHOD), e => e instanceof Unauthenticated && /unavailable/.test(e.message)); assert.ok(performance.now() - started >= 90, 'refused before the read timed out'); assert.equal(v.keysError?.name, 'TimeoutError'); hang = false; clock += 11_000; - assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); + assert.equal((await v.verify(fresh(v.now()), METHOD)).owner, fixture.identity.owner); assert.equal(keys.reads, 3); }); @@ -212,7 +219,7 @@ test('readiness follows trust, not the last read', async () => { let failing = false; const keys = source(() => failing ? Promise.reject(new Error('authority down')) : Promise.resolve(published)); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); assert.equal(v.ready(), true); failing = true; @@ -230,7 +237,7 @@ test('an unready replica becomes ready by polling alone', async () => { let serving = false; const keys = source(() => serving ? Promise.resolve(published) : Promise.reject(new Error('not serving yet'))); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); let ready = false; for (let poll = 0; poll < 30 && !ready; poll++) { @@ -248,7 +255,7 @@ test('an unready replica becomes ready by polling alone', async () => { test('an idle replica polled for readiness stays ready', async () => { const keys = source(); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); + const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); await settle(); for (let minute = 1; minute <= 60; minute++) { clock += 60_000; @@ -258,3 +265,37 @@ test('an idle replica polled for readiness stays ready', async () => { } assert.ok(keys.reads >= 10 && keys.reads <= 14, `reads=${keys.reads}`); }); + +test('review probes: read stamp cannot retarget, cross audiences or replay across replicas', async () => { + const store = replay(); + const first = new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000); + const second = new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000); + for (const method of ['/documents.v1.Service/Delete','/datasets.v1.Service/Register','Read','','POST '+METHOD]) { + await assert.rejects(first.verify(fixture.headers, method), /method mismatch/); + } + const other = new Verifier(source(), 'records', fixture.public_key, store, () => fixture.now*1000); + await assert.rejects(other.verify(fixture.headers, METHOD), /audience mismatch/); + assert.equal((await first.verify(fixture.headers, METHOD)).claims.context.replayPolicy, "single-use"); + await assert.rejects(first.verify(fixture.headers, METHOD), /replay/); + await assert.rejects(second.verify(fixture.headers, METHOD), /replay/); + await assert.rejects(second.verify({...fixture.headers, 'x-codefly-hop-proof': 'fixture-proof'}, METHOD), /proof/); +}); + +test('concurrent replay has one winner and a failed store refuses admission', async () => { + const store = replay(); + const replicas = [0,1].map(() => new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000)); + const results = await Promise.allSettled(Array.from({length:32}, (_,n) => replicas[n%2].verify(fixture.headers, METHOD))); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + const v = new Verifier(source(), fixture.audience, fixture.public_key, {async consume(){throw Error('store down');}}, () => fixture.now*1000); + await assert.rejects(v.verify(fixture.headers, METHOD), /store unavailable/); +}); + +test('expired stamps and repeated binding headers refuse before consumption', async () => { + const expired = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now+30)*1000); + await assert.rejects(expired.verify(fixture.headers, METHOD), /expired/); + for (const key of ['x-codefly-hop-proof', 'x-codefly-work-context','x-codefly-hop-context']) { + const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => fixture.now*1000); + await assert.rejects(v.verify([...Object.entries(fixture.headers),[key,fixture.headers[key]]], METHOD), Unauthenticated); + await v.verify(fixture.headers, METHOD); + } +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 25fc7b91..89500b5e 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,15 +1,9 @@ -/** Authenticate the Work Context the hop forwarded to a Node module, then read it. - * - * `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 { checkCall, Replayed, type ReplayStore } from "./hop-call.js"; +export { Replayed } from "./hop-call.js"; +export type { ReplayStore } from "./hop-call.js"; +/** Work Context identity readers and method-bound, single-use hop admission. + * Verifier consumes signed call nonces in a required durable shared ReplayStore. + * authenticateClaims alone authenticates identity, not admission. */ import { createHash, createPrivateKey, createPublicKey, timingSafeEqual, verify } from "node:crypto"; import { equals, fromBinary, toBinary } from "@bufbuild/protobuf"; import { createValidator } from "@bufbuild/protovalidate"; @@ -78,9 +72,9 @@ export type StampHeaders = Readonly> /** 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 { +export function authenticateClaims(headers: StampHeaders, audience: string, trust: Trust): Claims { if (!audience) throw new Unauthenticated("no audience: a module authenticates a context for itself"); - const token = boundToken(headers, proof); + const token = boundToken(headers); if (token.length > MAX_TOKEN_BYTES || token.split(".").length !== 2) { throw new Unauthenticated("expected a bounded payload.signature context"); } @@ -192,11 +186,10 @@ function checkStructure(context: WorkContextV1): void { 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. */ -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"); +/** Extract one digest-bound token. This is not hop admission authentication. */ +export function boundToken(headers: StampHeaders): string { + if (headers === null || typeof headers !== "object") { + throw new Unauthenticated("context requires request headers"); } const values = new Map(); const pairs = Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers); @@ -210,9 +203,6 @@ export function boundToken(headers: StampHeaders, proof: string): string { 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 (!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"); } @@ -292,9 +282,9 @@ export class Verifier { /** 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, + constructor(private readonly source: KeySource, private readonly audience: string, private readonly publicKey: string, private readonly replay: ReplayStore, private readonly now: () => number = Date.now, private readonly timeoutMs: number = KEY_TIMEOUT_MS) { - if (!source || !audience || !proof) throw new Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof"); + if (!source || !audience || !/^[0-9a-f]{64}$/.test(publicKey) || !replay) throw new Unauthenticated("a verifier needs the authority's Keys, this module's audience and the hop public key and shared replay store"); void this.refresh(); } @@ -311,11 +301,14 @@ export class Verifier { } /** Who the call is for, or Unauthenticated naming why it is refused. */ - async verify(headers: StampHeaders): Promise { + async verify(headers: StampHeaders, method: string): 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))); + let call: { nonce: string; expires: number }; + try { call = checkCall(pairs, this.publicKey, method, this.audience, this.now()); } + catch (error) { throw new Unauthenticated(error instanceof Error ? error.message : "invalid hop proof"); } + const token = boundToken(pairs); // the stamp first: nothing unbound reaches the key read + const claims = authenticateClaims(pairs, 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, @@ -328,7 +321,11 @@ export class Verifier { 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] ?? ""; + if (call.expires <= Math.floor(this.now() / 1000)) throw new Unauthenticated("hop proof expired while authenticating"); + try { await this.replay.consume(call.nonce, call.expires); } + catch (error) { throw new Unauthenticated(error instanceof Replayed ? "hop admission replayed" : "hop replay store unavailable"); } + if (call.expires <= Math.floor(this.now() / 1000)) throw new Unauthenticated("hop proof expired during replay reservation"); + const advisory = ""; return Object.freeze({ ...id, context: token, advisory, claims }); } diff --git a/libraries/hop/README.md b/libraries/hop/README.md new file mode 100644 index 00000000..4619be8b --- /dev/null +++ b/libraries/hop/README.md @@ -0,0 +1,109 @@ +# Hop admission v1 + +A Work Context authenticates an identity and capability. It does not prove that +this call passed method policy. Every protected listener must use `Verifier` +with the **actual full procedure**, its own audience, a trusted hop public key, +and an explicitly supplied atomic replay store. Metadata supplied by the caller +must never choose the expected method, audience, public key, or replay namespace. + +This is a breaking replacement of the shared `hop/PROOF` protocol. There is no +legacy acceptance path. Go, Python and TypeScript consume the same signed fixture. +`workcontext.Authenticate`, Python `authenticate`, and TS `authenticateClaims` +are lower-level identity readers, not admission checks. Their shared-proof +argument has been deleted; they do not authorize a method or consume a nonce. + +## Issuance and verification + +After the authority's live `Check` admits the method, the hop writes its identity +metadata and signs a UTF-8 JSON array with Ed25519: + +``` +["codefly-hop-v1", method, audience, token_sha256_hex, nonce_hex, issued_unix, expires_unix] +``` + +`x-codefly-hop-proof` is unpadded canonical base64url(payload), a dot, and +unpadded canonical base64url(signature). The signature covers the original bytes, +not a parsed and re-encoded JSON object. Nonces are 32 cryptographically random +bytes; digests and nonces are lowercase hex. Unix timestamps are integral seconds. +The maximum admission lifetime is 30 seconds, capped by the Work Context expiry. +No acceptance skew is added. Duplicate proof, context or digest headers fail. +The signing key is distinct from the authority's Work Context key. + +For gRPC and Connect, `method` is `/package.Service/Procedure`, including the +leading slash. HTTP routes use `VERB /actual/path`, not the matched route template. +The hop uses its selected generated route to distinguish Connect from ordinary +HTTP. A verifier must use the same transport's observed procedure. A translated +REST gateway uses the full procedure observed by its gRPC listener. The audience +is the checked module principal, never an upstream address. + +The verifier checks the signature, method, audience, token digest and admission +window before reading authority keys. It then verifies the Work Context signature, +issuer, audience, window and attenuation, and compares identity metadata with the +claims. Advisory policy refusals cannot be signed as admission; legacy warning mode +is refused at hop startup. Finally it atomically +consumes `codefly-hop-v1::` before entering the handler. +Any replay-store error refuses the call. Invalid stamps do not reserve nonces. + +The nonce is consumed for **every** stamp, including stamps on idempotent Work +Contexts. A single-use Work Context is consumed once by the hop's existing live +Check; downstream consumes the admission nonce, not that Work Context a second +time. A failed/uncertain downstream delivery burns its admission. Retry through +the hop for a fresh decision, and obtain a fresh capability if it was single-use. +A stream is admitted once before handler entry; this preserves streaming without +buffering its body. Expiry bounds starting a call, not the lifetime of its stream. +This is admission replay protection, not transactional exactly-once effects. + +## Replicas and durable replay storage + +A stateless signature or request digest cannot reject an identical replay. A +process-local set also fails on another replica or after restart. Therefore the +libraries require a replay-store dependency and have **no implicit memory cache**. +Go reuses Core's `workcontext.ReplayStore`; Python exposes `ReplayStore.consume` +and `consume_async`; TS exposes `ReplayStore.consume`. Python and TS adapters +raise the exported `Replayed` error for duplicate nonces (Go uses Core +`ErrReplayed`); other failures report store unavailability without leaking backend errors. Consumption must be atomic +across every process accepting the same signing key, fail closed on outage, and +be durable before it returns. A backend may retain entries longer than expiry. +It must not evict an entry while any verifier can still accept it: account for +clock differences and prevent clock rollback. Synchronize deployment clocks. + +`hop.FileReplayStore` implements this contract for a shared durable filesystem +with atomic exclusive creation and file/directory fsync. All replicas must mount +the same directory; per-pod ephemeral storage is not suitable. Reservations +survive process restart and are never automatically removed. An incomplete +reservation is retained after a crash. Operators may collect complete expired +records only once every verifier's clock has passed the recorded expiry; never +collect incomplete records while their key remains trusted. Monitor volume space; +full or unavailable storage refuses admission. Other hosts can supply their +existing durable replay implementation through the same Core contract. The +in-memory implementation used in tests is not a replicated deployment backend. + +## Codefly delivery and migration + +Generate the signing seed with Codefly, scoped to the hop service: + +``` +codefly config generate hop-signing SEED --service authority/hop +``` + +The hop reads `Secret("hop-signing", "SEED")`: a 32-byte hex seed, never the old +workspace `hop/PROOF`. Delivery derives its public half with +`stamp.NewSigner(seed).PublicKey()` and supplies that non-secret value as +`hop-verification/PUBLIC_KEY` to consumers. Do not deliver the seed or derive it +from a previously shared proof. The key must come from trusted composition, +never from a request or an unauthenticated key advertisement. Key rotation changes +the trusted public key and immediately refuses stamps from the retired key. + +The console example also resolves `hop-verification/REPLAY_DIRECTORY` from +Codefly's mounted shared volume and uses `FileReplayStore`. Configure the volume +and public key before upgrading consumers. No address, seed, key or volume path +is hard-coded. Python and Node hosts supply their shared durable replay adapter; +Python's async adapter must not block the event loop. Readiness continues to check +authority key availability; deployment health should also check its replay store. + +Upgrade the hop and consumers together and remove delivery of `hop/PROOF`. +Downstream repositories must pin this breaking version and pass their actual +procedure into `Verify`/`verify`; no method-less overload remains. The regression +suite covers the read-to-delete and zero-scope fabricated-stamp probes from the +two independent reviews, cross-audience use, repeated and concurrent admission, +expiry, duplicate headers, store failure, and cross-instance durable reservations. diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 5dc4aa9f..5fce47a2 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -12,25 +12,23 @@ import ( hop "github.com/codefly-dev/authority/libraries/hop/go" ) -proof, _ := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") +publicKey, _ := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "PUBLIC_KEY") +// replay is the host's durable shared Core ReplayStore; never a per-replica cache. conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) -verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, proof) +verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) 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) +// an HTTP module: id, err := verifier.Verify(ctx, metadataFromHeaders, observedFullProcedure) ``` -`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 -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. +`Verify` authenticates the signed method, audience, token digest and 30-second +admission window, authenticates the Work Context, compares identity metadata, +and atomically consumes the call nonce in the shared replay store. It refuses +replays across replicas without re-spending the Work Context's single use. +See the [wire protocol and migration contract](../README.md). The keys come from the interface's `Keys`, through the generated client: never typed in, never mounted. A read runs on its own, detached from every call and @@ -51,10 +49,6 @@ authority serves keys, and an idle one never goes stale. Never gate readiness on `KeysError()`: a brief authority outage would take every replica out of rotation while its keys are still trusted. -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 `hoptest.Forward` stamps it exactly as the hop does. diff --git a/libraries/hop/go/binding_test.go b/libraries/hop/go/binding_test.go new file mode 100644 index 00000000..60a87490 --- /dev/null +++ b/libraries/hop/go/binding_test.go @@ -0,0 +1,121 @@ +package hop_test + +import ( + "context" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// robin #512 finding 2 and evaluation #91 finding 1: a genuinely signed, +// single-use read capability cannot carry a captured read admission to a write. +func TestReviewReadStampCannotRetargetOrReplayAcrossReplicas(t *testing.T) { + ctx := context.Background() + a := hoptest.New(t) + token := a.IssueSingleUse(t, "documents", read) + const readMethod = "/documents.v1.Service/Get" + md := hoptest.Forward(t, token, proof, readMethod) + store := hoptest.Replay() + v, err := hop.NewVerifier(ctx, a, "documents", hoptest.PublicKey(proof), store) + if err != nil { + t.Fatal(err) + } + replica, err := hop.NewVerifier(ctx, a, "documents", hoptest.PublicKey(proof), store) + if err != nil { + t.Fatal(err) + } + for _, method := range []string{"/documents.v1.Service/Delete", "/datasets.v1.Service/Register", "Get", "", "POST " + readMethod} { + if _, err := v.Verify(ctx, md, method); err == nil || !strings.Contains(err.Error(), "method mismatch") { + t.Fatalf("retarget to %q: %v", method, err) + } + } + other, _ := hop.NewVerifier(ctx, a, "records", hoptest.PublicKey(proof), store) + if _, err := other.Verify(ctx, md, readMethod); err == nil || !strings.Contains(err.Error(), "audience mismatch") { + t.Fatalf("other audience: %v", err) + } + // Refused retargeting does not poison the legitimate reservation. + if _, err := v.Verify(ctx, md, readMethod); err != nil { + t.Fatal(err) + } + for _, verifier := range []*hop.Verifier{v, replica} { + if _, err := verifier.Verify(ctx, md, readMethod); err == nil || !strings.Contains(err.Error(), "replay") { + t.Fatalf("replay admitted: %v", err) + } + } +} + +func TestReviewZeroScopeSharedProofCannotFabricateAdmission(t *testing.T) { + a := hoptest.New(t) + token := a.Issue(t, "documents") // genuine zero-scope capability from evaluation probe + md := hoptest.Forward(t, token, proof, "/datasets.v1.Service/Register") + md.Set(hop.ProofKey, proof) // the old shared secret and a matching token digest + if _, err := verifier(t, a, "documents").Verify(context.Background(), md, "/datasets.v1.Service/Register"); err == nil { + t.Fatal("shared proof fabricated admission") + } +} + +func TestConcurrentReplayHasOneWinnerAcrossVerifiers(t *testing.T) { + a := hoptest.New(t) + store := hoptest.Replay() + first, _ := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) + second, _ := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) + md := hoptest.Forward(t, a.IssueSingleUse(t, "documents", read), proof, "/documents.v1.Service/Get") + var accepted atomic.Int64 + var wg sync.WaitGroup + for n := range 32 { + wg.Add(1) + go func() { + defer wg.Done() + v := []*hop.Verifier{first, second}[n%2] + if _, err := v.Verify(context.Background(), md, "/documents.v1.Service/Get"); err == nil { + accepted.Add(1) + } + }() + } + wg.Wait() + if accepted.Load() != 1 { + t.Fatalf("accepted %d times", accepted.Load()) + } +} + +func TestUnaryAndStreamInterceptorsBindTheObservedProcedure(t *testing.T) { + a := hoptest.New(t) + v := verifier(t, a, "documents") + md := hoptest.Forward(t, a.Issue(t, "documents", read), proof, "/documents.v1.Service/Get") + ctx := metadata.NewIncomingContext(context.Background(), md) + reached := false + _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/documents.v1.Service/Delete"}, func(context.Context, any) (any, error) { reached = true; return nil, nil }) + if err == nil || reached { + t.Fatal("unary reached retargeted handler") + } + err = v.StreamServerInterceptor()(nil, bindingStream{ctx: ctx}, &grpc.StreamServerInfo{FullMethod: "/documents.v1.Service/Delete"}, func(any, grpc.ServerStream) error { reached = true; return nil }) + if err == nil || reached { + t.Fatal("stream reached retargeted handler") + } +} + +type bindingStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s bindingStream) Context() context.Context { return s.ctx } + +func TestAdvisoryRefusalCannotBecomeASignedAdmission(t *testing.T) { + a := hoptest.New(t) + token := a.Issue(t, "documents", read) + md := metadata.Pairs(hop.WorkContextKey, token) + if err := hop.Stamp(md, hop.Identity{Advisory: "write scope missing"}, hoptest.Signer(proof), "/documents.v1.Service/Delete", "documents", time.Now()); err == nil { + t.Fatal("signed an advisory refusal") + } + if len(md.Get(hop.ProofKey)) != 0 { + t.Fatal("refusal leaked a usable proof") + } +} diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index 8ef4f6f3..764282bb 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -4,12 +4,13 @@ import ( "context" "crypto/ed25519" "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "os" "path/filepath" - "reflect" "strings" "sync" "testing" @@ -49,7 +50,7 @@ func (s signer) issue(t *testing.T, at time.Time, audience string, scopes ...*ba 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, + ReplayPolicy: core.ReplaySingleUse, AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, }) if err != nil { t.Fatal(err) @@ -66,7 +67,9 @@ func forwarded(t *testing.T, token, proof, advisory string) metadata.MD { id := IdentityOf(decoded.Context()) id.Advisory = advisory md := metadata.Pairs(WorkContextKey, token) - Stamp(md, id, proof) + if err := Stamp(md, id, testSigner(proof), "/test.Service/Read", decoded.Context().GetAudience(), time.Unix(decoded.Context().GetNotBeforeUnix(), 0)); err != nil { + t.Fatal(err) + } return md } @@ -118,12 +121,12 @@ func settled(v *Verifier) { } 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"` + PublicKey string `json:"public_key"` + 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 @@ -136,14 +139,14 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { if *updateFixture { 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") + md := forwarded(t, token, "fixture-proof", "") 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{}} + f := stampFixture{PublicKey: testSigner("fixture-proof").PublicKey(), Audience: "documents", Now: at.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"} + 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": ""} out, _ := json.MarshalIndent(f, "", " ") if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { t.Fatal(err) @@ -161,7 +164,7 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { if err := protojson.Unmarshal(f.Keys, published); err != nil { t.Fatal(err) } - v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, f.Proof) + v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, f.PublicKey, core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -170,13 +173,11 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { for key, value := range f.Headers { md.Set(key, value) } - id, err := v.Verify(context.Background(), md) + id, err := v.Verify(context.Background(), md, "/test.Service/Read") 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) } @@ -199,14 +200,14 @@ func TestPublishedKeysAreRereadOnRotationAndExpireWhenUnreadable(t *testing.T) { 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") + v, err := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) 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", "")) + _, err := v.Verify(context.Background(), forwarded(t, token, "proof", ""), "/test.Service/Read") settled(v) return err } @@ -247,7 +248,7 @@ 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, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) 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) @@ -258,13 +259,13 @@ func TestAnUnauthenticatedCallerNeitherReadsNorStarvesTheKeys(t *testing.T) { 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 { + if _, err := v.Verify(cancelled, md, "/test.Service/Read"); 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 { + if _, err := v.Verify(context.Background(), forwarded(t, second.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err == nil { genuine++ } settled(v) @@ -281,7 +282,7 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { 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, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start hold := make(chan struct{}) @@ -289,7 +290,7 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { 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", "")) + _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read") done <- err }() select { @@ -304,14 +305,14 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { 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") { + if _, err := v.Verify(short, forwarded(t, third.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); 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 { + if _, err := v.Verify(context.Background(), forwarded(t, third.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err != nil { t.Fatalf("the next read is not delayed by the caller who gave up: %v", err) } } @@ -325,11 +326,11 @@ func TestACallOnKeysPastKeyStaleWaitsForTheReread(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start clock = start.Add(39 * time.Minute) - if _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")); err != nil { + if _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read"); err != nil { t.Fatalf("the first call after an idle stretch is refused while its keys are re-read: %v", err) } if source.count() != 2 { @@ -345,7 +346,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.timeout = 100 * time.Millisecond v.fetched, v.tried = start, start @@ -359,7 +360,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { md := forwarded(t, first.issue(t, clock, "documents"), "proof", "") done := make(chan error, 1) go func() { - _, err := v.Verify(context.Background(), md) + _, err := v.Verify(context.Background(), md, "/test.Service/Read") done <- err }() select { @@ -378,7 +379,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { source.set(nil, nil, make(chan struct{})) // a read that never answers clock = clock.Add(keyRetry) began := time.Now() - _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", "")) + _, err := v.Verify(context.Background(), forwarded(t, first.issue(t, clock, "documents"), "proof", ""), "/test.Service/Read") if !refused(err) || time.Since(began) < v.timeout { t.Fatalf("a hung re-read refuses with the fixed reason after keyTimeout: %v after %v", err, time.Since(began)) } @@ -390,7 +391,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { // 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 := &Verifier{source: source, audience: "documents", publicKey: "proof", now: time.Now, timeout: 20 * time.Millisecond} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -422,7 +423,7 @@ func TestReadyHoldsWhileKeysAreTrusted(t *testing.T) { first := newSigner("first") 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, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock if !v.Ready() { @@ -448,7 +449,7 @@ func TestAnUnreadyReplicaBecomesReadyByPollingAlone(t *testing.T) { first := newSigner("first") source := &keys{err: errors.New("authority not serving yet")} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", "proof") + v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.tried = clock ready := false @@ -472,7 +473,7 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { first := newSigner("first") 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, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock for minute := 1; minute <= 60; minute++ { @@ -487,3 +488,9 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { t.Fatalf("an hour of readiness polls re-read the keys %d times; keyMaxAge says about 12", reads) } } + +func testSigner(label string) *stamp.Signer { + sum := sha256.Sum256([]byte(label)) + signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) + return signer +} diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 7ff6aec2..8ab5bdd1 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -50,3 +50,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/codefly-dev/authority/libraries/authority-client/go => ../../authority-client/go diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 1965a2a8..277a6190 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -3,21 +3,16 @@ // // 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. +// who the call is for and a signed admission for this call. // 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 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. +// The hop signs each admission with a service-only key. Verifiers pin its public +// key and atomically consume the signed nonce in a shared ReplayStore. Method, +// audience and token digest are authenticated; a captured stamp is single-use. package hop import ( @@ -70,9 +65,7 @@ const ( // the digest of the forwarded token. A module reading that token's claims // reads exactly what was checked, and nothing a caller swapped in after. ContextKey = StampPrefix + "context" - // AdvisoryKey carries what the authority would have refused when the hop - // runs permissive (hop/ENFORCEMENT=warn): the call went through, this says - // why it would not have. Empty, or absent, when nothing was refused. + // AdvisoryKey is always refused at admission. Diagnostic identity is never authority. AdvisoryKey = StampPrefix + "advisory" ) @@ -97,8 +90,7 @@ type Identity struct { // 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 is diagnostic only. Stamp refuses an identity carrying a refusal. Advisory string } @@ -122,9 +114,15 @@ func Strip(md metadata.MD) { } } -// Stamp writes identity and the run's proof onto md, after stripping whatever -// stamp a caller sent. -func Stamp(md metadata.MD, id Identity, proof string) { +// Stamp writes identity and a signed call admission, stripping caller stamps. +func Stamp(md metadata.MD, id Identity, signer *stamp.Signer, method, audience string, now time.Time) error { + if id.Advisory != "" { + return errors.New("hop: an advisory policy refusal cannot be signed as admission") + } + proof, err := signer.Sign(first(md, WorkContextKey), method, audience, now, id.Expires) + if err != nil { + return err + } Strip(md) md.Set(ProofKey, proof) md.Set(OwnerKey, id.Owner) @@ -147,6 +145,7 @@ func Stamp(md metadata.MD, id Identity, proof string) { if token := first(md, WorkContextKey); token != "" { md.Set(ContextKey, digest(token)) } + return nil } // IdentityOf reads who an authenticated Work Context is for. Its scopes are the @@ -233,11 +232,12 @@ var errKeysUnavailable = fmt.Errorf("%w: the authority's published keys are unav // 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 - timeout time.Duration + source KeySource + audience string + publicKey string + replay core.ReplayStore + now func() time.Time + timeout time.Duration mu sync.Mutex trust workcontext.Trust @@ -247,25 +247,26 @@ type Verifier struct { failure error // the last read's failure, for KeysError } -// 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. +// NewVerifier pins this module's audience and the delivered hop public key. +// replay must atomically reserve nonces across all replicas and survive restart. +// Keys is the generated authority client; no signing seed is delivered here. // // 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) { +func NewVerifier(ctx context.Context, keys KeySource, audience, publicKey string, replay core.ReplayStore) (*Verifier, error) { + raw, keyErr := hex.DecodeString(publicKey) 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)") + case replay == nil: + return nil, errors.New("hop: a verifier requires an atomic shared replay store") + case keyErr != nil || len(raw) != ed25519.PublicKeySize || hex.EncodeToString(raw) != publicKey: + return nil, errors.New("hop: a verifier needs the hop signing public key") } - v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now, timeout: keyTimeout} + v := &Verifier{source: keys, audience: audience, publicKey: publicKey, replay: replay, now: time.Now, timeout: keyTimeout} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -307,9 +308,10 @@ func (v *Verifier) Ready() bool { // 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. 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) { - token, err := stamp.Context(md, v.proof) +// signed method/audience binding and a bound token never reaches the keys. +func (v *Verifier) Verify(ctx context.Context, md metadata.MD, method string) (Identity, error) { + call, err := stamp.Check(md, v.publicKey, method, v.audience, v.now()) + token := first(md, WorkContextKey) if err != nil { return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) } @@ -317,7 +319,7 @@ func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) if err != nil { return Identity{}, err } - claims, err := workcontext.Authenticate(md, v.proof, v.audience, trust) + claims, err := workcontext.Authenticate(md, v.audience, trust) if err != nil { return Identity{}, err } @@ -325,7 +327,19 @@ 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 = token, claims, first(md, AdvisoryKey) + if call.Expires <= v.now().Unix() { + return Identity{}, fmt.Errorf("%w: hop proof expired while authenticating", workcontext.ErrUnauthenticated) + } + if err := v.replay.Consume(ctx, call.ReplayKey(v.publicKey), time.Unix(call.Expires, 0)); err != nil { + if errors.Is(err, core.ErrReplayed) { + return Identity{}, fmt.Errorf("%w: hop admission replayed", workcontext.ErrUnauthenticated) + } + return Identity{}, fmt.Errorf("%w: hop replay store unavailable", workcontext.ErrUnauthenticated) + } + if call.Expires <= v.now().Unix() { + return Identity{}, fmt.Errorf("%w: hop proof expired during replay reservation", workcontext.ErrUnauthenticated) + } + id.Context, id.Claims = token, claims return id, nil } @@ -476,7 +490,7 @@ func (v *Verifier) admit(ctx context.Context, method string) (context.Context, e return ctx, nil } md, _ := metadata.FromIncomingContext(ctx) - id, err := v.Verify(ctx, md) + id, err := v.Verify(ctx, md, method) if err != nil { return nil, status.Error(codes.Unauthenticated, err.Error()) } diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index bba7f65c..dfa549ab 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -27,7 +27,7 @@ var read = &basev0.WorkScopeV1{ResourceKind: "documents", Actions: []string{"rea func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier { t.Helper() - v, err := hop.NewVerifier(context.Background(), a, audience, proof) + v, err := hop.NewVerifier(context.Background(), a, audience, hoptest.PublicKey(proof), hoptest.Replay()) if err != nil { t.Fatal(err) } @@ -37,7 +37,7 @@ func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier 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)) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof, "/test.Service/Read"), "/test.Service/Read") if err != nil { t.Fatal(err) } @@ -64,18 +64,18 @@ func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { 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"}, + "forged signature": {stampedAs(t, a, forged, genuine), "digest mismatch"}, + "unsigned": {stampedAs(t, a, unsigned, genuine), "digest mismatch"}, + "another audience": {hoptest.Forward(t, elsewhere, proof, "/test.Service/Read"), "audience mismatch"}, "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"}, + "another run's hop": {hoptest.Forward(t, genuine, "another-run", "/test.Service/Read"), "proof"}, + "swapped token": {swapped(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), a.Issue(t, "documents", read)), "digest mismatch"}, + "stamped tenant lie": {with(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), hop.TenantKey, "globex"), "tenant"}, + "stamped scope lie": {with(hoptest.Forward(t, genuine, proof, "/test.Service/Read"), 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) + _, err := v.Verify(context.Background(), c.md, "/test.Service/Read") if !errors.Is(err, workcontext.ErrUnauthenticated) || !strings.Contains(err.Error(), c.reason) { t.Fatalf("want a refusal naming %q, got %v", c.reason, err) } @@ -86,7 +86,7 @@ func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { // 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) + md := hoptest.Forward(t, genuine, proof, "/test.Service/Read") sum := sha256.Sum256([]byte(token)) md.Set(hop.WorkContextKey, token) md.Set(hop.ContextKey, hex.EncodeToString(sum[:])) @@ -117,7 +117,7 @@ func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { if _, err := call(metadata.MD{}, "/api.ApiService/WhoAmI"); status.Code(err) != codes.Unauthenticated || !strings.Contains(err.Error(), "proof") { t.Fatalf("no stamp: %v", err) } - if got, err := call(hoptest.Forward(t, a.Issue(t, "console", read), proof), "/api.ApiService/WhoAmI"); err != nil || got != "principal-owner" { + if got, err := call(hoptest.Forward(t, a.Issue(t, "console", read), proof, "/api.ApiService/WhoAmI"), "/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 { @@ -133,9 +133,15 @@ func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { 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", "") }, + "no keys": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), nil, "documents", hoptest.PublicKey(proof), hoptest.Replay()) + }, + "no audience": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), a, "", hoptest.PublicKey(proof), hoptest.Replay()) + }, + "no proof": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), a, "documents", "", hoptest.Replay()) + }, } { 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 index 365565d6..58491ce4 100644 --- a/libraries/hop/go/hoptest/hoptest.go +++ b/libraries/hop/go/hoptest/hoptest.go @@ -8,7 +8,10 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "encoding/hex" "fmt" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "testing" "time" @@ -64,11 +67,20 @@ func (a *Authority) Keys(context.Context, *authorityv1.KeysRequest, ...grpc.Call // 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 { + return a.issue(t, audience, core.ReplayIdempotent, scopes...) +} + +// IssueSingleUse reproduces admission probes with a genuine single-use capability. +func (a *Authority) IssueSingleUse(t testing.TB, audience string, scopes ...*basev0.WorkScopeV1) string { + return a.issue(t, audience, core.ReplaySingleUse, scopes...) +} + +func (a *Authority) issue(t testing.TB, audience, replay 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, + ReplayPolicy: replay, AuthorityScopes: scopes, InstallationID: core.FixtureInstallation, TTL: 10 * time.Minute, }) if err != nil { t.Fatal(err) @@ -78,13 +90,26 @@ func (a *Authority) Issue(t testing.TB, audience string, scopes ...*basev0.WorkS // 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 { +func Forward(t testing.TB, token, proof, method 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) + if err := hop.Stamp(md, hop.IdentityOf(decoded.Context()), Signer(proof), method, decoded.Context().GetAudience(), time.Now()); err != nil { + t.Fatal(err) + } return md } + +// Signer derives a public test key. Production seeds come only from Codefly. +func Signer(label string) *stamp.Signer { + sum := sha256.Sum256([]byte(label)) + signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) + return signer +} +func PublicKey(label string) string { return Signer(label).PublicKey() } + +// Replay is for single-process tests only; deployments supply a shared store. +func Replay() core.ReplayStore { return core.NewMemoryReplayStore() } diff --git a/libraries/hop/go/owner_test.go b/libraries/hop/go/owner_test.go index f047ef38..35ba36cd 100644 --- a/libraries/hop/go/owner_test.go +++ b/libraries/hop/go/owner_test.go @@ -21,7 +21,7 @@ func ownedScope(t *testing.T, kind, action, owner string, ids ...string) *basev0 func TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind(t *testing.T) { a := hoptest.New(t) token := a.Issue(t, "documents", ownedScope(t, "documents", "read", "principal-u")) - id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof)) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof, "/test.Service/Read"), "/test.Service/Read") if err != nil { t.Fatalf("a capability with an owner-selected scope must verify: %v", err) } diff --git a/libraries/hop/go/replay.go b/libraries/hop/go/replay.go new file mode 100644 index 00000000..3a1e72c0 --- /dev/null +++ b/libraries/hop/go/replay.go @@ -0,0 +1,65 @@ +package hop + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + core "github.com/codefly-dev/core/workcontext" +) + +// FileReplayStore implements Core's ReplayStore on a durable shared filesystem. +// All replicas must mount the SAME directory with atomic exclusive creation and +// durable fsync semantics. A local ephemeral volume is not a shared store. +// Reservations deliberately survive expiry and restart. Operators may remove +// files only after their recorded expiry, with all verifiers' clocks past it. +// An incomplete file is retained: an interrupted reservation fails closed. +type FileReplayStore struct{ directory string } + +func NewFileReplayStore(directory string) (*FileReplayStore, error) { + if !filepath.IsAbs(directory) { + return nil, errors.New("hop: replay directory must be an absolute delivered shared volume path") + } + info, err := os.Stat(directory) + if err != nil || !info.IsDir() { + return nil, errors.New("hop: delivered replay directory is unavailable") + } + return &FileReplayStore{directory: directory}, nil +} + +func (s *FileReplayStore) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + if err := ctx.Err(); err != nil { + return err + } + sum := sha256.Sum256([]byte(nonce)) + file, err := os.OpenFile(filepath.Join(s.directory, hex.EncodeToString(sum[:])), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if errors.Is(err, os.ErrExist) { + return core.ErrReplayed + } + if err != nil { + return errors.New("hop: replay store reservation unavailable") + } + // Never remove a reservation on error: another process may already have + // observed it, and a failed fsync cannot safely establish whether it persisted. + _, writeErr := fmt.Fprintf(file, "%d\n", retainUntil.Unix()) + syncErr := file.Sync() + closeErr := file.Close() + if writeErr != nil || syncErr != nil || closeErr != nil { + return errors.New("hop: replay reservation could not be persisted") + } + directory, err := os.Open(s.directory) + if err != nil { + return errors.New("hop: replay directory unavailable") + } + syncErr = directory.Sync() + closeErr = directory.Close() + if syncErr != nil || closeErr != nil { + return errors.New("hop: replay directory could not be persisted") + } + return nil +} diff --git a/libraries/hop/go/replay_test.go b/libraries/hop/go/replay_test.go new file mode 100644 index 00000000..6d59c1b6 --- /dev/null +++ b/libraries/hop/go/replay_test.go @@ -0,0 +1,42 @@ +package hop_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + hop "github.com/codefly-dev/authority/libraries/hop/go" +) + +func TestReplayReservationIsAtomicAcrossInstancesAndSurvivesRestart(t *testing.T) { + directory := t.TempDir() + var accepted atomic.Int64 + var wg sync.WaitGroup + for n := 0; n < 32; n++ { + wg.Add(1) + go func() { + defer wg.Done() + store, err := hop.NewFileReplayStore(directory) + if err != nil { + t.Error(err) + return + } + if store.Consume(context.Background(), "same-call", time.Now().Add(time.Minute)) == nil { + accepted.Add(1) + } + }() + } + wg.Wait() + if accepted.Load() != 1 { + t.Fatalf("accepted %d calls, want one", accepted.Load()) + } + restarted, err := hop.NewFileReplayStore(directory) + if err != nil { + t.Fatal(err) + } + if restarted.Consume(context.Background(), "same-call", time.Now().Add(time.Minute)) == nil { + t.Fatal("replayed after restart") + } +} diff --git a/libraries/hop/testdata/stamped.json b/libraries/hop/testdata/stamped.json index 55219c1d..115b3897 100644 --- a/libraries/hop/testdata/stamped.json +++ b/libraries/hop/testdata/stamped.json @@ -1,7 +1,7 @@ { - "proof": "fixture-proof", + "public_key": "17053f7519568253029258eca5f61d05c76647ea0bd9143ad1bf5f36e37d9629", "audience": "documents", - "now": 1792818060, + "now": 1792818000, "keys": { "issuer": "https://authority.hop.test", "keys": [ @@ -14,20 +14,19 @@ }, "headers": { "x-codefly-hop-actors": "", - "x-codefly-hop-advisory": "documents.collection:write is not held", - "x-codefly-hop-context": "cfaa9f0a79b969b344d63c29cded67e41791cee66339a9e59881708433b90c1b", + "x-codefly-hop-context": "c32facab31f07e892551222e5acdfba8225f5ee9a44ff18168639a029548709f", "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-proof": "WyJjb2RlZmx5LWhvcC12MSIsIi90ZXN0LlNlcnZpY2UvUmVhZCIsImRvY3VtZW50cyIsImMzMmZhY2FiMzFmMDdlODkyNTUxMjIyZTVhY2RmYmE4MjI1ZjVlZTlhNDRmZjE4MTY4NjM5YTAyOTU0ODcwOWYiLCI3OWVjNDYzZDdiYjI0M2E2ZThjYzY0NDkwMTMxZGY5YzFiZjNmNWVkMTA2MzBjMDUxODllN2Y1OGMzM2ZjYzg2IiwxNzkyODE4MDAwLDE3OTI4MTgwMzBd.PkjjHEmoK7491qYH1TPs6sVOfUF-x7xdittoG2COw3gudr_d0TkqCFLg1djdcLU6DWX_e2K9pNWETB7Zb7pABQ", "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" + "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGRiMTMyYmE1LTUwZTctNDAwYi1iYjk0LTkxNzVkNDY0YWFkZVAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYWUzNzI0ZDAtNTU1MS00NmQ3LTg3NDMtODc3NmE1NGMzOGRiigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.zqmygW1AVYgFft6kBTBv6OzFekNqAJzKe50POaDTr5Q6uXV91DmI-RynvAGdUWfCeiAN6e4M5B0icRRijiG3Cw" }, "identity": { "actors": null, - "advisory": "documents.collection:write is not held", + "advisory": "", "binding": "", "expires": 1792818600, "owner": "principal-owner", diff --git a/modules/authority/services/hop/README.md b/modules/authority/services/hop/README.md index 18b463f6..2bcd2c0f 100644 --- a/modules/authority/services/hop/README.md +++ b/modules/authority/services/hop/README.md @@ -22,8 +22,7 @@ several services. A request selects its destination, then makes one Authority Ch actual HTTP verb and path. The authority selects the policy from the delivered manifest, including stricter literal routes and ambiguous-route refusals. Connect uses a published procedure. A dotted URL alone never selects -Connect or borrows a gRPC policy. Unknown or ambiguous routes refuse, including -in warn mode. The request body, path, query and streaming protocol stay intact. +Connect or borrows a gRPC policy. Unknown or ambiguous routes refuse. The request body, path, query and streaming protocol stay intact. Routes are immutable for one Codefly run. Ordinary source reload retains the accepted endpoint addresses. A changed published route set or address requires @@ -34,39 +33,19 @@ agent may already have stopped or initialized its process. This does not promise live route-set adoption or restore that process. Failed targets remain unavailable; a sibling service is never their fallback. -The composition selects `hop/ENFORCEMENT` through SDK-delivered configuration: -`enforce` (the default) returns authority refusals; `warn` logs them and allows -permissive developer forwarding. For method-policy failures, warn mode requests -an advisory Check and uses the authority's returned context, preserving its -owner, tenant, operation binding, task and effective scopes. The refusal travels -in the stamp's advisory field and is logged once. +The hop always enforces the authority's method decision. Legacy +`hop/ENFORCEMENT=warn` and unknown modes refuse startup in every environment. +Advisory refusals never produce signed admissions. See the +[signed admission and replica storage contract](../../../../libraries/hop/README.md) +for Codefly key delivery, the exact procedure binding, and durable nonce consumption. -Missing, malformed, expired, revoked or misaddressed Work Contexts remain -refused in both modes. The hop also refuses when it cannot reach the authority. -Warning mode uses the authority's verified advisory context; it creates no -synthetic identity or developer session. The checked audience and selected -published route identify the upstream. +Validation: run `go vet ./...` and `go test -race ./...` in this service's +`code/`, the local authority's `code/`, and `libraries/hop/go`. The shared fixture +is also read by the Python and TypeScript regression suites. -Warning mode relaxes method-policy admission only. Issuance and downstream -resource, visibility and ownership decisions still apply. A successful hop unit -test alone does not prove a complete solution journey or enforce-mode qualification. - -Validation: run `go test -race ./...` in this service's `code/`, the local authority's -`code/`, and `libraries/hop/go`. Local authority's generated-client advisory test checks -the real issuer; proxy's routing regression checks that a Records request never -reaches Documents when its destination is unresolved. - -`ENFORCEMENT=warn` is accepted only when the SDK reports a local environment. -Unknown enforcement modes and warn in other environments are refused by name. -The shared authority client dialer uses verified TLS, or accepts an in-cluster -Service address under `internal-transport/mesh-protected=true`. Plaintext -loopback is local-only. It refuses missing or invalid mesh assertions for -in-cluster plaintext, including SDK-resolved fallback addresses. - -The HTTP face refuses non-canonical paths before admission: dot segments, -repeated separators, encoded dots/slashes/backslashes and nested escapes. -It never cleans and forwards a different path. There are no module-wide or -heuristic Connect fallback routes; every admitted call names a published listener. +The HTTP face always listens on its declared endpoint. A missing generated +`hop-routes/ROUTES` view is refused before starting either face. Every admitted +call names a published listener; there are no module-wide fallback routes. All hop-to-module endpoints use the same transport gate as the authority dialer: verified TLS, local loopback, or the composition's mesh-protected assertion plus diff --git a/modules/authority/services/hop/code/enforcement_test.go b/modules/authority/services/hop/code/enforcement_test.go index 55839c9a..c409f456 100644 --- a/modules/authority/services/hop/code/enforcement_test.go +++ b/modules/authority/services/hop/code/enforcement_test.go @@ -5,14 +5,10 @@ import ( "testing" ) -func TestEnforcementRequiresLocalEnvironment(t *testing.T) { - for _, local := range []bool{false, true} { - for _, mode := range []string{"", "enforce", "warn", "invalid"} { - warn, err := enforcementMode(mode, local) - denied := mode == "invalid" || mode == "warn" && !local - if (err != nil) != denied || warn != (mode == "warn" && local) { - t.Fatalf("mode %q local %v: warn %v, error %v", mode, local, warn, err) - } +func TestSignedAdmissionRequiresEnforcementInEveryEnvironment(t *testing.T) { + for _, mode := range []string{"", "enforce", "warn", "invalid"} { + if err := requireEnforcement(mode); (err != nil) != (mode == "warn" || mode == "invalid") { + t.Fatalf("mode %q: %v", mode, err) } } } 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 f86f0259..106fd480 100644 --- a/modules/authority/services/hop/code/pkg/business/decide/decide.go +++ b/modules/authority/services/hop/code/pkg/business/decide/decide.go @@ -41,15 +41,6 @@ type Decider struct { Routes *routing.Routing Authority Checker Modules map[string]bool - // Permissive is hop/ENFORCEMENT=warn: the authority is asked for advice, - // verifies the Work Context as always, and answers what the method's - // policy would have refused beside the verified identity; the call goes - // through stamped with that identity and the refusal as its advisory, - // which the hop also logs. An unsound or missing Work Context is still - // refused. The composition says so; a cell never does. - Permissive bool - // Warned is told each policy refusal the permissive hop let through. - Warned func(method string, err error) // AudienceOf reads the module a token is addressed to; nil decodes it with // core, unverified, which is all the hop needs before the authority checks. AudienceOf func(token string) (string, error) @@ -64,10 +55,6 @@ func coreAudience(token string) (string, error) { } // Decide returns the module a call is for and who it acts for, or the refusal. -// Permissive changes one answer only: the authority verified the Work Context -// and the method's policy would have refused it (decide's advisory path). A -// call whose Work Context is missing, unsound or addressed elsewhere, or that -// the authority could not be asked about, is refused in either mode. func (d Decider) Decide(ctx context.Context, method string, md metadata.MD) (Decision, error) { return d.decide(ctx, method, md) } @@ -101,7 +88,7 @@ func (d Decider) decide(ctx context.Context, method string, md metadata.MD) (Dec return Decision{}, status.Error(codes.NotFound, routeErr.Error()) } route := &selected - checked, err := d.Authority.Check(ctx, &authorityv1.CheckRequest{Token: tokens[0], Audience: module, Method: policy, Advisory: d.Permissive}) + checked, err := d.Authority.Check(ctx, &authorityv1.CheckRequest{Token: tokens[0], Audience: module, Method: policy}) if err != nil { refusal := status.Convert(err) if refusal.Code() == codes.Unavailable || refusal.Code() == codes.DeadlineExceeded { @@ -109,13 +96,10 @@ func (d Decider) decide(ctx context.Context, method string, md metadata.MD) (Dec } return Decision{}, status.Errorf(refusal.Code(), "the authority refused the call to %s: %s", module, refusal.Message()) } - id := IdentityOf(checked.GetContext()) - if d.Permissive && checked.GetRefusal() != "" { - id.Advisory = checked.GetRefusal() - if d.Warned != nil { - d.Warned(method, status.Error(codes.PermissionDenied, id.Advisory)) - } + if checked.GetRefusal() != "" { + return Decision{}, status.Error(codes.PermissionDenied, "the authority refused the method policy") } + id := IdentityOf(checked.GetContext()) return Decision{Module: module, Identity: id, Route: route}, nil } 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 742fcdec..eafc4bd9 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 @@ -3,6 +3,7 @@ package decide import ( "context" "errors" + "testing" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" @@ -27,7 +28,7 @@ func (unsound) Check(context.Context, *authorityv1.CheckRequest, ...grpc.CallOpt // refused: a missing, ambiguous or undecodable Work Context, one addressed to a // module the hop does not front, one the authority refuses as unsound, and one // the authority could not be asked about. Nothing is stamped as a developer. -func TestAPermissiveHopStillRefusesWhatHasNoSoundIdentity(t *testing.T) { +func TestHopRefusesWhatHasNoSoundIdentity(t *testing.T) { audience := func(string) (string, error) { return "robin", nil } undecodable := func(string) (string, error) { return "", errors.New("not a Work Context") } for _, tc := range []struct { @@ -44,14 +45,13 @@ func TestAPermissiveHopStillRefusesWhatHasNoSoundIdentity(t *testing.T) { {"authority unreachable", Decider{Authority: unreachable{}, AudienceOf: audience}, metadata.Pairs(hop.WorkContextKey, "x"), codes.Unavailable}, } { t.Run(tc.name, func(t *testing.T) { - warnings := 0 d := tc.d d.Routes = testRoutes("robin", "GET /v1/robin/intents/version") - d.Modules, d.Permissive, d.Warned = map[string]bool{"robin": true}, true, func(string, error) { warnings++ } + d.Modules = map[string]bool{"robin": true} decision, err := d.Decide(context.Background(), "GET /v1/robin/intents/version", tc.md) module, id := decision.Module, decision.Identity - if status.Code(err) != tc.code || module != "" || id.Owner != "" || warnings != 0 { - t.Fatalf("module=%q id=%+v error=%v warnings=%d; want refused %v, nothing warned", module, id, err, warnings, tc.code) + if status.Code(err) != tc.code || module != "" || id.Owner != "" { + t.Fatalf("module=%q id=%+v error=%v; want refused %v", module, id, err, tc.code) } }) } @@ -79,40 +79,11 @@ func (advisoryChecker) Check(_ context.Context, req *authorityv1.CheckRequest, _ }}, nil } -// Permissive, a call the method's policy refuses goes through with the -// identity the authority verified and the refusal as the stamp's advisory, -// told to Warned. Enforcing, the same refusal stands. -func TestPermissivePolicyUsesTheAuthorityAdvisoryIdentity(t *testing.T) { - warnings := 0 - d := Decider{Routes: testRoutes("documents", "POST /documents/write"), Authority: advisoryChecker{}, Modules: map[string]bool{"documents": true}, Permissive: true, - AudienceOf: func(string) (string, error) { return "documents", nil }, - Warned: func(method string, err error) { - warnings++ - if method != "POST /documents/write" || status.Convert(err).Message() != "write scope missing" { - t.Errorf("warning = %q, %v", method, err) - } - }, - } - stamp := metadata.MD{} - decision, err := d.Decide(context.Background(), "POST /documents/write", metadata.Pairs(hop.WorkContextKey, "test-context")) - module, id := decision.Module, decision.Identity - if err != nil || module != "documents" || warnings != 1 { - t.Fatalf("decision = %q, %v; warnings = %d", module, err, warnings) - } - if id.Owner != "maya" || id.Tenant != "acme" || id.Binding != "acme/robin/documents" || id.Task != "task-1" || id.Expires != 42 { - t.Fatalf("lost authority identity: %+v", id) - } - if id.Advisory != "write scope missing" || len(id.Actors) != 1 || id.Actors[0] != "robin" || len(id.Scopes) != 1 || id.Scopes[0] != "documents:read" { - t.Fatalf("lost advisory or effective scopes: %+v", id) - } - hop.Stamp(stamp, id, "proof") - if got := stamp.Get(hop.AdvisoryKey); len(got) != 1 || got[0] != id.Advisory { - t.Fatalf("the advisory rides the stamp: %v", got) - } - +// A policy refusal can never become a signed downstream admission. +func TestPolicyRefusalStopsBeforeStamping(t *testing.T) { strict := Decider{Routes: testRoutes("documents", "POST /documents/write"), Authority: advisoryChecker{}, Modules: map[string]bool{"documents": true}, AudienceOf: func(string) (string, error) { return "documents", nil }} if _, err := strict.Decide(context.Background(), "POST /documents/write", metadata.Pairs(hop.WorkContextKey, "test-context")); status.Code(err) != codes.PermissionDenied { - t.Fatalf("enforcing, the policy refusal stands: %v", err) + t.Fatalf("policy refusal: %v", err) } } diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go index 11f117fc..e4c01e8c 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/canonical_path_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "io" "net" "net/http" @@ -12,6 +13,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" @@ -25,9 +27,9 @@ func TestNonCanonicalPathsRefuseBeforeDecisionOverRawHTTP(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded.Add(1); w.WriteHeader(204) })) defer backend.Close() target, _ := url.Parse(backend.URL) - face := &proxy.HTTP{Proof: "test-proof", Upstreams: map[string]*url.URL{backend.URL: target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + face := &proxy.HTTP{Signer: hoptest.Signer("test-proof"), Upstreams: map[string]*url.URL{backend.URL: target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { checked.Add(1) - return decide.Decision{Module: "module", Route: &routing.RouteEndpoint{Address: backend.URL}, Identity: hop.Identity{Owner: "person", Tenant: "tenant"}}, nil + return decide.Decision{Module: "module", Route: &routing.RouteEndpoint{Address: backend.URL}, Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "person", Tenant: "tenant"}}, nil }} server := httptest.NewServer(face) defer server.Close() diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go index 20667dc9..f084a707 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "net" "net/http" "net/http/httptest" @@ -53,10 +54,10 @@ func TestMain(m *testing.M) { func TestTheHTTPFaceNeverForwardsThroughAnEnvironmentProxy(t *testing.T) { target, _ := url.Parse("http://documents.ns.svc.cluster.local:8080") h := &HTTP{ - Proof: "run-proof", + Signer: hoptest.Signer("run-proof"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { - return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "documents", Identity: hop.Identity{Owner: "maya", Tenant: "acme"}}, nil + return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "documents", Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "maya", Tenant: "acme"}}, nil }, } before := proxied.Load() 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 6ac61db7..3856d9fc 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 @@ -2,6 +2,7 @@ package proxy_test import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "google.golang.org/grpc/health" healthv1 "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/test/bufconn" @@ -11,6 +12,7 @@ import ( "net/url" "sync/atomic" "testing" + "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/hop/go" @@ -37,7 +39,7 @@ func (c *routeChecker) Check(_ context.Context, req *authorityv1.CheckRequest, _ if req.Method == c.denied { return nil, status.Error(codes.PermissionDenied, "declared route requires write") } - return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{OwnerPrincipalId: "person", TenantId: "tenant"}}, nil + return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{OwnerPrincipalId: "person", TenantId: "tenant", ExpiresAtUnix: time.Now().Add(time.Minute).Unix()}}, nil } func routeEndpoint(service, transport, address string, methods ...string) routing.RouteEndpoint { @@ -45,7 +47,7 @@ func routeEndpoint(service, transport, address string, methods ...string) routin } // A procedure-shaped HTTP path is not evidence of a Connect listener. Its -// matched HTTP policy and destination must be one decision, even in warn mode. +// matched HTTP policy and destination must be one decision. func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { var reached atomic.Int32 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -69,16 +71,15 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { } checker := &routeChecker{denied: "POST /gauge.v1.Service/Call"} decision := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true}, AudienceOf: func(string) (string, error) { return "module", nil }} - for _, warn := range []bool{false, true} { - decision.Permissive = warn + for range 2 { refused := 0 - face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Proof: proof, Refused: func(string, error) { refused++ }} + face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Signer: hoptest.Signer(proof), Refused: func(string, error) { refused++ }} req := httptest.NewRequest(http.MethodPost, "/gauge.v1.Service/Call?original=yes", nil) req.Header.Set(hop.WorkContextKey, "context") rec := httptest.NewRecorder() face.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || reached.Load() != 0 || refused != 1 { - t.Fatalf("warn %v: status=%d reached=%d refused=%d", warn, rec.Code, reached.Load(), refused) + t.Fatalf("status=%d reached=%d refused=%d", rec.Code, reached.Load(), refused) } } if len(checker.calls) != 2 || checker.calls[0] != checker.denied || checker.calls[1] != checker.denied { @@ -86,7 +87,7 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { } // Inverting the grants admits the HTTP route despite the gRPC refusal. checker.denied = "/gauge.v1.Service/Call" - face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Proof: proof} + face := proxy.HTTP{Decide: decision.Decide, Upstreams: map[string]*url.URL{backend.URL: u}, Signer: hoptest.Signer(proof)} req := httptest.NewRequest(http.MethodPost, "/gauge.v1.Service/Call", nil) req.Header.Set(hop.WorkContextKey, "context") rec := httptest.NewRecorder() @@ -115,7 +116,7 @@ func TestPublishedDestinationAndPolicyAreOneSelection(t *testing.T) { rec = httptest.NewRecorder() face.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized || reached.Load() != 1 { - t.Fatal("invalid identity reached a route in warn mode") + t.Fatal("invalid identity reached a route") } } @@ -135,7 +136,7 @@ func TestGeneratedGRPCRoutesKeepSiblingAndAudienceSeparate(t *testing.T) { routes.Endpoints = append(routes.Endpoints, routeEndpoint("health", "grpc", "third", "/grpc.health.v1.Health/Check", "/grpc.health.v1.Health/Watch")) checker := &routeChecker{} decision := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true, "other": true}, AudienceOf: func(token string) (string, error) { return token, nil }} - p := &proxy.Proxy{Decide: decision.Decide, Proof: proof, Upstreams: map[string]*grpc.ClientConn{"first": first, "second": second, "third": third, "module": second}} + p := &proxy.Proxy{Decide: decision.Decide, Signer: hoptest.Signer(proof), Upstreams: map[string]*grpc.ClientConn{"first": first, "second": second, "third": third, "module": second}} conn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) for _, audience := range []string{"module", "other"} { ctx := metadata.AppendToOutgoingContext(t.Context(), hop.WorkContextKey, audience) diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/http.go b/modules/authority/services/hop/code/pkg/infra/proxy/http.go index 5e264028..a1044325 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/http.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/http.go @@ -2,10 +2,12 @@ package proxy import ( "encoding/json" + callstamp "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "net/http" "net/http/httputil" "net/url" "strings" + "time" "github.com/codefly-dev/authority/libraries/authority-client/go/host" "github.com/codefly-dev/authority/libraries/hop/go" @@ -24,7 +26,7 @@ import ( type HTTP struct { Decide Decide Upstreams map[string]*url.URL - Proof string + Signer *callstamp.Signer // Refused is told every refusal, so the hop's log names it. Refused func(method string, err error) } @@ -63,7 +65,15 @@ func (h *HTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { } stamp := metadata.MD{} stamp.Set(hop.WorkContextKey, md.Get(hop.WorkContextKey)...) - hop.Stamp(stamp, id, h.Proof) + boundMethod := method + // Connect's server interceptor observes the full procedure, not the HTTP verb. + if decision.Route.Transport == "connect" { + boundMethod = r.URL.Path + } + if err := hop.Stamp(stamp, id, h.Signer, boundMethod, module, time.Now()); err != nil { + refuse(w, status.Error(codes.Unauthenticated, err.Error())) + return + } (&httputil.ReverseProxy{ // Never through a proxy from the environment, which would see the stamp, // the run's proof and the forwarded Work Context. 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 0345af00..c651465e 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 @@ -2,6 +2,8 @@ package proxy import ( "context" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + core "github.com/codefly-dev/core/workcontext" "hop/pkg/business/decide" "io" "net/http" @@ -9,6 +11,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" @@ -30,14 +33,14 @@ func TestAnHTTPRequestIsDecidedThenStampedAndForwarded(t *testing.T) { target, _ := url.Parse(module.URL) var decided string h := &HTTP{ - Proof: "run-proof", + Signer: hoptest.Signer("run-proof"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(_ context.Context, method string, md metadata.MD) (decide.Decision, error) { decided = method if md.Get(hop.WorkContextKey)[0] != "wc-model" { t.Fatalf("the decision did not read the Work Context header: %v", md) } - return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "model", Identity: hop.Identity{Owner: "remi", OwnerKind: "human", Tenant: "acme", Scopes: []string{"modelservice.profiles:read"}}}, nil + return decide.Decision{Route: &routing.RouteEndpoint{Address: target.String()}, Module: "model", Identity: hop.Identity{Expires: time.Now().Add(time.Minute).Unix(), Owner: "remi", OwnerKind: "human", Tenant: "acme", Scopes: []string{"modelservice.profiles:read"}}}, nil }, } req := httptest.NewRequest(http.MethodGet, "/v1/model/profiles?x=1", nil) @@ -64,7 +67,7 @@ func TestAnHTTPRequestIsDecidedThenStampedAndForwarded(t *testing.T) { 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) == "" { + }; first(hop.TenantKey) != "acme" || first(hop.OwnerKey) != "remi" || first(hop.WorkContextKey) != "wc-model" || first(hop.ProofKey) == "run-proof" || first(hop.ProofKey) == "" || first(hop.ContextKey) == "" { t.Fatalf("the hop stamped %v", md) } } @@ -81,7 +84,7 @@ func TestAnHTTPRefusalNeverReachesTheModule(t *testing.T) { codes.PermissionDenied: http.StatusForbidden, codes.Unavailable: http.StatusServiceUnavailable, } { - h := &HTTP{Proof: "p", Upstreams: map[string]*url.URL{target.String(): target}, + h := &HTTP{Signer: hoptest.Signer("p"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { return decide.Decision{}, status.Error(code, "the authority said why") }} @@ -95,3 +98,56 @@ func TestAnHTTPRefusalNeverReachesTheModule(t *testing.T) { t.Fatal("a refused request reached the module") } } + +func TestHTTPAndConnectIssueTheBindingObservedByTheListener(t *testing.T) { + for _, transport := range []string{"http", "connect"} { + t.Run(transport, func(t *testing.T) { + const procedure = "/example.v1.Service/Read" + expected := procedure + if transport == "http" { + expected = "POST " + procedure + } + a := hoptest.New(t) + token := a.IssueSingleUse(t, "module") + decoded, err := core.Decode(token) + if err != nil { + t.Fatal(err) + } + verifier, err := hop.NewVerifier(context.Background(), a, "module", hoptest.PublicKey("binding-test"), hoptest.Replay()) + if err != nil { + t.Fatal(err) + } + var captured metadata.MD + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = metadata.MD{} + for key, values := range r.Header { + captured[strings.ToLower(key)] = values + } + if _, err := verifier.Verify(r.Context(), captured, expected); err != nil { + t.Error(err) + w.WriteHeader(401) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + target, _ := url.Parse(backend.URL) + face := HTTP{Signer: hoptest.Signer("binding-test"), Upstreams: map[string]*url.URL{target.String(): target}, Decide: func(context.Context, string, metadata.MD) (decide.Decision, error) { + return decide.Decision{Module: "module", Identity: hop.IdentityOf(decoded.Context()), Route: &routing.RouteEndpoint{Transport: transport, Address: target.String()}}, nil + }} + req := httptest.NewRequest(http.MethodPost, procedure, nil) + req.Header.Set(hop.WorkContextKey, token) + rec := httptest.NewRecorder() + face.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("forwarded status %d", rec.Code) + } + if _, err := verifier.Verify(context.Background(), captured, expected); err == nil || !strings.Contains(err.Error(), "replay") { + t.Fatalf("captured replay: %v", err) + } + if _, err := verifier.Verify(context.Background(), captured, "/example.v1.Service/Delete"); err == nil || !strings.Contains(err.Error(), "method mismatch") { + t.Fatalf("retargeted stamp: %v", err) + } + }) + } +} diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go index 479c46c5..42d7265d 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/permissive_routing_test.go @@ -1,6 +1,7 @@ package proxy_test import ( + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" "net/http" "net/http/httptest" "net/url" @@ -15,7 +16,7 @@ import ( // This is a hermetic transport probe, not a replacement module or playground. // A routing failure may be reported, but a Records request must never go to // Documents merely because its name sorts first in the hop's module list. -func TestWarnModeDoesNotSendRecordsToDocuments(t *testing.T) { +func TestMissingContextDoesNotSendRecordsToDocuments(t *testing.T) { var documentsCalls atomic.Int32 documents := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { documentsCalls.Add(1) @@ -28,8 +29,8 @@ func TestWarnModeDoesNotSendRecordsToDocuments(t *testing.T) { defer records.Close() docsURL, _ := url.Parse(documents.URL) recordsURL, _ := url.Parse(records.URL) - decision := decide.Decider{Permissive: true, Modules: map[string]bool{"documents": true, "records": true}} - face := proxy.HTTP{Proof: "test-run-proof", Decide: decision.Decide, + decision := decide.Decider{Modules: map[string]bool{"documents": true, "records": true}} + face := proxy.HTTP{Signer: hoptest.Signer("test-run-proof"), Decide: decision.Decide, Upstreams: map[string]*url.URL{"documents": docsURL, "records": recordsURL}} req := httptest.NewRequest(http.MethodPost, "/records.v1.RecordsService/ReadRecord", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go b/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go index bf2e708b..a64d7e7c 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/proxy.go @@ -7,8 +7,10 @@ package proxy import ( "context" "errors" + callstamp "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "io" "strings" + "time" "github.com/codefly-dev/authority/libraries/hop/go" "google.golang.org/grpc" @@ -27,7 +29,7 @@ type Decide func(ctx context.Context, method string, md metadata.MD) (decide.Dec type Proxy struct { Decide Decide Upstreams map[string]*grpc.ClientConn - Proof string + Signer *callstamp.Signer // Refused is told every refusal, so the hop's log names it. Refused func(method string, err error) } @@ -62,7 +64,9 @@ func (p *Proxy) handle(_ any, in grpc.ServerStream) error { out[key] = values } } - hop.Stamp(out, id, p.Proof) + if err := hop.Stamp(out, id, p.Signer, method, module, time.Now()); err != nil { + return status.Error(codes.Unauthenticated, err.Error()) + } ctx, cancel := context.WithCancel(metadata.NewOutgoingContext(in.Context(), out)) defer cancel() if decision.Route == nil { 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 482c3e58..12bf8cdd 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 @@ -96,7 +96,7 @@ 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{Authority: hoptest.New(t), revoked: map[string]bool{}}, &[]string{} - verifier, err := hop.NewVerifier(context.Background(), a, "console", proof) + verifier, err := hop.NewVerifier(context.Background(), a, "console", hoptest.PublicKey(proof), hoptest.Replay()) if err != nil { t.Fatal(err) } @@ -108,7 +108,7 @@ func setup(t *testing.T) (through gen.HopServiceClient, direct gen.HopServiceCli 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}}.Decide, Upstreams: map[string]*grpc.ClientConn{"console": dial(t, moduleListener, grpc.WithDefaultCallOptions(proxy.CallOptions()...))}, - Proof: proof, + Signer: hoptest.Signer(proof), Refused: func(method string, err error) { *refused = append(*refused, method+": "+err.Error()) }, } hopConn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) @@ -162,7 +162,7 @@ func TestACallAroundTheHopIsRefusedByTheModule(t *testing.T) { // 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{}) + _, err = direct.Version(metadata.NewOutgoingContext(context.Background(), hoptest.Forward(t, forged, proof, "/api.HopService/Version")), &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) } diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index a14b47d0..4175f872 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -10,6 +10,7 @@ import ( "time" "github.com/codefly-dev/authority/libraries/authority-client/go/host" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/wool" codefly "github.com/codefly-dev/sdk-go" @@ -29,9 +30,13 @@ func init() { func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, error) { w := wool.Get(ctx).In("hop") - proof, err := codefly.For(ctx).WorkspaceSecret("hop", "PROOF") - if err != nil || proof == "" { - return nil, fmt.Errorf("hop: no run proof delivered (codefly config generate hop PROOF): %v", err) + seed, err := codefly.For(ctx).Secret("hop-signing", "SEED") + if err != nil || seed == "" { + return nil, fmt.Errorf("hop: no service signing seed delivered (codefly config generate hop-signing SEED --service authority/hop): %v", err) + } + signer, err := stamp.NewSigner(seed) + if err != nil { + return nil, err } // The authority, through the interface the hop depends on: the host the // composition names in authority/endpoint (saas accounts, say), else the @@ -109,29 +114,19 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e refused := func(method string, err error) { w.Warn("refused", wool.Field("method", method), wool.Field("reason", err.Error())) } - // hop/ENFORCEMENT: enforce (the default) refuses what the authority - // refuses; warn lets every call through stamped and logs what would have - // been refused — a composition's choice for a developer's run, never a - // cell's. + // Legacy warning mode must not issue an admission for a policy refusal. enforcement, _ := codefly.For(ctx).WorkspaceConfiguration("hop", "ENFORCEMENT") - permissive, err := enforcementMode(enforcement, codefly.IsLocal()) - if err != nil { + if err := requireEnforcement(enforcement); err != nil { return nil, err } - decider := decide.Decider{Routes: routes, Authority: authorityv1.NewAuthorityServiceClient(authority), Modules: modules, Permissive: permissive, - Warned: func(method string, err error) { - w.Warn("PERMISSIVE: let through a call the authority refused", wool.Field("method", method), wool.Field("reason", err.Error())) - }} + decider := decide.Decider{Routes: routes, Authority: authorityv1.NewAuthorityServiceClient(authority), Modules: modules} decision := decider.Decide - if permissive { - w.Warn("hop/ENFORCEMENT=warn: the hop lets every call through and logs what it would have refused") - } - p := &proxy.Proxy{Decide: decision, Upstreams: upstreams, Proof: proof, Refused: refused} + p := &proxy.Proxy{Decide: decision, Upstreams: upstreams, Signer: signer, Refused: refused} config.GRPCServerOptions = append(config.GRPCServerOptions, p.ServerOptions()...) // The service declares this endpoint even when every upstream uses gRPC. // Keep it listening so the declared readiness contract holds; the proxy // still refuses requests for which no authorized HTTP route exists. - stopWeb, err := serveHTTP(ctx, &proxy.HTTP{Decide: decision, Upstreams: webs, Proof: proof, Refused: refused}) + stopWeb, err := serveHTTP(ctx, &proxy.HTTP{Decide: decision, Upstreams: webs, Signer: signer, Refused: refused}) if err != nil { return nil, err } @@ -181,17 +176,12 @@ func requiredRoutes(raw string, readErr error) (*routing.Routing, error) { return routes, nil } -// enforcementMode refuses configuration mistakes and permits advice only locally. -func enforcementMode(value string, local bool) (bool, error) { +// requireEnforcement refuses legacy permissive configuration in every environment. +func requireEnforcement(value string) error { switch strings.ToLower(strings.TrimSpace(value)) { case "", "enforce": - return false, nil - case "warn": - if !local { - return false, fmt.Errorf("hop/ENFORCEMENT=warn requires a local environment; use enforce outside local runs") - } - return true, nil + return nil default: - return false, fmt.Errorf("hop/ENFORCEMENT must be enforce or warn") + return fmt.Errorf("hop/ENFORCEMENT must be enforce: signed admissions require enforced method policy") } } diff --git a/modules/authority/services/hop/code/work_test.go b/modules/authority/services/hop/code/work_test.go index 09ef391e..2c8988e8 100644 --- a/modules/authority/services/hop/code/work_test.go +++ b/modules/authority/services/hop/code/work_test.go @@ -13,13 +13,15 @@ import ( // Startup reads the same SDK carriers as a composed process. grpc.NewClient // is lazy: these refusals neither contact an authority nor start a proxy. func TestHopStartupRequiresGeneratedRoutes(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") t.Setenv(resources.EnvironmentPrefix, resources.LocalEnvironment().Name) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() - t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__HOP__PROOF", "controlled-test-proof") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", strings.Repeat("01", 32)) t.Setenv(resources.WorkspaceConfigurationPrefix+"__AUTHORITY__ENDPOINT", listener.Addr().String()) t.Setenv(resources.WorkspaceConfigurationPrefix+"__HOP__MODULES", "example") // Legacy values must not make an absent or invalid generated view acceptable. @@ -46,13 +48,15 @@ func TestHopStartupRequiresGeneratedRoutes(t *testing.T) { } func TestHopStartupRefusesUnprotectedUpstreamTransport(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") t.Setenv(resources.EnvironmentPrefix, resources.LocalEnvironment().Name) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() - t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__HOP__PROOF", "test-proof") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", strings.Repeat("01", 32)) t.Setenv(resources.WorkspaceConfigurationPrefix+"__AUTHORITY__ENDPOINT", listener.Addr().String()) t.Setenv(resources.WorkspaceConfigurationPrefix+"__HOP__MODULES", "example") for _, transport := range []string{"grpc", "http", "connect"} { diff --git a/modules/authority/services/hop/service.codefly.yaml b/modules/authority/services/hop/service.codefly.yaml index 75ccf5a2..af1068c6 100644 --- a/modules/authority/services/hop/service.codefly.yaml +++ b/modules/authority/services/hop/service.codefly.yaml @@ -36,9 +36,8 @@ spec: # accounts, say), and the local authority when it names none (code/host.go). service-dependencies: - interface: codefly.dev/authority@0.2.0 -# The hop group: the run's proof, which the hop writes on every stamp so the -# module behind it believes only stamps from this run's hop, and the module the -# composition puts the hop in front of (MODULES). hop-routes is generated +# The hop group names the modules this hop fronts (MODULES). Its signing seed +# is service-scoped hop-signing/SEED, never a workspace secret. hop-routes is generated # by Codefly from their published API catalogs, resolved only for this hop. workspace-configuration-dependencies: - hop diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index ebe90ba4..0049edc6 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -26,9 +26,17 @@ func init() { // 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) + publicKey, err := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "PUBLIC_KEY") + if err != nil || publicKey == "" { + return nil, fmt.Errorf("console: no hop verification public key delivered: %v", err) + } + directory, err := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "REPLAY_DIRECTORY") + if err != nil { + return nil, fmt.Errorf("console: replay directory not delivered: %w", err) + } + replay, err := hop.NewFileReplayStore(directory) + if err != nil { + return nil, err } audience, err := hop.Audience() if err != nil { @@ -40,7 +48,7 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl } // 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) + verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) if err != nil { _ = conn.Close() return nil, fmt.Errorf("console: %w", err) diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index b3fee51a..3c8df868 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -36,7 +36,6 @@ service-dependencies: # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true -# The run's proof the hop writes on every stamp; the console believes a stamp -# only when it carries it. +# Public signing key and durable shared replay volume; no signing secret. workspace-configuration-dependencies: - - hop + - hop-verification diff --git a/playground/configurations/README.md b/playground/configurations/README.md index 386e9b88..e23af740 100644 --- a/playground/configurations/README.md +++ b/playground/configurations/README.md @@ -6,9 +6,13 @@ the repository root, because a module's checkout root provides its groups to every workspace that composes it. The authority module provides none, so a workspace composing it owns its edge and hop groups whole. -The run's proof and internal token are generated, never committed: +The hop's service-only signing seed and internal token are generated, never committed: ```bash -codefly config generate hop PROOF +codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN ``` + +Deliver the derived public key and shared replay volume as described in the +[hop admission migration](../../libraries/hop/README.md). The old shared PROOF +and warning mode cannot authorize calls. From fb2208ca4a02ab81785e3c65afa89da73e3ba15c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:14:12 -0400 Subject: [PATCH 02/19] Pin the published admission client and migrate merged conformance callers Co-Authored-By: Claude Opus 5.5 --- .../authority-client/go/workcontext/owner_test.go | 2 +- libraries/conformance/go/forwarded_context.go | 13 ++++++------- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 4 ++-- libraries/hop/go/go.mod | 4 +--- libraries/hop/go/go.sum | 4 ++-- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 ++-- 8 files changed, 16 insertions(+), 19 deletions(-) diff --git a/libraries/authority-client/go/workcontext/owner_test.go b/libraries/authority-client/go/workcontext/owner_test.go index c8f7436c..9c21b650 100644 --- a/libraries/authority-client/go/workcontext/owner_test.go +++ b/libraries/authority-client/go/workcontext/owner_test.go @@ -138,7 +138,7 @@ func TestResourceOwnerVectorsAreCores(t *testing.T) { authenticate := func(owner string) error { c := body(t, "session") c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", owner)} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) return err } for _, v := range vectors.Accepted { diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go index 9af2575e..cd480897 100644 --- a/libraries/conformance/go/forwarded_context.go +++ b/libraries/conformance/go/forwarded_context.go @@ -41,8 +41,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) 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 { + if claims, err := forwarded.Authenticate(stamped(token), 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, ".") @@ -71,7 +70,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) "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) + _, err := forwarded.Authenticate(stamped(probe.token), 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) } @@ -80,9 +79,9 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) }) } -// 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 { +// stamped supplies a digest-bound token to the identity reader. Admission +// requires a signed hop proof and replay consumption, tested in libraries/hop. +func stamped(token 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[:])) + return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-context", hex.EncodeToString(sum[:])) } diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 1d9bcafb..853a2ffa 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-20261025234644-fff5385f9203 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 0d8a5270..aa5dff50 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-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/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= 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.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index a5c717f3..c28637c3 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-20261026032520-f688c5493b4d + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -50,5 +50,3 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/codefly-dev/authority/libraries/authority-client/go => ../../authority-client/go diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 944ab148..a6aa9937 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index acb0109f..0918130d 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -31,7 +31,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-20261026032520-f688c5493b4d // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f // 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 c3b89701..82c75eee 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-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 84137724ae7bbf9adb0a8aeaa6785335c617e270 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:18:05 -0400 Subject: [PATCH 03/19] Clarify replay volume isolation and shared-store deployment scope Co-Authored-By: Claude Opus 5.5 --- libraries/hop/README.md | 12 ++++++++---- libraries/hop/go/replay.go | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/libraries/hop/README.md b/libraries/hop/README.md index 4619be8b..00b240f6 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -62,19 +62,23 @@ Go reuses Core's `workcontext.ReplayStore`; Python exposes `ReplayStore.consume` and `consume_async`; TS exposes `ReplayStore.consume`. Python and TS adapters raise the exported `Replayed` error for duplicate nonces (Go uses Core `ErrReplayed`); other failures report store unavailability without leaking backend errors. Consumption must be atomic -across every process accepting the same signing key, fail closed on outage, and +across every listener accepting the same signing key and audience, fail closed on outage, and be durable before it returns. A backend may retain entries longer than expiry. It must not evict an entry while any verifier can still accept it: account for clock differences and prevent clock rollback. Synchronize deployment clocks. `hop.FileReplayStore` implements this contract for a shared durable filesystem -with atomic exclusive creation and file/directory fsync. All replicas must mount -the same directory; per-pod ephemeral storage is not suitable. Reservations +with atomic exclusive creation and file/directory fsync. All replicas for an +audience must mount the same directory; per-pod ephemeral storage is not suitable. Reservations survive process restart and are never automatically removed. An incomplete reservation is retained after a crash. Operators may collect complete expired records only once every verifier's clock has passed the recorded expiry; never collect incomplete records while their key remains trusted. Monitor volume space; -full or unavailable storage refuses admission. Other hosts can supply their +full or unavailable storage refuses admission. Isolate each audience's volume +from other workloads: an unrelated workload must not be able to remove or edit +reservations. Remote adapters likewise expose atomic reservation, not caller- +controlled deletion. Protect the signing seed from every consumer, including +through filesystem and process isolation in local deployments. Other hosts can supply their existing durable replay implementation through the same Core contract. The in-memory implementation used in tests is not a replicated deployment backend. diff --git a/libraries/hop/go/replay.go b/libraries/hop/go/replay.go index 3a1e72c0..70d0261f 100644 --- a/libraries/hop/go/replay.go +++ b/libraries/hop/go/replay.go @@ -14,7 +14,8 @@ import ( ) // FileReplayStore implements Core's ReplayStore on a durable shared filesystem. -// All replicas must mount the SAME directory with atomic exclusive creation and +// All replicas of an audience must mount the SAME directory, isolated from +// other workloads, with atomic exclusive creation and // durable fsync semantics. A local ephemeral volume is not a shared store. // Reservations deliberately survive expiry and restart. Operators may remove // files only after their recorded expiry, with all verifiers' clocks past it. From 33c436b4f6c579975fa1915faac232f98d12124b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:20:50 -0400 Subject: [PATCH 04/19] Pin admission consumers to the verified merged client revision Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 4 ++-- libraries/hop/go/README.md | 4 ++-- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 ++-- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 853a2ffa..44e9909d 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-20261026091034-d87cc7e56e4f + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index aa5dff50..ded98935 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-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b h1:Wry0WOQOrS32AgAKFK2/QZuuvgN3tKbNEbTrgnB8tSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= 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.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 5fce47a2..9cd49040 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -81,7 +81,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 pins authority-client at the commit that introduced -`workcontext.Authenticate`; standalone library tests need no workspace replacement. +The Go dependency pins the matching signed-admission authority-client; +standalone library tests need no workspace replacement. `../testdata/stamped.json` is a genuinely signed call as the hop stamps it; the 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/go.mod b/libraries/hop/go/go.mod index c28637c3..6f53a6df 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-20261026091034-d87cc7e56e4f + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index a6aa9937..48bf040f 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b h1:Wry0WOQOrS32AgAKFK2/QZuuvgN3tKbNEbTrgnB8tSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 0918130d..5c77a234 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -31,7 +31,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-20261026091034-d87cc7e56e4f // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b // 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 82c75eee..e75b10a0 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-20261026091034-d87cc7e56e4f h1:/K5tkVnbcXuJcjlsZlWzIcIdRVdZN0y2adx9uzIpBSo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091034-d87cc7e56e4f/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b h1:Wry0WOQOrS32AgAKFK2/QZuuvgN3tKbNEbTrgnB8tSo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026091805-84137724ae7b/go.mod h1:JV1XWr3K26NXk+BPKuDfS/GEl5N3i8W2jGMzSX7C344= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From c189ca160b4fc250e84c6a0dd80fd6f99a596d4b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:16:57 -0400 Subject: [PATCH 05/19] Pin admission consumers to the merged client revision libraries/hop/go, libraries/conformance/go and the local authority resolve authority-client from the proxy rather than a path replacement, so they must name the revision that carries both the signed-admission API and the typed refusal reasons: 4b1c57a, the merge commit. Every module is tidied, and the single core pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged. Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 4 ++-- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 ++-- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 64081225..044d94c5 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-20261026082258-3acb2c3acc46 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 2c0a19dd..d5f65a80 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-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= 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.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 1c175c1a..1021db1a 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-20261026082258-3acb2c3acc46 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index f78729a1..3cb89bd1 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 95c7bffd..0320f23d 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -31,7 +31,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-20261026082258-3acb2c3acc46 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 // 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 205ba4f8..ad076d5f 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-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 3d0a11cf77e4abd413d66101258a72d248f89331 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:58:19 -0400 Subject: [PATCH 06/19] Fix the security review's blockers: admission window, REST, replay delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review r164 on 33c436b. Each blocker and major, and the #26 conflicts it listed. B3, and R1/R2 with it: the admission window is judged once, at the instant the call starts. grpc-go decodes a unary request fully before it runs the unary interceptor, so a module serving large or slow uploads refused admissions the authority had granted; Python's interceptors already ran before the body. Verifier.TapHandle, installed with grpc.InTapHandle, records the arrival instant when the stream is accepted and Verify judges against it. It reads no header and runs no cryptography, because it runs on the connection's I/O goroutine. A server installing no handle judges the window when Verify runs, as before, so nothing is admitted by its absence. The redundant re-checks after the key read and the reservation are gone in all three languages. A two-second clock allowance (stamp.Skew, Python and TypeScript SKEW) applies identically in Go, Python and TypeScript: an admission may be issued that far ahead of the verifier's clock and stays admissible that far past its expiry. Being issued further ahead has its own reason, stamp_issued, so a verifier running behind is no longer diagnosed as an expiry. The signed span is still capped at Lifetime exactly. Reservations are retained to expiry plus the allowance, which is sound because the window is judged at arrival: a replay can only be admitted if it too arrives inside the window, and inside the window the nonce is still retained. B2: a REST face transcoded into a module's gRPC or Connect listener cannot be admitted. The hop signs an http route as "VERB /path" — all a published http route tells it — while that listener's verifier observes the full procedure, and a module cannot verify at both layers because the second would consume the nonce again. Signing the procedure instead needs the published route to carry its producer's template-to-procedure mapping, and the hop-routes schema has no such field, so routing.Validate refuses the composition at hop startup (routing.ErrTranscodedREST) rather than refusing every REST call at runtime. A module serving HTTP natively, with no procedural face on the same service, is unaffected. B1, the replay half: libraries/hop/go/replaypg is the durable shared store the contract required and nothing delivered, over a store Codefly does deliver; INSERT ... ON CONFLICT DO NOTHING over a primary key is the atomic reservation, shared by every replica. The console composes its own Postgres for it (modules/console/services/store) and drops hop-verification/REPLAY_DIRECTORY, which asked for a shared volume the platform cannot mount and in practice meant a per-pod directory. NewVerifier now refuses a store that does not declare itself shared outside a local run (hop.ErrReplayStoreNotShared); NewFileReplayStore does not declare it, because an absolute path to per-pod storage is indistinguishable from a shared mount, and NewSharedFileReplayStore is how an operator asserts otherwise. R4 and the #26 conflict behind it: one identity-only reader per language. workcontext.Authenticate, Python authenticate and TS authenticateClaims are gone; AuthenticateToken / authenticate_token / authenticateToken remain. A reader that took a call's metadata read as though it admitted the call, and after the shared proof's removal it accepted any token plus a self-computed digest, so a caller fixing a build break by deleting the proof argument lost admission silently. A caller holding metadata now extracts the bound token with stamp.Context first: two visible calls, neither an admission. B1, the verification key, is NOT fixed and is stated as open. Nothing produces hop-verification/PUBLIC_KEY: `codefly config generate` makes random bytes only, and a configuration value template assembles its producer's own primitives, so neither can derive a public half from the hop's seed. The agreed direction is the authority publishing the hop's admission key through its own Keys, which the verifier already trusts, caches and re-reads on rotation, and which the Work Context trust set ignores because it filters on algorithm. Keys is part of interfaces/authority, so that is a contract change: proposed in the handbook first, and implemented by accounts alongside the local authority. Until then a run configures the key by hand, and libraries/hop/README.md, the console's manifest and the playground group all say so rather than implying a producer exists. The playground also gains its hop-verification group, and the README states the documents/records adoption order the review found unsequenced. Co-Authored-By: Claude Opus 5.5 --- .../authority-client/go/refusal/reason.go | 3 + libraries/authority-client/go/stamp/call.go | 27 ++- .../authority-client/go/stamp/call_test.go | 37 ++++- .../authority-client/go/workcontext/claims.go | 52 +++--- .../go/workcontext/claims_test.go | 30 +++- .../go/workcontext/owner_test.go | 4 +- .../go/workcontext/vectors_test.go | 7 +- libraries/authority-client/python/README.md | 5 +- .../python/authority_client/work_context.py | 29 ++-- .../python/codefly_hop/__init__.py | 18 +- .../python/codefly_hop/call.py | 14 +- .../authority-client/python/tests/test_hop.py | 50 +++++- .../python/tests/test_owner.py | 6 +- .../python/tests/test_work_context.py | 19 ++- .../authority-client/typescript/README.md | 4 +- .../authority-client/typescript/hop-call.ts | 10 +- .../authority-client/typescript/refusal.ts | 2 + .../typescript/tests/owner.test.mjs | 4 +- .../typescript/tests/work-context.test.mjs | 48 +++++- .../typescript/work-context.ts | 36 ++-- libraries/conformance/go/forwarded_context.go | 19 ++- libraries/conformance/go/go.sum | 2 - libraries/hop/README.md | 157 +++++++++++++++--- libraries/hop/go/README.md | 19 ++- libraries/hop/go/arrival_test.go | 132 +++++++++++++++ libraries/hop/go/durable_test.go | 77 +++++++++ libraries/hop/go/go.mod | 5 + libraries/hop/go/go.sum | 28 +++- libraries/hop/go/hop.go | 102 +++++++++++- libraries/hop/go/replay.go | 30 +++- libraries/hop/go/replaypg/replaypg.go | 95 +++++++++++ libraries/hop/go/replaypg/replaypg_test.go | 147 ++++++++++++++++ libraries/hop/go/routing/routes.go | 36 ++++ libraries/hop/go/routing/routes_test.go | 38 +++++ modules/authority/services/hop/code/go.sum | 6 + modules/authority/services/local/code/go.mod | 1 - modules/authority/services/local/code/go.sum | 7 +- modules/console/module.codefly.yaml | 3 + modules/console/services/api/code/go.mod | 5 + modules/console/services/api/code/go.sum | 25 ++- .../console/services/api/code/store_test.go | 33 ++++ modules/console/services/api/code/work.go | 76 ++++++++- .../console/services/api/service.codefly.yaml | 15 +- modules/console/services/store/.gitignore | 17 ++ modules/console/services/store/README.md | 29 ++++ .../store/configurations/local/README.md | 29 ++++ .../configurations/local/postgres.secret.env | 4 + .../migrations/1_hop_admissions.down.sql | 1 + .../store/migrations/1_hop_admissions.up.sql | 38 +++++ .../services/store/service.codefly.yaml | 49 ++++++ playground/configurations/README.md | 20 ++- .../configurations/local/hop-verification.env | 18 ++ 52 files changed, 1494 insertions(+), 174 deletions(-) create mode 100644 libraries/hop/go/arrival_test.go create mode 100644 libraries/hop/go/durable_test.go create mode 100644 libraries/hop/go/replaypg/replaypg.go create mode 100644 libraries/hop/go/replaypg/replaypg_test.go create mode 100644 modules/console/services/api/code/store_test.go create mode 100644 modules/console/services/store/.gitignore create mode 100644 modules/console/services/store/README.md create mode 100644 modules/console/services/store/configurations/local/README.md create mode 100644 modules/console/services/store/configurations/local/postgres.secret.env create mode 100644 modules/console/services/store/migrations/1_hop_admissions.down.sql create mode 100644 modules/console/services/store/migrations/1_hop_admissions.up.sql create mode 100644 modules/console/services/store/service.codefly.yaml create mode 100644 playground/configurations/local/hop-verification.env diff --git a/libraries/authority-client/go/refusal/reason.go b/libraries/authority-client/go/refusal/reason.go index cd182bd9..d958bd34 100644 --- a/libraries/authority-client/go/refusal/reason.go +++ b/libraries/authority-client/go/refusal/reason.go @@ -14,6 +14,7 @@ const ( StampMethod Reason = "stamp_method" StampAudience Reason = "stamp_audience" StampWindow Reason = "stamp_window" + StampIssued Reason = "stamp_issued" StampAdvisory Reason = "stamp_advisory" StampReplayed Reason = "stamp_replayed" StampStore Reason = "stamp_store" @@ -53,6 +54,8 @@ func (r Reason) Text() string { return "the hop admission was signed for another module" case StampWindow: return "the hop admission is expired or outside its validity window" + case StampIssued: + return "the hop admission is issued further ahead than the clock allowance" case StampAdvisory: return "an advisory policy refusal cannot admit a call" case StampReplayed: diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go index 8744915f..b23440aa 100644 --- a/libraries/authority-client/go/stamp/call.go +++ b/libraries/authority-client/go/stamp/call.go @@ -18,6 +18,20 @@ import ( // Lifetime bounds how long an admitted call may start. It does not limit a stream's duration. const Lifetime = 30 * time.Second +// Skew is the clock difference tolerated between the hop that signed an +// admission and the verifier judging it. Without it a verifier lagging the hop +// by a fraction of a second refuses genuine calls whenever the signing instant +// falls just after a second boundary, and the refusal reads as an expiry. +// +// It widens two bounds and no others: an admission may be issued up to Skew +// ahead of the verifier's clock, and remains admissible until Skew past its +// expiry. The signed span itself (expires - issued) is still capped at Lifetime +// exactly, so the allowance cannot be used to mint a longer-lived admission. +// Go, Python and TypeScript apply the same value, and a replay reservation is +// retained to expiry plus Skew so a store evicting on its own clock cannot +// release a nonce a lagging verifier would still accept. +const Skew = 2 * time.Second + // Call is a signed, single-use admission, independent of the Work Context's replay policy. // The array encoding and domain separate this protocol from Work Context signatures. type Call struct { @@ -113,7 +127,18 @@ func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (C if audience == "" || call.Audience != audience { return fail(refusal.StampAudience) } - if call.Issued < 0 || call.Expires < 0 || call.Issued > now.Unix() || call.Expires <= now.Unix() || call.Expires <= call.Issued || call.Expires-call.Issued > int64(Lifetime/time.Second) { + seconds, skew := now.Unix(), int64(Skew/time.Second) + switch { + case call.Issued < 0 || call.Expires < 0: + return fail(refusal.StampWindow) + case call.Issued > seconds+skew: + // Further ahead than a clock difference explains: its own reason, so a + // verifier running behind is not diagnosed as an expiry. + return fail(refusal.StampIssued) + case call.Expires+skew <= seconds: + return fail(refusal.StampWindow) + case call.Expires <= call.Issued || call.Expires-call.Issued > int64(Lifetime/time.Second): + // The signed span is judged without the allowance. return fail(refusal.StampWindow) } nonce, err := hex.DecodeString(call.Nonce) diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go index d225d21e..8b09a92f 100644 --- a/libraries/authority-client/go/stamp/call_test.go +++ b/libraries/authority-client/go/stamp/call_test.go @@ -3,10 +3,12 @@ package stamp import ( "crypto/sha256" "encoding/hex" + "errors" "strings" "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "google.golang.org/grpc/metadata" ) @@ -39,11 +41,40 @@ func TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof(t *testing.T t.Fatal("mutated proof accepted") } } - for _, at := range []time.Time{now.Add(-time.Second), now.Add(Lifetime)} { - if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", at); err == nil { - t.Fatal("invalid admission window accepted") + // Inside the clock allowance, a verifier running behind or a little past the + // expiry still admits the call: these are clock differences, not forgeries. + for _, at := range []time.Time{now.Add(-Skew), now.Add(Lifetime), now.Add(Lifetime + Skew - time.Second)} { + if _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", at); err != nil { + t.Fatalf("a clock difference of %v inside the allowance was refused: %v", at.Sub(now), err) } } + // Beyond it, each bound is refused under its own reason. + for _, tc := range []struct { + at time.Time + want refusal.Reason + }{ + {now.Add(-Skew - time.Second), refusal.StampIssued}, + {now.Add(Lifetime + Skew), refusal.StampWindow}, + } { + _, err := Check(md, signer.PublicKey(), "/pkg.Service/Read", "module", tc.at) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != tc.want { + t.Fatalf("at %v want %s: %v", tc.at.Sub(now), tc.want, err) + } + } + // The allowance widens no span: a signed span longer than Lifetime is + // refused however the clocks sit. + long, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Add(time.Hour).Unix()) + if err != nil { + t.Fatal(err) + } + wide := md.Copy() + wide.Set("x-codefly-hop-proof", long) + if call, err := Check(wide, signer.PublicKey(), "/pkg.Service/Read", "module", now); err != nil { + t.Fatalf("a token outliving the admission caps the span, not refuses it: %v", err) + } else if call.Expires-call.Issued != int64(Lifetime/time.Second) { + t.Fatalf("span %ds, want %ds", call.Expires-call.Issued, int64(Lifetime/time.Second)) + } if _, err := signer.Sign(token, "/pkg.Service/Read", "module", now, now.Unix()); err == nil { t.Fatal("signed expired capability") } diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index 9875eeaa..ad34f837 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -1,6 +1,8 @@ // 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. +// module, and projects its claims. AuthenticateToken is the one check a +// module's reading rests on: nothing here decodes a token it has not +// authenticated. It establishes identity, never hop admission, which is +// hop.Verifier's. package workcontext import ( @@ -11,9 +13,6 @@ import ( "strings" "time" - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" - "google.golang.org/grpc/metadata" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" core "github.com/codefly-dev/core/workcontext" "google.golang.org/protobuf/proto" @@ -43,34 +42,25 @@ type Claims struct { context *basev0.WorkContextV1 } -// Authenticate returns the claims of the Work Context the stamp bound to this -// call, for this module (audience), after two checks, in order: +// AuthenticateToken authenticates one Work Context 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 returns the +// claims, or a caller-safe refusal.Reason naming why they must not be believed. // -// 1. the stamp's token binding: md carries exactly one token and the digest -// the hop stamped for it, 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. +// This is the one identity-only reader here, and identity is all it +// establishes. It proves no hop admission: it does not check that a call was +// admitted for the procedure it arrived on, consumes no single-use nonce, and +// asks the authority nothing, so it checks no live revocation either. A service +// admits a call through hop.Verifier, which checks the hop's signed admission +// and reserves its nonce in a store every replica shares, and reaches here for +// the identity. // -// This is identity authentication only. It establishes no hop admission: it -// does not check that the call was admitted for this procedure, and consumes -// no single-use nonce. A service admits a call through hop.Verifier, which -// checks the hop's signed admission and reserves its nonce in a shared store -// before reaching here. A digest binding alone authorizes nothing: whoever -// forwards a call can write the token and its digest, but cannot sign either -// the token or the admission. -func Authenticate(md metadata.MD, audience string, trust Trust) (*Claims, error) { - token, err := stamp.Context(md) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, safeReason(err)) - } - return AuthenticateToken(token, audience, trust) -} - -// AuthenticateToken authenticates an unstamped token without consuming it or -// checking live revocation. Callers still enforce their operation's policy. +// A caller holding a call's metadata rather than a token extracts the token the +// stamp's digest binds with stamp.Context first. That is deliberately two +// calls (review r164 R4): a reader taking metadata reads as though it admitted +// the call, and it does not — a digest binding authorizes nothing, because +// whoever forwards a call can write both the token and its digest. func AuthenticateToken(token, audience string, trust Trust) (*Claims, error) { if audience == "" { return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, refusal.Configuration) diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index 47c6acfa..166c4894 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" 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" @@ -79,7 +80,7 @@ func tokenFor(t *testing.T, c *basev0.WorkContextV1) string { return signWith(t, func read(t *testing.T, c *basev0.WorkContextV1) *reader.Claims { t.Helper() - out, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) + out, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) if err != nil { t.Fatal(err) } @@ -157,7 +158,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { 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 := reader.Authenticate(stampedToken(token), session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.AuthenticateToken(token, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("envelope not refused: %v", err) } } @@ -181,7 +182,7 @@ func TestEnvelopesAndClaimsCoreRefuses(t *testing.T) { b = append(b, b...) token = base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, b)) } - if _, err := reader.Authenticate(stampedToken(token), session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { + if _, err := reader.AuthenticateToken(token, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { t.Fatalf("malformed claims not refused: %v", err) } }) @@ -214,7 +215,7 @@ func TestAForgedOrMisaddressedContextIsRefusedByName(t *testing.T) { "configuration": {tokenFor(t, session), "", "configuration", trust}, } { t.Run(name, func(t *testing.T) { - _, err := reader.Authenticate(stampedToken(c.token), c.audience, c.trust) + _, err := reader.AuthenticateToken(c.token, 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) } @@ -226,7 +227,12 @@ 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 TestAuthenticateRequiresTheStamp(t *testing.T) { + +// A caller holding a call's metadata extracts the bound token with +// stamp.Context before authenticating it (review r164 R4: two calls, so that +// neither reads as admitting the call). stamp.Context refuses the binding; +// admission is hop.Verifier's, and neither of these is that. +func TestTheBoundTokenIsExtractedBeforeItIsAuthenticated(t *testing.T) { session := body(t, "session") token := tokenFor(t, session) for _, change := range []func(metadata.MD){ @@ -236,12 +242,20 @@ func TestAuthenticateRequiresTheStamp(t *testing.T) { } { md := stampedToken(token) change(md) - if _, err := reader.Authenticate(md, session.GetAudience(), trustFor(session)); !errors.Is(err, reader.ErrUnauthenticated) { - t.Fatalf("unbound claims accepted: %v", err) + bound, err := stamp.Context(md) + if err == nil { + _, err = reader.AuthenticateToken(bound, session.GetAudience(), trustFor(session)) + } + if err == nil { + t.Fatal("unbound claims accepted") } } - if c, err := reader.Authenticate(stampedToken(token), session.GetAudience(), trustFor(session)); err != nil || c.Context().GetTenantId() != session.GetTenantId() { + bound, err := stamp.Context(stampedToken(token)) + if err != nil { + t.Fatal(err) + } + if c, err := reader.AuthenticateToken(bound, 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/go/workcontext/owner_test.go b/libraries/authority-client/go/workcontext/owner_test.go index 9c21b650..84e395bc 100644 --- a/libraries/authority-client/go/workcontext/owner_test.go +++ b/libraries/authority-client/go/workcontext/owner_test.go @@ -91,7 +91,7 @@ func TestAChainThatDropsOrChangesTheOwnerIsRefused(t *testing.T) { actor.GrantedScopes = []*basev0.WorkScopeV1{owned} } c.ActorChain[len(c.ActorChain)-1].GrantedScopes = []*basev0.WorkScopeV1{tc.last} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) + _, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) if (err == nil) != tc.ok { t.Fatalf("authenticate: %v", err) } @@ -138,7 +138,7 @@ func TestResourceOwnerVectorsAreCores(t *testing.T) { authenticate := func(owner string) error { c := body(t, "session") c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", owner)} - _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), c.GetAudience(), trustFor(c)) + _, err := reader.AuthenticateToken(tokenFor(t, c), c.GetAudience(), trustFor(c)) return err } for _, v := range vectors.Accepted { diff --git a/libraries/authority-client/go/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go index 10181aaa..d1828da2 100644 --- a/libraries/authority-client/go/workcontext/vectors_test.go +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" 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" @@ -195,7 +196,11 @@ func TestTheSharedVectorsAreGosVerdicts(t *testing.T) { t.Run(mode, func(t *testing.T) { var err error if mode == "stamped" { - _, err = reader.Authenticate(stampedToken(v.Token), v.Audience, trust) + // The same verdict reached through the binding a hop writes. + bound, bindErr := stamp.Context(stampedToken(v.Token)) + if err = bindErr; err == nil { + _, err = reader.AuthenticateToken(bound, v.Audience, trust) + } } else { _, err = reader.AuthenticateToken(v.Token, v.Audience, trust) } diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index ccb7a106..f74b7a53 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -44,8 +44,9 @@ as the Go library writes it. commit installs both, and no dependency is ever resolved by name from a public index. -`authenticate` is an identity reader, not admission. It checks one token and its -digest; then Core's rules in Core's order: the decode path (size, the +`authenticate_token` is the one identity reader, not admission. It checks one +token (a caller holding a call's headers extracts the bound token with +`bound_token` first); 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 diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index d3141611..4b6affc8 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -1,6 +1,6 @@ """Authenticate the Work Context the hop forwarded to a module, then project it. -``authenticate`` is the Go library's ``workcontext.Authenticate``, check for +``authenticate_token`` is the Go library's ``workcontext.AuthenticateToken``, 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 @@ -80,6 +80,7 @@ class Reason(str, Enum): STAMPMETHOD = "stamp_method" STAMPAUDIENCE = "stamp_audience" STAMPWINDOW = "stamp_window" + STAMPISSUED = "stamp_issued" STAMPADVISORY = "stamp_advisory" STAMPREPLAYED = "stamp_replayed" STAMPSTORE = "stamp_store" @@ -108,6 +109,7 @@ class Reason(str, Enum): Reason.STAMPMETHOD: 'the hop admission was signed for another procedure', Reason.STAMPAUDIENCE: 'the hop admission was signed for another module', Reason.STAMPWINDOW: 'the hop admission is expired or outside its validity window', + Reason.STAMPISSUED: 'the hop admission is issued further ahead than the clock allowance', Reason.STAMPADVISORY: 'an advisory policy refusal cannot admit a call', Reason.STAMPREPLAYED: 'the hop admission has already been used', Reason.STAMPSTORE: 'the hop admission store is unavailable', @@ -213,18 +215,21 @@ def allows_owned(self, kind: str, action: str, resource_id: str, owner_id: str) ) -def authenticate( - headers: Mapping[str, str] | Iterable[tuple[str, 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(Reason.CONFIGURATION) - return authenticate_token(bound_token(headers), audience, trust) - - def authenticate_token(token: str, audience: str, trust: Trust) -> Claims: - """Authenticate bare bytes; no hop admission, live checks or consumption.""" + """The claims of one Work Context, for this module (``audience``), or + ``Unauthenticated`` naming why they must not be believed. + + This is the one identity-only reader here, and identity is all it + establishes. It proves no hop admission: it does not check that a call was + admitted for the procedure it arrived on, consumes no single-use nonce, and + asks the authority nothing, so it checks no live revocation either. A + service admits a call with ``codefly_hop.Verifier.verify``. + + A caller holding a call's headers extracts the token the stamp's digest + binds with ``bound_token`` first. That is deliberately two calls (security + review r164 R4): a reader taking headers reads as though it admitted the + call, and it does not. + """ if not audience: raise Unauthenticated(Reason.CONFIGURATION) if len(token) > MAX_TOKEN_BYTES or not token.isascii() or token.count(".") != 1: diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 44da9416..c3f7fff0 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from .call import Replayed, ReplayStore, check +from .call import SKEW, Replayed, ReplayStore, check 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, Reason, authenticate_token, bound_token, collect @@ -161,15 +161,13 @@ async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, st pass # judged below on the keys held: stale ones are refused with the reason identity = self._finish(pairs, token, call, consume=False) try: - await self._replay.consume_async(call.nonce, call.expires) + await self._replay.consume_async(call.nonce, call.expires + SKEW) except Replayed as error: raise Unauthenticated(Reason.STAMPREPLAYED) from error except Unauthenticated: raise except Exception as error: raise Unauthenticated(Reason.STAMPSTORE) from error - if call.expires <= int(self._now()): - raise Unauthenticated(Reason.STAMPWINDOW) return identity def verify_token(self, token: str) -> Identity: @@ -238,19 +236,21 @@ def _finish(self, pairs: list[tuple[str, str]], token: str, call, consume=True) got = values.get(key, []) if len(got) > 1 or (got[0] if got else "") != want: raise Unauthenticated(Reason.STAMPIDENTITY) - if call.expires <= int(self._now()): - raise Unauthenticated(Reason.STAMPWINDOW) + # ``check`` judged the admission window when the call started, which in + # Python is before the request is read; the window bounds a call's + # start, so it is judged once and not again here. The reservation is + # retained to expiry plus the allowance and no longer: a replay can + # only be admitted if it too arrives inside the window, and inside the + # window the nonce is still retained. if consume: try: - self._replay.consume(call.nonce, call.expires) + self._replay.consume(call.nonce, call.expires + SKEW) except Replayed as error: raise Unauthenticated(Reason.STAMPREPLAYED) from error except Unauthenticated: raise except Exception as error: raise Unauthenticated(Reason.STAMPSTORE) from error - if call.expires <= int(self._now()): - raise Unauthenticated(Reason.STAMPWINDOW) # An admission carrying an advisory refusal is refused by ``check``, so # an admitted call never has one: diagnostic identity is never authority. return Identity(**{**identity.__dict__, "context": token, "advisory": "", "claims": identity.claims}) diff --git a/libraries/authority-client/python/codefly_hop/call.py b/libraries/authority-client/python/codefly_hop/call.py index e828b4c3..416a1759 100644 --- a/libraries/authority-client/python/codefly_hop/call.py +++ b/libraries/authority-client/python/codefly_hop/call.py @@ -27,6 +27,13 @@ async def consume_async(self, nonce: str, retain_until: int) -> None: ... +# The hop admission lifetime and the clock allowance, as Go's stamp.Lifetime +# and stamp.Skew define them. An admission may be issued up to SKEW ahead of +# this verifier's clock and stays admissible until SKEW past its expiry; the +# signed span is still capped at LIFETIME exactly. +LIFETIME, SKEW = 30, 2 + + @dataclass(frozen=True) class Call: nonce: str @@ -68,7 +75,12 @@ def segment(value): raise Unauthenticated(Reason.STAMPMETHOD) if not audience or audience != bound_audience: raise Unauthenticated(Reason.STAMPAUDIENCE) - if issued < 0 or expires < 0 or issued > int(now) or expires <= int(now) or not 0 < expires - issued <= 30: + seconds = int(now) + if issued < 0 or expires < 0: + raise Unauthenticated(Reason.STAMPWINDOW) + if issued > seconds + SKEW: + raise Unauthenticated(Reason.STAMPISSUED) + if expires + SKEW <= seconds or not 0 < expires - issued <= LIFETIME: raise Unauthenticated(Reason.STAMPWINDOW) if not re.fullmatch(r"[0-9a-f]{64}", nonce): raise Unauthenticated(Reason.STAMPPROOF) diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 7af634ba..97b4c83c 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -11,6 +11,7 @@ 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.call import SKEW from codefly_hop import ( ACTORS_KEY, CONTEXT_KEY, KEY_RETRY, KEY_STALE, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, Verifier, Replayed, identity_of, @@ -411,10 +412,53 @@ def consume(self, nonce, until): v.verify(fresh(FIXTURE["now"]), METHOD) -@pytest.mark.parametrize("issued,expires", [(0, 30), (FIXTURE["now"] + 1, FIXTURE["now"] + 30), (FIXTURE["now"], FIXTURE["now"] + 31)]) -def test_signed_admission_window_is_bounded(issued, expires): - with pytest.raises(Unauthenticated, match="validity window"): +@pytest.mark.parametrize("issued,expires,code", [ + ((0, 30, "stamp_window")), + # Issued further ahead than the clock allowance: its own reason, so a + # verifier running behind is not diagnosed as an expiry. + ((FIXTURE["now"] + SKEW + 1, FIXTURE["now"] + 30, "stamp_issued")), + # The allowance widens no span: 31 seconds is refused however clocks sit. + ((FIXTURE["now"], FIXTURE["now"] + 31, "stamp_window")), +]) +def test_signed_admission_window_is_bounded(issued, expires, code): + with pytest.raises(Unauthenticated) as refused: verifier().verify(fresh(FIXTURE["now"], issued=issued, expires=expires), METHOD) + assert refused.value.code == code + + +# Review r164 R1: a verifier whose clock lags the hop refused genuine calls +# whenever the signing instant fell just after a second boundary, under a reason +# that read as an expiry. Go, Python and TypeScript allow the same SKEW. +@pytest.mark.parametrize("lag", [0, 1, SKEW]) +def test_a_clock_difference_inside_the_allowance_still_admits(lag): + identity = verifier(clock=lambda: FIXTURE["now"] - lag).verify(fresh(FIXTURE["now"]), METHOD) + assert identity.owner == FIXTURE["identity"]["owner"] + + +@pytest.mark.parametrize("lag,code", [(SKEW + 1, "stamp_issued"), (-(30 + SKEW), "stamp_window")]) +def test_a_clock_difference_beyond_the_allowance_is_refused(lag, code): + with pytest.raises(Unauthenticated) as refused: + verifier(clock=lambda: FIXTURE["now"] - lag).verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == code + + +# Review r164 R2: the reservation outlives the admission by the same allowance, +# so a store evicting on its own clock cannot release a nonce a lagging verifier +# would still admit. +def test_a_reservation_is_retained_past_expiry_by_the_allowance(): + retained = [] + + class Recording: + def consume(self, nonce, retain_until): + retained.append(retain_until) + + v = verifier() + v._replay = Recording() + headers = fresh(FIXTURE["now"]) + payload = json.loads(base64.urlsafe_b64decode( + headers["x-codefly-hop-proof"].split(".")[0] + "=" * (-len(headers["x-codefly-hop-proof"].split(".")[0]) % 4))) + v.verify(headers, METHOD) + assert retained == [payload[6] + SKEW] def test_duplicate_proof_and_token_are_refused_before_consumption(): diff --git a/libraries/authority-client/python/tests/test_owner.py b/libraries/authority-client/python/tests/test_owner.py index ec0a650b..0bb1b8cd 100644 --- a/libraries/authority-client/python/tests/test_owner.py +++ b/libraries/authority-client/python/tests/test_owner.py @@ -1,7 +1,7 @@ """The solution principal (conformance RunSolutionPrincipalTarget), read in Python. A module served in Python never reads an owner-selected scope as the whole -kind. Claims are built through ``authenticate`` from a signed context, so the +kind. Claims are built through ``authenticate_token`` from a signed context, so the decode path keeps the owner: the owner-blind ``allows`` refuses an owner-selected scope, ``allows_owned`` admits a resource only for its owner, the hop's identity leaves owner-selected scopes out, and a chain whose hop @@ -13,14 +13,14 @@ import pytest from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 -from authority_client.work_context import Unauthenticated, authenticate +from authority_client.work_context import Unauthenticated, authenticate_token from codefly_hop import identity_of from test_work_context import PROOF, body, sign, stamped, trust_for def claims_of(context): - return authenticate(stamped(sign(context)), context.audience, trust_for(context)) + return authenticate_token(sign(context), context.audience, trust_for(context)) # The owner-selected rows (allows refusing, allows_owned admitting only the diff --git a/libraries/authority-client/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 4a70b508..58be17c2 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -12,7 +12,7 @@ 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 ( - CONTEXT_KEY, PROOF_KEY, WORK_CONTEXT_KEY, Claims, Scope, Trust, Unauthenticated, authenticate, + CONTEXT_KEY, PROOF_KEY, WORK_CONTEXT_KEY, Claims, Scope, Trust, Unauthenticated, authenticate_token, bound_token, ) FIXTURES = json.loads((Path(__file__).parents[2] / "testdata/claims.json").read_text()) @@ -49,7 +49,7 @@ def stamped(token, proof=PROOF): def read(context): - return authenticate(stamped(sign(context)), context.audience, trust_for(context)) + return authenticate_token(sign(context), context.audience, trust_for(context)) @pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"]) @@ -110,7 +110,7 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): (sign(context), "", trust, "configuration"), ]: with pytest.raises(Unauthenticated, match=reason): - authenticate(stamped(token), audience, use) + authenticate_token(token, audience, use) @pytest.mark.parametrize("token", ["", "abc", "a.b.c", ".", "a." + encoded(bytes(64)), "é.x", @@ -118,7 +118,7 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): def test_unreadable_envelopes_are_refused(token): context = body("session") with pytest.raises(Unauthenticated): - authenticate(stamped(token), context.audience, trust_for(context)) + authenticate_token(token, context.audience, trust_for(context)) @pytest.mark.parametrize("change", ["digest", "token", "duplicate", "missing"]) @@ -130,8 +130,10 @@ def test_the_reader_refuses_a_token_the_stamp_does_not_bind(change): 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) + # bound_token refuses the binding; authenticate_token then the token. The + # two are separate calls because neither admits a call (review r164 R4). with pytest.raises(Unauthenticated): - authenticate(headers, context.audience, trust_for(context)) + authenticate_token(bound_token(headers), context.audience, trust_for(context)) def test_trust_comes_only_from_published_ed25519_keys(): @@ -154,10 +156,11 @@ def test_trust_comes_only_from_published_ed25519_keys(): 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"]) + # The same verdict reached through the binding a hop writes. + bound = bound_token(stamped(vector["token"])) if vector["accept"]: - authenticate(headers, vector["audience"], trust) + authenticate_token(bound, vector["audience"], trust) else: with pytest.raises(Unauthenticated) as refused: - authenticate(headers, vector["audience"], trust) + authenticate_token(bound, vector["audience"], trust) assert vector["reason"] in str(refused.value) diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index 0ef6e643..eea2b29b 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -17,7 +17,9 @@ It is the Go library's `hop.Verifier`: the signed method, audience, token digest 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, when a call waits for the re-read as it does on an unknown key), then -`authenticateClaims`: Core's decode path (size, the contract's `buf.validate` +`authenticateToken`, the one identity reader (a caller holding headers extracts +the bound token with `boundToken` first): 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 diff --git a/libraries/authority-client/typescript/hop-call.ts b/libraries/authority-client/typescript/hop-call.ts index 2033a7da..92073845 100644 --- a/libraries/authority-client/typescript/hop-call.ts +++ b/libraries/authority-client/typescript/hop-call.ts @@ -1,6 +1,12 @@ import { createHash, createPublicKey, verify } from "node:crypto"; import { Reason, Unauthenticated } from "./refusal.js"; +/** The hop admission lifetime and the clock allowance, as Go's stamp.Lifetime + * and stamp.Skew define them, in seconds. An admission may be issued up to SKEW + * ahead of this verifier's clock and stays admissible until SKEW past its + * expiry; the signed span is still capped at LIFETIME exactly. */ +export const LIFETIME = 30, SKEW = 2; + /** Durable atomic consumption, shared by all replicas. Throw Replayed on reuse, another error on outage. * retainUntil is Unix seconds. Successful reservations must survive restart. */ export class Replayed extends Error { override name = "Replayed"; } @@ -29,7 +35,9 @@ export function checkCall(pairs: readonly (readonly [string, string])[], publicK if (!method || method !== boundMethod) fail(Reason.StampMethod); if (!audience || audience !== boundAudience) fail(Reason.StampAudience); const seconds = Math.floor(now / 1000); - if (issued < 0 || expires < 0 || issued > seconds || expires <= seconds || expires <= issued || expires - issued > 30) fail(Reason.StampWindow); + if (issued < 0 || expires < 0) fail(Reason.StampWindow); + if (issued > seconds + SKEW) fail(Reason.StampIssued); + if (expires + SKEW <= seconds || expires <= issued || expires - issued > LIFETIME) fail(Reason.StampWindow); if (!/^[0-9a-f]{64}$/.test(nonce)) fail(Reason.StampProof); const tokens = pairs.filter(([k]) => k === "x-codefly-work-context"); if (tokens.length !== 1 || !tokens[0][1]) fail(Reason.StampToken); diff --git a/libraries/authority-client/typescript/refusal.ts b/libraries/authority-client/typescript/refusal.ts index 466e6f47..7acf73cf 100644 --- a/libraries/authority-client/typescript/refusal.ts +++ b/libraries/authority-client/typescript/refusal.ts @@ -13,6 +13,7 @@ export enum Reason { StampMethod = "stamp_method", StampAudience = "stamp_audience", StampWindow = "stamp_window", + StampIssued = "stamp_issued", StampAdvisory = "stamp_advisory", StampReplayed = "stamp_replayed", StampStore = "stamp_store", @@ -40,6 +41,7 @@ const reasonText: Record = { [Reason.StampMethod]: "the hop admission was signed for another procedure", [Reason.StampAudience]: "the hop admission was signed for another module", [Reason.StampWindow]: "the hop admission is expired or outside its validity window", + [Reason.StampIssued]: "the hop admission is issued further ahead than the clock allowance", [Reason.StampAdvisory]: "an advisory policy refusal cannot admit a call", [Reason.StampReplayed]: "the hop admission has already been used", [Reason.StampStore]: "the hop admission store is unavailable", diff --git a/libraries/authority-client/typescript/tests/owner.test.mjs b/libraries/authority-client/typescript/tests/owner.test.mjs index 916e947d..caad8573 100644 --- a/libraries/authority-client/typescript/tests/owner.test.mjs +++ b/libraries/authority-client/typescript/tests/owner.test.mjs @@ -8,7 +8,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto'; import { test } from 'node:test'; import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, 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'); @@ -18,7 +18,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f const signed = context => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,signer)); }; const trustFor = context => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>(Number(context.notBeforeUnix)+1)*1000); 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 read = context => authenticateClaims(stamped(signed(context)), context.audience, trustFor(context)); +const read = context => authenticateToken(signed(context), context.audience, trustFor(context)); const scope = fields => create(WorkScopeV1Schema, fields); // The owner-selected rows (allows refusing, allowsOwned admitting only the diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index b91159b5..8d8191d1 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -5,7 +5,7 @@ import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:cr import { test } from 'node:test'; import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Reason } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Reason, SKEW } 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'); @@ -16,7 +16,7 @@ const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f 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)), context.audience, trustFor(context)); +const read = context => authenticateToken(signed(context), context.audience, trustFor(context)); const scope = (actions, resourceIds=[]) => create(WorkScopeV1Schema,{resourceKind:'profiles',actions,resourceIds}); for (const fixture of fixtures) { @@ -56,7 +56,7 @@ test('a forged, unsigned, misaddressed or expired context is refused by name', ( [signed(create(WorkContextV1Schema,{...context, keyId:'rotated'})), context.audience, trust, /no verification key/], [signed(context), '', trust, /configuration/], ]) { - assert.throws(()=>authenticateClaims(stamped(token), audience, use), e => e instanceof Unauthenticated && reason.test(e.message)); + assert.throws(()=>authenticateToken(token, audience, use), e => e instanceof Unauthenticated && reason.test(e.message)); } }); @@ -66,9 +66,11 @@ test('the stamp must bind the token under this run\'s proof', () => { {...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, context.audience, trust),Unauthenticated); + // boundToken refuses the binding; authenticateToken then the token. Two + // calls, because neither of them admits a call (review r164 R4). + ]) assert.throws(()=>authenticateToken(boundToken(headers), context.audience, trust),Unauthenticated); for (const bad of ['abc','a.b.c','é.x', payloadOnly(token)+'.AA']) { - assert.throws(()=>authenticateClaims(stamped(bad), context.audience, trust),Unauthenticated); + assert.throws(()=>authenticateToken(bad, context.audience, trust),Unauthenticated); } }); @@ -87,7 +89,7 @@ const vectors = JSON.parse(readFileSync(new URL('../../testdata/vectors.json',im 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), v.audience, trust); + const run = () => authenticateToken(v.token, v.audience, trust); if (v.accept) run(); else assert.throws(run, e => e instanceof Unauthenticated && e.message.includes(v.reason), v.reason); }); @@ -296,8 +298,8 @@ test('concurrent replay has one winner and a failed store refuses admission', as }); test('expired stamps and repeated binding headers refuse before consumption', async () => { - const expired = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now+30)*1000); - await assert.rejects(expired.verify(fixture.headers, METHOD), /expired/); + const expired = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now+30+SKEW)*1000); + await assert.rejects(expired.verify(fixture.headers, METHOD), /stamp_window/); for (const key of ['x-codefly-hop-proof', 'x-codefly-work-context','x-codefly-hop-context']) { const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => fixture.now*1000); await assert.rejects(v.verify([...Object.entries(fixture.headers),[key,fixture.headers[key]]], METHOD), Unauthenticated); @@ -346,3 +348,33 @@ test('invalid publication and unreadable keys have distinct safe codes', async ( await assert.rejects(v.verifyToken(fixture.headers['x-codefly-work-context']), e=>e.code===code && !e.message.includes('private-')); } }); + +// Review r164 R1: a verifier whose clock lags the hop refused genuine calls +// whenever the signing instant fell just after a second boundary, under a +// reason that read as an expiry. Go, Python and TypeScript allow the same SKEW. +for (const lag of [0, 1, SKEW]) { + test(`a clock difference of ${lag}s inside the allowance still admits`, async () => { + const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now-lag)*1000); + assert.equal((await v.verify(fresh(fixture.now*1000), METHOD)).owner, fixture.identity.owner); + }); +} + +for (const [lag, code] of [[SKEW+1, Reason.StampIssued], [-(30+SKEW), Reason.StampWindow]]) { + test(`a clock difference of ${lag}s beyond the allowance is refused as ${code}`, async () => { + const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now-lag)*1000); + await assert.rejects(v.verify(fresh(fixture.now*1000), METHOD), e => e instanceof Unauthenticated && e.code === code); + }); +} + +// Review r164 R2: the reservation outlives the admission by the same +// allowance, so a store evicting on its own clock cannot release a nonce a +// lagging verifier would still admit. +test('a reservation is retained past expiry by the allowance', async () => { + const retained = []; + const recording = { async consume(_nonce, retainUntil) { retained.push(retainUntil); } }; + const v = new Verifier(source(), fixture.audience, fixture.public_key, recording, () => fixture.now*1000); + const headers = fresh(fixture.now*1000); + const payload = JSON.parse(Buffer.from(headers['x-codefly-hop-proof'].split('.')[0], 'base64url').toString('utf8')); + await v.verify(headers, METHOD); + assert.deepEqual(retained, [payload[6] + SKEW]); +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index d50e816d..3344f933 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,9 +1,9 @@ -import { checkCall, Replayed, type ReplayStore } from "./hop-call.js"; -export { Replayed } from "./hop-call.js"; +import { checkCall, Replayed, SKEW, type ReplayStore } from "./hop-call.js"; +export { Replayed, LIFETIME, SKEW } from "./hop-call.js"; export type { ReplayStore } from "./hop-call.js"; /** Work Context identity readers and method-bound, single-use hop admission. * Verifier consumes signed call nonces in a required durable shared ReplayStore. - * authenticateClaims alone authenticates identity, not admission. */ + * authenticateToken alone authenticates identity, not admission. */ import { createHash, createPrivateKey, createPublicKey, timingSafeEqual, verify } from "node:crypto"; import { equals, fromBinary, toBinary } from "@bufbuild/protobuf"; import { createValidator } from "@bufbuild/protovalidate"; @@ -69,14 +69,19 @@ export interface Claims { /** Server-side request headers; repeated values must not be discarded. */ export type StampHeaders = Readonly> | Iterable; -/** 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, audience: string, trust: Trust): Claims { - if (!audience) throw new Unauthenticated(Reason.Configuration); - return authenticateToken(boundToken(headers), audience, trust); -} - -/** Authenticate bare bytes without claiming hop admission or live checks. */ +/** The claims of one Work Context, for this module (audience), or + * Unauthenticated naming why they must not be believed. + * + * This is the one identity-only reader here, and identity is all it + * establishes. It proves no hop admission: it does not check that a call was + * admitted for the procedure it arrived on, consumes no single-use nonce, and + * asks the authority nothing, so it checks no live revocation either. A + * service admits a call with Verifier.verify. + * + * A caller holding a call's headers extracts the token the stamp's digest binds + * with boundToken first. That is deliberately two calls (security review r164 + * R4): a reader taking headers reads as though it admitted the call, and it + * does not. */ export function authenticateToken(token: string, audience: string, trust: Trust): Claims { if (!audience) throw new Unauthenticated(Reason.Configuration); if (token.length > MAX_TOKEN_BYTES || token.split(".").length !== 2) { @@ -338,10 +343,13 @@ export class Verifier { throw new Unauthenticated(Reason.StampIdentity); } } - if (call.expires <= Math.floor(this.now() / 1000)) throw new Unauthenticated(Reason.StampWindow); - try { await this.replay.consume(call.nonce, call.expires); } + // checkCall judged the admission window when the call started, and the + // window bounds a call's start, so it is judged once and not again here. + // The reservation is retained to expiry plus the allowance and no longer: + // a replay can only be admitted if it too arrives inside the window, and + // inside the window the nonce is still retained. + try { await this.replay.consume(call.nonce, call.expires + SKEW); } catch (error) { throw new Unauthenticated(error instanceof Replayed ? Reason.StampReplayed : Reason.StampStore); } - if (call.expires <= Math.floor(this.now() / 1000)) throw new Unauthenticated(Reason.StampWindow); // An admission carrying an advisory refusal is refused by checkCall, so an // admitted call never has one: diagnostic identity is never authority. return Object.freeze({ ...id, advisory: "" }); diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go index 52b980f7..ebb193ef 100644 --- a/libraries/conformance/go/forwarded_context.go +++ b/libraries/conformance/go/forwarded_context.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" forwarded "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" @@ -22,7 +23,7 @@ import ( // 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, +// the same check every module runs behind the hop (workcontext.AuthenticateToken, // 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) { @@ -41,7 +42,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) at := time.Unix(i64(msg(issued, "context"), "issued_at_unix"), 0).Add(time.Second) trust.Now = func() time.Time { return at } - if claims, err := forwarded.Authenticate(stamped(token), f.Audience, trust); err != nil || claims.Audience() != f.Audience { + if claims, err := forwarded.AuthenticateToken(bound(t, token), 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, ".") @@ -70,7 +71,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) "expired": {token, f.Audience, "expired", later}, } { t.Run(name+" is refused by name", func(t *testing.T) { - _, err := forwarded.Authenticate(stamped(probe.token), probe.audience, probe.trust) + _, err := forwarded.AuthenticateToken(bound(t, probe.token), 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) } @@ -81,6 +82,18 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) // stamped supplies a digest-bound token to the identity reader. Admission // requires a signed hop proof and replay consumption, tested in libraries/hop. +// bound is the token a hop's stamp binds, extracted the way a module does: +// stamp.Context first, then AuthenticateToken. The two are separate calls +// because neither of them admits a call (review r164 R4). +func bound(t *testing.T, token string) string { + t.Helper() + out, err := stamp.Context(stamped(token)) + if err != nil { + t.Fatal(err) + } + return out +} + func stamped(token string) metadata.MD { sum := sha256.Sum256([]byte(token)) return metadata.Pairs("x-codefly-work-context", token, "x-codefly-hop-context", hex.EncodeToString(sum[:])) diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index d5f65a80..0548861b 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,8 +28,6 @@ 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-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= 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.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= diff --git a/libraries/hop/README.md b/libraries/hop/README.md index 00b240f6..f886ae3d 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -8,9 +8,16 @@ must never choose the expected method, audience, public key, or replay namespace This is a breaking replacement of the shared `hop/PROOF` protocol. There is no legacy acceptance path. Go, Python and TypeScript consume the same signed fixture. -`workcontext.Authenticate`, Python `authenticate`, and TS `authenticateClaims` -are lower-level identity readers, not admission checks. Their shared-proof -argument has been deleted; they do not authorize a method or consume a nonce. +`workcontext.AuthenticateToken`, Python `authenticate_token` and TS +`authenticateToken` are the lower-level identity readers, not admission checks: +they do not authorize a method or consume a nonce. There is exactly **one** of +them per language. The header-taking variants are gone with the shared proof +(review r164 R4): a reader that took a call's metadata read as though it +admitted the call, and after the proof's removal it accepted any token plus a +self-computed digest, so a caller fixing a build break by deleting the proof +argument would have lost admission silently. A caller holding metadata now +extracts the bound token with `stamp.Context` / `bound_token` / `boundToken` +first, which is two visible calls and neither of them an admission. ## Issuance and verification @@ -26,16 +33,37 @@ unpadded canonical base64url(signature). The signature covers the original bytes not a parsed and re-encoded JSON object. Nonces are 32 cryptographically random bytes; digests and nonces are lowercase hex. Unix timestamps are integral seconds. The maximum admission lifetime is 30 seconds, capped by the Work Context expiry. -No acceptance skew is added. Duplicate proof, context or digest headers fail. -The signing key is distinct from the authority's Work Context key. +Duplicate proof, context or digest headers fail. The signing key is distinct +from the authority's Work Context key. + +A two-second clock allowance (`stamp.Skew`, Python and TypeScript `SKEW`) is +applied identically in the three languages: an admission may be issued up to +that far ahead of the verifier's clock, and stays admissible until that far past +its expiry. Being issued further ahead than the allowance has its own reason, +`stamp_issued`, so a verifier running behind is not diagnosed as an expiry. The +signed span (`expires - issued`) is still capped at 30 seconds exactly, so the +allowance cannot mint a longer-lived admission. For gRPC and Connect, `method` is `/package.Service/Procedure`, including the leading slash. HTTP routes use `VERB /actual/path`, not the matched route template. The hop uses its selected generated route to distinguish Connect from ordinary -HTTP. A verifier must use the same transport's observed procedure. A translated -REST gateway uses the full procedure observed by its gRPC listener. The audience +HTTP. A verifier must use the same transport's observed procedure. The audience is the checked module principal, never an upstream address. +A REST face a module **transcodes into its gRPC or Connect listener** cannot be +admitted behind the hop. The hop signs an http route as `VERB /path`, which is +what it decided and all a published http route tells it, while the listener the +request reaches observes the full procedure; the two can never agree. A module +cannot verify at both layers either, because the second would consume the +admission nonce again and refuse as `stamp_replayed`. Signing the procedure +instead would need the published route to carry its producer's +template-to-procedure mapping, and the `hop-routes` schema has no such field. +So `routing.Validate` refuses such a composition when the hop starts +(`routing.ErrTranscodedREST`) rather than refusing every REST call at runtime. +Publish the module's HTTP face as a service of its own, or reach the procedure +through Connect, which the hop signs correctly. A module serving HTTP natively, +with no gRPC or Connect face on the same service, is unaffected. + The verifier checks the signature, method, audience, token digest and admission window before reading authority keys. It then verifies the Work Context signature, issuer, audience, window and attenuation, and compares identity metadata with the @@ -53,6 +81,26 @@ A stream is admitted once before handler entry; this preserves streaming without buffering its body. Expiry bounds starting a call, not the lifetime of its stream. This is admission replay protection, not transactional exactly-once effects. +The window is judged exactly once, at the instant the call starts, and not again +while the admitted call runs. Python's interceptors already run before the +request is read. Go's do not: grpc-go decodes a unary request fully before it +invokes the unary interceptor, and the Connect and REST faces buffer the message +too, so a module serving large or slow unary requests refused admissions the +authority had granted. `Verifier.TapHandle`, installed with +`grpc.InTapHandle`, records the arrival instant when the stream is accepted and +`Verify` judges the window against it. The handle reads no header and runs no +cryptography, because it runs on the connection's I/O goroutine where a +signature check would serialise every stream on that connection; admission stays +in the interceptor. A server that installs no handle judges the window when +`Verify` runs, as before, so nothing is admitted by its absence. + +A reservation is retained to expiry plus the allowance, and no longer. That +horizon is sound because the window is judged at arrival: a replay of a stamp +can only be admitted if it too arrives inside the window, and inside the window +the nonce is still retained. A store evicting on its own clock therefore +releases a nonce only once every call carrying it would already be refused on +its window. + ## Replicas and durable replay storage A stateless signature or request digest cannot reject an identical replay. A @@ -67,9 +115,30 @@ be durable before it returns. A backend may retain entries longer than expiry. It must not evict an entry while any verifier can still accept it: account for clock differences and prevent clock rollback. Synchronize deployment clocks. +`replaypg.New` is the adapter to prefer: a module's own Postgres, where +`INSERT ... ON CONFLICT (nonce) DO NOTHING` over a primary key is the atomic +reservation and the database is already shared by every replica. Codefly +delivers a store, so this needs nothing the platform cannot provide. +`replaypg.Migration` is the table, `Collect` removes reservations whose +retention has passed, and the console composes exactly this +(`modules/console/services/store`). + +Outside a local run, `NewVerifier` now **refuses** a store that does not declare +itself the shared one (`hop.ErrReplayStoreNotShared`), rather than leaving the +rule to documentation. Core's in-memory store and `hoptest.Replay` never +declare it, so a laptop and a test keep working while a deployment cannot +quietly run per-replica. `CODEFLY__ENVIRONMENT` decides which run this is. + `hop.FileReplayStore` implements this contract for a shared durable filesystem -with atomic exclusive creation and file/directory fsync. All replicas for an -audience must mount the same directory; per-pod ephemeral storage is not suitable. Reservations +with atomic exclusive creation and file/directory fsync. Nothing in the library +can tell whether the directory it is given is genuinely shared: an absolute path +to per-pod storage (`/tmp`, an `emptyDir`) looks exactly the same, and a +per-replica store lets a captured admission replay once per replica inside its +window. So `NewFileReplayStore` does not declare itself shared and is refused +outside a local run; an operator who knows the mount is one durable volume every +replica shares says so with `NewSharedFileReplayStore`, which is an assertion +about the deployment, not a check. All replicas for an audience must mount the +same directory; per-pod ephemeral storage is not suitable. Reservations survive process restart and are never automatically removed. An incomplete reservation is retained after a crash. Operators may collect complete expired records only once every verifier's clock has passed the recorded expiry; never @@ -91,23 +160,65 @@ codefly config generate hop-signing SEED --service authority/hop ``` The hop reads `Secret("hop-signing", "SEED")`: a 32-byte hex seed, never the old -workspace `hop/PROOF`. Delivery derives its public half with -`stamp.NewSigner(seed).PublicKey()` and supplies that non-secret value as -`hop-verification/PUBLIC_KEY` to consumers. Do not deliver the seed or derive it -from a previously shared proof. The key must come from trusted composition, -never from a request or an unauthenticated key advertisement. Key rotation changes -the trusted public key and immediately refuses stamps from the retired key. - -The console example also resolves `hop-verification/REPLAY_DIRECTORY` from -Codefly's mounted shared volume and uses `FileReplayStore`. Configure the volume -and public key before upgrading consumers. No address, seed, key or volume path -is hard-coded. Python and Node hosts supply their shared durable replay adapter; -Python's async adapter must not block the event loop. Readiness continues to check -authority key availability; deployment health should also check its replay store. +workspace `hop/PROOF`. Do not deliver the seed or derive it from a previously +shared proof. The key must come from trusted composition, never from a request +or an unauthenticated key advertisement. Key rotation changes the trusted public +key and immediately refuses stamps from the retired key. + +### Delivering the verification key + +**This is open.** A consumer needs the public half of that seed as +`hop-verification/PUBLIC_KEY`, and nothing produces it. `codefly config +generate` makes random bytes in hex or base64; it has no derived-value +generator, and a configuration value template assembles only its own producer's +primitives, so it cannot transform a seed either. Deriving the public half +therefore means reading the seed outside Codefly and writing the result in by +hand — the hand-written configuration the platform rules forbid — and a value +that drifts from the seed refuses every call as `stamp_proof` (security review +r164, B1). + +The agreed direction is for **the authority to publish the hop's admission key +through its own `Keys`**, which a verifier already trusts, already caches, and +already re-reads on rotation with a fail-closed staleness policy. A hop +admission key carried there is ignored by the Work Context trust set, which +filters on the algorithm it accepts, so it grants no Work Context signing +authority. `Keys` is part of `interfaces/authority`, so this is a contract +change: it is proposed in the handbook first and `accounts` implements it +alongside the local authority. Until that lands, a run configures +`PUBLIC_KEY` by hand and the playground documents it as such +(`playground/configurations/local/hop-verification.env`). + +`hop-verification/REPLAY_DIRECTORY` is gone. Reservations live in the consumer's +own store (above), because Codefly delivers a store and does not deliver a +shared volume. No address, seed, key or store connection is hard-coded. Python +and Node hosts supply their shared durable replay adapter; Python's async +adapter must not block the event loop. Readiness continues to check authority +key availability; deployment health should also check its replay store. Upgrade the hop and consumers together and remove delivery of `hop/PROOF`. Downstream repositories must pin this breaking version and pass their actual -procedure into `Verify`/`verify`; no method-less overload remains. The regression +procedure into `Verify`/`verify`; no method-less overload remains. + +### Adoption order + +The hop stops writing the shared proof the moment this lands, so every module +behind a hop is refused until it adopts. The order is therefore fixed, and +nothing in this repository can sequence it: + +1. `module-document-store` and `module-record-store` adopt the signed admission + and release. Both are pinned by the authority playground + (`playground/workspace.codefly.yaml`, documents 0.1.13 and records 0.5.0) and + both still verify the shared proof, so calls through the hop to them are + refused until those releases exist and the playground repins them. +2. `accounts` publishes the hop's admission key through `Keys`, once that + contract change is agreed (above). Until then every consumer's + `hop-verification/PUBLIC_KEY` is configured by hand. +3. This repository's hop and console upgrade together, and `hop/PROOF` + delivery is removed. + +A composition that upgrades the hop before step 1 keeps serving the console, +whose verifier is in this repository and upgrades with it, and refuses +documents and records. The regression suite covers the read-to-delete and zero-scope fabricated-stamp probes from the two independent reviews, cross-audience use, repeated and concurrent admission, expiry, duplicate headers, store failure, and cross-instance durable reservations. diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 1f172904..80d34d72 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -10,14 +10,21 @@ 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" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" ) publicKey, _ := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "PUBLIC_KEY") -// replay is the host's durable shared Core ReplayStore; never a per-replica cache. +// The reservations: this module's own store. replaypg.Migration is its table. +// NewVerifier refuses a store that is not the shared one outside a local run. +pool, err := pgxpool.New(ctx, connection) // Service("store").Secret("postgres", "read-write-connection") +replay, err := replaypg.New(pool) conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) config.GRPCServerOptions = append(config.GRPCServerOptions, + // Records when each call arrived, so the admission window is judged at + // call start and not after a large request body has decoded. + grpc.InTapHandle(verifier.TapHandle()), 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) @@ -25,9 +32,13 @@ config.GRPCServerOptions = append(config.GRPCServerOptions, ``` `Verify` authenticates the signed method, audience, token digest and 30-second -admission window, authenticates the Work Context, compares identity metadata, -and atomically consumes the call nonce in the shared replay store. It refuses -replays across replicas without re-spending the Work Context's single use. +admission window (with a two-second clock allowance, `stamp.Skew`, judged at the +instant the call arrived), authenticates the Work Context, compares identity +metadata, and atomically consumes the call nonce in the shared replay store. It +refuses replays across replicas without re-spending the Work Context's single +use. A process that receives no hop calls authenticates a bare Work Context with +`NewTokenVerifier` and `VerifyToken` instead, which establishes identity and no +admission. See the [wire protocol and migration contract](../README.md). The keys come from the interface's `Keys`, through the generated client: never diff --git a/libraries/hop/go/arrival_test.go b/libraries/hop/go/arrival_test.go new file mode 100644 index 00000000..1b1fdc29 --- /dev/null +++ b/libraries/hop/go/arrival_test.go @@ -0,0 +1,132 @@ +package hop + +import ( + "context" + "errors" + "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/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/tap" +) + +const arrivalMethod = "/test.Service/Read" + +func arrivalVerifier(t *testing.T, clock *time.Time) (*Verifier, signer) { + t.Helper() + s := newSigner("arrival") + source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} + v, err := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + if err != nil { + t.Fatal(err) + } + v.now = func() time.Time { return *clock } + v.fetched, v.tried = *clock, *clock + return v, s +} + +// Review r164 B3: grpc-go decodes a unary request fully before it runs the +// unary interceptor, so a slow or large upload reached Verify long after the +// hop admitted it and was refused for an expiry that had not happened at call +// start. The window is judged at the instant the stream was accepted, which +// TapHandle records. +func TestTheAdmissionWindowIsJudgedWhenTheCallArrivesNotAfterItsBodyDecodes(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + + // The stream is accepted now; its body then takes two minutes to arrive, + // well past stamp.Lifetime. + ctx, err := v.TapHandle()(metadata.NewIncomingContext(context.Background(), md), &tap.Info{FullMethodName: arrivalMethod, Header: md}) + if err != nil { + t.Fatal(err) + } + clock = clock.Add(2 * time.Minute) + reached := false + if _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { + reached = true + return nil, nil + }); err != nil || !reached { + t.Fatalf("a call admitted at arrival was refused after its body decoded: %v", err) + } +} + +// Nothing is admitted by the absence of a tap handle: a server that installs +// none judges the window at the instant Verify runs, as it did before. +func TestWithoutATapHandleTheWindowIsJudgedWhenVerifyRuns(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + clock = clock.Add(2 * time.Minute) + _, err := v.Verify(context.Background(), md, arrivalMethod) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampWindow { + t.Fatalf("want %s without a tap handle: %v", refusal.StampWindow, err) + } +} + +// The window the tap handle opens is the admission's own, not an unbounded one: +// a call accepted further ahead than the clock allowance, or long after the +// admission expired, is still refused, each under its own reason. +func TestTheArrivalInstantDoesNotWidenTheAdmissionWindow(t *testing.T) { + for _, tc := range []struct { + name string + shift time.Duration + want refusal.Reason + }{ + {"accepted further ahead than the allowance", -stamp.Skew - 5*time.Second, refusal.StampIssued}, + {"accepted long after it expired", stamp.Lifetime + stamp.Skew + time.Second, refusal.StampWindow}, + } { + t.Run(tc.name, func(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + clock = clock.Add(tc.shift) + ctx, err := v.TapHandle()(metadata.NewIncomingContext(context.Background(), md), &tap.Info{FullMethodName: arrivalMethod, Header: md}) + if err != nil { + t.Fatal(err) + } + _, err = v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { + return nil, nil + }) + // The interceptor answers a gRPC status, which carries the reason + // as its text rather than a wrapped error. + if err == nil || !strings.Contains(err.Error(), tc.want.Code()) { + t.Fatalf("want %s: %v", tc.want, err) + } + }) + } +} + +// A reservation outlives the admission by the same allowance, so a shared store +// evicting on its own clock cannot release a nonce a lagging verifier admits. +func TestAReservationIsRetainedPastExpiryByTheClockAllowance(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + store := &retention{} + v.replay = store + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + call, err := stamp.Check(md, v.publicKey, arrivalMethod, "documents", clock) + if err != nil { + t.Fatal(err) + } + if _, err := v.Verify(context.Background(), md, arrivalMethod); err != nil { + t.Fatal(err) + } + if want := time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0); !store.until.Equal(want) { + t.Fatalf("retained until %v, want the admission's expiry plus the allowance %v", store.until, want) + } +} + +type retention struct{ until time.Time } + +func (r *retention) Consume(_ context.Context, _ string, until time.Time) error { + r.until = until + return nil +} diff --git a/libraries/hop/go/durable_test.go b/libraries/hop/go/durable_test.go new file mode 100644 index 00000000..dab3ddae --- /dev/null +++ b/libraries/hop/go/durable_test.go @@ -0,0 +1,77 @@ +package hop_test + +import ( + "context" + "errors" + "testing" + + hop "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/replaypg" + "github.com/codefly-dev/core/resources" + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5/pgconn" +) + +type insertingPool struct{} + +func (insertingPool) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +// Review r164 B1: the contract forbids a per-replica replay store outside a +// local run, but nothing refused one. A hand-written path to per-pod storage, +// or Core's in-memory store, passed and let a captured admission replay once +// per replica inside its window. The verifier refuses it at startup now. +func TestAReplayStoreThatIsNotSharedIsRefusedOutsideALocalRun(t *testing.T) { + a := hoptest.New(t) + postgres, err := replaypg.New(insertingPool{}) + if err != nil { + t.Fatal(err) + } + directory, err := hop.NewFileReplayStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mount, err := hop.NewSharedFileReplayStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + build := func(store core.ReplayStore) error { + _, err := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) + return err + } + + // A laptop and a test keep working with any store, including Core's + // in-memory one, and so does a run that names no environment. + for _, environment := range []string{"", "local", "local-docker"} { + t.Setenv(resources.EnvironmentPrefix, environment) + for name, store := range map[string]core.ReplayStore{ + "in-memory": hoptest.Replay(), "a directory": directory, + "a shared mount": mount, "postgres": postgres, + } { + if err := build(store); err != nil { + t.Fatalf("environment %q refused %s: %v", environment, name, err) + } + } + } + + for _, environment := range []string{"staging", "production"} { + t.Setenv(resources.EnvironmentPrefix, environment) + for name, store := range map[string]core.ReplayStore{ + "in-memory": hoptest.Replay(), "a directory not stated to be shared": directory, + } { + if err := build(store); !errors.Is(err, hop.ErrReplayStoreNotShared) { + t.Fatalf("environment %q admitted %s: %v", environment, name, err) + } + } + // A store that states it is the one every replica shares is admitted. + for name, store := range map[string]core.ReplayStore{ + "postgres": postgres, "a mount stated to be shared": mount, + } { + if err := build(store); err != nil { + t.Fatalf("environment %q refused %s: %v", environment, name, err) + } + } + } +} diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 1021db1a..73821811 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -5,6 +5,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 + github.com/jackc/pgx/v5 v5.11.0 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) @@ -36,9 +37,13 @@ require ( 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/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/kr/text v0.2.0 // 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/rogpeppe/go-internal v1.16.0 // 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 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 3cb89bd1..611099b6 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,8 +28,6 @@ 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-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -38,6 +36,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -127,6 +127,18 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -152,16 +164,22 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= @@ -196,6 +214,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ 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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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= @@ -212,10 +232,12 @@ 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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index b9f4e1db..e1122136 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -47,6 +47,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/grpc/tap" ) // WorkContextKey carries the caller's Work Context to the hop, as everywhere. @@ -267,9 +268,45 @@ func NewVerifier(ctx context.Context, keys KeySource, audience, publicKey string if replay == nil || keyErr != nil || len(raw) != ed25519.PublicKeySize || hex.EncodeToString(raw) != publicKey { return nil, refusal.Configuration } + if err := durableOutsideLocal(replay); err != nil { + return nil, err + } return newVerifier(ctx, keys, audience, publicKey, replay) } +// SharedReplayStore is a replay store that reports whether it is the durable +// reservation shared by every replica that the admission contract requires. +// replaypg.Store and a FileReplayStore opened with NewSharedFileReplayStore +// report true; Core's in-memory store does not implement this at all. +type SharedReplayStore interface { + core.ReplayStore + Shared() bool +} + +// ErrReplayStoreNotShared is the named refusal for a replay store that cannot +// be the one every replica shares. A process-local set answers only for its own +// process, so a captured stamp replays once per replica inside its window, and +// a restart forgets every reservation. The contract has always forbidden that +// outside local; this refuses it at startup instead of documenting it. +// +// It is judged by CODEFLY__ENVIRONMENT, the environment the composition started +// this process in. A local run may use any store, so a laptop and a test keep +// working with Core's in-memory store and hoptest.Replay. +var ErrReplayStoreNotShared = errors.New("hop: this replay store is not the durable store every replica shares, which admission requires outside a local run: a captured admission would replay once per replica, and a restart would forget every reservation. Use replaypg.New over the module's own store, or NewSharedFileReplayStore for a genuinely shared mount") + +// durableOutsideLocal refuses a store that does not declare itself shared, +// unless this process runs in a local environment. +func durableOutsideLocal(replay core.ReplayStore) error { + environment := strings.TrimSpace(os.Getenv(resources.EnvironmentPrefix)) + if environment == "" || strings.HasPrefix(environment, "local") { + return nil + } + if shared, ok := replay.(SharedReplayStore); ok && shared.Shared() { + return nil + } + return fmt.Errorf("%w (environment %s)", ErrReplayStoreNotShared, environment) +} + // NewTokenVerifier builds a verifier for Issue responses in a process that does // not receive hop calls. It needs neither the hop's public key nor a replay // store. Verify always refuses stamps, because admission cannot be checked @@ -332,7 +369,13 @@ func (v *Verifier) Verify(ctx context.Context, md metadata.MD, method string) (I // public key nor a replay store, so it can establish no admission. return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Configuration) } - call, err := stamp.Check(md, v.publicKey, method, v.audience, v.now()) + // The window is judged at the instant the call arrived, which TapHandle + // recorded when the stream was accepted. grpc-go decodes a unary request + // before it runs the interceptor, so judging at this point would refuse a + // genuinely admitted upload that simply took longer than Lifetime to + // arrive. Without a tap handle the arrival instant is now, as before. + arrival := arrivalFrom(ctx, v.now) + call, err := stamp.Check(md, v.publicKey, method, v.audience, arrival) if err != nil { return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) } @@ -343,18 +386,23 @@ func (v *Verifier) Verify(ctx context.Context, md metadata.MD, method string) (I if err := sameAsStamped(md, id); err != nil { return Identity{}, err } - if call.Expires <= v.now().Unix() { - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampWindow) - } - if err := v.replay.Consume(ctx, call.ReplayKey(v.publicKey), time.Unix(call.Expires, 0)); err != nil { + // stamp.Check judged the admission window against arrival above, and the + // window bounds when a call may start, so it is judged once and not again + // here: the key read and the reservation below happen while the admitted + // call is already running. + skew := int64(stamp.Skew / time.Second) + // The reservation is retained to expiry plus the same allowance, and no + // longer. That horizon is sound because the window above is judged at + // arrival: a replay of this stamp can only be admitted if it too arrives + // inside the window, and inside the window the nonce is still retained. A + // store evicting on its own clock therefore releases a nonce only once + // every call carrying it would already be refused on its window. + if err := v.replay.Consume(ctx, call.ReplayKey(v.publicKey), time.Unix(call.Expires+skew, 0)); err != nil { if errors.Is(err, core.ErrReplayed) { return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampReplayed) } return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampStore) } - if call.Expires <= v.now().Unix() { - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampWindow) - } // An admission carrying an advisory refusal is refused by stamp.Check, so // an admitted call never has one: diagnostic identity is never authority. return id, nil @@ -547,3 +595,41 @@ func first(md metadata.MD, key string) string { } return "" } + +type arrivalKey struct{} + +// arrivalFrom is the instant the call arrived, as TapHandle recorded it, or +// now when the server runs no tap handle. +func arrivalFrom(ctx context.Context, now func() time.Time) time.Time { + if at, ok := ctx.Value(arrivalKey{}).(time.Time); ok { + return at + } + return now() +} + +// TapHandle records when each call arrived, before its body is read, so Verify +// judges the admission window at call start. +// +// grpc-go decodes a unary request fully before it invokes the unary +// interceptor, and the Connect and REST faces buffer the message too. A module +// that serves large or slow unary requests would therefore refuse an admission +// the authority granted, because more than stamp.Lifetime passed while the +// body arrived. Installing this handle moves that judgement to the instant the +// stream was accepted, which is also where Python's interceptors already run, +// so the three languages agree on when a call starts. +// +// Install it beside the interceptors: +// +// grpc.NewServer(grpc.InTapHandle(verifier.TapHandle()), +// grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), +// grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) +// +// It authenticates nothing and refuses nothing: it reads no header and runs no +// cryptography, because it runs on the connection's I/O goroutine, where a +// signature check would serialise every stream on that connection. Admission +// stays in the interceptor. +func (v *Verifier) TapHandle() tap.ServerInHandle { + return func(ctx context.Context, _ *tap.Info) (context.Context, error) { + return context.WithValue(ctx, arrivalKey{}, v.now()), nil + } +} diff --git a/libraries/hop/go/replay.go b/libraries/hop/go/replay.go index 70d0261f..43f0edad 100644 --- a/libraries/hop/go/replay.go +++ b/libraries/hop/go/replay.go @@ -20,9 +20,35 @@ import ( // Reservations deliberately survive expiry and restart. Operators may remove // files only after their recorded expiry, with all verifiers' clocks past it. // An incomplete file is retained: an interrupted reservation fails closed. -type FileReplayStore struct{ directory string } +type FileReplayStore struct { + directory string + shared bool +} +// NewFileReplayStore opens a store on directory. Nothing here can tell whether +// that directory is genuinely shared by every replica: an absolute path to +// per-pod storage (/tmp, an emptyDir) looks exactly the same, and a store that +// is per-pod lets a captured admission replay once per replica. So this store +// does not declare itself shared, and NewVerifier refuses it outside a local +// run. An operator who knows the mount is shared says so with +// NewSharedFileReplayStore. func NewFileReplayStore(directory string) (*FileReplayStore, error) { + return openFileReplayStore(directory, false) +} + +// NewSharedFileReplayStore is NewFileReplayStore for a directory the caller +// states is one durable mount every replica of this audience shares, with +// atomic exclusive creation and durable fsync semantics. Saying so is an +// assertion about the deployment that this library cannot check; a module with +// a store should prefer replaypg, where the guarantee is the database's. +func NewSharedFileReplayStore(directory string) (*FileReplayStore, error) { + return openFileReplayStore(directory, true) +} + +// Shared reports what the constructor was told. NewVerifier reads it. +func (s *FileReplayStore) Shared() bool { return s.shared } + +func openFileReplayStore(directory string, shared bool) (*FileReplayStore, error) { if !filepath.IsAbs(directory) { return nil, errors.New("hop: replay directory must be an absolute delivered shared volume path") } @@ -30,7 +56,7 @@ func NewFileReplayStore(directory string) (*FileReplayStore, error) { if err != nil || !info.IsDir() { return nil, errors.New("hop: delivered replay directory is unavailable") } - return &FileReplayStore{directory: directory}, nil + return &FileReplayStore{directory: directory, shared: shared}, nil } func (s *FileReplayStore) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { diff --git a/libraries/hop/go/replaypg/replaypg.go b/libraries/hop/go/replaypg/replaypg.go new file mode 100644 index 00000000..9c0e92ce --- /dev/null +++ b/libraries/hop/go/replaypg/replaypg.go @@ -0,0 +1,95 @@ +// Package replaypg is the hop admission replay store on Postgres: the durable, +// atomic, shared reservation the admission contract requires, over a store +// Codefly already delivers to a module. +// +// It exists because the contract's requirement had no deployable backend. A +// process-local set fails on a second replica and after a restart, and +// hop.FileReplayStore needs a shared mount the platform does not deliver, so a +// hand-written path to per-pod storage would silently become a per-replica +// store and let a captured stamp replay once per replica. A module with a store +// has somewhere atomic to put a nonce, and INSERT ... ON CONFLICT DO NOTHING +// over a primary key is that atomic reservation, shared by every replica +// reaching the same database. +// +// Create the table with Migration, as the module's own migration, and keep +// Collect on a schedule: reservations are retained past their admission's +// expiry (hop passes expiry plus the clock allowance) and nothing removes them +// otherwise. +package replaypg + +import ( + "context" + "errors" + "time" + + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5/pgconn" +) + +// Migration creates the table, as the module's own migration. The primary key +// is the reservation: Postgres decides one winner among concurrent inserts. +const Migration = `CREATE TABLE IF NOT EXISTS hop_admissions ( + nonce TEXT PRIMARY KEY, + retain_until TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until);` + +// Pool is the part of *pgxpool.Pool this store uses, so a caller may wrap it +// and a test may stand in for it. +type Pool interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// Store reserves admission nonces in a module's own Postgres. +type Store struct{ pool Pool } + +// New wraps a pool connected as the role that may insert into hop_admissions. +func New(pool Pool) (*Store, error) { + if pool == nil { + return nil, errors.New("hop replay: a Postgres replay store needs a connection pool") + } + return &Store{pool: pool}, nil +} + +var _ core.ReplayStore = (*Store)(nil) + +// Shared marks this store as the durable shared reservation the contract +// requires, so hop.NewVerifier admits it outside local. +func (s *Store) Shared() bool { return true } + +// Consume reserves nonce until retainUntil, atomically across every replica +// reaching this database. It reports core.ErrReplayed when the nonce is already +// held, and any other error as unavailability, which the verifier refuses: a +// store that cannot answer must never admit a call. +// +// A reservation is never updated. Letting a second insert refresh retain_until +// would let a replay extend the window it is being refused for. +func (s *Store) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + if nonce == "" { + return errors.New("hop replay: a reservation needs a nonce") + } + tag, err := s.pool.Exec(ctx, `INSERT INTO hop_admissions (nonce, retain_until) + VALUES ($1, $2) ON CONFLICT (nonce) DO NOTHING`, nonce, retainUntil.UTC()) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return core.ErrReplayed + } + return nil +} + +// Collect removes reservations whose retention has passed, by the database's +// own clock, and answers how many it removed. Run it on a schedule: without it +// the table grows by one row per admitted call. +// +// Removing a reservation at its retention is safe because the verifier judges +// the admission window when a call arrives: a call carrying a nonce this +// removes would already be refused on its window, whatever the store answers. +func (s *Store) Collect(ctx context.Context) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now()`) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/libraries/hop/go/replaypg/replaypg_test.go b/libraries/hop/go/replaypg/replaypg_test.go new file mode 100644 index 00000000..13773618 --- /dev/null +++ b/libraries/hop/go/replaypg/replaypg_test.go @@ -0,0 +1,147 @@ +package replaypg + +import ( + "context" + "errors" + "testing" + "time" + + core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5/pgconn" +) + +// pool is a Postgres that holds one nonce per primary key, as the real table's +// ON CONFLICT DO NOTHING does: the first insert affects a row, a repeat none. +type pool struct { + held map[string]time.Time + fail error + sql []string + args [][]any +} + +func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + p.sql, p.args = append(p.sql, sql), append(p.args, args) + if p.fail != nil { + return pgconn.CommandTag{}, p.fail + } + if p.held == nil { + p.held = map[string]time.Time{} + } + if len(args) != 2 { + // The collection statement; remove what has aged out. + removed := 0 + for nonce, until := range p.held { + if until.Before(time.Now()) { + delete(p.held, nonce) + removed++ + } + } + return pgconn.NewCommandTag("DELETE 0 " + itoa(removed)), nil + } + nonce, until := args[0].(string), args[1].(time.Time) + if _, replayed := p.held[nonce]; replayed { + return pgconn.NewCommandTag("INSERT 0 0"), nil + } + p.held[nonce] = until + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var out []byte + for ; n > 0; n /= 10 { + out = append([]byte{byte('0' + n%10)}, out...) + } + return string(out) +} + +func TestAReservationIsHeldOnceAndAReplayIsRefused(t *testing.T) { + p := &pool{} + store, err := New(p) + if err != nil { + t.Fatal(err) + } + if !store.Shared() { + t.Fatal("a Postgres store is the shared reservation the contract requires") + } + until := time.Now().Add(32 * time.Second) + if err := store.Consume(context.Background(), "nonce", until); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "nonce", until); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a replay was not refused: %v", err) + } + // A different nonce is unaffected, and retention is stored in UTC. + if err := store.Consume(context.Background(), "other", until); err != nil { + t.Fatal(err) + } + if stored := p.args[0][1].(time.Time); stored.Location() != time.UTC || !stored.Equal(until) { + t.Fatalf("retention stored as %v", stored) + } + // A reservation is never refreshed: a replay cannot extend its own window. + if err := store.Consume(context.Background(), "nonce", until.Add(time.Hour)); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a replay was not refused: %v", err) + } + if held := p.held["nonce"]; !held.Equal(until) { + t.Fatalf("a refused replay moved the retention to %v", held) + } +} + +// A store that cannot answer must never admit a call: the error is neither +// swallowed nor reported as a replay, so hop.Verify refuses it as stamp_store. +func TestAnUnavailableStoreIsNeitherAdmissionNorReplay(t *testing.T) { + outage := errors.New("connection refused") + store, err := New(&pool{fail: outage}) + if err != nil { + t.Fatal(err) + } + err = store.Consume(context.Background(), "nonce", time.Now().Add(time.Minute)) + if !errors.Is(err, outage) || errors.Is(err, core.ErrReplayed) { + t.Fatalf("an outage must not read as a replay: %v", err) + } + if _, err := New(nil); err == nil { + t.Fatal("a store with no pool was accepted") + } + if err := store.Consume(context.Background(), "", time.Now()); err == nil { + t.Fatal("a reservation with no nonce was accepted") + } +} + +func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { + p := &pool{} + store, _ := New(p) + if err := store.Consume(context.Background(), "aged", time.Now().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "current", time.Now().Add(time.Minute)); err != nil { + t.Fatal(err) + } + removed, err := store.Collect(context.Background()) + if err != nil || removed != 1 { + t.Fatalf("collected %d: %v", removed, err) + } + if _, held := p.held["current"]; !held { + t.Fatal("collection removed a reservation still retained") + } +} + +// The migration is the module's own, and the primary key is what makes the +// reservation atomic across replicas. +func TestTheMigrationReservesTheNonceAsAPrimaryKey(t *testing.T) { + for _, want := range []string{"hop_admissions", "nonce TEXT PRIMARY KEY", "retain_until"} { + if !contains(Migration, want) { + t.Fatalf("the migration does not declare %q: %s", want, Migration) + } + } +} + +func contains(haystack, needle string) bool { + for n := 0; n+len(needle) <= len(haystack); n++ { + if haystack[n:n+len(needle)] == needle { + return true + } + } + return false +} diff --git a/libraries/hop/go/routing/routes.go b/libraries/hop/go/routing/routes.go index 7a5e4d6c..d21d3724 100644 --- a/libraries/hop/go/routing/routes.go +++ b/libraries/hop/go/routing/routes.go @@ -3,6 +3,7 @@ package routing import ( "encoding/json" + "errors" "fmt" "io" "net/url" @@ -107,6 +108,41 @@ func (r *Routing) Validate() error { } } } + return r.refuseTranscodedREST() +} + +// ErrTranscodedREST is the named refusal for a REST face a module transcodes +// into the listener its admission is checked on. The hop signs an HTTP route as +// "VERB /path", because that is what it decided and all a published http route +// tells it; a transcoded face hands the request to the same gRPC listener, +// whose verifier observes "/package.Service/Procedure". The two can never +// agree, so every REST call behind the hop would be refused as stamp_method +// after the authority had already admitted it. +// +// A module cannot work around it: verifying at the REST layer as well as the +// interceptor would consume the admission nonce twice and refuse as replayed, +// so exactly one layer may verify and the hop cannot tell which one will. +// Signing the procedure instead would need the published route to carry the +// template-to-procedure mapping its producer holds; the hop-routes schema +// carries no such field, so this is refused at startup rather than at every +// call. A module serving HTTP natively, with no gRPC or Connect face on the +// same service, is unaffected. +var ErrTranscodedREST = errors.New("hop routes: a REST face transcoded into a gRPC or Connect listener cannot be admitted behind the hop: the hop signs \"VERB /path\" for an http route while that listener's verifier observes the full procedure. Publish the module's HTTP face as its own service, or reach it through Connect") + +// refuseTranscodedREST refuses an http route published beside a gRPC or +// Connect route on the same listener's service: that is a transcoded REST face. +func (r *Routing) refuseTranscodedREST() error { + procedural := map[string]bool{} + for _, e := range r.Endpoints { + if e.Transport == "grpc" || e.Transport == "connect" { + procedural[e.Audience+"/"+e.Service] = true + } + } + for _, e := range r.Endpoints { + if e.Transport == "http" && procedural[e.Audience+"/"+e.Service] { + return fmt.Errorf("%w (%s/%s, endpoint %s)", ErrTranscodedREST, e.Audience, e.Service, e.Endpoint) + } + } return nil } diff --git a/libraries/hop/go/routing/routes_test.go b/libraries/hop/go/routing/routes_test.go index 26943b68..f1eb1b34 100644 --- a/libraries/hop/go/routing/routes_test.go +++ b/libraries/hop/go/routing/routes_test.go @@ -2,6 +2,7 @@ package routing import ( "encoding/json" + "errors" "strings" "testing" ) @@ -125,3 +126,40 @@ func TestConnectGRPCOverlapRefusesInEitherOrder(t *testing.T) { } } } + +// Review r164 B2: a go-grpc service with rest-endpoint: true transcodes its +// REST face into the same listener the hop's verifier guards, so the hop's +// "VERB /path" admission can never match the procedure that listener observes. +// Every REST call behind the hop was refused as stamp_method after the +// authority had admitted it. The composition is refused when the hop starts +// instead, naming the reason. +func TestARESTFaceTranscodedIntoAProceduralListenerIsRefusedAtStartup(t *testing.T) { + for _, beside := range []string{"grpc", "connect"} { + t.Run("beside "+beside, func(t *testing.T) { + routes := &Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{ + {Audience: "module", Service: "api", Endpoint: beside, Transport: beside, Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"/pkg.Service/Read"}}, + {Audience: "module", Service: "api", Endpoint: "rest", Transport: "http", Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"GET /read"}}, + }} + err := routes.Validate() + if !errors.Is(err, ErrTranscodedREST) { + t.Fatalf("want ErrTranscodedREST: %v", err) + } + if !strings.Contains(err.Error(), "module/api") { + t.Fatalf("the refusal names the listener: %v", err) + } + }) + } +} + +// A module serving HTTP natively keeps working: its http face is the only one +// on its service, so the verifier observes the same "VERB /path" the hop signs. +func TestANativeHTTPFaceIsAdmittedBesideOtherServicesProceduralFaces(t *testing.T) { + routes := &Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{ + {Audience: "module", Service: "web", Endpoint: "http", Transport: "http", Address: "a", Package: "p", Version: "1", Digest: "d", Methods: []string{"GET /read"}}, + // Another service of the same module, procedural, is no transcoding. + {Audience: "module", Service: "api", Endpoint: "grpc", Transport: "grpc", Address: "b", Package: "p", Version: "1", Digest: "d", Methods: []string{"/pkg.Service/Read"}}, + }} + if err := routes.Validate(); err != nil { + t.Fatalf("a native HTTP face was refused: %v", err) + } +} diff --git a/modules/authority/services/hop/code/go.sum b/modules/authority/services/hop/code/go.sum index d6554232..14afea15 100644 --- a/modules/authority/services/hop/code/go.sum +++ b/modules/authority/services/hop/code/go.sum @@ -137,6 +137,12 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 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= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 0320f23d..510ca3ab 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -56,7 +56,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // 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 diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index ad076d5f..2b14433c 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,8 +36,6 @@ 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-20261026121322-4b1c57a5a4c2 h1:YNhd/AhURL/exsQo7IBdgvn6M1C01JcJuDXKr2v2dHk= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026121322-4b1c57a5a4c2/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -48,7 +46,6 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -186,8 +183,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= diff --git a/modules/console/module.codefly.yaml b/modules/console/module.codefly.yaml index e91b00d1..79f921b1 100644 --- a/modules/console/module.codefly.yaml +++ b/modules/console/module.codefly.yaml @@ -2,6 +2,9 @@ kind: module name: console services: - name: api + # The console's own Postgres, holding the hop admission reservations its + # verifier consumes. Private: the module interface does not export it. + - name: store # The console's API is what a person's page calls; exported so the composition # routes it and the package manifest names it. interface: diff --git a/modules/console/services/api/code/go.mod b/modules/console/services/api/code/go.mod index 2a373136..414b6f62 100644 --- a/modules/console/services/api/code/go.mod +++ b/modules/console/services/api/code/go.mod @@ -11,6 +11,7 @@ require ( github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 + github.com/jackc/pgx/v5 v5.11.0 github.com/rs/cors v1.11.1 golang.org/x/net v0.60.0 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b @@ -44,12 +45,16 @@ require ( 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/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.23.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 diff --git a/modules/console/services/api/code/go.sum b/modules/console/services/api/code/go.sum index d6554232..c3682d41 100644 --- a/modules/console/services/api/code/go.sum +++ b/modules/console/services/api/code/go.sum @@ -46,6 +46,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -137,6 +138,18 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -162,10 +175,13 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= @@ -174,6 +190,9 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= @@ -208,6 +227,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ 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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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= @@ -224,10 +245,12 @@ 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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= diff --git a/modules/console/services/api/code/store_test.go b/modules/console/services/api/code/store_test.go new file mode 100644 index 00000000..322b1493 --- /dev/null +++ b/modules/console/services/api/code/store_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" +) + +// The console's store carries the table the adapter writes to. Two copies of +// one DDL drift, so this is the one that fails when they do: the migration is +// what the store runs, replaypg.Migration is what a module without a migration +// pipeline embeds, and they must stay the same table. +func TestTheStoreMigrationCarriesTheAdaptersTable(t *testing.T) { + const path = "../../store/migrations/1_hop_admissions.up.sql" + migration, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(migration), replaypg.Migration) { + t.Fatalf("%s no longer carries replaypg.Migration:\n%s", path, replaypg.Migration) + } + // The verifier reserves, reads back and collects; it never updates, because + // a refreshed reservation would let a replay extend its own window. + grant := "GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console;" + if !strings.Contains(string(migration), grant) { + t.Fatalf("%s does not grant exactly %q", path, grant) + } + if strings.Contains(string(migration), "UPDATE ON hop_admissions") { + t.Fatalf("%s grants UPDATE on the reservations", path) + } +} diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index 0049edc6..447e37d4 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -3,11 +3,16 @@ package main import ( "context" "fmt" + "strings" + "time" 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" + "github.com/codefly-dev/authority/libraries/hop/go/replaypg" + "github.com/codefly-dev/core/wool" codefly "github.com/codefly-dev/sdk-go" + "github.com/jackc/pgx/v5/pgxpool" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -30,34 +35,91 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl if err != nil || publicKey == "" { return nil, fmt.Errorf("console: no hop verification public key delivered: %v", err) } - directory, err := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "REPLAY_DIRECTORY") - if err != nil { - return nil, fmt.Errorf("console: replay directory not delivered: %w", err) - } - replay, err := hop.NewFileReplayStore(directory) + replay, closeReplay, err := reservations(ctx) if err != nil { return nil, err } audience, err := hop.Audience() if err != nil { + closeReplay() return nil, fmt.Errorf("console: %w", err) } conn, _, err := host.Dial(ctx) if err != nil { + closeReplay() return nil, fmt.Errorf("console: %w", err) } // It starts whether or not the authority serves its keys yet, and refuses - // calls with the reason until it does. + // calls with the reason until it does. Outside a local run it also refuses + // a replay store that is not the one every replica shares. verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) if err != nil { _ = conn.Close() + closeReplay() return nil, fmt.Errorf("console: %w", err) } config.GRPCServerOptions = append(config.GRPCServerOptions, + // TapHandle records when each call arrived, so the admission window is + // judged at call start rather than after a request body has decoded. + grpc.InTapHandle(verifier.TapHandle()), grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) config.Service = &console{} - return func() { _ = conn.Close() }, nil + return func() { _ = conn.Close(); closeReplay() }, nil +} + +// collectEvery is how often aged-out reservations are removed. Retention is the +// admission's expiry plus the clock allowance, so anything this removes would +// already be refused on its window. +const collectEvery = time.Minute + +// reservations opens the console's hop admission reservations in its own store. +// The store is composed (services/store), so a run starts it first and delivers +// its read-write connection here. There is no in-memory fallback: a console +// that forgot an admission would admit a captured one again, so a composed +// store that does not answer is refused rather than fallen back from, and a run +// that composes no store is refused too. +func reservations(ctx context.Context) (*replaypg.Store, func(), error) { + w := wool.Get(ctx).In("console") + connection, err := codefly.For(ctx).Service("store").Secret("postgres", "read-write-connection") + if err != nil || strings.TrimSpace(connection) == "" { + return nil, nil, fmt.Errorf("console: the hop admission reservations need this module's store: %v", err) + } + pool, err := pgxpool.New(ctx, connection) + if err != nil { + return nil, nil, fmt.Errorf("console: the store's read-write connection: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, nil, fmt.Errorf("console: the store is composed but does not answer: %w", err) + } + store, err := replaypg.New(pool) + if err != nil { + pool.Close() + return nil, nil, fmt.Errorf("console: %w", err) + } + collecting, stop := context.WithCancel(context.Background()) + go collect(collecting, store, w) + w.Info("hop admissions are reserved in the console's store") + return store, func() { stop(); pool.Close() }, nil +} + +// collect removes aged-out reservations until the process ends. A failure is +// logged and retried: collection is housekeeping, and a console that cannot +// collect still refuses replays. +func collect(ctx context.Context, store *replaypg.Store, w *wool.Wool) { + ticker := time.NewTicker(collectEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := store.Collect(ctx); err != nil && ctx.Err() == nil { + w.Warn("hop admission reservations were not collected", wool.ErrField(err)) + } + } + } } type console struct { diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index 286aea34..33b93934 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -30,12 +30,23 @@ spec: rest-endpoint: false runtime-assets: [] # The console reaches the authority through the interface it depends on, never a -# service: the composition resolves which implementation answers. +# service: the composition resolves which implementation answers. Its own store +# it names directly: the hop admission reservations its verifier consumes must +# survive a restart and be shared by every replica, so a run starts the store +# first and delivers its read-write connection here (code/work.go reservations). service-dependencies: - interface: codefly.dev/authority@>=0.2.0 <0.4.0 + - name: store + module: console + endpoints: + - name: tcp # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true -# Public signing key and durable shared replay volume; no signing secret. +# The hop's public verification key, and nothing else: no signing secret, and no +# replay volume (the reservations live in this module's store). PUBLIC_KEY has +# no producer yet — see libraries/hop/README.md, "Delivering the verification +# key" — so this run is configured with it by hand until the authority +# publishes it. workspace-configuration-dependencies: - hop-verification diff --git a/modules/console/services/store/.gitignore b/modules/console/services/store/.gitignore new file mode 100644 index 00000000..f1e3ec89 --- /dev/null +++ b/modules/console/services/store/.gitignore @@ -0,0 +1,17 @@ +# runtime-access.sql is re-rendered into the service directory on every +# `codefly build` — it is the bootstrap image's build context, generated and +# never hand-edited. Keep it out of version control. +/runtime-access.sql + +# bootstrap.sql is generated beside it and runs the migration under the +# runtime-access lock before including runtime-access.sql. +/bootstrap.sql + +# Postgres credentials. The `local` profile's postgres.secret.env is COMMITTED on +# purpose, as the obin modules' stores commit theirs: its values are throwaway, +# and a workspace composing this module resolves it into a read-only cache it +# cannot write a secret into, so the file travels with the module or the store +# never boots. The repository root ignores every *.secret.env under a +# configurations directory; exactly the local file is let back through here. +# Every other profile keeps its credentials in the provider's secret store. +!/configurations/local/postgres.secret.env diff --git a/modules/console/services/store/README.md b/modules/console/services/store/README.md new file mode 100644 index 00000000..9237ff7e --- /dev/null +++ b/modules/console/services/store/README.md @@ -0,0 +1,29 @@ +# store — the console's hop admission reservations + +One table, `hop_admissions`, holding the nonce of every call the hop admitted +to the console. The console's verifier reserves a nonce before it enters a +handler, so a captured admission is refused the second time it is presented. + +It is a store and not a set in the process because the guarantee has to hold +across every replica of the console and across a restart. The admission +contract asked for "a durable shared replay volume" instead, which Codefly does +not deliver: in practice that meant an absolute path to per-pod storage, which +looks identical to a shared mount and silently becomes a per-replica store, +letting a captured admission replay once per replica inside its window +(security review r164, B1). A module that has a store has somewhere atomic to +put a nonce, so the console composes one. + +`INSERT ... ON CONFLICT (nonce) DO NOTHING` is the reservation: Postgres decides +the one winner among concurrent inserts, whichever replica they reach. +`libraries/hop/go/replaypg` is the adapter, and `replaypg.Migration` is the +table this service's migration carries; `code/store_test.go` asserts the two +have not drifted. + +Reservations are retained to the admission's expiry plus the clock allowance and +no longer, so the table does not grow without bound: the console runs +`replaypg.Store.Collect` on a schedule. Removing a reservation at its retention +is safe because the verifier judges the admission window when a call arrives, so +a call carrying a collected nonce would already be refused on its window. + +Its one reader is this module's `api`. The endpoint is private and the module +interface does not export it. diff --git a/modules/console/services/store/configurations/local/README.md b/modules/console/services/store/configurations/local/README.md new file mode 100644 index 00000000..2f7cf433 --- /dev/null +++ b/modules/console/services/store/configurations/local/README.md @@ -0,0 +1,29 @@ +# store — local configuration + +`postgres.secret.env` here is **committed on purpose**. It holds the throwaway +credentials the `local` profile boots the `store` Postgres service with: an +owner login and two role passwords that protect nothing and must never be +reused anywhere. + +It is committed because a composing workspace cannot supply it: Codefly reads a +service's configuration from the service's own directory, and a workspace that +composes this module by tag resolves it into a read-only module cache that is +the published tree byte for byte. The repository root ignores every +`*.secret.env` under a configurations directory; this service's `.gitignore` +lets exactly this file back through. + +The file also carries the **key names** a render needs: `codefly deploy gitops +render` promotes each key into a typed Kubernetes Secret reference, and the +postgres agent refuses to render without one for each. Only the names reach a +rendered manifest; the values never do. **No hosted profile ever ships one**: +every other profile's credentials live in the provider's secret store, +generated there. + +The keys the postgres agent reads: + +- `POSTGRES_USER` — the owner / migrator role +- `POSTGRES_PASSWORD` — owner password +- `POSTGRES_READ_ONLY_PASSWORD` — read-only principal password +- `POSTGRES_READ_WRITE_PASSWORD` — read-write principal password; the agent + exports its connection as the `postgres/read-write-connection` secret the + local authority reads diff --git a/modules/console/services/store/configurations/local/postgres.secret.env b/modules/console/services/store/configurations/local/postgres.secret.env new file mode 100644 index 00000000..2bf598a0 --- /dev/null +++ b/modules/console/services/store/configurations/local/postgres.secret.env @@ -0,0 +1,4 @@ +POSTGRES_USER=user +POSTGRES_PASSWORD=local-owner-password-change-me +POSTGRES_READ_ONLY_PASSWORD=local-read-only-password-change-me +POSTGRES_READ_WRITE_PASSWORD=local-read-write-password-change-me diff --git a/modules/console/services/store/migrations/1_hop_admissions.down.sql b/modules/console/services/store/migrations/1_hop_admissions.down.sql new file mode 100644 index 00000000..82dfc4be --- /dev/null +++ b/modules/console/services/store/migrations/1_hop_admissions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS hop_admissions; diff --git a/modules/console/services/store/migrations/1_hop_admissions.up.sql b/modules/console/services/store/migrations/1_hop_admissions.up.sql new file mode 100644 index 00000000..4c8108e7 --- /dev/null +++ b/modules/console/services/store/migrations/1_hop_admissions.up.sql @@ -0,0 +1,38 @@ +-- Hop admission reservations: one row per admitted call, keyed by the nonce the +-- hop signed into its admission. The verifier consumes a nonce before it enters +-- a handler, so a captured admission is refused the second time it is +-- presented. This has to hold across every replica of the console and across a +-- restart, which is why it is a table and not a set in the process. +-- +-- The runtime read-write role first: service.codefly.yaml declares +-- `runtime-read-write-roles: [app_console]`, the postgres agent reconciles its +-- managed read-write principal into it on every boot and refuses to boot while +-- the role is absent. Idempotent: created only when absent. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_console') THEN + CREATE ROLE app_console NOINHERIT; + END IF; +END $$; + +GRANT USAGE ON SCHEMA public TO app_console; + +-- The migration owner may SET ROLE app_console, so a test runs under exactly +-- the privileges the console has. +DO $$ +BEGIN + EXECUTE format('GRANT app_console TO %I', current_user); +END $$; + +-- The table is exactly libraries/hop/go/replaypg.Migration; the console's test +-- asserts this file still carries it, so the two cannot drift. The primary key +-- is the reservation: Postgres decides one winner among concurrent inserts. +CREATE TABLE IF NOT EXISTS hop_admissions ( + nonce TEXT PRIMARY KEY, + retain_until TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until); + +-- Exactly what the verifier does: reserve, read back, and collect what has +-- aged out. No UPDATE: a reservation is never refreshed. +GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console; diff --git a/modules/console/services/store/service.codefly.yaml b/modules/console/services/store/service.codefly.yaml new file mode 100644 index 00000000..227394a1 --- /dev/null +++ b/modules/console/services/store/service.codefly.yaml @@ -0,0 +1,49 @@ +name: store +version: 0.0.0 +description: | + The console's own Postgres, holding one thing: the hop admission reservations + its verifier consumes. An admission is single-use, and that has to hold across + every replica of the console and across a restart, so the reservation cannot + live in the process. It used to be asked of a "shared replay volume" the + platform does not deliver, which in practice meant a per-pod directory and a + captured admission replaying once per replica. A row here is the reservation, + and Postgres decides the one winner among concurrent inserts. +agent: + kind: codefly:service + name: postgres + version: 0.0.144 + publisher: codefly.dev +endpoints: + - name: tcp + # Only this module's api reaches the store, and the module interface does + # not export it. In-system (no `location`): the run allocates its address. + visibility: private +spec: + auth-mode: "" + database-name: console + docker-image: "" + docker-image-platforms: [] + extensions: [] + external-instances: {} + hot-reload: true + keep-running: false + log-level: "" + migration-sources: [] + no-migration: false + runtime-logins: [] + # The agent reconciles its managed read-write principal into this role on + # every boot; migrations/1_hop_admissions.up.sql creates it and grants + # exactly what the verifier does: reserve a nonce, read it back, and remove + # one whose retention has passed. Never update: letting a second insert + # refresh a reservation would let a replay extend its own window. + runtime-read-write-roles: + - app_console + runtime-schemas: [] + timeouts: + bootstrap-job-seconds: 0 + bootstrap-readiness-seconds: 0 + connect-seconds: 0 + migration-lock-seconds: 0 + migration-statement-seconds: 0 + readiness-seconds: 0 + without-ssl: false diff --git a/playground/configurations/README.md b/playground/configurations/README.md index e23af740..e21e6ef9 100644 --- a/playground/configurations/README.md +++ b/playground/configurations/README.md @@ -13,6 +13,20 @@ codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN ``` -Deliver the derived public key and shared replay volume as described in the -[hop admission migration](../../libraries/hop/README.md). The old shared PROOF -and warning mode cannot authorize calls. +`hop-verification.env` carries the public half of that seed, which every module +behind the hop checks admissions with. It is committed (a public key) but it is +**written by hand**: nothing derives it from the seed, so the playground sets it +after generating the seed, as `hop-verification.env` and the +[hop admission migration](../../libraries/hop/README.md) describe. That is the +open half of security review r164 B1; the sound producer is the authority +publishing the hop's admission key through its own Keys path, which needs an +`interfaces/authority` change proposed in the handbook first. + +No replay volume is configured here any more. Admission reservations live in the +console's own Postgres (`modules/console/services/store`), because Codefly +delivers a store and does not deliver a shared volume; an absolute path to +per-pod storage looks identical to a shared mount and silently becomes a +per-replica store. Outside a local run the verifier now refuses a store that +does not declare itself shared. + +The old shared PROOF and warning mode cannot authorize calls. diff --git a/playground/configurations/local/hop-verification.env b/playground/configurations/local/hop-verification.env new file mode 100644 index 00000000..9684628c --- /dev/null +++ b/playground/configurations/local/hop-verification.env @@ -0,0 +1,18 @@ +# What a module behind the hop needs to check an admission: the public half of +# the hop's signing key. Public by design — a verifier must be able to hold it — +# so it is committed here with the playground's other local values. +# +# It is NOT generated, and that is the open half of security review r164 B1. +# `codefly config generate hop-signing SEED --service authority/hop` makes the +# hop's seed and keeps it inside Codefly; nothing derives the public half from +# it, so this value is written by hand for the playground and the two can drift +# silently (a wrong key refuses every call as stamp_proof). The sound producer +# is the authority publishing the hop's admission key through its own Keys path, +# which the verifier already trusts and already caches — an interfaces/authority +# change, proposed in the handbook before it is built. +# +# Until then: after generating the seed, derive its public half and set this key. +# libraries/hop/README.md, "Delivering the verification key", has the one +# command. The reservations are no longer configured here at all: they live in +# the console's own store (modules/console/services/store). +PUBLIC_KEY= From 2a8cb592ec3136952edc7a8756ca3cbdaa001844 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:01:46 -0400 Subject: [PATCH 07/19] Pin admission consumers to the review-fix client revision libraries/hop/go, libraries/conformance/go and the local authority resolve authority-client from the proxy, so they must name the revision carrying stamp.Skew, the stamp_issued and stamp_* admission reasons, and the single AuthenticateToken reader: 3d0a11c. Every module is tidied and the single core pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged. Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 2 ++ libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 2 ++ modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 2 ++ 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 044d94c5..c0b5839e 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-20261026121322-4b1c57a5a4c2 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 0548861b..8b6357b4 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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= 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.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 73821811..151ee94f 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-20261026121322-4b1c57a5a4c2 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/jackc/pgx/v5 v5.11.0 google.golang.org/grpc v1.84.0 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 611099b6..7b216019 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 510ca3ab..749f005d 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -31,7 +31,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-20261026121322-4b1c57a5a4c2 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4 // 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 2b14433c..8a2e2341 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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 8a3e953b678af331c03d1d925c1614cad20fd371 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:05:41 -0400 Subject: [PATCH 08/19] Say in hoptest that the memory store is refused outside local The comment claimed single-process use by convention; NewVerifier now enforces it, so the comment says which. Co-Authored-By: Claude Opus 5.5 --- libraries/hop/go/hoptest/hoptest.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/hop/go/hoptest/hoptest.go b/libraries/hop/go/hoptest/hoptest.go index 58491ce4..0e4ef566 100644 --- a/libraries/hop/go/hoptest/hoptest.go +++ b/libraries/hop/go/hoptest/hoptest.go @@ -112,4 +112,6 @@ func Signer(label string) *stamp.Signer { func PublicKey(label string) string { return Signer(label).PublicKey() } // Replay is for single-process tests only; deployments supply a shared store. +// It declares no Shared(), so hop.NewVerifier refuses it outside a local run +// rather than leaving that to this comment. func Replay() core.ReplayStore { return core.NewMemoryReplayStore() } From 073a40e79228cc0e2abea8fce197ba09f1a3b523 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:13:43 -0400 Subject: [PATCH 09/19] Consume a single-use admission exactly once: reserve at arrival, hold while the call runs The follow-up security review of #28 at 8a3e953b reproduced, over the real grpc-go transport and a real Postgres, one single-use admission running its handler twice: two deliveries arrive inside the window, the first runs, the second is held before its body is decoded, collection removes the reservation after expiry plus skew, and the held delivery then consumes the nonce afresh. The tap handle only recorded the arrival instant; the nonce was consumed at body decode, and collection could drop a reservation a call still needed. Go: TapHandle now starts the admission check and the reservation when the stream is accepted (beside the call, not on the connection's I/O goroutine), and the interceptors await it and hold the reservation until the handler returns; a call cancelled before its handler releases its hold. A new hop.ReservationStore (Reserve/Finish) keeps a reservation until Finish AND expiry plus skew; replaypg implements it with an active column that Collect never removes, and a Reserve conditioned on the retention not having passed by the database's clock, so a delayed delivery can never revive a collected nonce (stamp_window). A store with only Consume keeps a nonce to its retention and a consumption that completes past it is refused on the window. Verify releases its hold before returning; Admit is Verify for an HTTP module that runs the call's work itself and holds the reservation until done. Python and TypeScript reserve before the key read with the same store protocol (reserve/finish, Expired), hold it through admit / admit_async, and now refuse at construction, outside the environment named exactly "local", a store that does not declare itself shared (replay_store_not_shared), as Go does. Go's local check now matches exactly "local": an unset or empty environment, or any other local-prefixed name, is refused. The console's migration adds active and grants UPDATE on that column only. CI starts the runner's Postgres for libraries/hop/go so the transport regressions run there. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/go.yml | 9 + libraries/authority-client/python/README.md | 7 +- .../python/authority_client/work_context.py | 2 + .../python/codefly_hop/__init__.py | 166 ++++++++--- .../python/codefly_hop/call.py | 24 ++ .../authority-client/python/tests/conftest.py | 5 + .../authority-client/python/tests/test_hop.py | 158 +++++++++- .../authority-client/typescript/README.md | 9 +- .../authority-client/typescript/hop-call.ts | 19 +- .../authority-client/typescript/refusal.ts | 2 + .../typescript/tests/work-context.test.mjs | 100 ++++++- .../typescript/work-context.ts | 101 +++++-- libraries/hop/README.md | 66 +++-- libraries/hop/go/README.md | 11 +- libraries/hop/go/admission.go | 176 +++++++++++ libraries/hop/go/arrival_test.go | 24 ++ libraries/hop/go/durable_test.go | 9 +- libraries/hop/go/go.mod | 2 + libraries/hop/go/hop.go | 152 +++++----- libraries/hop/go/main_test.go | 14 + libraries/hop/go/replaypg/replaypg.go | 58 +++- libraries/hop/go/replaypg/replaypg_test.go | 96 +++++- libraries/hop/go/transport_postgres_test.go | 277 ++++++++++++++++++ modules/authority/services/hop/code/go.sum | 4 + .../pkg/infra/proxy/environment_proxy_test.go | 3 + .../console/services/api/code/store_test.go | 17 +- modules/console/services/api/code/work.go | 12 +- modules/console/services/store/README.md | 19 +- .../store/migrations/1_hop_admissions.up.sql | 18 +- 29 files changed, 1353 insertions(+), 207 deletions(-) create mode 100644 libraries/authority-client/python/tests/conftest.py create mode 100644 libraries/hop/go/admission.go create mode 100644 libraries/hop/go/main_test.go create mode 100644 libraries/hop/go/transport_postgres_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d94ffc49..a33f2d19 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -50,6 +50,15 @@ jobs: with: go-version-file: ${{ matrix.module }}/go.mod cache-dependency-path: ${{ matrix.module }}/go.sum + # The hop library's single-use admission regressions run over the real + # transport and a real Postgres: the runner's own, started here, reached + # over its socket as the runner's user, with no password written down. + - name: start Postgres for the hop admission regressions + if: matrix.module == 'libraries/hop/go' + run: | + sudo systemctl start postgresql.service + sudo -u postgres createuser --superuser "$USER" + echo "HOP_TEST_POSTGRES=host=/var/run/postgresql dbname=postgres" >> "$GITHUB_ENV" - name: go vet run: go vet ./... - name: go test -race diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index f74b7a53..a8786dc3 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -22,11 +22,16 @@ from codefly_hop import Unauthenticated, Verifier verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], public_key=public_key, replay=shared_replay_store) # once identity = verifier.verify(request.headers, full_procedure) # a sync handler identity = await verifier.verify_async(request.headers, full_procedure) # an asyncio handler: a key wait runs off the loop +with verifier.admit(request.headers, full_procedure) as identity: ... # holds the single-use reservation while the handler runs +async with verifier.admit_async(request.headers, full_procedure) as identity: ... identity.tenant, identity.has(kind, action), identity.claims.allows(kind, action, resource_id) ``` The verifier requires the [signed admission protocol](../../hop/README.md), -including an atomic durable replay store shared across replicas. It checks the stamp first, then reads the published keys through `Keys` +including an atomic durable replay store shared across replicas, and refuses at +construction, outside the run named exactly `local`, a store whose `shared` is +not true (`replay_store_not_shared`). It checks the stamp first and reserves its +single-use nonce, 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, and a call on keys older than diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 4b6affc8..9ee83e64 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -84,6 +84,7 @@ class Reason(str, Enum): STAMPADVISORY = "stamp_advisory" STAMPREPLAYED = "stamp_replayed" STAMPSTORE = "stamp_store" + STORENOTSHARED = "replay_store_not_shared" KEYSUNAVAILABLE = "keys_unavailable" CANCELLED = "cancelled" MALFORMED = "malformed" @@ -112,6 +113,7 @@ class Reason(str, Enum): Reason.STAMPISSUED: 'the hop admission is issued further ahead than the clock allowance', Reason.STAMPADVISORY: 'an advisory policy refusal cannot admit a call', Reason.STAMPREPLAYED: 'the hop admission has already been used', + Reason.STORENOTSHARED: "the admission store must be durable and shared outside local", Reason.STAMPSTORE: 'the hop admission store is unavailable', Reason.KEYSUNAVAILABLE: "the authority's published keys are unavailable", Reason.CANCELLED: "the call ended while the authority's keys were being read", diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index c3f7fff0..3f6828b4 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -9,20 +9,22 @@ import asyncio import base64 +import os import re import threading import time from collections.abc import Callable, Iterable, Mapping from concurrent import futures +from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone -from .call import SKEW, Replayed, ReplayStore, check +from .call import SKEW, Expired, Replayed, ReplayStore, ReservationStore, check 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, Reason, authenticate_token, bound_token, collect -__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of", "ReplayStore", "Replayed"] +__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of", "ReplayStore", "ReservationStore", "Replayed", "Expired"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -90,6 +92,26 @@ def identity_of(context) -> Identity: ) +def _store_refusal(error: Exception) -> Unauthenticated: + """The refusal a failed reservation answers: never the backend's error.""" + if isinstance(error, Unauthenticated): + return error + if isinstance(error, Replayed): + return Unauthenticated(Reason.STAMPREPLAYED) + if isinstance(error, Expired): + return Unauthenticated(Reason.STAMPWINDOW) + return Unauthenticated(Reason.STAMPSTORE) + + +def _within(until: int, arrival: float, mark: float) -> None: + """A store without ``reserve`` keeps a nonce to its retention and may then + evict it. A consumption that only succeeded because that retention had + passed found an evicted nonce, not a new one: it is refused on the window, + so a second delivery never runs.""" + if arrival + (time.monotonic() - mark) >= until: + raise Unauthenticated(Reason.STAMPWINDOW) + + class Verifier: """The one module-side entry point: authenticates every call the hop forwards. @@ -108,6 +130,11 @@ def __init__(self, keys, *, audience: str, public_key: str | None = None, replay # Admission needs both the delivered hop public key and a store # every replica shares; one without the other admits nothing. raise Unauthenticated(Reason.CONFIGURATION) + if admitting and os.environ.get("CODEFLY__ENVIRONMENT") != "local" and getattr(replay, "shared", False) is not True: + # A process-local store answers only for its own process: a captured + # admission would replay once per replica, and a restart would + # forget every reservation. Only the run named exactly "local" may. + raise Unauthenticated(Reason.STORENOTSHARED) self._source, self._audience, self._timeout = keys, audience, timeout self._public_key, self._replay = public_key, replay self._now = now or time.time @@ -139,36 +166,112 @@ def ready(self) -> bool: def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """Who the call is for, or ``Unauthenticated`` naming why it is refused. The admission is checked first: nothing that is not signed for this exact - method and audience reaches the key read. It may wait up to the read's - timeout for a key it does not hold yet, or for keys past the stale - window; an asyncio handler uses ``verify_async``.""" - pairs, token, call = self._bound(headers, method) - wait = self._start(_key_id(token)) - if wait is not None: - futures.wait((wait,), self._timeout) - return self._finish(pairs, token, call) + method and audience reaches the key read, and its single-use nonce is + reserved before the keys are read. It may wait up to the read's timeout + for a key it does not hold yet, or for keys past the stale window; an + asyncio handler uses ``verify_async``. + + The reservation's in-flight hold ends when ``verify`` returns, so it + lasts to the admission's expiry plus the clock allowance. A handler + whose work follows uses ``admit``, which holds it until that work ends.""" + identity, finish = self._admit(headers, method) + finish() + return identity + + @contextmanager + def admit(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str): + """``verify`` that holds the single-use reservation until the block + exits, however it exits. Enter it as soon as the call's headers have + arrived, before its body is read:: + + with verifier.admit(metadata, method) as identity: + ... # the handler + """ + identity, finish = self._admit(headers, method) + try: + yield identity + finally: + finish() async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """``verify`` for an asyncio handler: a wait for the keys is awaited on the event loop and holds no thread, so any number of waiting calls leave the loop's executor free; a call whose key is held never waits.""" + identity, finish = await self._admit_async(headers, method) + await finish() + return identity + + @asynccontextmanager + async def admit_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str): + """``admit`` for an asyncio handler.""" + identity, finish = await self._admit_async(headers, method) + try: + yield identity + finally: + await finish() + + def _admit(self, headers, method): pairs, token, call = self._bound(headers, method) - wait = self._start(_key_id(token)) - if wait is not None: - try: - await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) - except asyncio.TimeoutError: - pass # judged below on the keys held: stale ones are refused with the reason - identity = self._finish(pairs, token, call, consume=False) + arrival, mark = self._now(), time.monotonic() + managed = callable(getattr(self._replay, "reserve", None)) + until = call.expires + SKEW + try: + if managed: + self._replay.reserve(call.nonce, until) + else: + self._replay.consume(call.nonce, until) + except Exception as error: + raise _store_refusal(error) from error + if not managed: + _within(until, arrival, mark) + + def finish(): + if managed: + try: + self._replay.finish(call.nonce) + except Exception: + pass # a failed release keeps the reservation held: never reopen try: - await self._replay.consume_async(call.nonce, call.expires + SKEW) - except Replayed as error: - raise Unauthenticated(Reason.STAMPREPLAYED) from error - except Unauthenticated: + wait = self._start(_key_id(token)) + if wait is not None: + futures.wait((wait,), self._timeout) + return self._finish(pairs, token), finish + except BaseException: + finish() raise + + async def _admit_async(self, headers, method): + pairs, token, call = self._bound(headers, method) + arrival, mark = self._now(), time.monotonic() + managed = callable(getattr(self._replay, "reserve_async", None)) + until = call.expires + SKEW + try: + if managed: + await self._replay.reserve_async(call.nonce, until) + else: + await self._replay.consume_async(call.nonce, until) except Exception as error: - raise Unauthenticated(Reason.STAMPSTORE) from error - return identity + raise _store_refusal(error) from error + if not managed: + _within(until, arrival, mark) + + async def finish(): + if managed: + try: + await self._replay.finish_async(call.nonce) + except Exception: + pass # a failed release keeps the reservation held: never reopen + try: + wait = self._start(_key_id(token)) + if wait is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) + except asyncio.TimeoutError: + pass # judged below on the keys held: stale ones are refused with the reason + return self._finish(pairs, token), finish + except BaseException: + await finish() + raise def verify_token(self, token: str) -> Identity: """Authenticate a bare token with the same fail-closed key cache. It @@ -223,7 +326,7 @@ def _token_identity(self, token: str) -> Identity: identity = identity_of(claims.context) return Identity(**{**identity.__dict__, "context": token, "claims": claims}) - def _finish(self, pairs: list[tuple[str, str]], token: str, call, consume=True) -> Identity: + def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: identity = self._token_identity(token) values = collect(pairs) stamped = { @@ -236,21 +339,6 @@ def _finish(self, pairs: list[tuple[str, str]], token: str, call, consume=True) got = values.get(key, []) if len(got) > 1 or (got[0] if got else "") != want: raise Unauthenticated(Reason.STAMPIDENTITY) - # ``check`` judged the admission window when the call started, which in - # Python is before the request is read; the window bounds a call's - # start, so it is judged once and not again here. The reservation is - # retained to expiry plus the allowance and no longer: a replay can - # only be admitted if it too arrives inside the window, and inside the - # window the nonce is still retained. - if consume: - try: - self._replay.consume(call.nonce, call.expires + SKEW) - except Replayed as error: - raise Unauthenticated(Reason.STAMPREPLAYED) from error - except Unauthenticated: - raise - except Exception as error: - raise Unauthenticated(Reason.STAMPSTORE) from error # An admission carrying an advisory refusal is refused by ``check``, so # an admitted call never has one: diagnostic identity is never authority. return Identity(**{**identity.__dict__, "context": token, "advisory": "", "claims": identity.claims}) diff --git a/libraries/authority-client/python/codefly_hop/call.py b/libraries/authority-client/python/codefly_hop/call.py index 416a1759..550bb293 100644 --- a/libraries/authority-client/python/codefly_hop/call.py +++ b/libraries/authority-client/python/codefly_hop/call.py @@ -15,6 +15,8 @@ class Replayed(Exception): class ReplayStore(Protocol): + shared: bool # true only for durable storage shared by every replica + def consume(self, nonce: str, retain_until: int) -> None: """Atomically reserve nonce until Unix seconds; raise Replayed on reuse, another exception on unavailability. @@ -92,3 +94,25 @@ def segment(value): if "x-codefly-hop-advisory" in values: raise Unauthenticated(Reason.STAMPADVISORY) return Call("codefly-hop-v1:" + public_key + ":" + nonce, expires) + + +class Expired(Exception): + """The reservation's retention had already passed when the store decided.""" + + +class ReservationStore(ReplayStore, Protocol): + """A store that holds a single-use admission while the call carrying it runs. + + The verifier reserves when the call arrives and finishes when its handler + has returned; the store keeps the reservation until ``finish`` AND + ``retain_until`` have both passed. ``reserve`` refuses atomically, across + every replica, a nonce already held (``Replayed``), including one held by a + call still running past its retention, and refuses a reservation whose + ``retain_until`` has already passed by the store's clock (``Expired``): a + reservation delayed past its own retention must never revive a nonce that + was collected. A ``finish`` that fails leaves the reservation held. + """ + def reserve(self, nonce: str, retain_until: int) -> None: ... + def finish(self, nonce: str) -> None: ... + async def reserve_async(self, nonce: str, retain_until: int) -> None: ... + async def finish_async(self, nonce: str) -> None: ... diff --git a/libraries/authority-client/python/tests/conftest.py b/libraries/authority-client/python/tests/conftest.py new file mode 100644 index 00000000..64a9abbc --- /dev/null +++ b/libraries/authority-client/python/tests/conftest.py @@ -0,0 +1,5 @@ +import pytest + +@pytest.fixture(autouse=True) +def local_environment(monkeypatch): + monkeypatch.setenv("CODEFLY__ENVIRONMENT", "local") diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 97b4c83c..87513f64 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -14,7 +14,7 @@ from codefly_hop.call import SKEW from codefly_hop import ( ACTORS_KEY, CONTEXT_KEY, KEY_RETRY, KEY_STALE, SCOPES_KEY, TENANT_KEY, WORK_CONTEXT_KEY, Identity, Unauthenticated, - Verifier, Replayed, identity_of, + Verifier, Replayed, Expired, identity_of, ) FIXTURE = json.loads((Path(__file__).parents[3] / "hop/testdata/stamped.json").read_text()) @@ -543,3 +543,159 @@ def test_invalid_publication_and_unreadable_keys_have_distinct_safe_codes(): v.verify_token(FIXTURE["headers"][WORK_CONTEXT_KEY]) assert refused.value.code == code assert "private-" not in str(refused.value) + + +class Reservations: + """A reservation store as replaypg is: a nonce is held until it is finished + AND its retention has passed, and a reservation past its retention is + refused rather than inserted. ``collect`` removes what has aged out.""" + + shared = True + + def __init__(self, clock): + self.clock, self.held, self.active = clock, {}, set() + + def reserve(self, nonce, until): + if until <= self.clock(): + raise Expired() + if nonce in self.held: + raise Replayed() + self.held[nonce] = until + self.active.add(nonce) + + def finish(self, nonce): + self.active.discard(nonce) + + async def reserve_async(self, nonce, until): + self.reserve(nonce, until) + + async def finish_async(self, nonce): + self.finish(nonce) + + def consume(self, nonce, until): + raise AssertionError("a reservation store is reserved, never consumed") + + async def consume_async(self, nonce, until): + self.consume(nonce, until) + + def collect(self): + for nonce, until in list(self.held.items()): + if nonce not in self.active and until < self.clock(): + del self.held[nonce] + + +# Review of #28 at 8a3e953b: a single-use admission is consumed exactly once. +# Two deliveries of one admission arrive inside its window; the first holds its +# reservation while its handler runs, past its retention and collection; the +# second is refused by name, and so is a delivery after the handler returned. +def test_a_second_delivery_of_one_admission_is_refused_while_and_after_the_first_runs(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + now=lambda: clock[0]) + headers = fresh(FIXTURE["now"]) + ran = 0 + with v.admit(headers, METHOD) as identity: + ran += 1 + assert identity.owner == FIXTURE["identity"]["owner"] + with pytest.raises(Unauthenticated, match="stamp_replayed"): + v.verify(headers, METHOD) + clock[0] += 30 + SKEW + 5 # past the retention while the handler still runs + store.collect() + assert store.held, "collection removed the reservation of a running call" + with pytest.raises(Unauthenticated) as refused: + with v.admit(headers, METHOD): + ran += 1 + assert refused.value.code == "stamp_window" + store.collect() + assert not store.held and ran == 1 + + +# The reservation is taken before the keys are read, so a delivery that arrives +# while the first one waits for the keys meets it; and a refusal after the +# reservation ends the hold rather than leaving it pinned. +def test_the_reservation_is_taken_before_the_key_read_and_released_on_refusal(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + source = Keys(error=RuntimeError("authority down")) + v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + now=lambda: clock[0], timeout=0.05) + headers = fresh(FIXTURE["now"]) + with pytest.raises(Unauthenticated, match="keys"): + v.verify(headers, METHOD) + assert list(store.held) and not store.active, "a refused call kept its hold, or never reserved" + with pytest.raises(Unauthenticated, match="stamp_replayed"): + v.verify(headers, METHOD) + + +def test_admit_releases_its_hold_however_the_handler_ends(): + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + now=lambda: clock[0]) + with pytest.raises(ZeroDivisionError): + with v.admit(fresh(FIXTURE["now"]), METHOD): + assert store.active + 1 / 0 + assert store.held and not store.active + + +def test_admit_async_holds_the_reservation_until_the_handler_returns(): + import asyncio + clock = [FIXTURE["now"]] + store = Reservations(lambda: clock[0]) + v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + now=lambda: clock[0]) + headers = fresh(FIXTURE["now"]) + + async def run(): + async with v.admit_async(headers, METHOD): + assert store.active + with pytest.raises(Unauthenticated, match="stamp_replayed"): + await v.verify_async(headers, METHOD) + assert not store.active + asyncio.run(run()) + + +# A store without reserve keeps a nonce to its retention and may then evict it. +# A delivery that arrived inside the window, but whose consumption completed +# after the retention, found an evicted nonce: it is refused on the window. +def test_a_consumption_completing_past_its_retention_is_refused(monkeypatch): + import time as clock_module + real, slow = clock_module.monotonic, [0] + monkeypatch.setattr(clock_module, "monotonic", lambda: real() + slow[0]) + + class Evicted: + """Every nonce has been evicted; the second consumption takes a minute.""" + def __init__(self): + self.calls = 0 + def consume(self, nonce, until): + self.calls += 1 + slow[0] = 60 if self.calls == 2 else slow[0] + + v = verifier() + v._replay = Evicted() + v.verify(fresh(FIXTURE["now"]), METHOD) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == "stamp_window" + + +# Outside the run named exactly "local", a store that does not declare itself +# the durable one every replica shares is refused at startup, as Go refuses it. +@pytest.mark.parametrize("environment", [None, "", "production", "staging", "local-production", "local-docker", " local", "local ", "local"]) +def test_startup_requires_a_shared_store_outside_exactly_local(monkeypatch, environment): + if environment is None: + monkeypatch.delenv("CODEFLY__ENVIRONMENT", raising=False) + else: + monkeypatch.setenv("CODEFLY__ENVIRONMENT", environment) + if environment == "local": + verifier() + else: + with pytest.raises(Unauthenticated) as refused: + verifier() + assert refused.value.code == "replay_store_not_shared" + store = Replay() + store.shared = True + Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store) + Verifier(Keys(published()), audience=FIXTURE["audience"]) # a token-only verifier holds no store diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index eea2b29b..c5aefa8d 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -10,6 +10,7 @@ import { Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-co const verifier = new Verifier(createClient(AuthorityService, transport), audience, publicKey, sharedReplayStore); const id = await verifier.verify(requestHeaders, fullProcedure); // throws Unauthenticated, naming why +await verifier.admit(requestHeaders, fullProcedure, async id => handle(id)); // holds the single-use reservation while handle runs id.tenant; id.scopes; id.claims.allows(resourceKind, action, exactResourceID); ``` @@ -30,9 +31,11 @@ caller. **Readiness uses `verifier.ready()`**: true while trusted keys are held, through failed re-reads inside the 15-minute window; false before the first read or once they are stale. It also starts the same rate-limited, non-blocking re-read a call would, so an unready replica recovers and an idle one never goes -stale. Never gate readiness on `keysError`. After authenticating the identity, -it consumes the nonce through the required durable replay store shared across -replicas. See the [admission protocol](../../hop/README.md). +stale. Never gate readiness on `keysError`. Before reading the keys, it reserves +the nonce through the required durable replay store shared across replicas, and +outside the run named exactly `local` the constructor refuses a store whose +`shared` is not true (`replay_store_not_shared`). See the [admission +protocol](../../hop/README.md). `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/hop-call.ts b/libraries/authority-client/typescript/hop-call.ts index 92073845..e39dd723 100644 --- a/libraries/authority-client/typescript/hop-call.ts +++ b/libraries/authority-client/typescript/hop-call.ts @@ -11,7 +11,24 @@ export const LIFETIME = 30, SKEW = 2; * retainUntil is Unix seconds. Successful reservations must survive restart. */ export class Replayed extends Error { override name = "Replayed"; } -export interface ReplayStore { consume(nonce: string, retainUntil: number): Promise; } +/** The reservation's retention had already passed when the store decided. */ +export class Expired extends Error { override name = "Expired"; } + +export interface ReplayStore { + /** True only for the durable store every replica shares; required outside local. */ + readonly shared?: boolean; + consume(nonce: string, retainUntil: number): Promise; + /** A reservation store holds a single-use admission while the call carrying + * it runs: the verifier reserves when the call arrives and finishes when its + * handler has returned, and the store keeps the reservation until finish AND + * retainUntil have both passed. reserve refuses atomically, across every + * replica, a nonce already held (Replayed), including one held by a call still + * running past its retention, and refuses a reservation whose retainUntil has + * already passed by the store's clock (Expired), so a delayed reservation + * never revives a collected nonce. A finish that fails leaves it held. */ + reserve?(nonce: string, retainUntil: number): Promise; + finish?(nonce: string): Promise; +} export function checkCall(pairs: readonly (readonly [string, string])[], publicKey: string, method: string, audience: string, now: number): { nonce: string; expires: number } { const fail = (reason: Reason): never => { throw new Unauthenticated(reason); }; diff --git a/libraries/authority-client/typescript/refusal.ts b/libraries/authority-client/typescript/refusal.ts index 7acf73cf..86b85d6e 100644 --- a/libraries/authority-client/typescript/refusal.ts +++ b/libraries/authority-client/typescript/refusal.ts @@ -17,6 +17,7 @@ export enum Reason { StampAdvisory = "stamp_advisory", StampReplayed = "stamp_replayed", StampStore = "stamp_store", + StoreNotShared = "replay_store_not_shared", KeysUnavailable = "keys_unavailable", Cancelled = "cancelled", Malformed = "malformed", @@ -44,6 +45,7 @@ const reasonText: Record = { [Reason.StampIssued]: "the hop admission is issued further ahead than the clock allowance", [Reason.StampAdvisory]: "an advisory policy refusal cannot admit a call", [Reason.StampReplayed]: "the hop admission has already been used", + [Reason.StoreNotShared]: "the admission store must be durable and shared outside local", [Reason.StampStore]: "the hop admission store is unavailable", [Reason.KeysUnavailable]: "the authority's published keys are unavailable", [Reason.Cancelled]: "the call ended while the authority's keys were being read", diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 8d8191d1..f878a611 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -5,7 +5,11 @@ import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:cr import { test } from 'node:test'; import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Reason, SKEW } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Expired, Reason, SKEW } from '@codefly-dev/authority-client/work-context'; + +// Every verifier here runs in the local run, which alone may use a +// process-local replay store; the startup guard's test sets its own. +process.env.CODEFLY__ENVIRONMENT = 'local'; const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); const encode = bytes => Buffer.from(bytes).toString('base64url'); @@ -118,7 +122,7 @@ test('the Verifier reads the hop fixture as Go reads it', async () => { 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',/stamp_identity/],['x-codefly-hop-scopes','documents.collection:admin',/stamp_identity/],['x-codefly-hop-proof','another-run',/stamp_proof/]]) { - await assert.rejects(v.verify({...fixture.headers, [key]: value}, METHOD), e => e instanceof Unauthenticated && reason.test(e.message)); + await assert.rejects(v.verify({...fresh(v.now()), [key]: value}, METHOD), e => e instanceof Unauthenticated && reason.test(e.message)); } }); @@ -378,3 +382,95 @@ test('a reservation is retained past expiry by the allowance', async () => { await v.verify(headers, METHOD); assert.deepEqual(retained, [payload[6] + SKEW]); }); + +// A reservation store as replaypg is: a nonce is held until it is finished AND +// its retention has passed, and a reservation past its retention is refused +// rather than inserted. collect removes what has aged out. +const reservations = clock => { + const store = { shared: true, held: new Map(), active: new Set(), + async reserve(nonce, until) { + if (until * 1000 <= clock()) throw new Expired(); + if (store.held.has(nonce)) throw new Replayed(); + store.held.set(nonce, until); store.active.add(nonce); + }, + async finish(nonce) { store.active.delete(nonce); }, + async consume() { throw new Error('a reservation store is reserved, never consumed'); }, + collect() { for (const [nonce, until] of store.held) if (!store.active.has(nonce) && until * 1000 < clock()) store.held.delete(nonce); }, + }; + return store; +}; + +// Review of #28 at 8a3e953b: a single-use admission is consumed exactly once. +// Two deliveries of one admission arrive inside its window; the first holds its +// reservation while its handler runs, past its retention and collection; the +// second is refused by name, and so is a delivery after the handler returned. +test('a second delivery of one admission is refused while and after the first runs', async () => { + let now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(), fixture.audience, fixture.public_key, store, () => now); + const headers = fresh(now); + let ran = 0; + await v.admit(headers, METHOD, async identity => { + ran++; + assert.equal(identity.owner, fixture.identity.owner); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.StampReplayed); + now += (30 + SKEW + 5) * 1000; // past the retention while the handler still runs + store.collect(); + assert.equal(store.held.size, 1, 'collection removed the reservation of a running call'); + await assert.rejects(v.admit(headers, METHOD, async () => { ran++; }), e => e.code === Reason.StampWindow); + }); + store.collect(); + assert.equal(store.held.size, 0); + assert.equal(ran, 1); +}); + +// The reservation is taken before the keys are read, and a refusal after it +// ends the hold rather than leaving it pinned. +test('the reservation is taken before the key read and released on refusal', async () => { + const now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(() => Promise.reject(new Error('authority down'))), fixture.audience, fixture.public_key, store, () => now, 50); + const headers = fresh(now); + await assert.rejects(v.verify(headers, METHOD), e => e instanceof Unauthenticated && /keys/.test(e.code)); + assert.equal(store.held.size, 1, 'the call never reserved'); + assert.equal(store.active.size, 0, 'a refused call kept its hold'); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.StampReplayed); +}); + +test('admit releases its hold however the handler ends', async () => { + const now = fixture.now * 1000; + const store = reservations(() => now); + const v = new Verifier(source(), fixture.audience, fixture.public_key, store, () => now); + await assert.rejects(v.admit(fresh(now), METHOD, async () => { assert.equal(store.active.size, 1); throw new Error('handler failed'); }), /handler failed/); + assert.equal(store.held.size, 1); + assert.equal(store.active.size, 0); +}); + +// A store without reserve keeps a nonce to its retention and may then evict it. +// A delivery that arrived inside the window, but whose consumption completed +// after the retention, found an evicted nonce: it is refused on the window. +test('a consumption completing past its retention is refused', async t => { + let slow = 0, calls = 0; + const real = performance.now.bind(performance); + t.mock.method(performance, 'now', () => real() + slow); + const evicted = { async consume() { if (++calls === 2) slow = 60_000; } }; + const v = new Verifier(source(), fixture.audience, fixture.public_key, evicted, () => fixture.now * 1000); + await v.verify(fresh(fixture.now * 1000), METHOD); + await assert.rejects(v.verify(fresh(fixture.now * 1000), METHOD), e => e.code === Reason.StampWindow); +}); + +// Outside the run named exactly "local", a store that does not declare itself +// the durable one every replica shares is refused at startup, as Go refuses it. +test('startup requires a shared store outside exactly local', () => { + try { + for (const environment of [undefined, '', 'production', 'staging', 'local-production', 'local-docker', ' local', 'local ', 'local']) { + if (environment === undefined) delete process.env.CODEFLY__ENVIRONMENT; + else process.env.CODEFLY__ENVIRONMENT = environment; + const build = store => new Verifier(source(), fixture.audience, fixture.public_key, store); + if (environment === 'local') build(replay()); + else assert.throws(() => build(replay()), e => e.code === Reason.StoreNotShared); + build({ ...replay(), shared: true }); + new Verifier(source(), fixture.audience); // a token-only verifier holds no store + } + } finally { process.env.CODEFLY__ENVIRONMENT = 'local'; } +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 3344f933..7a003840 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,5 +1,5 @@ -import { checkCall, Replayed, SKEW, type ReplayStore } from "./hop-call.js"; -export { Replayed, LIFETIME, SKEW } from "./hop-call.js"; +import { checkCall, Expired, Replayed, SKEW, type ReplayStore } from "./hop-call.js"; +export { Expired, Replayed, LIFETIME, SKEW } from "./hop-call.js"; export type { ReplayStore } from "./hop-call.js"; /** Work Context identity readers and method-bound, single-use hop admission. * Verifier consumes signed call nonces in a required durable shared ReplayStore. @@ -303,6 +303,12 @@ export class Verifier { if ((publicKey !== undefined || replay !== undefined) && (!replay || !/^[0-9a-f]{64}$/.test(publicKey ?? ""))) { throw new Unauthenticated(Reason.Configuration); } + // A process-local store answers only for its own process: a captured + // admission would replay once per replica, and a restart would forget every + // reservation. Only the run named exactly "local" may use one. + if (replay && process.env.CODEFLY__ENVIRONMENT !== "local" && replay.shared !== true) { + throw new Unauthenticated(Reason.StoreNotShared); + } void this.refresh(); } @@ -318,8 +324,27 @@ export class Verifier { return this.trust !== undefined && now - this.fetched <= KEY_STALE_MS; } - /** 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 + * admission is checked first, and its single-use nonce is reserved before the + * keys are read. The reservation's in-flight hold ends when verify returns, so + * it lasts to the admission's expiry plus the clock allowance; a handler whose + * work follows uses admit, which holds it until that work has ended. */ async verify(headers: StampHeaders, method: string): Promise { + const admission = await this.begin(headers, method); + await admission.finish(); + return admission.identity; + } + + /** verify that holds the single-use reservation until handler has settled, + * however it settles. Call it as soon as the request's headers have arrived, + * before its body is read. */ + async admit(headers: StampHeaders, method: string, handler: (identity: Identity) => Promise): Promise { + const admission = await this.begin(headers, method); + try { return await handler(admission.identity); } + finally { await admission.finish(); } + } + + private async begin(headers: StampHeaders, method: string): Promise<{ identity: Identity; finish: () => 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)); if (this.publicKey === undefined || this.replay === undefined) { @@ -329,30 +354,56 @@ export class Verifier { } // The admission first: nothing signed for another procedure, another // audience or another token reaches the key read. - const call = checkCall(pairs, this.publicKey, method, this.audience, this.now()); - const token = boundToken(pairs); - const id = await this.verifyToken(token); - 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(Reason.StampIdentity); + const arrival = this.now(), mark = performance.now(); + const call = checkCall(pairs, this.publicKey, method, this.audience, arrival); + const finish = await this.reserve(call.nonce, call.expires + SKEW, arrival, mark); + try { + const token = boundToken(pairs); + const id = await this.verifyToken(token); + 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(Reason.StampIdentity); + } } + // An admission carrying an advisory refusal is refused by checkCall, so an + // admitted call never has one: diagnostic identity is never authority. + return { identity: Object.freeze({ ...id, advisory: "" }), finish }; + } catch (error) { + await finish(); + throw error; } - // checkCall judged the admission window when the call started, and the - // window bounds a call's start, so it is judged once and not again here. - // The reservation is retained to expiry plus the allowance and no longer: - // a replay can only be admitted if it too arrives inside the window, and - // inside the window the nonce is still retained. - try { await this.replay.consume(call.nonce, call.expires + SKEW); } - catch (error) { throw new Unauthenticated(error instanceof Replayed ? Reason.StampReplayed : Reason.StampStore); } - // An admission carrying an advisory refusal is refused by checkCall, so an - // admitted call never has one: diagnostic identity is never authority. - return Object.freeze({ ...id, advisory: "" }); + } + + /** Consume the admission's nonce, retained to its expiry plus the clock + * allowance at least, and answer the release that ends its in-flight hold. */ + private async reserve(nonce: string, until: number, arrival: number, mark: number): Promise<() => Promise> { + const store = this.replay!; + const managed = typeof store.reserve === "function" && typeof store.finish === "function"; + try { + if (managed) await store.reserve!(nonce, until); + else await store.consume(nonce, until); + } catch (error) { + if (error instanceof Unauthenticated) throw error; + throw new Unauthenticated(error instanceof Replayed ? Reason.StampReplayed : error instanceof Expired ? Reason.StampWindow : Reason.StampStore); + } + // A store without reserve keeps a nonce to its retention and may then evict + // it. A consumption that only succeeded because that retention had passed + // found an evicted nonce, not a new one: it is refused on the window, so a + // second delivery never runs. + if (!managed && arrival + (performance.now() - mark) >= until * 1000) throw new Unauthenticated(Reason.StampWindow); + let released = false; + return async () => { + if (!managed || released) return; + released = true; + // A failed release keeps the reservation held: nothing reopens admission. + try { await store.finish!(nonce); } catch { /* held */ } + }; } /** Authenticate a bare token with the stamped path's fail-closed key cache. diff --git a/libraries/hop/README.md b/libraries/hop/README.md index f886ae3d..0121950f 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -68,9 +68,11 @@ The verifier checks the signature, method, audience, token digest and admission window before reading authority keys. It then verifies the Work Context signature, issuer, audience, window and attenuation, and compares identity metadata with the claims. Advisory policy refusals cannot be signed as admission; legacy warning mode -is refused at hop startup. Finally it atomically -consumes `codefly-hop-v1::` before entering the handler. -Any replay-store error refuses the call. Invalid stamps do not reserve nonces. +is refused at hop startup. It atomically reserves +`codefly-hop-v1::` as soon as the admission checks, +before the keys are read and before the request body is read, and holds the +reservation until the handler has returned. Any replay-store error refuses the +call. Invalid stamps do not reserve nonces. The nonce is consumed for **every** stamp, including stamps on idempotent Work Contexts. A single-use Work Context is consumed once by the hop's existing live @@ -82,24 +84,34 @@ buffering its body. Expiry bounds starting a call, not the lifetime of its strea This is admission replay protection, not transactional exactly-once effects. The window is judged exactly once, at the instant the call starts, and not again -while the admitted call runs. Python's interceptors already run before the -request is read. Go's do not: grpc-go decodes a unary request fully before it -invokes the unary interceptor, and the Connect and REST faces buffer the message -too, so a module serving large or slow unary requests refused admissions the -authority had granted. `Verifier.TapHandle`, installed with +while the admitted call runs; the reservation is taken at that instant too. +Python's and TypeScript's verifiers run before the request is read. Go's +interceptors do not: grpc-go decodes a unary request fully before it invokes +the unary interceptor, and the Connect and REST faces buffer the message too. +Judging the window there refused admissions the authority had granted to slow +uploads; consuming the nonce there let a second delivery of one admission, held +back while its body arrived, find the nonce collected after its retention and +run the handler a second time. `Verifier.TapHandle`, installed with `grpc.InTapHandle`, records the arrival instant when the stream is accepted and -`Verify` judges the window against it. The handle reads no header and runs no -cryptography, because it runs on the connection's I/O goroutine where a -signature check would serialise every stream on that connection; admission stays -in the interceptor. A server that installs no handle judges the window when -`Verify` runs, as before, so nothing is admitted by its absence. - -A reservation is retained to expiry plus the allowance, and no longer. That -horizon is sound because the window is judged at arrival: a replay of a stamp -can only be admitted if it too arrives inside the window, and inside the window -the nonce is still retained. A store evicting on its own clock therefore -releases a nonce only once every call carrying it would already be refused on -its window. +starts the admission check and the reservation beside the call, so neither +waits for the body; the interceptor awaits them and refuses with the reason. It +does no work on the connection's I/O goroutine itself, where it would serialise +every stream on that connection. A server that installs no handle judges and +reserves when the interceptor runs, so nothing is admitted by its absence. + +A single-use admission is consumed exactly once. Its reservation is retained at +least to expiry plus the allowance, and for as long as a call holding it runs: a +store with a completion operation (Go's `hop.ReservationStore`, Python's +`ReservationStore`, TypeScript's `reserve`/`finish`) keeps it until the handler +has returned AND its retention has passed, and refuses a reservation that only +reaches it after its retention (`stamp_window`), so a delayed delivery can never +revive a collected nonce. A store with only `consume` keeps a nonce to its +retention; a consumption that completes after that retention found an evicted +nonce, not a new one, and is refused on the window. Every later delivery of the +admission is refused by name: `stamp_replayed` while the reservation is held, +`stamp_window` after. `Verify`/`verify` end the in-flight hold before returning; +a caller that runs the call's work itself holds it with `Admit` (Go) or `admit` +(Python and TypeScript) until that work has ended. ## Replicas and durable replay storage @@ -117,7 +129,9 @@ clock differences and prevent clock rollback. Synchronize deployment clocks. `replaypg.New` is the adapter to prefer: a module's own Postgres, where `INSERT ... ON CONFLICT (nonce) DO NOTHING` over a primary key is the atomic -reservation and the database is already shared by every replica. Codefly +reservation, conditioned on the retention not having passed by the database's +clock, and the database is already shared by every replica. Its `active` column +holds a running call's reservation; `Collect` never removes one. Codefly delivers a store, so this needs nothing the platform cannot provide. `replaypg.Migration` is the table, `Collect` removes reservations whose retention has passed, and the console composes exactly this @@ -125,9 +139,13 @@ retention has passed, and the console composes exactly this Outside a local run, `NewVerifier` now **refuses** a store that does not declare itself the shared one (`hop.ErrReplayStoreNotShared`), rather than leaving the -rule to documentation. Core's in-memory store and `hoptest.Replay` never -declare it, so a laptop and a test keep working while a deployment cannot -quietly run per-replica. `CODEFLY__ENVIRONMENT` decides which run this is. +rule to documentation; Python and TypeScript refuse it the same way +(`replay_store_not_shared`), reading a store's `shared` attribute. Core's +in-memory store and `hoptest.Replay` never declare it, so a laptop and a test +keep working while a deployment cannot quietly run per-replica. +`CODEFLY__ENVIRONMENT` decides which run this is: only the name exactly `local` +is a local run. An unset or empty environment, and any other name beginning +with `local`, is not. `hop.FileReplayStore` implements this contract for a shared durable filesystem with atomic exclusive creation and file/directory fsync. Nothing in the library diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 80d34d72..c054c869 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -22,19 +22,22 @@ conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) config.GRPCServerOptions = append(config.GRPCServerOptions, - // Records when each call arrived, so the admission window is judged at - // call start and not after a large request body has decoded. + // Judges the admission window and reserves its single-use nonce when each + // call arrives, not after a large request body has decoded. grpc.InTapHandle(verifier.TapHandle()), 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, observedFullProcedure) +// an HTTP module, once the headers arrive and before reading the body: +// id, done, err := verifier.Admit(ctx, metadataFromHeaders, observedFullProcedure); defer done() ``` `Verify` authenticates the signed method, audience, token digest and 30-second admission window (with a two-second clock allowance, `stamp.Skew`, judged at the instant the call arrived), authenticates the Work Context, compares identity -metadata, and atomically consumes the call nonce in the shared replay store. It +metadata, and atomically reserves the call nonce in the shared replay store +before the keys are read. `Admit` does the same and holds the reservation until +the caller's work has ended, as the interceptors do for a handler. It refuses replays across replicas without re-spending the Work Context's single use. A process that receives no hop calls authenticates a bare Work Context with `NewTokenVerifier` and `VerifyToken` instead, which establishes identity and no diff --git a/libraries/hop/go/admission.go b/libraries/hop/go/admission.go new file mode 100644 index 00000000..5df929ac --- /dev/null +++ b/libraries/hop/go/admission.go @@ -0,0 +1,176 @@ +package hop + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + core "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc/metadata" +) + +// ReservationStore is a replay store that can hold a single-use admission for +// as long as a call carrying it runs, not only until a fixed instant. The +// verifier reserves at the call's arrival and finishes when its handler +// returns; the store keeps the reservation until Finish AND retainUntil have +// both passed. A store without these operations keeps a nonce to retainUntil, +// and the verifier refuses any consumption that completes past it. +// +// Reserve must refuse atomically, across every replica, a nonce that is already +// held (core.ErrReplayed), including one held by a call still running past +// retainUntil. It must also refuse, at the point the store decides, a +// reservation whose retainUntil has already passed (refusal.StampWindow): a +// reservation delayed past its own horizon must never revive a nonce that +// collection removed. A Finish that fails leaves the reservation held, so a +// failure never reopens admission. replaypg.Store implements it. +type ReservationStore interface { + core.ReplayStore + Reserve(ctx context.Context, nonce string, retainUntil time.Time) error + Finish(ctx context.Context, nonce string) error +} + +// instant is when a call arrived: on the verifier's clock, and as a monotonic +// mark from which the time since then is measured. +type instant struct{ at, mark time.Time } + +func (v *Verifier) instant() instant { return instant{at: v.now(), mark: time.Now()} } + +// since is the verifier's clock now, measured from the arrival. +func (i instant) since() time.Time { return i.at.Add(time.Since(i.mark)) } + +// storeTimeout bounds one reservation or release, which run detached from the +// call so that their outcome is always known, even for a call that ends. +const storeTimeout = 5 * time.Second + +// reserve checks the admission md carries against the instant the call arrived +// and consumes its single-use nonce. It returns the release that ends the +// reservation's in-flight hold, safe to call more than once. +func (v *Verifier) reserve(ctx context.Context, md metadata.MD, method string, arrival instant) (func(), error) { + if v.publicKey == "" || v.replay == nil { + // A token-only verifier (NewTokenVerifier) holds neither the hop's + // public key nor a replay store, so it can establish no admission. + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Configuration) + } + call, err := stamp.Check(md, v.publicKey, method, v.audience, arrival.at) + if err != nil { + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) + } + nonce := call.ReplayKey(v.publicKey) + // Retained to expiry plus the clock allowance at least, so a lagging + // verifier never admits a second delivery of a nonce another released. + until := time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + release := func() {} + reserving, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) + defer cancel() + if managed, ok := v.replay.(ReservationStore); ok { + err = managed.Reserve(reserving, nonce, until) + var once sync.Once + release = func() { + once.Do(func() { + finishing, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + // A failed release leaves the reservation held: never reopen. + _ = managed.Finish(finishing, nonce) + }) + } + } else { + // A store with no completion operation keeps the nonce to its + // retention and may then evict it. A consumption that only succeeded + // because that retention had passed found an evicted nonce, not a new + // one, and is refused on the window: a second delivery never runs. + err = v.replay.Consume(reserving, nonce, until) + if err == nil && !arrival.since().Before(until) { + err = refusal.StampWindow + } + } + if err != nil { + reason := refusal.StampStore + switch { + case errors.Is(err, core.ErrReplayed): + reason = refusal.StampReplayed + case errors.Is(err, refusal.StampWindow): + reason = refusal.StampWindow + } + return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, reason) + } + return release, nil +} + +// establish authenticates the Work Context an admitted call carries. A +// refusal releases the reservation's hold: no handler will run for it. +func (v *Verifier) establish(ctx context.Context, md metadata.MD, release func()) (Identity, error) { + id, err := v.VerifyToken(ctx, first(md, WorkContextKey)) + if err == nil { + err = sameAsStamped(md, id) + } + if err != nil { + release() + return Identity{}, err + } + // An admission carrying an advisory refusal is refused by stamp.Check, so + // an admitted call never has one: diagnostic identity is never authority. + return id, nil +} + +type admissionKey struct{} + +// admission is one call's single-use reservation, from the instant the call +// arrived to the instant its handler returned. +type admission struct { + verifier *Verifier + method string + md metadata.MD + reserved chan struct{} // closed once release and err are set + release func() + err error + + mu sync.Mutex + running, ended bool +} + +// start claims the admission for the handler, unless the call already ended. +func (a *admission) start() bool { + a.mu.Lock() + defer a.mu.Unlock() + if a.running || a.ended { + return false + } + a.running = true + return true +} + +// abandon releases the hold of a call that ended before its handler started, +// such as one cancelled while its body was arriving. A call whose handler +// started is released when the handler returns, never by its cancellation. +func (a *admission) abandon() { + a.mu.Lock() + if a.running || a.ended { + a.mu.Unlock() + return + } + a.ended = true + a.mu.Unlock() + <-a.reserved + if a.err == nil { + a.release() + } +} + +// finish releases the hold once the handler has returned. +func (a *admission) finish() { + a.mu.Lock() + a.ended = true + a.mu.Unlock() + a.release() +} + +func finishAdmission(ctx context.Context) { + if a, ok := ctx.Value(admissionKey{}).(*admission); ok { + a.finish() + } +} diff --git a/libraries/hop/go/arrival_test.go b/libraries/hop/go/arrival_test.go index 1b1fdc29..28515228 100644 --- a/libraries/hop/go/arrival_test.go +++ b/libraries/hop/go/arrival_test.go @@ -130,3 +130,27 @@ func (r *retention) Consume(_ context.Context, _ string, until time.Time) error r.until = until return nil } + +// accepting is a store that has evicted every nonce: each consumption succeeds. +type accepting struct{} + +func (accepting) Consume(context.Context, string, time.Time) error { return nil } + +// A store with no completion operation keeps a nonce only to its retention. A +// delivery whose consumption completes past that retention found an evicted +// nonce, so it is refused on the window rather than run a second time. +func TestAConsumptionCompletingPastItsRetentionIsRefused(t *testing.T) { + clock := time.Unix(1792818000, 0) + v, s := arrivalVerifier(t, &clock) + v.replay = accepting{} + md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") + if _, err := v.reserve(context.Background(), md, arrivalMethod, instant{at: clock, mark: time.Now()}); err != nil { + t.Fatalf("a delivery consumed inside its retention was refused: %v", err) + } + // Arrived inside the window, but its consumption completed a minute later. + _, err := v.reserve(context.Background(), md, arrivalMethod, instant{at: clock, mark: time.Now().Add(-time.Minute)}) + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampWindow { + t.Fatalf("want %s for a consumption past its retention: %v", refusal.StampWindow, err) + } +} diff --git a/libraries/hop/go/durable_test.go b/libraries/hop/go/durable_test.go index dab3ddae..d81443be 100644 --- a/libraries/hop/go/durable_test.go +++ b/libraries/hop/go/durable_test.go @@ -10,6 +10,7 @@ import ( "github.com/codefly-dev/authority/libraries/hop/go/replaypg" "github.com/codefly-dev/core/resources" core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) @@ -19,6 +20,8 @@ func (insertingPool) Exec(context.Context, string, ...any) (pgconn.CommandTag, e return pgconn.NewCommandTag("INSERT 0 1"), nil } +func (insertingPool) QueryRow(context.Context, string, ...any) pgx.Row { return nil } + // Review r164 B1: the contract forbids a per-replica replay store outside a // local run, but nothing refused one. A hand-written path to per-pod storage, // or Core's in-memory store, passed and let a captured admission replay once @@ -43,8 +46,8 @@ func TestAReplayStoreThatIsNotSharedIsRefusedOutsideALocalRun(t *testing.T) { } // A laptop and a test keep working with any store, including Core's - // in-memory one, and so does a run that names no environment. - for _, environment := range []string{"", "local", "local-docker"} { + // in-memory one, only under the exact local environment name. + for _, environment := range []string{"local"} { t.Setenv(resources.EnvironmentPrefix, environment) for name, store := range map[string]core.ReplayStore{ "in-memory": hoptest.Replay(), "a directory": directory, @@ -56,7 +59,7 @@ func TestAReplayStoreThatIsNotSharedIsRefusedOutsideALocalRun(t *testing.T) { } } - for _, environment := range []string{"staging", "production"} { + for _, environment := range []string{"", "staging", "production", "local-docker", "local-production", " local", "local "} { t.Setenv(resources.EnvironmentPrefix, environment) for name, store := range map[string]core.ReplayStore{ "in-memory": hoptest.Replay(), "a directory not stated to be shared": directory, diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 151ee94f..bad50897 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -39,6 +39,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect @@ -48,6 +49,7 @@ require ( 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/sync v0.23.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 diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index e1122136..ad5d6efe 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -297,8 +297,8 @@ var ErrReplayStoreNotShared = errors.New("hop: this replay store is not the dura // durableOutsideLocal refuses a store that does not declare itself shared, // unless this process runs in a local environment. func durableOutsideLocal(replay core.ReplayStore) error { - environment := strings.TrimSpace(os.Getenv(resources.EnvironmentPrefix)) - if environment == "" || strings.HasPrefix(environment, "local") { + environment := os.Getenv(resources.EnvironmentPrefix) + if environment == "local" { return nil } if shared, ok := replay.(SharedReplayStore); ok && shared.Shared() { @@ -363,49 +363,42 @@ func (v *Verifier) Ready() bool { // carries a caller-safe refusal.Reason. The admission is checked first: a call // that does not carry a hop admission signed for this exact procedure and // audience, binding this token, never reaches the keys. +// +// Verify consumes the admission and ends its in-flight hold before returning, +// so the reservation lasts to the admission's expiry plus the clock allowance. +// A caller that runs work for the call afterwards uses Admit, which holds the +// reservation until that work has finished. func (v *Verifier) Verify(ctx context.Context, md metadata.MD, method string) (Identity, error) { - if v.publicKey == "" || v.replay == nil { - // A token-only verifier (NewTokenVerifier) holds neither the hop's - // public key nor a replay store, so it can establish no admission. - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Configuration) - } - // The window is judged at the instant the call arrived, which TapHandle - // recorded when the stream was accepted. grpc-go decodes a unary request - // before it runs the interceptor, so judging at this point would refuse a - // genuinely admitted upload that simply took longer than Lifetime to - // arrive. Without a tap handle the arrival instant is now, as before. - arrival := arrivalFrom(ctx, v.now) - call, err := stamp.Check(md, v.publicKey, method, v.audience, arrival) - if err != nil { - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) - } - id, err := v.VerifyToken(ctx, first(md, WorkContextKey)) + id, done, err := v.Admit(ctx, md, method) if err != nil { return Identity{}, err } - if err := sameAsStamped(md, id); err != nil { - return Identity{}, err + done() + return id, nil +} + +// Admit is Verify for a caller that runs the call's work itself, such as an +// HTTP module: it reserves the single-use admission before any key read, and +// holds the reservation, past its retention if need be, until done is called. +// Call done once the work has returned, whatever its outcome: +// +// id, done, err := verifier.Admit(ctx, md, procedure) +// if err != nil { return err } +// defer done() +// +// The window is judged now. Call Admit as soon as the request's headers have +// arrived, before its body is read, so a slow body neither fails the window +// nor delays the reservation. +func (v *Verifier) Admit(ctx context.Context, md metadata.MD, method string) (Identity, func(), error) { + release, err := v.reserve(ctx, md, method, v.instant()) + if err != nil { + return Identity{}, nil, err } - // stamp.Check judged the admission window against arrival above, and the - // window bounds when a call may start, so it is judged once and not again - // here: the key read and the reservation below happen while the admitted - // call is already running. - skew := int64(stamp.Skew / time.Second) - // The reservation is retained to expiry plus the same allowance, and no - // longer. That horizon is sound because the window above is judged at - // arrival: a replay of this stamp can only be admitted if it too arrives - // inside the window, and inside the window the nonce is still retained. A - // store evicting on its own clock therefore releases a nonce only once - // every call carrying it would already be refused on its window. - if err := v.replay.Consume(ctx, call.ReplayKey(v.publicKey), time.Unix(call.Expires+skew, 0)); err != nil { - if errors.Is(err, core.ErrReplayed) { - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampReplayed) - } - return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampStore) + id, err := v.establish(ctx, md, release) + if err != nil { + return Identity{}, nil, err } - // An admission carrying an advisory refusal is refused by stamp.Check, so - // an admitted call never has one: diagnostic identity is never authority. - return id, nil + return id, release, nil } // VerifyToken authenticates a bare Work Context using the same published keys @@ -555,6 +548,7 @@ func (v *Verifier) UnaryServerInterceptor() grpc.UnaryServerInterceptor { if err != nil { return nil, err } + defer finishAdmission(ctx) return handler(ctx, req) } } @@ -566,6 +560,7 @@ func (v *Verifier) StreamServerInterceptor() grpc.StreamServerInterceptor { if err != nil { return err } + defer finishAdmission(ctx) return handler(srv, &stamped{ServerStream: ss, ctx: ctx}) } } @@ -574,12 +569,31 @@ func (v *Verifier) admit(ctx context.Context, method string) (context.Context, e if strings.HasPrefix(method, "/grpc.health.v1.Health/") { return ctx, nil } - md, _ := metadata.FromIncomingContext(ctx) - id, err := v.Verify(ctx, md, method) + held, ok := ctx.Value(admissionKey{}).(*admission) + if !ok || held.verifier != v || held.method != method { + // No tap handle reserved this call: it is reserved now, judged at now. + md, _ := metadata.FromIncomingContext(ctx) + release, err := v.reserve(ctx, md, method, v.instant()) + held = &admission{verifier: v, method: method, md: md, reserved: make(chan struct{}), release: release, err: err} + close(held.reserved) + } + select { + case <-held.reserved: + case <-ctx.Done(): + return nil, status.Error(codes.Unauthenticated, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled).Error()) + } + if held.err != nil { + return nil, status.Error(codes.Unauthenticated, held.err.Error()) + } + if !held.start() { + return nil, status.Error(codes.Unauthenticated, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled).Error()) + } + id, err := v.establish(ctx, held.md, held.release) if err != nil { + held.finish() return nil, status.Error(codes.Unauthenticated, err.Error()) } - return context.WithValue(ctx, identityKey{}, id), nil + return context.WithValue(context.WithValue(ctx, admissionKey{}, held), identityKey{}, id), nil } type stamped struct { @@ -596,27 +610,20 @@ func first(md metadata.MD, key string) string { return "" } -type arrivalKey struct{} - -// arrivalFrom is the instant the call arrived, as TapHandle recorded it, or -// now when the server runs no tap handle. -func arrivalFrom(ctx context.Context, now func() time.Time) time.Time { - if at, ok := ctx.Value(arrivalKey{}).(time.Time); ok { - return at - } - return now() -} - -// TapHandle records when each call arrived, before its body is read, so Verify -// judges the admission window at call start. +// TapHandle reserves each call's single-use admission when the call arrives, +// before its body is read, and the interceptors hold that reservation until +// the handler returns. // // grpc-go decodes a unary request fully before it invokes the unary -// interceptor, and the Connect and REST faces buffer the message too. A module -// that serves large or slow unary requests would therefore refuse an admission -// the authority granted, because more than stamp.Lifetime passed while the -// body arrived. Installing this handle moves that judgement to the instant the -// stream was accepted, which is also where Python's interceptors already run, -// so the three languages agree on when a call starts. +// interceptor, and the Connect and REST faces buffer the message too. Judging +// the admission there would refuse a genuinely admitted upload that took +// longer than stamp.Lifetime to arrive; consuming its nonce there would let a +// second delivery of the same admission, held back while its body arrived, +// consume a nonce that had since been released, and run the handler twice. +// Installing this handle judges the window at the instant the stream was +// accepted and reserves the nonce then, so every delivery that arrives inside +// the window meets the reservation and is refused as stamp_replayed. Python's +// and TypeScript's verifiers run at the same point, before the body is read. // // Install it beside the interceptors: // @@ -624,12 +631,23 @@ func arrivalFrom(ctx context.Context, now func() time.Time) time.Time { // grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), // grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) // -// It authenticates nothing and refuses nothing: it reads no header and runs no -// cryptography, because it runs on the connection's I/O goroutine, where a -// signature check would serialise every stream on that connection. Admission -// stays in the interceptor. +// It runs on the connection's I/O goroutine, so it does no work there: it +// records the arrival instant and starts the check and the reservation beside +// the call, which the interceptor awaits and which refuses with the reason. A +// call that ends before its handler starts releases its hold; one whose handler +// started is released when the handler returns. func (v *Verifier) TapHandle() tap.ServerInHandle { - return func(ctx context.Context, _ *tap.Info) (context.Context, error) { - return context.WithValue(ctx, arrivalKey{}, v.now()), nil + return func(ctx context.Context, info *tap.Info) (context.Context, error) { + if strings.HasPrefix(info.FullMethodName, "/grpc.health.v1.Health/") { + return ctx, nil + } + arrival := v.instant() + held := &admission{verifier: v, method: info.FullMethodName, md: info.Header, reserved: make(chan struct{})} + go func() { + defer close(held.reserved) + held.release, held.err = v.reserve(ctx, info.Header, info.FullMethodName, arrival) + }() + context.AfterFunc(ctx, held.abandon) + return context.WithValue(ctx, admissionKey{}, held), nil } } diff --git a/libraries/hop/go/main_test.go b/libraries/hop/go/main_test.go new file mode 100644 index 00000000..1eb02e24 --- /dev/null +++ b/libraries/hop/go/main_test.go @@ -0,0 +1,14 @@ +package hop_test + +import ( + "os" + "testing" +) + +// TestMain runs this package's tests in the local run: most stand up a verifier +// on a process-local store, which only the run named exactly "local" may use. +// A test of the startup guard or over Postgres sets its own environment. +func TestMain(m *testing.M) { + _ = os.Setenv("CODEFLY__ENVIRONMENT", "local") + os.Exit(m.Run()) +} diff --git a/libraries/hop/go/replaypg/replaypg.go b/libraries/hop/go/replaypg/replaypg.go index 9c0e92ce..45140520 100644 --- a/libraries/hop/go/replaypg/replaypg.go +++ b/libraries/hop/go/replaypg/replaypg.go @@ -22,15 +22,21 @@ import ( "errors" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + hop "github.com/codefly-dev/authority/libraries/hop/go" core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // Migration creates the table, as the module's own migration. The primary key // is the reservation: Postgres decides one winner among concurrent inserts. +// active marks a reservation whose call is still running, which collection +// never removes. const Migration = `CREATE TABLE IF NOT EXISTS hop_admissions ( nonce TEXT PRIMARY KEY, - retain_until TIMESTAMPTZ NOT NULL + retain_until TIMESTAMPTZ NOT NULL, + active BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until);` @@ -38,6 +44,7 @@ CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain // and a test may stand in for it. type Pool interface { Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) + QueryRow(ctx context.Context, sql string, arguments ...any) pgx.Row } // Store reserves admission nonces in a module's own Postgres. @@ -83,13 +90,54 @@ func (s *Store) Consume(ctx context.Context, nonce string, retainUntil time.Time // own clock, and answers how many it removed. Run it on a schedule: without it // the table grows by one row per admitted call. // -// Removing a reservation at its retention is safe because the verifier judges -// the admission window when a call arrives: a call carrying a nonce this -// removes would already be refused on its window, whatever the store answers. +// A reservation whose call is still running is never removed, however long +// the call runs. One whose retention has passed can be removed safely because +// Reserve refuses, on the same clock, any reservation past that retention. func (s *Store) Collect(ctx context.Context) (int64, error) { - tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now()`) + tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE NOT active AND retain_until < now()`) if err != nil { return 0, err } return tag.RowsAffected(), nil } + +var _ hop.ReservationStore = (*Store)(nil) + +// Reserve consumes nonce when a call arrives and holds it while the call runs, +// until Finish and retainUntil have both passed. It reports core.ErrReplayed +// when the nonce is already held, and refusal.StampWindow when retainUntil has +// already passed by the database's clock: a reservation delayed past its own +// horizon must never revive a nonce that Collect removed. +func (s *Store) Reserve(ctx context.Context, nonce string, retainUntil time.Time) error { + if nonce == "" { + return errors.New("hop replay: a reservation needs a nonce") + } + var outcome string + err := s.pool.QueryRow(ctx, `WITH reserved AS ( + INSERT INTO hop_admissions (nonce, retain_until, active) + SELECT $1, $2::timestamptz, TRUE WHERE $2::timestamptz > clock_timestamp() + ON CONFLICT (nonce) DO NOTHING + RETURNING nonce) + SELECT CASE WHEN EXISTS (SELECT 1 FROM reserved) THEN 'reserved' + WHEN $2::timestamptz > clock_timestamp() THEN 'replayed' + ELSE 'expired' END`, nonce, retainUntil.UTC()).Scan(&outcome) + if err != nil { + return err + } + switch outcome { + case "reserved": + return nil + case "replayed": + return core.ErrReplayed + default: + return refusal.StampWindow + } +} + +// Finish ends the in-flight hold of a call that has returned. The reservation +// itself stays until its retention passes. A Finish that fails, or a process +// that stops before it, leaves the hold in place: nothing reopens admission. +func (s *Store) Finish(ctx context.Context, nonce string) error { + _, err := s.pool.Exec(ctx, `UPDATE hop_admissions SET active = FALSE WHERE nonce = $1`, nonce) + return err +} diff --git a/libraries/hop/go/replaypg/replaypg_test.go b/libraries/hop/go/replaypg/replaypg_test.go index 13773618..d2f6d01c 100644 --- a/libraries/hop/go/replaypg/replaypg_test.go +++ b/libraries/hop/go/replaypg/replaypg_test.go @@ -6,17 +6,20 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" core "github.com/codefly-dev/core/workcontext" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // pool is a Postgres that holds one nonce per primary key, as the real table's // ON CONFLICT DO NOTHING does: the first insert affects a row, a repeat none. type pool struct { - held map[string]time.Time - fail error - sql []string - args [][]any + held map[string]time.Time + active map[string]bool + fail error + sql []string + args [][]any } func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { @@ -27,11 +30,16 @@ func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandT if p.held == nil { p.held = map[string]time.Time{} } + if len(args) == 1 { + // Finish: the call has returned. + delete(p.active, args[0].(string)) + return pgconn.NewCommandTag("UPDATE 1"), nil + } if len(args) != 2 { - // The collection statement; remove what has aged out. + // The collection statement; remove what has aged out and is not running. removed := 0 for nonce, until := range p.held { - if until.Before(time.Now()) { + if until.Before(time.Now()) && !p.active[nonce] { delete(p.held, nonce) removed++ } @@ -46,6 +54,41 @@ func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandT return pgconn.NewCommandTag("INSERT 0 1"), nil } +// QueryRow is the reservation statement: the insert happens only while the +// retention has not passed, and the answer says which way it went. +func (p *pool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.sql, p.args = append(p.sql, sql), append(p.args, args) + if p.fail != nil { + return row{err: p.fail} + } + if p.held == nil { + p.held, p.active = map[string]time.Time{}, map[string]bool{} + } + nonce, until := args[0].(string), args[1].(time.Time) + _, taken := p.held[nonce] + switch { + case !until.After(time.Now()): + return row{outcome: "expired"} + case taken: + return row{outcome: "replayed"} + } + p.held[nonce], p.active[nonce] = until, true + return row{outcome: "reserved"} +} + +type row struct { + outcome string + err error +} + +func (r row) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + *dest[0].(*string) = r.outcome + return nil +} + func itoa(n int) string { if n == 0 { return "0" @@ -127,10 +170,49 @@ func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { } } +// A reservation is held while its call runs, past its retention, and refused +// to anyone else; once the call finishes, collection removes it after its +// retention. A reservation whose retention has already passed is refused as +// the window, never inserted, so it cannot revive a collected nonce. +func TestAReservationIsHeldWhileItsCallRuns(t *testing.T) { + p := &pool{} + store, _ := New(p) + ctx := context.Background() + if err := store.Reserve(ctx, "running", time.Now().Add(50*time.Millisecond)); err != nil { + t.Fatal(err) + } + if err := store.Reserve(ctx, "running", time.Now().Add(time.Minute)); !errors.Is(err, core.ErrReplayed) { + t.Fatalf("a second reservation was not refused: %v", err) + } + time.Sleep(60 * time.Millisecond) + if removed, err := store.Collect(ctx); err != nil || removed != 0 { + t.Fatalf("collection removed %d running reservations: %v", removed, err) + } + if err := store.Finish(ctx, "running"); err != nil { + t.Fatal(err) + } + if removed, err := store.Collect(ctx); err != nil || removed != 1 { + t.Fatalf("collected %d finished reservations: %v", removed, err) + } + if err := store.Reserve(ctx, "running", time.Now().Add(-time.Second)); !errors.Is(err, refusal.StampWindow) { + t.Fatalf("a reservation past its retention was not refused as the window: %v", err) + } + if _, held := p.held["running"]; held { + t.Fatal("a reservation past its retention revived a collected nonce") + } + outage := errors.New("connection refused") + if err := (&Store{pool: &pool{fail: outage}}).Reserve(ctx, "n", time.Now().Add(time.Minute)); !errors.Is(err, outage) { + t.Fatalf("an outage must not read as a replay: %v", err) + } + if err := store.Reserve(ctx, "", time.Now().Add(time.Minute)); err == nil { + t.Fatal("a reservation with no nonce was accepted") + } +} + // The migration is the module's own, and the primary key is what makes the // reservation atomic across replicas. func TestTheMigrationReservesTheNonceAsAPrimaryKey(t *testing.T) { - for _, want := range []string{"hop_admissions", "nonce TEXT PRIMARY KEY", "retain_until"} { + for _, want := range []string{"hop_admissions", "nonce TEXT PRIMARY KEY", "retain_until", "active BOOLEAN NOT NULL DEFAULT FALSE"} { if !contains(Migration, want) { t.Fatalf("the migration does not declare %q: %s", want, Migration) } diff --git a/libraries/hop/go/transport_postgres_test.go b/libraries/hop/go/transport_postgres_test.go new file mode 100644 index 00000000..8d882ae0 --- /dev/null +++ b/libraries/hop/go/transport_postgres_test.go @@ -0,0 +1,277 @@ +package hop_test + +import ( + "context" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + hop "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/replaypg" + "github.com/codefly-dev/core/resources" + "github.com/jackc/pgx/v5/pgxpool" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/tap" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/emptypb" +) + +// postgresStore is the console's reservation store on a real Postgres, named +// by HOP_TEST_POSTGRES (a libpq connection string). CI starts one. +func postgresStore(t *testing.T) (*replaypg.Store, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("HOP_TEST_POSTGRES") + if dsn == "" { + t.Skip("HOP_TEST_POSTGRES names no Postgres for the transport regression") + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), replaypg.Migration); err != nil { + t.Fatal(err) + } + store, err := replaypg.New(pool) + if err != nil { + t.Fatal(err) + } + return store, pool +} + +// transport is a real grpc-go server, installed the way a module installs the +// verifier, whose one unary method counts how often its handler ran. Calls are +// opened as raw streams so a test decides when each request body is sent. +type transport struct { + conn *grpc.ClientConn + arrived chan struct{} + reached atomic.Int64 + entered chan struct{} + hold chan struct{} + method string + metadata metadata.MD + expires time.Time // the admission's expiry plus the clock allowance + nonce string +} + +func newTransport(t *testing.T, ctx context.Context, store *replaypg.Store) *transport { + t.Helper() + tr := &transport{arrived: make(chan struct{}, 8), entered: make(chan struct{}, 8), hold: make(chan struct{}), method: "/regression.Service/Write"} + close(tr.hold) + authority := hoptest.New(t) + token := authority.IssueSingleUse(t, "regression") + tr.metadata = hoptest.Forward(t, token, "regression", tr.method) + // A short-lived admission, so its retention passes inside the test. + issued := time.Now() + proof, err := hoptest.Signer("regression").Sign(token, tr.method, "regression", issued, issued.Add(time.Second).Unix()) + if err != nil { + t.Fatal(err) + } + tr.metadata.Set(hop.ProofKey, proof) + call, err := stamp.Check(tr.metadata, hoptest.PublicKey("regression"), tr.method, "regression", issued) + if err != nil { + t.Fatal(err) + } + tr.nonce = call.ReplayKey(hoptest.PublicKey("regression")) + tr.expires = time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + + v, err := hop.NewVerifier(ctx, authority, "regression", hoptest.PublicKey("regression"), store) + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer( + grpc.InTapHandle(func(ctx context.Context, info *tap.Info) (context.Context, error) { + admitted, err := v.TapHandle()(ctx, info) + tr.arrived <- struct{}{} + return admitted, err + }), + grpc.ChainUnaryInterceptor(v.UnaryServerInterceptor())) + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: "regression.Service", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{MethodName: "Write", Handler: func(srv any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + request := new(emptypb.Empty) + if err := decode(request); err != nil { + return nil, err + } + return interceptor(ctx, request, &grpc.UnaryServerInfo{Server: srv, FullMethod: tr.method}, func(context.Context, any) (any, error) { + tr.reached.Add(1) + tr.entered <- struct{}{} + <-tr.hold + return &emptypb.Empty{}, nil + }) + }}}, + }, new(struct{})) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + tr.conn, err = grpc.NewClient("passthrough:///regression", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tr.conn.Close() }) + return tr +} + +// deliver opens one delivery of the admission and returns once the server has +// accepted its stream; its body is sent only when send is called. +func (tr *transport) deliver(t *testing.T, ctx context.Context) (send func() error) { + t.Helper() + stream, err := tr.conn.NewStream(metadata.NewOutgoingContext(ctx, tr.metadata.Copy()), &grpc.StreamDesc{}, tr.method) + if err != nil { + t.Fatal(err) + } + select { + case <-tr.arrived: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + return func() error { + if err := stream.SendMsg(&emptypb.Empty{}); err != nil { + return err + } + if err := stream.CloseSend(); err != nil { + return err + } + return stream.RecvMsg(&emptypb.Empty{}) + } +} + +// pastRetention waits until the admission's retention has passed and runs +// collection, as the console's schedule does. +func (tr *transport) pastRetention(t *testing.T, ctx context.Context, store *replaypg.Store) { + t.Helper() + select { + case <-time.After(time.Until(tr.expires) + 200*time.Millisecond): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } +} + +// reserved waits, briefly, for the reservation of the delivery just opened, so +// the deliveries that follow it meet it in a known order. +func reserved(t *testing.T, ctx context.Context, pool *pgxpool.Pool, nonce string) { + t.Helper() + for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline) && !held(t, ctx, pool, nonce); { + time.Sleep(10 * time.Millisecond) + } +} + +func held(t *testing.T, ctx context.Context, pool *pgxpool.Pool, nonce string) bool { + t.Helper() + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM hop_admissions WHERE nonce = $1`, nonce).Scan(&count); err != nil { + t.Fatal(err) + } + return count == 1 +} + +// Review of #28 at 8a3e953b: two deliveries of one single-use admission arrive +// inside its window. The first runs. The second is held before its body is +// read until the admission's retention has passed and collection has run; +// then its body arrives. It must be refused by name, and the handler must have +// run exactly once. +func TestASecondDeliveryHeldPastCollectionIsRefusedOverTheRealTransport(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + + first := tr.deliver(t, ctx) + reserved(t, ctx, pool, tr.nonce) + second := tr.deliver(t, ctx) + if err := first(); err != nil { + t.Fatalf("the first delivery was refused: %v", err) + } + tr.pastRetention(t, ctx, store) + err := second() + if err == nil || !strings.Contains(err.Error(), "stamp_replayed") { + t.Errorf("the second delivery was not refused as stamp_replayed: %v", err) + } + if got := tr.reached.Load(); got != 1 { + t.Fatalf("SECURITY: one single-use admission ran its handler %d times", got) + } +} + +// The reservation of a call whose handler is still running outlives its +// retention: collection never removes it while the call runs, and removes it +// once the call has returned. +func TestARunningCallKeepsItsReservationPastItsRetention(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + tr.hold = make(chan struct{}) + + first := tr.deliver(t, ctx) + result := make(chan error, 1) + go func() { result <- first() }() + select { + case <-tr.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + tr.pastRetention(t, ctx, store) + if !held(t, ctx, pool, tr.nonce) { + t.Fatal("collection removed the reservation of a call that is still running") + } + if err := tr.deliver(t, ctx)(); err == nil || !strings.Contains(err.Error(), "stamp_") { + t.Errorf("a delivery after the retention was admitted: %v", err) + } + close(tr.hold) + if err := <-result; err != nil { + t.Fatalf("the running call was refused: %v", err) + } + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } + if held(t, ctx, pool, tr.nonce) { + t.Fatal("a finished call's reservation outlived its retention") + } + if got := tr.reached.Load(); got != 1 { + t.Fatalf("the handler ran %d times", got) + } +} + +// A delivery cancelled before its body arrived ran no handler, so its hold is +// released and collection removes the reservation once its retention passes; +// a second delivery inside the window is still refused. +func TestACallCancelledBeforeItsBodyReleasesItsHold(t *testing.T) { + t.Setenv(resources.EnvironmentPrefix, "production") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store, pool := postgresStore(t) + tr := newTransport(t, ctx, store) + + cancelled, stop := context.WithCancel(ctx) + _ = tr.deliver(t, cancelled) + reserved(t, ctx, pool, tr.nonce) + second := tr.deliver(t, ctx) + stop() + if err := second(); err == nil || !strings.Contains(err.Error(), "stamp_replayed") { + t.Errorf("a second delivery inside the window was not refused as stamp_replayed: %v", err) + } + for { + tr.pastRetention(t, ctx, store) + if !held(t, ctx, pool, tr.nonce) { + break + } + } + if got := tr.reached.Load(); got != 0 { + t.Fatalf("the handler ran %d times", got) + } +} diff --git a/modules/authority/services/hop/code/go.sum b/modules/authority/services/hop/code/go.sum index 14afea15..f3736e2c 100644 --- a/modules/authority/services/hop/code/go.sum +++ b/modules/authority/services/hop/code/go.sum @@ -143,6 +143,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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= @@ -214,6 +216,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ 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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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= diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go index f084a707..612c49b8 100644 --- a/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go +++ b/modules/authority/services/hop/code/pkg/infra/proxy/environment_proxy_test.go @@ -44,6 +44,9 @@ func TestMain(m *testing.M) { } _ = os.Unsetenv("NO_PROXY") _ = os.Unsetenv("no_proxy") + // The modules these tests stand up keep their admissions in hoptest.Replay, + // a process-local store that only the run named exactly "local" may use. + _ = os.Setenv("CODEFLY__ENVIRONMENT", "local") code := m.Run() _ = listener.Close() os.Exit(code) diff --git a/modules/console/services/api/code/store_test.go b/modules/console/services/api/code/store_test.go index 322b1493..7a9e3e6f 100644 --- a/modules/console/services/api/code/store_test.go +++ b/modules/console/services/api/code/store_test.go @@ -21,13 +21,18 @@ func TestTheStoreMigrationCarriesTheAdaptersTable(t *testing.T) { if !strings.Contains(string(migration), replaypg.Migration) { t.Fatalf("%s no longer carries replaypg.Migration:\n%s", path, replaypg.Migration) } - // The verifier reserves, reads back and collects; it never updates, because - // a refreshed reservation would let a replay extend its own window. - grant := "GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console;" - if !strings.Contains(string(migration), grant) { - t.Fatalf("%s does not grant exactly %q", path, grant) + // The verifier reserves, reads back, ends a finished call's hold and + // collects. It updates active and nothing else, because a refreshed + // reservation would let a replay extend its own window. + for _, grant := range []string{ + "GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console;", + "GRANT UPDATE (active) ON hop_admissions TO app_console;", + } { + if !strings.Contains(string(migration), grant) { + t.Fatalf("%s does not grant exactly %q", path, grant) + } } if strings.Contains(string(migration), "UPDATE ON hop_admissions") { - t.Fatalf("%s grants UPDATE on the reservations", path) + t.Fatalf("%s grants UPDATE on every column of the reservations", path) } } diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index 447e37d4..5d5a4cbb 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -59,8 +59,9 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl return nil, fmt.Errorf("console: %w", err) } config.GRPCServerOptions = append(config.GRPCServerOptions, - // TapHandle records when each call arrived, so the admission window is - // judged at call start rather than after a request body has decoded. + // TapHandle judges the admission window and reserves its single-use + // nonce when each call arrives, not after a request body has decoded; + // the interceptors hold the reservation until the handler returns. grpc.InTapHandle(verifier.TapHandle()), grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) @@ -68,9 +69,10 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl return func() { _ = conn.Close(); closeReplay() }, nil } -// collectEvery is how often aged-out reservations are removed. Retention is the -// admission's expiry plus the clock allowance, so anything this removes would -// already be refused on its window. +// collectEvery is how often aged-out reservations are removed. Collection +// removes only a finished call's reservation past the admission's expiry plus +// the clock allowance, and the store refuses any reservation past that, so +// nothing it removes can be reserved again. const collectEvery = time.Minute // reservations opens the console's hop admission reservations in its own store. diff --git a/modules/console/services/store/README.md b/modules/console/services/store/README.md index 9237ff7e..23103c4d 100644 --- a/modules/console/services/store/README.md +++ b/modules/console/services/store/README.md @@ -1,8 +1,9 @@ # store — the console's hop admission reservations One table, `hop_admissions`, holding the nonce of every call the hop admitted -to the console. The console's verifier reserves a nonce before it enters a -handler, so a captured admission is refused the second time it is presented. +to the console. The console's verifier reserves a nonce when the call arrives, +before its body is read, and holds it until the handler has returned, so a +captured admission is refused the second time it is presented. It is a store and not a set in the process because the guarantee has to hold across every replica of the console and across a restart. The admission @@ -19,11 +20,15 @@ the one winner among concurrent inserts, whichever replica they reach. table this service's migration carries; `code/store_test.go` asserts the two have not drifted. -Reservations are retained to the admission's expiry plus the clock allowance and -no longer, so the table does not grow without bound: the console runs -`replaypg.Store.Collect` on a schedule. Removing a reservation at its retention -is safe because the verifier judges the admission window when a call arrives, so -a call carrying a collected nonce would already be refused on its window. +Reservations are retained to the admission's expiry plus the clock allowance, +and for as long as the call holding one runs (`active`), so the table does not +grow without bound: the console runs `replaypg.Store.Collect` on a schedule, and +it removes only finished reservations past their retention. Removing one is +safe because a reservation is refused, on the database's clock, once its +retention has passed, so a later delivery of a collected nonce can never +reserve it again. A console that stops while a call runs leaves that one row +held; nothing reopens admission. The service may update `active` and no other +column. Its one reader is this module's `api`. The endpoint is private and the module interface does not export it. diff --git a/modules/console/services/store/migrations/1_hop_admissions.up.sql b/modules/console/services/store/migrations/1_hop_admissions.up.sql index 4c8108e7..58f37b57 100644 --- a/modules/console/services/store/migrations/1_hop_admissions.up.sql +++ b/modules/console/services/store/migrations/1_hop_admissions.up.sql @@ -1,8 +1,9 @@ -- Hop admission reservations: one row per admitted call, keyed by the nonce the --- hop signed into its admission. The verifier consumes a nonce before it enters --- a handler, so a captured admission is refused the second time it is --- presented. This has to hold across every replica of the console and across a --- restart, which is why it is a table and not a set in the process. +-- hop signed into its admission. The verifier consumes a nonce when the call +-- arrives, before its body is read, and holds it while the handler runs, so a +-- captured admission is refused the second time it is presented. This has to +-- hold across every replica of the console and across a restart, which is why +-- it is a table and not a set in the process. -- -- The runtime read-write role first: service.codefly.yaml declares -- `runtime-read-write-roles: [app_console]`, the postgres agent reconciles its @@ -29,10 +30,13 @@ END $$; -- is the reservation: Postgres decides one winner among concurrent inserts. CREATE TABLE IF NOT EXISTS hop_admissions ( nonce TEXT PRIMARY KEY, - retain_until TIMESTAMPTZ NOT NULL + retain_until TIMESTAMPTZ NOT NULL, + active BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS hop_admissions_retain_until ON hop_admissions (retain_until); --- Exactly what the verifier does: reserve, read back, and collect what has --- aged out. No UPDATE: a reservation is never refreshed. +-- Exactly what the verifier does: reserve, read back, end a finished call's +-- hold, and collect what has aged out. The only UPDATE is of active: a +-- reservation's retention is never refreshed. GRANT SELECT, INSERT, DELETE ON hop_admissions TO app_console; +GRANT UPDATE (active) ON hop_admissions TO app_console; From fc6fca15667ab129171d37adbcf38d5c4337feba Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:48:05 -0400 Subject: [PATCH 10/19] feat(authority): Keys publishes the hop's admission keys (authority 0.3.2) KeysResponse gains admission_keys and PublicKey gains purpose: the authority publishes the keys a hop signs call admissions with (purpose hop-admission) beside the Work Context keys (purpose work-context). They are a field of their own, never entries of keys, so a verifier that reads keys alone (an earlier release, or the JSON Web Key Set) can never trust a hop key to sign a Work Context. More than one is published while a hop key rotates. Regenerated only through codefly: generate proto in the local service, then generate client --no-facade for authority-client (CLI v0.1.215, whose Python template resolves protovalidate). noproxygate's manifest records the five regenerated Go files. The interface moves to 0.3.2, additive, so existing >=0.2.0 <0.4.0 consumers admit it. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++- interfaces/authority/interface.codefly.yaml | 2 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 395056 -> 396076 bytes .../go/gen/host/authority/v1/authority.pb.go | 99 +++++++++++------- .../host/authority/v1/authority_grpc.pb.go | 8 +- .../authority-client/library.codefly.yaml | 2 +- .../_gen/host/authority/v1/authority_pb2.py | 50 ++++----- .../host/authority/v1/authority_pb2_grpc.py | 4 +- .../src/gen/host/authority/v1/authority_pb.ts | 30 +++++- modules/authority/module.codefly.yaml | 2 +- .../pkg/gen/host/authority/v1/authority.pb.go | 99 +++++++++++------- .../host/authority/v1/authority_grpc.pb.go | 8 +- .../authorityv1connect/authority.connect.go | 8 +- .../proto/host/authority/v1/authority.proto | 18 +++- tools/noproxygate/generated.json | 10 +- 16 files changed, 242 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 7e442f90..e2eed9e0 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ staging validated. **Rung 1 first; it is the one that matters (owner, 2026-10-21).** The loop: 1. Cut a branch in module-saas-starter. accounts declares in its module - interface that it implements `codefly.dev/authority@0.3.1`, `codefly.dev/composition@0.2.0`, + interface that it implements `codefly.dev/authority@0.3.2`, `codefly.dev/composition@0.2.0`, `codefly.dev/installation@0.1.0` and `codefly.dev/session@0.2.0`, takes this repo's protos (bindings via `codefly generate proto`), and serves the four services as adapters over what it already has: `Issue` → StartTask / StartInstallationTask / @@ -276,7 +276,7 @@ Gap 7 was a decision, taken on 2026-10-22: the interface carries the host's name ## The namespace, settled (2026-10-22) The interfaces are the host's contract, so they carry the host's names: publisher -`codefly.dev` (ids `codefly.dev/authority@0.3.1`, `composition`, `installation`, +`codefly.dev` (ids `codefly.dev/authority@0.3.2`, `composition`, `installation`, `installable`, `session`) and protobuf packages `host.authority.v1`, `host.composition.v1`, `host.installation.v1`, `host.session.v1` under `proto/host/`. Not `codefly.*`: the host's buf configuration excludes a `codefly/` proto directory @@ -1020,6 +1020,20 @@ installation-owned rows contain only attached ids, and an empty owned set emits no grant. The local host is an adapter to that core. Accounts adoption and its durable adapter are not qualified by a local authority test. +`codefly.dev/authority@0.3.2` adds `KeysResponse.admission_keys` and +`PublicKey.purpose`: `Keys` publishes the hop's admission verification keys +(purpose `hop-admission`) beside the Work Context keys (purpose +`work-context`), and every verifier takes the admission keys from the `Keys` +read it already caches and refreshes, so no consumer is configured with a hop +public key. The admission keys are a field of their own, never entries of +`keys`, so a verifier that reads `keys` alone (an earlier release, or the JSON +Web Key Set) can never trust a hop key to sign a Work Context. More than one is +published while a hop key rotates. The local authority derives the public half +from the hop's own `hop-signing/SEED`. It is additive, so `>=0.2.0 <0.4.0` +consumers admit it; a host that publishes no admission key leaves every hop +admission refused (`keys_unavailable`), so `accounts` must publish its hop's key +before a cell's modules adopt this verifier. + The 0.3.1 authority version is an additive pre-stable revision selected for this refresh so existing `>=0.2.0 <0.4.0` consumers admit it. It retains 0.3.0's `AuthorizeTransfer` and adds attachment operations. Composition remains 0.2.0. diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index 3e416475..f3774030 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -2,7 +2,7 @@ kind: interface publisher: codefly.dev name: authority description: What a module or solution calls on its host to be issued, checked and verified authority. Implemented by accounts and by the local authority. -version: 0.3.1 +version: 0.3.2 type: connect protobuf: package: host.authority.v1 diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 0ec26be6..b75c1aa0 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:58c434f9e507929a6cb98a6056464df2cbc27ad486b7c85c88c282d6fc092146", + "digest": "sha256:79c6ffc50eb6cb219d8ff789e893747bc4fabc06c4ba814b969bef94d19bd238", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index ca70b22d7e96fd994ee72c3f9c853a6878853a04..4d606a9b6bc6311321b7c3fdc235af9cbad7f1e6 100644 GIT binary patch delta 6193 zcmZ`-eQ;FO6@T}>cVBkj0tq)?Yn0711`3IrwnmEbA>~7Ai?yhM1f*g}7P63JncWQ` zh+PXoT54Otws#ncRT(B=Y)8;>sROn{vC~mtWN>u+qJB(jr^Ue;?I6Pp_IK`kH&|!- z2gCmEJHKuaYl~gHYPLAW*nP!!zAYx%Wq)Lz<64|cyOw{w zW|*n6`C?{ax}Wb#`K_7kTF;-C>CLA$(ck=bzbDh#*PZfL zrn=H!GVAC{b*#;KeyY8r%kOCK?*3ZtOVpJEO`bQ#6BGnP6fV%D08&t~D)Z3>x;E+U zH8&_RQjIw~8*eai&`l+Bjg$tZXm{ zjG0pxdS3){2x-qPuD)ITm^IEFWe70ljxq!obHUi}={a0!FD{nfAsR&EVz)Z<>VmO2 z{tbh`SUjV}+Yty~BJDQSb+>r5q0Jo^nv{UhR+=yfgtjTo-eNsNMB2;L#1_%oxXd-u zL}0(Hx+%1ez<$}ycX~&IMMR~&qBztdX0wJBqe4Z&STQP86pR(kv%G~KcPuG7te>m0 zC1PP&N2J1=<~fcPv80pY>($yNV%mgMMCZ$~xP|3*Ipe+USPDTP^`|A`;f5}3NTig; z;JWMTYeK|8SmRWA^Rzv(y+-x5id!4kRM#2{*srO(E<_ymJx;l|FPH#9_o$IpG1Smg z;TsbWdakPvk&TpP9M8K)Pk>Ju^?aN7VPhtylYxM28mzfGw0AH;A6D=Yw8BShk-p+B zOGTPB^hp!614du%wIM@bh7CO8&DR71>hvd){(wkLfn5 zIroTrZ`v4aY)tAxhmF+}j6K*JeKT7LCfLMFygM`-du5Y);~uf7VN*nJd9X`hzp2Ws zE&}@kq8rfm*eeBf=W=oX^@XVMPrIW4|6_G}qCz@@41_&_08zl==gUPcYZ_$HxUsUy z3Rw(Do-3Vgj8}M11ZnXA)vfo6y-nL#WsNZcXB(SP8^RbSdYC49JeVj3Sgcwh>R9c= zEGmr^NDs5hYOfhPkxBOvW<9D}?-Ms99$}G6ZN#_*S3AkoEG!ZbNDbX5X8Su?w944P z(N1<%jW^8TiAz^8>oMiFi>DHbMXI$G*PeEfr*Fa&pmcVrQ|+QTxr;?hjSW2QVzNAh z%#qG+hJ3bY3IkG2E5(wTyBQKn`k;!@C__d=2oJkiC^_dG;qReI@6sMPkoxmV(NepI zMax182W1Z{ukhv~dRw|rFzad6+#%)^o?wwcEL%taBsoHPa{#3SbhY*XgczPQdH}-V zlcWcQWOIfX_FkdF7AS}pA2ke<4+!dBll)3s$G?xbbjARsvrk1j#SfGFO#B!GXCK8M zOovnH3=!ozO~J<@wX;)9EDRZ)Z912*33ax)1D*Gi&cWgWR2TZgsl~@oZ`dN>{YGy< zc-n9DwmA;0bKuHa0~G9~W_$(rz?HyJ&H+aQcVRuuxa-}f1IM((DxDJF^M_f<*f1Cu zlwnpj-g}U?nj_t3nDv}GlM;oc&#*{vW*nWYgC+_<=>P@E0zwc6O|k&t@t{eT!%;My zXX&_62>}FB!>h!-_0O8qhCxuCWfKzKBE%r1TV&QB8LOG=tJ$}RYBhhgXkJ)kk>E56 z9nE1A4WM*@f@pv+!(kH*AY31&Xl{pVSGq@-^`bhqS}eT%2#fey1eb0C=P2W2bh-dF z3f2w;hez4i;M4)a;ZcSX{C5(pM7n=r)?d}qE^#*X7dquJ&vr^sh>kNpR{JZ7!ryTg z8>1%!g1;|AY(Wh*5yj{w#_9TToDvVjml$gQNt!Am-B*}(O8qu1P9$Dok>E5$v>GQ# z4LXs4(mAOfT7wgLl0}1ak3j@|lA+Gt21ilpo@Ul->f##loy2KVPomn<8FEw)M}X2f zqwZZRCe@xXwG@MJbcT-8op9ty_YG#9ReRTpMTs{|iSx9hH_6d#I0BT;n`(Twn3H^y zMaO9sFbGF)vhhJR@br;>o8hXsUQ-~^+p5qlnv!pu_+SId+oU`lOONArb>3yXRNpRu zKNupW$^tj$%684=LO!m>WnpR$sm*29X1iF7j-J4U(} znDvF4mlZc9E|}bm(Odl@sWutY14`$j+LaXz$%{rc48qYxBlH-(*h>^LJsJR|b4h)Y z6$_d!v1o84F$l^fhL;bma*VFBmx=N?-Lb&ZxvV;K;>l|+n`(tYa4u7|qIkzjXS1ry ziy38`IbH`hVQ8Fmw{mNz>dA{~iLE@$*EpT8Z5+;TfNwy$zmMg`jfHJIy!S8&-`jXt zkH>Mk_qKD?<1G{qu!f16KEDNx!n(ed-RX{?3s^Zo|NH6A>3)A#dmdc@wGpFTL^|i^ z(yMz@oqm5h-xc%|ncnUJ4_!r}J>TTt8gvQSY&w;LW8KvFb^Y*>>S^-7pUI~1 zv%Oc3LpiN;u8YL$N~6)z(W0Bug6I5fCf}YUA7uoO+X89tS(jfvfOVx{PP+8vyE55y9=oYG)1RY>pzmnr zsdnwSl6l&XJ9uSqdXQRVeg~iQHEeM1luGy4+TGa|$pBN6MUnQhktj5rrO%fb?LTvYsA{Jbc4q5R_fKGI$J@>Vv$Sqb^fz z1f&P!PuGja!fqbEa|kU4F;I$1I?ZA61I3&NAul<<`?ssb;#b%DyEk)VcMdsdg_9pGsvqFZ*cuq!=Kc zl3We2aae8NAZGZ(X5YYuMrq%CP7jaq z(mlwnXVu6Cabd+l9tkSacy6I09ioavj}<_T>UH%3!VW*gV?hrFh}9h8R|VGvASO7( zab2{-zm)Ft-1-w|HFx{!nVF(eE!rp+B%U`jNv-K&ikY4`fOyiW=QfI#0eNOf%#-U}RWPpZjMD|&>cnxT16^oTlm zznGOgVibi9C`XK%REkwegH>+co?dGZ4!6ZALn5|gF!et&hY?ifujoP{*7Cwq1*yry+18b zu0s9f0WmT0HzQw#&c(|{3qa|-Oj`WlhL?>D7=+)KjSLlfFP@-KXqNy=2WUb~qq@9Y zRH>Ce5`OZ82@0Prq9eAVa)NZ&}$C6J*~y03BT4ajhtujb4`fWO=%Ca9+hVnX6IBX6aa_jU4q zJG=u*=XEtw5O>tSZdQUp_{1 zXH9ylw8HNhg#o4WUZ603gZQ3N7!btwjKWn~;SY?$fYSLuD~uUQ;SY?$fb@+Nrkqw$ zTjq=qv0JYcM}I^;vRU*dN3P68t?S5@xu|vhh$z!Fg^+68B4*ZqWc04$)Ukdl5gGi`KhbJ+?(OC;rVN!5@68wca0-OWKxz()n1O-y&utKjzWkg^fYD{Fujs zXH>P`$>)hee+mSY&Uw{3C~m&?ya^nGpq!_`e@68#F5S;K{?x1v4vL@7`i!Hg8wYM2 ze@1lw!}%B7)|vpr*?)K>Xr<#ixC`jhyhgI7;4Y~Cts_z{t{700zi6=PkBhpxbYH?joN^6$=nig zo%G8@`4!#Bz<75gVbmM0TIE$ypB?e*s(Yu3Zs)rtxY3S96)Bo!9iwl+Vkzb8~RsrzWbfC z-=6*TCA(jV@7)k@UBy=MRiRa4znq@pZ@rhEk&-`UusU5mT-Nd8S^9_Ja#p(Wb{UF9 zziVe+hLVo+BTqMvm4_LdnI1h}4hv;X_Jr$ju0lijO@(*p$0o?pBg;K6(~}fth`arW zvZDF+TL|ddl-J3P0#I?f`ffRtl@+_WK~4ya;@I^TQBK7q?{ubN2oon(zo9ypJ8R=`WX_8as3Pl#yBu`cxDV&g{srvOp&Fs ztlBLIR9!Ht<2PFbMs-z<2ZzXs3>B)?BWmPJrL}HWkdgsHZFa&U5NdCz@TQskunN`b zi#2k3S)FUG2_wI*pghPABfoCc1KwZ!NkmkrKD}g`9Lq}U`;Cf#QQvP=1dRHMG2Ud4 z3rES>&N|&&D<|j74#&I^o)AvhQDQECTvtw)BZkZkn*|aMcQD=gVxYGcQy~bXH%yn$ zm(EvlDu!Z^od3lx!yK^02JM* z_t(icOB-WJn*u`PbtM4`VaXN=&wI!WfKQ8b+YDJ>wkT>A0|kXBSU4!i7Z{)kGx!!> z;Ul(4Q+mLIasexCQnqLTMpMzX0YhShB|Pj+Fa!d+;~_b*bV+2O%|QN=SYCi!XfEZR z_pr&wa7%U0!*b@xrT)gope}q^S}?@sgS|9qN6WwfEj+`UV%XR#EqeRIa%yQy*lc;Q zGmzhsZ)X=q{xVe8Z1S;Jmg!`@eDcO+5vxzTV;S@ZnB|E`B~~yHw)h4_0Mmc3mqo06 z1&hROR*15&iayD6m1tu;=DqGOiwCGa_n#A4a{lR!)D7{6B}4K&m=MKKj3IozV&ylsGH!&2MOVN6M#wp4KfNqsG-C9 z0SLhk@`FkWafTXRze+_ekY6u88W=1-AgHg~#m@_w`a6kBXADq@PTl>8tWR~?`Vj=D zlj`?}!>JV8h;p5w;Nvzud!8J+e4F(-MCTGYfzKf>;PZC!*`J(`iUU2IT6_%rMlK58 zZv6&?(st{2h~vPDH?PjsNB&-_z*lhJyjnP}bHM(>U1aZM-1R1z!ZGYlJ#xPMRdOfG z7!VZWg0hq44D?!Qs|n@qVa~hyjrnrm6GKpb|j-VgaFuckE&TLh&8D zSc2oy6#MA7(G>y+q+5O~XO`@Ej_JnV$zzF6SlB-e zVdKV8a)VAJpb|&*v__oBqb%Z|djwJRQHFbV5`-ek{fs#$^^rz-PvSFspG1t%2@)!S z5TFt#bZVg-R&>JNr3gak1RbXbAml0cOXmDjKekX#O?+vuIL`=mlh9ZQ0V>h0|GiL- zPj$0MmT>_=2z9f8{%zoyBi+OBRNQDNu&75*StQF-J+?mNfYL+G)3Nk8URUuI!Uo7uR^fGh@BosB}_EEVvU~E7o`gHSR zS(fUvnzmdZWF@RItFF3z(=c{gM@sP}Ao z#_#2RR({srBT-ZES$mH}IbA&$=mdB9BuHLJSN%c$nH61N8U7`YED9586lR&J+#i|q zvsO)VWa3A=D4AxDUn0A%$MAqkT+$6qvNUzc+Jzv5E?I{&%`7id$gJqn^}^SA8Q78Vv+N*~s(!7dzXS-p+A&XafT((XMwi%d(<& z9z3@Qg3?YDx&d;~QE=x^`m+{!PtnUfxaMNiB^3auMDyVM0zyyo z;QZ#8sy7jn4g?@I7SBH^AG~!F4-N!^plss7fynVY38K*M0z}Wi`Jlqv_1+b7kgj-2 z4ohw3K}&?sAaCYD3y@ryqSr#`JHVM*FwJ4yTO8yQpxwtpSzTrT0E9Yf`(cw+KRam-SYejkufR z1-;KuP{VG0%W`>tYBvx1RpfxOo8twgy{nA3duWs@LxHz@^xMnjnA9HYC~`pAV;xn- z(Y-_&X(;%(R~NL(%A&nI;-3NJP?Q(My&0Hru5$NtC#@fCm34{zJnWC1Yi8I*Lia-m z5bv)(-6|g_>Ec0Gh9HExIG&Lj2*vO!bLS&CH!-QlkMx}zm3ZHp7BkCmz&Ze^!~t^P zmpW&KbW;bc4fq1x1J;I^*_8*W6xt<#N*vU8t&mlzgSHd|K{-gJkaIEP+#%~6pc043 zxe*ok9sEPqKR^%FEfcP~l@RN{#4TPdd$9kHb&2%#fXdL4xFm3xdk|Ikxb$>~Fn@u0WKH$ulr zh;|R45mH(18VVu(i`8;;Nso0opHoxaLoQPXS)kmn zx$_P7>K#elH%8{_nU&Jj9jj$3@ih9k)y8dv(+OAN*`6fj&8KLa!+&oi%P$}Mm{&@G)ME91#TSw z?r^{5`~nXdV}K#{Ef4!Gf7}#z2K}U0M%h%{8Qt`pOr_57h~K0lh>_0lZ2vF1xGC-& z8b0$d04i}#fBGC+q;ouKWvRGx9DhmBYQ)X@pC<}g0I0-yJ#?+CDnHMo{W+kVw=Y86 zto-*x`6D$Cz)F0tH>{Pf-1I$<`kf+z6t!<(+${P host.authority.v1.WorkContextV1 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 24, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 27, // [27:37] is the sub-list for method output_type - 17, // [17:27] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey + 24, // 14: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 15: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 16: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 17: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 18: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 19: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 20: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 21: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 22: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 23: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 22, // 24: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 25: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 26: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 27: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 28: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 29: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 30: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 31: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 32: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 33: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 23, // 34: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 35: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 36: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 37: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 28, // [28:38] is the sub-list for method output_type + 18, // [18:28] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index c2e63df5..40b9d3d6 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -53,7 +53,9 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(ctx context.Context, in *CheckRequest, opts ...grpc.CallOption) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -244,7 +246,9 @@ type AuthorityServiceServer interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *CheckRequest) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 3bcc5120..5405d355 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,7 +20,7 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:58c434f9e507929a6cb98a6056464df2cbc27ad486b7c85c88c282d6fc092146 + contract-digest: sha256:79c6ffc50eb6cb219d8ff789e893747bc4fabc06c4ba814b969bef94d19bd238 services: - LocalService generated-by: diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py index 4bf50adb..c03ff343 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py @@ -25,7 +25,7 @@ from authority_client._gen.host.authority.v1 import work_context_pb2 as host_dot_authority_dot_v1_dot_work__context__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"X\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xaa\x07\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"\x9d\x01\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\x12\x43\n\x0e\x61\x64mission_keys\x18\x03 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\x12\x18\n\x07purpose\x18\x04 \x01(\tR\x07purpose\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xaa\x07\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -61,28 +61,28 @@ _globals['_CHECKRESPONSE']._serialized_end=1993 _globals['_KEYSREQUEST']._serialized_start=1995 _globals['_KEYSREQUEST']._serialized_end=2008 - _globals['_KEYSRESPONSE']._serialized_start=2010 - _globals['_KEYSRESPONSE']._serialized_end=2098 - _globals['_PUBLICKEY']._serialized_start=2100 - _globals['_PUBLICKEY']._serialized_end=2195 - _globals['_LISTRESOURCESREQUEST']._serialized_start=2198 - _globals['_LISTRESOURCESREQUEST']._serialized_end=2331 - _globals['_LISTRESOURCESRESPONSE']._serialized_start=2333 - _globals['_LISTRESOURCESRESPONSE']._serialized_end=2414 - _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2417 - _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2593 - _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2595 - _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2660 - _globals['_DETACHRESOURCEREQUEST']._serialized_start=2663 - _globals['_DETACHRESOURCEREQUEST']._serialized_end=2858 - _globals['_DETACHRESOURCERESPONSE']._serialized_start=2860 - _globals['_DETACHRESOURCERESPONSE']._serialized_end=2925 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=2928 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3183 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3185 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3284 - _globals['_RESOURCEGRANT']._serialized_start=3287 - _globals['_RESOURCEGRANT']._serialized_end=3447 - _globals['_AUTHORITYSERVICE']._serialized_start=3450 - _globals['_AUTHORITYSERVICE']._serialized_end=4388 + _globals['_KEYSRESPONSE']._serialized_start=2011 + _globals['_KEYSRESPONSE']._serialized_end=2168 + _globals['_PUBLICKEY']._serialized_start=2170 + _globals['_PUBLICKEY']._serialized_end=2291 + _globals['_LISTRESOURCESREQUEST']._serialized_start=2294 + _globals['_LISTRESOURCESREQUEST']._serialized_end=2427 + _globals['_LISTRESOURCESRESPONSE']._serialized_start=2429 + _globals['_LISTRESOURCESRESPONSE']._serialized_end=2510 + _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2513 + _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2689 + _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2691 + _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2756 + _globals['_DETACHRESOURCEREQUEST']._serialized_start=2759 + _globals['_DETACHRESOURCEREQUEST']._serialized_end=2954 + _globals['_DETACHRESOURCERESPONSE']._serialized_start=2956 + _globals['_DETACHRESOURCERESPONSE']._serialized_end=3021 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=3024 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3279 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3281 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3380 + _globals['_RESOURCEGRANT']._serialized_start=3383 + _globals['_RESOURCEGRANT']._serialized_end=3543 + _globals['_AUTHORITYSERVICE']._serialized_start=3546 + _globals['_AUTHORITYSERVICE']._serialized_end=4484 # @@protoc_insertion_point(module_scope) diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py index eed48328..ed3e8777 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py @@ -111,7 +111,9 @@ def Check(self, request, context): raise NotImplementedError('Method not implemented!') def Keys(self, request, context): - """Keys returns the issuer and the public keys a verifier pins. + """Keys returns the issuer and the public keys a verifier pins: the Work + Context keys, and the hop admission keys a module behind the hop checks + each call's admission by. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index ca93200d..4bd5e819 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMuoHChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJuChFBdXRob3JpemVUcmFuc2ZlchIrLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBosLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMuoHChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJuChFBdXRob3JpemVUcmFuc2ZlchIrLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBosLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -535,11 +535,24 @@ export type KeysResponse = Message<"host.authority.v1.KeysResponse"> & { issuer: string; /** - * The public keys by key id. + * The public keys by key id: what a Work Context is signed with, and only + * that. Each carries purpose "work-context". * * @generated from field: repeated host.authority.v1.PublicKey keys = 2; */ keys: PublicKey[]; + + /** + * The keys a hop signs each call's admission with, each with purpose + * "hop-admission": a module behind the hop verifies the admission by one of + * them. More than one is published while a hop's key is rotated, so the + * retiring and the new key overlap. They are never Work Context keys and are + * kept out of keys, so no verifier that reads keys alone can trust one to + * sign a Work Context. Empty when the authority knows no hop. + * + * @generated from field: repeated host.authority.v1.PublicKey admission_keys = 3; + */ + admissionKeys: PublicKey[]; }; /** @@ -575,6 +588,15 @@ export type PublicKey = Message<"host.authority.v1.PublicKey"> & { * @generated from field: bytes public_key = 3; */ publicKey: Uint8Array; + + /** + * What the key verifies: "work-context" (a Work Context) or "hop-admission" + * (a hop's signed call admission). A verifier uses a key only for its own + * purpose. + * + * @generated from field: string purpose = 4; + */ + purpose: string; }; /** @@ -951,7 +973,9 @@ export const AuthorityService: GenService<{ output: typeof CheckResponseSchema; }, /** - * Keys returns the issuer and the public keys a verifier pins. + * Keys returns the issuer and the public keys a verifier pins: the Work + * Context keys, and the hop admission keys a module behind the hop checks + * each call's admission by. * * @generated from rpc host.authority.v1.AuthorityService.Keys */ diff --git a/modules/authority/module.codefly.yaml b/modules/authority/module.codefly.yaml index 7fcb0e6b..f82816d0 100644 --- a/modules/authority/module.codefly.yaml +++ b/modules/authority/module.codefly.yaml @@ -8,7 +8,7 @@ interface: - service: local endpoint: grpc implements: - - codefly.dev/authority@0.3.1 + - codefly.dev/authority@0.3.2 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.2.0 - codefly.dev/session@0.2.0 diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go index 24747191..5468ff9b 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go @@ -983,8 +983,16 @@ type KeysResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The issuer every capability names. Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` - // The public keys by key id. - Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". + Keys []*PublicKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty when the authority knows no hop. + AdmissionKeys []*PublicKey `protobuf:"bytes,3,rep,name=admission_keys,json=admissionKeys,proto3" json:"admission_keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1033,6 +1041,13 @@ func (x *KeysResponse) GetKeys() []*PublicKey { return nil } +func (x *KeysResponse) GetAdmissionKeys() []*PublicKey { + if x != nil { + return x.AdmissionKeys + } + return nil +} + // PublicKey is one verification key. type PublicKey struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1041,7 +1056,11 @@ type PublicKey struct { // The signature algorithm; Ed25519. Algorithm string `protobuf:"bytes,2,opt,name=algorithm,proto3" json:"algorithm,omitempty"` // The raw public key bytes. - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + Purpose string `protobuf:"bytes,4,opt,name=purpose,proto3" json:"purpose,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1097,6 +1116,13 @@ func (x *PublicKey) GetPublicKey() []byte { return nil } +func (x *PublicKey) GetPurpose() string { + if x != nil { + return x.Purpose + } + return "" +} + // ListResourcesRequest asks about one kind and one action. type ListResourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1801,15 +1827,17 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\acontext\x18\x01 \x01(\v2 .host.authority.v1.WorkContextV1R\acontext\x125\n" + "\x16authorization_revision\x18\x02 \x01(\x04R\x15authorizationRevision\x12\x18\n" + "\arefusal\x18\x03 \x01(\tR\arefusal\"\r\n" + - "\vKeysRequest\"X\n" + + "\vKeysRequest\"\x9d\x01\n" + "\fKeysResponse\x12\x16\n" + "\x06issuer\x18\x01 \x01(\tR\x06issuer\x120\n" + - "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n" + + "\x04keys\x18\x02 \x03(\v2\x1c.host.authority.v1.PublicKeyR\x04keys\x12C\n" + + "\x0eadmission_keys\x18\x03 \x03(\v2\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n" + "\tPublicKey\x12\x15\n" + "\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + - "public_key\x18\x03 \x01(\fR\tpublicKey\"\x85\x01\n" + + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"\x85\x01\n" + "\x14ListResourcesRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12#\n" + @@ -1919,35 +1947,36 @@ var file_host_authority_v1_authority_proto_depIdxs = []int32{ 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 24, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 27, // [27:37] is the sub-list for method output_type - 17, // [17:27] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey + 24, // 14: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 15: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 16: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 17: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 18: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 19: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 20: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 21: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 22: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 23: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 22, // 24: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 25: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 26: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 27: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 28: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 29: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 30: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 31: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 32: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 33: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 23, // 34: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 35: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 36: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 37: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 28, // [28:38] is the sub-list for method output_type + 18, // [18:28] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index c2e63df5..40b9d3d6 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -53,7 +53,9 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(ctx context.Context, in *CheckRequest, opts ...grpc.CallOption) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -244,7 +246,9 @@ type AuthorityServiceServer interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *CheckRequest) (*CheckResponse, error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index c340058b..52e66ca7 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -79,7 +79,9 @@ type AuthorityServiceClient interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *connect.Request[v1.CheckRequest]) (*connect.Response[v1.CheckResponse], error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -296,7 +298,9 @@ type AuthorityServiceHandler interface { // Check verifies a presented Work Context for the audience the caller is, // re-checking the authorization revision and the seal live. Check(context.Context, *connect.Request[v1.CheckRequest]) (*connect.Response[v1.CheckResponse], error) - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 7b716dcc..82b4d258 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -26,7 +26,9 @@ service AuthorityService { rpc Check(CheckRequest) returns (CheckResponse) { option (google.api.http) = {post: "/v1/authority:check" body: "*"}; } - // Keys returns the issuer and the public keys a verifier pins. + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. rpc Keys(KeysRequest) returns (KeysResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -260,8 +262,16 @@ message KeysRequest {} message KeysResponse { // The issuer every capability names. string issuer = 1; - // The public keys by key id. + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". repeated PublicKey keys = 2; + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty when the authority knows no hop. + repeated PublicKey admission_keys = 3; } // PublicKey is one verification key. @@ -272,6 +282,10 @@ message PublicKey { string algorithm = 2; // The raw public key bytes. bytes public_key = 3; + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + string purpose = 4; } // ListResourcesRequest asks about one kind and one action. diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 202d32fa..41e85008 100644 --- a/tools/noproxygate/generated.json +++ b/tools/noproxygate/generated.json @@ -3,8 +3,8 @@ "libraries/authority-client/go/gen/api_grpc.pb.go": "71617cc6107e9332fa88dc1889892378cf36e0887a5d05b87e7af8089a158f5a", "libraries/authority-client/go/gen/host/artifact/v1/artifact.pb.go": "8f032d82c2a6921e47ec08cde4c797fa75ec05e6c7cccb5b5a1be744805e8f4e", "libraries/authority-client/go/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", - "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "e9e35ce1a1b3e5d627b33b44653ae1d7913d684dfe12d512b2d8fd6526dd294a", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "435f959a8826ae58052fb48e8f43a9975de548cb65fea620a2c482f242707306", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "5a1f16daacb02f2811b5d58b878cf95cd1ab7996b1c0a953d2cdec730724cc93", "libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go": "c53b6b47355ee9a502441173931b3b809e510054168aa0cc2984b5152e771bf1", "libraries/authority-client/go/gen/host/composition/v1/composition.pb.go": "8fc4657b7b207b2fd80db74b238b10e5b0afd65b25851d56817cb0b960b1c750", "libraries/authority-client/go/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", @@ -39,10 +39,10 @@ "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact.pb.go": "13863e4c49e9b038ea946ac6c2a373dc222f5b1fb8ddc437109c9bd2afa7bb3f", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifactv1connect/artifact.connect.go": "8d5ab439f1293c76d3b5d2fe0e2a4845694a08b007f13c62f19c2de6c6e2b20f", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "8394375d99a2fcc535ff76821f0793897d17356d089c8463d28e5043ce70cf73", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "76ee9672455652f0649d51e43028e00456484b37e58d3588267d34251f757faa", "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.gw.go": "37a1508e28e8bdb5310471401c9f78a5b14faa646e826bc5592ef64684fb7ea2", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "988913b6a4466f9143731dedc62782c468769d156c7cc5aa9fa6062efaee0f87", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "5a1f16daacb02f2811b5d58b878cf95cd1ab7996b1c0a953d2cdec730724cc93", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "3034a760140acba69c15b85395e5afc4c392629dc32abd74e9a910f00511a46e", "modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go": "30da6150c9d3567ba0552590dd4ae3736f313097d4f4df8fcbaf242fca8af029", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go": "c7a1220dc4bfeb3419485a4b41b5a8a5f8a9a35654180402b46010345f5094e4", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", From d534e628e95ff67772e7b8692b9ca354c656c343 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:51:07 -0400 Subject: [PATCH 11/19] feat(stamp): choose the published admission key a proof is signed by SignedBy names which of the authority's published hop admission keys an admission is signed by, so a verifier trusts every published key while a hop's key rotates. Published is a seed's public half as Keys carries it in admission_keys: Ed25519, purpose hop-admission, a key id derived from the key. Check keeps judging one key; the proof's parsing is shared. Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/go/stamp/call.go | 70 +++++++++++++++---- .../authority-client/go/stamp/call_test.go | 43 ++++++++++++ 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go index b23440aa..24f4e7ea 100644 --- a/libraries/authority-client/go/stamp/call.go +++ b/libraries/authority-client/go/stamp/call.go @@ -11,6 +11,7 @@ import ( "strings" "time" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "google.golang.org/grpc/metadata" ) @@ -81,7 +82,8 @@ func (s *Signer) Sign(token, method, audience string, now time.Time, tokenExpiry } // Check authenticates the binding before any keys lookup or replay-store write. -// publicKey is trusted delivery configuration, never read from incoming metadata. +// publicKey is one of the authority's published admission keys (SignedBy +// chooses it), never read from incoming metadata. // Every refusal is a caller-safe refusal.Reason: it names no proof, token, // digest or nonce, and callers read its code with errors.As. func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (Call, error) { @@ -93,18 +95,9 @@ func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (C } return v[0] } - encoded := one("x-codefly-hop-proof") - parts := strings.Split(encoded, ".") + payload, signature, ok := proof(md) pub, err := hex.DecodeString(publicKey) - if err != nil || len(pub) != ed25519.PublicKeySize || len(parts) != 2 || len(encoded) > 8192 { - return fail(refusal.StampProof) - } - payload, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { - return fail(refusal.StampProof) - } - signature, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] || !ed25519.Verify(pub, payload, signature) { + if !ok || err != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(pub, payload, signature) { return fail(refusal.StampProof) } var fields []json.RawMessage @@ -162,6 +155,59 @@ func Check(md metadata.MD, publicKey, method, audience string, now time.Time) (C return call, nil } +// proof is the one admission md carries, split into its signed payload and +// signature, each in canonical encoding; ok is false for anything else. +func proof(md metadata.MD) (payload, signature []byte, ok bool) { + values := md.Get("x-codefly-hop-proof") + if len(values) != 1 || len(values[0]) > 8192 { + return nil, nil, false + } + parts := strings.Split(values[0], ".") + if len(parts) != 2 { + return nil, nil, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { + return nil, nil, false + } + signature, err = base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] { + return nil, nil, false + } + return payload, signature, true +} + +// SignedBy is which of the published admission keys (hex) the admission md +// carries is signed by, or "" when it is signed by none of them. It +// authenticates only the signature: Check, given the key it names, judges the +// rest. A verifier trusts more than one key while a hop's key rotates. +func SignedBy(md metadata.MD, publicKeys []string) string { + payload, signature, ok := proof(md) + if !ok { + return "" + } + for _, key := range publicKeys { + pub, err := hex.DecodeString(key) + if err == nil && len(pub) == ed25519.PublicKeySize && ed25519.Verify(pub, payload, signature) { + return key + } + } + return "" +} + +// Purpose is what a published admission key verifies: Keys carries the hop's +// keys in admission_keys with this purpose, never among the Work Context keys. +const Purpose = "hop-admission" + +// Published is the signer's public key as the authority publishes it in Keys' +// admission_keys: Ed25519, this purpose, and a key id derived from the key, so +// the same seed always publishes the same entry. +func (s *Signer) Published() *authorityv1.PublicKey { + public := s.key.Public().(ed25519.PublicKey) + sum := sha256.Sum256(public) + return &authorityv1.PublicKey{KeyId: "hop-" + hex.EncodeToString(sum[:8]), Algorithm: "Ed25519", PublicKey: public, Purpose: Purpose} +} + // ReplayKey is globally namespaced by signing key and random nonce, not by method: // even an accidental nonce reuse on another method must not authorize another call. func (c Call) ReplayKey(publicKey string) string { diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go index 8b09a92f..5a69550e 100644 --- a/libraries/authority-client/go/stamp/call_test.go +++ b/libraries/authority-client/go/stamp/call_test.go @@ -79,3 +79,46 @@ func TestSignedCallBindingRejectsMutationAndExpiredOrAmbiguousProof(t *testing.T t.Fatal("signed expired capability") } } + +// A verifier trusts every published admission key while a hop's key rotates: +// SignedBy names the one a proof is signed by, and none for a key nobody +// published or a proof that is not one. +func TestSignedByNamesThePublishedKeyAProofIsSignedBy(t *testing.T) { + retiring, _ := NewSigner(strings.Repeat("01", 32)) + current, _ := NewSigner(strings.Repeat("02", 32)) + unpublished, _ := NewSigner(strings.Repeat("03", 32)) + now := time.Unix(1700000000, 0) + published := []string{retiring.PublicKey(), current.PublicKey()} + for _, tc := range []struct { + signer *Signer + want string + }{{retiring, retiring.PublicKey()}, {current, current.PublicKey()}, {unpublished, ""}} { + proof, err := tc.signer.Sign("synthetic-context", "/pkg.Service/Read", "module", now, now.Add(time.Minute).Unix()) + if err != nil { + t.Fatal(err) + } + if got := SignedBy(metadata.Pairs("x-codefly-hop-proof", proof), published); got != tc.want { + t.Fatalf("signed by %q, want %q", got, tc.want) + } + if got := SignedBy(metadata.Pairs("x-codefly-hop-proof", proof, "x-codefly-hop-proof", proof), published); got != "" { + t.Fatalf("a repeated proof named %q", got) + } + } + if SignedBy(metadata.Pairs("x-codefly-hop-proof", "not.a-proof"), published) != "" || SignedBy(metadata.MD{}, published) != "" { + t.Fatal("a malformed or missing proof named a key") + } +} + +// The authority publishes a seed's public half as one stable entry: the same +// seed always yields the same key id, with the admission purpose. +func TestPublishedIsTheSignersStableAdmissionEntry(t *testing.T) { + signer, _ := NewSigner(strings.Repeat("01", 32)) + again, _ := NewSigner(strings.Repeat("01", 32)) + entry := signer.Published() + if entry.GetPurpose() != Purpose || entry.GetAlgorithm() != "Ed25519" || hex.EncodeToString(entry.GetPublicKey()) != signer.PublicKey() { + t.Fatalf("entry %v", entry) + } + if entry.GetKeyId() == "" || entry.GetKeyId() != again.Published().GetKeyId() { + t.Fatalf("key id %q is not stable", entry.GetKeyId()) + } +} From c5b5e23960ab90cfb57f717fe59c7b55abedf705 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:01:13 -0400 Subject: [PATCH 12/19] fix(stamp): the admission entry names no generated type A host with bindings of its own (the local authority) links its own copy of host/authority/v1, so a stamp package importing the client's bindings would register the same proto files twice. Admission answers the key id and the raw key, and each caller builds the entry in its own bindings. Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/go/stamp/call.go | 17 +++++++++-------- .../authority-client/go/stamp/call_test.go | 18 +++++++++++------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go index 24f4e7ea..ce238fff 100644 --- a/libraries/authority-client/go/stamp/call.go +++ b/libraries/authority-client/go/stamp/call.go @@ -11,7 +11,6 @@ import ( "strings" "time" - authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "google.golang.org/grpc/metadata" ) @@ -199,13 +198,15 @@ func SignedBy(md metadata.MD, publicKeys []string) string { // keys in admission_keys with this purpose, never among the Work Context keys. const Purpose = "hop-admission" -// Published is the signer's public key as the authority publishes it in Keys' -// admission_keys: Ed25519, this purpose, and a key id derived from the key, so -// the same seed always publishes the same entry. -func (s *Signer) Published() *authorityv1.PublicKey { - public := s.key.Public().(ed25519.PublicKey) - sum := sha256.Sum256(public) - return &authorityv1.PublicKey{KeyId: "hop-" + hex.EncodeToString(sum[:8]), Algorithm: "Ed25519", PublicKey: public, Purpose: Purpose} +// Admission is the signer's public key as the authority publishes it in Keys' +// admission_keys, with algorithm Ed25519 and purpose Purpose: a key id derived +// from the key, so the same seed always publishes the same entry, and the raw +// public key. It names no generated type, so a host with bindings of its own +// builds the entry in them. +func (s *Signer) Admission() (keyID string, public []byte) { + key := s.key.Public().(ed25519.PublicKey) + sum := sha256.Sum256(key) + return "hop-" + hex.EncodeToString(sum[:8]), []byte(key) } // ReplayKey is globally namespaced by signing key and random nonce, not by method: diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go index 5a69550e..8dd0935e 100644 --- a/libraries/authority-client/go/stamp/call_test.go +++ b/libraries/authority-client/go/stamp/call_test.go @@ -110,15 +110,19 @@ func TestSignedByNamesThePublishedKeyAProofIsSignedBy(t *testing.T) { } // The authority publishes a seed's public half as one stable entry: the same -// seed always yields the same key id, with the admission purpose. -func TestPublishedIsTheSignersStableAdmissionEntry(t *testing.T) { +// seed always yields the same key id beside its public key. +func TestAdmissionIsTheSignersStablePublishedEntry(t *testing.T) { signer, _ := NewSigner(strings.Repeat("01", 32)) again, _ := NewSigner(strings.Repeat("01", 32)) - entry := signer.Published() - if entry.GetPurpose() != Purpose || entry.GetAlgorithm() != "Ed25519" || hex.EncodeToString(entry.GetPublicKey()) != signer.PublicKey() { - t.Fatalf("entry %v", entry) + other, _ := NewSigner(strings.Repeat("02", 32)) + id, public := signer.Admission() + if hex.EncodeToString(public) != signer.PublicKey() || id == "" { + t.Fatalf("entry %q %x", id, public) } - if entry.GetKeyId() == "" || entry.GetKeyId() != again.Published().GetKeyId() { - t.Fatalf("key id %q is not stable", entry.GetKeyId()) + if againID, _ := again.Admission(); againID != id { + t.Fatalf("key id %q is not stable", id) + } + if otherID, _ := other.Admission(); otherID == id { + t.Fatal("two keys share a key id") } } From f1a7f05f9f23ba739ae083e5b9c638dd8b387750 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:04:26 -0400 Subject: [PATCH 13/19] fix(hop): verifiers take the hop's admission key from Keys; judge arrival on every listener; collect crashed holds Review r3 (codefly-dev/authority#28 at 073a40e) B1, M1 and M2. B1. The Go verifier reads the hop's admission keys from the authority's Keys, the read it already caches and refreshes, and trusts every one published, so a rotation overlaps. NewVerifier no longer takes a public key; hop-verification/PUBLIC_KEY and the playground's empty file are deleted. A stamp signed by no published key is refused as stamp_proof and starts no key read; a verifier holding no trusted keys at all reads them, at most once per keyRetry, as readiness does. An authority publishing no admission key refuses every call as keys_unavailable and KeysError says why (ErrNoAdmissionKeys). A key is used for its own purpose only. The local authority derives the public half from the hop's own hop-signing/SEED in code and publishes it; hoptest publishes the hops a test names (PublishHop, WithdrawHop). M1. StatsHandler replaces TapHandle. gRPC tags every call with it in handleStream, on its own listener and on the Connect listener that reaches the same server through ServeHTTP, where no tap handle runs. The console installs it; its regression drives the generated server on both listeners and over the Connect protocol with a body that arrives after the window. M2. replaypg.Collect removes every reservation past its retention by the database clock, active or not: Reserve refuses past retention on that clock, so nothing is revived, and a holder that stopped before Finish leaves no permanent row. Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 4 +- libraries/hop/go/admission.go | 23 +- libraries/hop/go/arrival_test.go | 40 +-- libraries/hop/go/bare_test.go | 2 +- libraries/hop/go/binding_test.go | 10 +- libraries/hop/go/durable_test.go | 2 +- libraries/hop/go/fixture_test.go | 57 ++-- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 +- libraries/hop/go/hop.go | 266 +++++++++++++----- libraries/hop/go/hop_test.go | 14 +- libraries/hop/go/hoptest/hoptest.go | 69 ++++- libraries/hop/go/published_test.go | 121 ++++++++ libraries/hop/go/reasons_test.go | 6 +- libraries/hop/go/replaypg/replaypg.go | 19 +- libraries/hop/go/replaypg/replaypg_test.go | 79 +++++- libraries/hop/go/transport_postgres_test.go | 47 ++-- libraries/hop/testdata/stamped.json | 18 +- .../hop/code/pkg/infra/proxy/http_test.go | 2 +- .../hop/code/pkg/infra/proxy/proxy_test.go | 2 +- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 +- .../services/local/code/hopkey_test.go | 44 +++ .../local/code/pkg/business/issuer/issuer.go | 25 +- modules/authority/services/local/code/work.go | 31 +- .../console/services/api/code/arrival_test.go | 222 +++++++++++++++ modules/console/services/api/code/work.go | 31 +- .../console/services/api/service.codefly.yaml | 7 - .../configurations/local/hop-verification.env | 18 -- 30 files changed, 922 insertions(+), 251 deletions(-) create mode 100644 libraries/hop/go/published_test.go create mode 100644 modules/authority/services/local/code/hopkey_test.go create mode 100644 modules/console/services/api/code/arrival_test.go delete mode 100644 playground/configurations/local/hop-verification.env diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index f44c3570..2b37d00c 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-20261026135819-3d0a11cf77e4 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/google/uuid v1.6.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 8b6357b4..a665ca1f 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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= 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.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= diff --git a/libraries/hop/go/admission.go b/libraries/hop/go/admission.go index 5df929ac..7ecf1b48 100644 --- a/libraries/hop/go/admission.go +++ b/libraries/hop/go/admission.go @@ -27,7 +27,12 @@ import ( // reservation whose retainUntil has already passed (refusal.StampWindow): a // reservation delayed past its own horizon must never revive a nonce that // collection removed. A Finish that fails leaves the reservation held, so a -// failure never reopens admission. replaypg.Store implements it. +// failure never reopens admission. Because Reserve refuses on the store's own +// clock any reservation past retainUntil, a store may collect a reservation +// once retainUntil has passed by that clock whether or not its call has +// finished: nothing can reserve that nonce again. replaypg.Store implements +// it, and collects so, so a holder that stops before Finish leaves no +// permanent row. type ReservationStore interface { core.ReplayStore Reserve(ctx context.Context, nonce string, retainUntil time.Time) error @@ -51,16 +56,22 @@ const storeTimeout = 5 * time.Second // and consumes its single-use nonce. It returns the release that ends the // reservation's in-flight hold, safe to call more than once. func (v *Verifier) reserve(ctx context.Context, md metadata.MD, method string, arrival instant) (func(), error) { - if v.publicKey == "" || v.replay == nil { - // A token-only verifier (NewTokenVerifier) holds neither the hop's - // public key nor a replay store, so it can establish no admission. + if v.replay == nil { + // A token-only verifier (NewTokenVerifier) holds no replay store, so + // it can establish no admission. return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Configuration) } - call, err := stamp.Check(md, v.publicKey, method, v.audience, arrival.at) + // The admission is judged at the instant the call arrived, whenever the + // published key it is signed by is read. + key, err := v.admissionKey(ctx, md) + if err != nil { + return nil, err + } + call, err := stamp.Check(md, key, method, v.audience, arrival.at) if err != nil { return nil, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) } - nonce := call.ReplayKey(v.publicKey) + nonce := call.ReplayKey(key) // Retained to expiry plus the clock allowance at least, so a lagging // verifier never admits a second delivery of a nonce another released. until := time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) diff --git a/libraries/hop/go/arrival_test.go b/libraries/hop/go/arrival_test.go index 28515228..27828286 100644 --- a/libraries/hop/go/arrival_test.go +++ b/libraries/hop/go/arrival_test.go @@ -13,16 +13,26 @@ import ( core "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/metadata" - "google.golang.org/grpc/tap" + "google.golang.org/grpc/stats" ) const arrivalMethod = "/test.Service/Read" +// arrive is gRPC tagging a call that reached the server carrying md, as it +// does on every transport before the call's body is read. It returns once the +// reservation started at arrival has been decided, so the test's clock moves +// only after it. +func arrive(v *Verifier, md metadata.MD) context.Context { + ctx := v.StatsHandler().TagRPC(metadata.NewIncomingContext(context.Background(), md), &stats.RPCTagInfo{FullMethodName: arrivalMethod}) + <-ctx.Value(admissionKey{}).(*admission).reserved + return ctx +} + func arrivalVerifier(t *testing.T, clock *time.Time) (*Verifier, signer) { t.Helper() s := newSigner("arrival") source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} - v, err := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -35,7 +45,7 @@ func arrivalVerifier(t *testing.T, clock *time.Time) (*Verifier, signer) { // unary interceptor, so a slow or large upload reached Verify long after the // hop admitted it and was refused for an expiry that had not happened at call // start. The window is judged at the instant the stream was accepted, which -// TapHandle records. +// the stats handler records. func TestTheAdmissionWindowIsJudgedWhenTheCallArrivesNotAfterItsBodyDecodes(t *testing.T) { clock := time.Unix(1792818000, 0) v, s := arrivalVerifier(t, &clock) @@ -43,10 +53,7 @@ func TestTheAdmissionWindowIsJudgedWhenTheCallArrivesNotAfterItsBodyDecodes(t *t // The stream is accepted now; its body then takes two minutes to arrive, // well past stamp.Lifetime. - ctx, err := v.TapHandle()(metadata.NewIncomingContext(context.Background(), md), &tap.Info{FullMethodName: arrivalMethod, Header: md}) - if err != nil { - t.Fatal(err) - } + ctx := arrive(v, md) clock = clock.Add(2 * time.Minute) reached := false if _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { @@ -57,9 +64,9 @@ func TestTheAdmissionWindowIsJudgedWhenTheCallArrivesNotAfterItsBodyDecodes(t *t } } -// Nothing is admitted by the absence of a tap handle: a server that installs -// none judges the window at the instant Verify runs, as it did before. -func TestWithoutATapHandleTheWindowIsJudgedWhenVerifyRuns(t *testing.T) { +// Nothing is admitted by the absence of the stats handler: a server that +// installs none judges the window at the instant Verify runs, as it did before. +func TestWithoutTheStatsHandlerTheWindowIsJudgedWhenVerifyRuns(t *testing.T) { clock := time.Unix(1792818000, 0) v, s := arrivalVerifier(t, &clock) md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") @@ -67,11 +74,11 @@ func TestWithoutATapHandleTheWindowIsJudgedWhenVerifyRuns(t *testing.T) { _, err := v.Verify(context.Background(), md, arrivalMethod) var reason refusal.Reason if !errors.As(err, &reason) || reason != refusal.StampWindow { - t.Fatalf("want %s without a tap handle: %v", refusal.StampWindow, err) + t.Fatalf("want %s without the stats handler: %v", refusal.StampWindow, err) } } -// The window the tap handle opens is the admission's own, not an unbounded one: +// The window the stats handler opens is the admission's own, not an unbounded one: // a call accepted further ahead than the clock allowance, or long after the // admission expired, is still refused, each under its own reason. func TestTheArrivalInstantDoesNotWidenTheAdmissionWindow(t *testing.T) { @@ -88,11 +95,8 @@ func TestTheArrivalInstantDoesNotWidenTheAdmissionWindow(t *testing.T) { v, s := arrivalVerifier(t, &clock) md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") clock = clock.Add(tc.shift) - ctx, err := v.TapHandle()(metadata.NewIncomingContext(context.Background(), md), &tap.Info{FullMethodName: arrivalMethod, Header: md}) - if err != nil { - t.Fatal(err) - } - _, err = v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { + ctx := arrive(v, md) + _, err := v.UnaryServerInterceptor()(ctx, nil, &grpc.UnaryServerInfo{FullMethod: arrivalMethod}, func(context.Context, any) (any, error) { return nil, nil }) // The interceptor answers a gRPC status, which carries the reason @@ -112,7 +116,7 @@ func TestAReservationIsRetainedPastExpiryByTheClockAllowance(t *testing.T) { store := &retention{} v.replay = store md := forwarded(t, s.issue(t, clock, "documents"), "proof", "") - call, err := stamp.Check(md, v.publicKey, arrivalMethod, "documents", clock) + call, err := stamp.Check(md, testSigner("proof").PublicKey(), arrivalMethod, "documents", clock) if err != nil { t.Fatal(err) } diff --git a/libraries/hop/go/bare_test.go b/libraries/hop/go/bare_test.go index a81c0e89..5b667319 100644 --- a/libraries/hop/go/bare_test.go +++ b/libraries/hop/go/bare_test.go @@ -55,7 +55,7 @@ func TestBareTokenWaitsForStaleRefreshAndRefusesFailure(t *testing.T) { now := time.Now() signer := newSigner("bare-refresh") source := &keys{response: &authorityv1.KeysResponse{Issuer: signer.issuer, Keys: []*authorityv1.PublicKey{signer.published()}}} - v, err := NewVerifier(t.Context(), source, "module", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } diff --git a/libraries/hop/go/binding_test.go b/libraries/hop/go/binding_test.go index 11bb5495..c42d51b8 100644 --- a/libraries/hop/go/binding_test.go +++ b/libraries/hop/go/binding_test.go @@ -23,11 +23,11 @@ func TestReviewReadStampCannotRetargetOrReplayAcrossReplicas(t *testing.T) { const readMethod = "/documents.v1.Service/Get" md := hoptest.Forward(t, token, proof, readMethod) store := hoptest.Replay() - v, err := hop.NewVerifier(ctx, a, "documents", hoptest.PublicKey(proof), store) + v, err := hop.NewVerifier(ctx, a.PublishHop(proof), "documents", store) if err != nil { t.Fatal(err) } - replica, err := hop.NewVerifier(ctx, a, "documents", hoptest.PublicKey(proof), store) + replica, err := hop.NewVerifier(ctx, a.PublishHop(proof), "documents", store) if err != nil { t.Fatal(err) } @@ -36,7 +36,7 @@ func TestReviewReadStampCannotRetargetOrReplayAcrossReplicas(t *testing.T) { t.Fatalf("retarget to %q: %v", method, err) } } - other, _ := hop.NewVerifier(ctx, a, "records", hoptest.PublicKey(proof), store) + other, _ := hop.NewVerifier(ctx, a.PublishHop(proof), "records", store) if _, err := other.Verify(ctx, md, readMethod); err == nil || !strings.Contains(err.Error(), "stamp_audience") { t.Fatalf("other audience: %v", err) } @@ -64,8 +64,8 @@ func TestReviewZeroScopeSharedProofCannotFabricateAdmission(t *testing.T) { func TestConcurrentReplayHasOneWinnerAcrossVerifiers(t *testing.T) { a := hoptest.New(t) store := hoptest.Replay() - first, _ := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) - second, _ := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) + first, _ := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) + second, _ := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) md := hoptest.Forward(t, a.IssueSingleUse(t, "documents", read), proof, "/documents.v1.Service/Get") var accepted atomic.Int64 var wg sync.WaitGroup diff --git a/libraries/hop/go/durable_test.go b/libraries/hop/go/durable_test.go index d81443be..ac6ce55c 100644 --- a/libraries/hop/go/durable_test.go +++ b/libraries/hop/go/durable_test.go @@ -41,7 +41,7 @@ func TestAReplayStoreThatIsNotSharedIsRefusedOutsideALocalRun(t *testing.T) { t.Fatal(err) } build := func(store core.ReplayStore) error { - _, err := hop.NewVerifier(context.Background(), a, "documents", hoptest.PublicKey(proof), store) + _, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), "documents", store) return err } diff --git a/libraries/hop/go/fixture_test.go b/libraries/hop/go/fixture_test.go index 764282bb..e6218f65 100644 --- a/libraries/hop/go/fixture_test.go +++ b/libraries/hop/go/fixture_test.go @@ -23,6 +23,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) var updateFixture = flag.Bool("update-fixture", false, "rewrite ../testdata/stamped.json") @@ -41,7 +42,7 @@ func newSigner(seed string) signer { } func (s signer) published() *authorityv1.PublicKey { - return &authorityv1.PublicKey{KeyId: s.id, Algorithm: core.Algorithm, PublicKey: s.key.Public().(ed25519.PublicKey)} + return &authorityv1.PublicKey{KeyId: s.id, Algorithm: core.Algorithm, PublicKey: s.key.Public().(ed25519.PublicKey), Purpose: workContextPurpose} } func (s signer) issue(t *testing.T, at time.Time, audience string, scopes ...*basev0.WorkScopeV1) string { @@ -74,7 +75,9 @@ func forwarded(t *testing.T, token, proof, advisory string) metadata.MD { } // 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). +// read waits for it to be closed (or for its own context to end). A response +// that publishes no hop admission key publishes the test hop's +// (testSigner("proof")), as an authority publishes its hop's key. type keys struct { mu sync.Mutex response *authorityv1.KeysResponse @@ -95,6 +98,10 @@ func (k *keys) Keys(ctx context.Context, _ *authorityv1.KeysRequest, _ ...grpc.C return nil, ctx.Err() } } + if response != nil && len(response.GetAdmissionKeys()) == 0 { + response = proto.Clone(response).(*authorityv1.KeysResponse) + response.AdmissionKeys = []*authorityv1.PublicKey{publishedHop("proof")} + } return response, err } @@ -121,18 +128,17 @@ func settled(v *Verifier) { } type stampFixture struct { - PublicKey string `json:"public_key"` - 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"` + 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 (codefly_hop, in libraries/authority-client/python) reads it back. +// Context, the published keys that authenticate it at a fixed clock (the Work +// Context's and the hop's admission key), and the 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") @@ -140,8 +146,8 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { 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", "") - published, _ := protojson.Marshal(&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}) - f := stampFixture{PublicKey: testSigner("fixture-proof").PublicKey(), Audience: "documents", Now: at.Unix(), Keys: published, Headers: map[string]string{}} + published, _ := protojson.Marshal(&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}, AdmissionKeys: []*authorityv1.PublicKey{publishedHop("fixture-proof")}}) + f := stampFixture{Audience: "documents", Now: at.Unix(), Keys: published, Headers: map[string]string{}} for key, values := range md { f.Headers[key] = values[0] } @@ -164,7 +170,7 @@ func TestTheStampFixtureAuthenticatesAsTheHopWroteIt(t *testing.T) { if err := protojson.Unmarshal(f.Keys, published); err != nil { t.Fatal(err) } - v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, f.PublicKey, core.NewMemoryReplayStore()) + v, err := NewVerifier(context.Background(), &keys{response: published}, f.Audience, core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -200,7 +206,7 @@ func TestPublishedKeysAreRereadOnRotationAndExpireWhenUnreadable(t *testing.T) { 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", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -248,7 +254,7 @@ 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", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) 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) @@ -282,7 +288,7 @@ func TestAHungReadStallsNoHeldKeyAndACallerGivingUpDelaysNoOne(t *testing.T) { 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", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start hold := make(chan struct{}) @@ -326,7 +332,7 @@ func TestACallOnKeysPastKeyStaleWaitsForTheReread(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = start, start clock = start.Add(39 * time.Minute) @@ -346,7 +352,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} start := time.Unix(1792818000, 0) clock := start - v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.timeout = 100 * time.Millisecond v.fetched, v.tried = start, start @@ -391,7 +397,7 @@ func TestACallOnKeysPastKeyStaleIsRefusedWhenTheRereadFails(t *testing.T) { // 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", publicKey: "proof", now: time.Now, timeout: 20 * time.Millisecond} + v := &Verifier{source: source, audience: "documents", replay: core.NewMemoryReplayStore(), now: time.Now, timeout: 20 * time.Millisecond} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -423,7 +429,7 @@ func TestReadyHoldsWhileKeysAreTrusted(t *testing.T) { first := newSigner("first") source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock if !v.Ready() { @@ -449,7 +455,7 @@ func TestAnUnreadyReplicaBecomesReadyByPollingAlone(t *testing.T) { first := newSigner("first") source := &keys{err: errors.New("authority not serving yet")} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.tried = clock ready := false @@ -473,7 +479,7 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { first := newSigner("first") source := &keys{response: &authorityv1.KeysResponse{Issuer: first.issuer, Keys: []*authorityv1.PublicKey{first.published()}}} clock := time.Unix(1792818000, 0) - v, _ := NewVerifier(context.Background(), source, "documents", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) v.now = func() time.Time { return clock } v.fetched, v.tried = clock, clock for minute := 1; minute <= 60; minute++ { @@ -489,6 +495,13 @@ func TestAnIdleReplicaPolledForReadinessStaysReady(t *testing.T) { } } +// publishedHop is the admission key of the test hop that signs as label, as +// an authority's Keys carries it in admission_keys. +func publishedHop(label string) *authorityv1.PublicKey { + id, public := testSigner(label).Admission() + return &authorityv1.PublicKey{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: stamp.Purpose} +} + func testSigner(label string) *stamp.Signer { sum := sha256.Sum256([]byte(label)) signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index bad50897..32575924 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-20261026135819-3d0a11cf77e4 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/jackc/pgx/v5 v5.11.0 google.golang.org/grpc v1.84.0 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 7b216019..ef7c731c 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index ad5d6efe..5b32d992 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -10,9 +10,12 @@ // 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. // -// The hop signs each admission with a service-only key. Verifiers pin its public -// key and atomically consume the signed nonce in a shared ReplayStore. Method, -// audience and token digest are authenticated; a captured stamp is single-use. +// The hop signs each admission with a service-only key. The authority publishes +// its public half in Keys (admission_keys, purpose hop-admission), so verifiers +// take it from the keys they already read, cache and refresh, never from their +// own configuration, and trust every published one while a key rotates. They +// atomically consume the signed nonce in a shared ReplayStore. Method, audience +// and token digest are authenticated; a captured stamp is single-use. // // Every refusal a caller is given is a refusal.Reason (Reason here): a stable // code with fixed text that names no token, claim, key or transport failure. @@ -46,8 +49,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" "google.golang.org/grpc/status" - "google.golang.org/grpc/tap" ) // WorkContextKey carries the caller's Work Context to the hop, as everywhere. @@ -213,7 +216,8 @@ func Audience() (string, error) { } // 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. +// it: the issuer, the public keys every Work Context is signed with, and the +// hop's admission keys. type KeySource interface { Keys(ctx context.Context, in *authorityv1.KeysRequest, opts ...grpc.CallOption) (*authorityv1.KeysResponse, error) } @@ -238,40 +242,46 @@ const ( // so it goes to KeysError, for the module's logs, never to a caller. var errKeysUnavailable = fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.KeysUnavailable) +// ErrNoAdmissionKeys is what KeysError reports, for the module's logs, while +// the authority's keys read but publish no hop admission key: every call is +// refused as keys_unavailable until the authority publishes its hop's key. +var ErrNoAdmissionKeys = errors.New("hop: the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted: the authority must publish its hop's key") + // 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 - publicKey string - replay core.ReplayStore - now func() time.Time - timeout time.Duration - - 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 delivered hop public key. -// replay must atomically reserve nonces across all replicas and survive restart. -// Keys is the generated authority client; no signing seed is delivered here. + source KeySource + audience string + replay core.ReplayStore + now func() time.Time + timeout time.Duration + + mu sync.Mutex + trust workcontext.Trust + admission []string // the published hop admission keys, hex, read with 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. Keys is the generated authority +// client: the Work Context keys and the hop's admission keys are both read +// through it, so nothing about the hop is configured here and no signing seed +// is delivered. replay must atomically reserve nonces across all replicas and +// survive restart. // // 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, publicKey string, replay core.ReplayStore) (*Verifier, error) { - raw, keyErr := hex.DecodeString(publicKey) - if replay == nil || keyErr != nil || len(raw) != ed25519.PublicKeySize || hex.EncodeToString(raw) != publicKey { +func NewVerifier(ctx context.Context, keys KeySource, audience string, replay core.ReplayStore) (*Verifier, error) { + if replay == nil { return nil, refusal.Configuration } if err := durableOutsideLocal(replay); err != nil { return nil, err } - return newVerifier(ctx, keys, audience, publicKey, replay) + return newVerifier(ctx, keys, audience, replay) } // SharedReplayStore is a replay store that reports whether it is the durable @@ -308,18 +318,18 @@ func durableOutsideLocal(replay core.ReplayStore) error { } // NewTokenVerifier builds a verifier for Issue responses in a process that does -// not receive hop calls. It needs neither the hop's public key nor a replay -// store. Verify always refuses stamps, because admission cannot be checked -// without them; VerifyToken uses the same key cache and validation as NewVerifier. +// not receive hop calls. It needs no replay store. Verify always refuses stamps, +// because admission cannot be checked without one; VerifyToken uses the same +// key cache and validation as NewVerifier. func NewTokenVerifier(ctx context.Context, keys KeySource, audience string) (*Verifier, error) { - return newVerifier(ctx, keys, audience, "", nil) + return newVerifier(ctx, keys, audience, nil) } -func newVerifier(ctx context.Context, keys KeySource, audience, publicKey string, replay core.ReplayStore) (*Verifier, error) { +func newVerifier(ctx context.Context, keys KeySource, audience string, replay core.ReplayStore) (*Verifier, error) { if keys == nil || audience == "" { return nil, refusal.Configuration } - v := &Verifier{source: keys, audience: audience, publicKey: publicKey, replay: replay, now: time.Now, timeout: keyTimeout} + v := &Verifier{source: keys, audience: audience, replay: replay, now: time.Now, timeout: keyTimeout} v.mu.Lock() done := v.refresh() v.mu.Unlock() @@ -332,6 +342,8 @@ func newVerifier(ctx context.Context, keys KeySource, audience, publicKey string // 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. +// A verifier that admits calls also reports ErrNoAdmissionKeys when the keys +// read but publish no hop admission key. func (v *Verifier) KeysError() error { v.mu.Lock() defer v.mu.Unlock() @@ -433,22 +445,81 @@ func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trus } wait, judgeable := v.inflight, held && now.Sub(v.fetched) <= keyStale v.mu.Unlock() - if !judgeable && wait != nil { - select { - case <-wait: - case <-ctx.Done(): - return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled) + if !judgeable { + if err := v.await(ctx, wait); err != nil { + return workcontext.Trust{}, err } } v.mu.Lock() defer v.mu.Unlock() + if err := v.trusted(); err != nil { + return workcontext.Trust{}, err + } + return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil +} + +// admissionKey is the published hop admission key the admission md carries is +// signed by. It is judged on the keys held: the admission is what a call has +// not yet proven, so neither a stamp signed by no published key nor keys past +// keyMaxAge start a read here, and a caller without an admission can neither +// drive the authority's keys nor delay the read a genuine call needs. Only +// when no trusted keys are held at all (never read, or past keyStale, as on an +// idle replica's first call) does it start a read, at most once per keyRetry +// as readiness does, and wait for it. A hop's new key is therefore learnt on +// the next read an admitted call or readiness starts, within keyMaxAge, so an +// authority publishes it that long before its hop signs with it, beside the +// retiring key. +func (v *Verifier) admissionKey(ctx context.Context, md metadata.MD) (string, error) { + v.mu.Lock() + untrusted := v.trusted() != nil + if untrusted && v.inflight == nil && v.now().Sub(v.tried) >= keyRetry { + v.refresh() + } + wait := v.inflight + v.mu.Unlock() + if untrusted { + if err := v.await(ctx, wait); err != nil { + return "", err + } + } + v.mu.Lock() + defer v.mu.Unlock() + if err := v.trusted(); err != nil { + return "", err + } + if len(v.admission) == 0 { + return "", errKeysUnavailable + } + key := stamp.SignedBy(md, v.admission) + if key == "" { + return "", fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampProof) + } + return key, nil +} + +// await waits for the running read, if any, as long as the caller does. +func (v *Verifier) await(ctx context.Context, read chan struct{}) error { + if read == nil { + return nil + } + select { + case <-read: + return nil + case <-ctx.Done(): + return fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled) + } +} + +// trusted refuses, with the reason, keys that were never read or are past +// keyStale; v.mu is held. +func (v *Verifier) trusted() error { if v.fetched.IsZero() || v.now().Sub(v.fetched) > keyStale { if errors.Is(v.failure, refusal.InvalidKey) { - return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.InvalidKey) + return fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.InvalidKey) } - return workcontext.Trust{}, errKeysUnavailable + return errKeysUnavailable } - return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil + return nil } // refresh starts the one read, detached from every call; v.mu is held. It @@ -462,11 +533,14 @@ func (v *Verifier) refresh() chan struct{} { go func() { ctx, cancel := context.WithTimeout(context.Background(), v.timeout) defer cancel() - trust, err := v.read(ctx) + trust, admission, err := v.read(ctx) v.mu.Lock() defer v.mu.Unlock() if err == nil { - v.trust, v.fetched = trust, v.now() + v.trust, v.admission, v.fetched = trust, admission, v.now() + if v.replay != nil && len(admission) == 0 { + err = ErrNoAdmissionKeys + } } v.failure, v.inflight = err, nil close(done) @@ -474,28 +548,47 @@ func (v *Verifier) refresh() chan struct{} { return done } -// read asks the authority for its published keys. -func (v *Verifier) read(ctx context.Context) (workcontext.Trust, error) { +// read asks the authority for its published keys: the Work Context keys from +// keys, each used only if its purpose is a Work Context's, and the hop's +// admission keys from admission_keys, each used only if its purpose is +// stamp.Purpose. A key in the wrong field is never used for the other. +func (v *Verifier) read(ctx context.Context) (workcontext.Trust, []string, error) { response, err := v.source.Keys(ctx, &authorityv1.KeysRequest{}) if err != nil { - return workcontext.Trust{}, fmt.Errorf("hop: the authority's published keys cannot be read: %w", err) + return workcontext.Trust{}, nil, 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() { - if key.GetAlgorithm() != core.Algorithm { + if key.GetAlgorithm() != core.Algorithm || (key.GetPurpose() != "" && key.GetPurpose() != workContextPurpose) { continue } if key.GetKeyId() == "" || len(key.GetPublicKey()) != ed25519.PublicKeySize || trust.Keys[key.GetKeyId()] != nil { - return workcontext.Trust{}, refusal.InvalidKey + return workcontext.Trust{}, nil, refusal.InvalidKey } trust.Keys[key.GetKeyId()] = ed25519.PublicKey(slices.Clone(key.GetPublicKey())) } if trust.Issuer == "" || len(trust.Keys) == 0 { - return workcontext.Trust{}, refusal.InvalidKey + return workcontext.Trust{}, nil, refusal.InvalidKey } - return trust, nil + var admission []string + for _, key := range response.GetAdmissionKeys() { + if key.GetAlgorithm() != core.Algorithm || key.GetPurpose() != stamp.Purpose { + continue + } + if len(key.GetPublicKey()) != ed25519.PublicKeySize { + return workcontext.Trust{}, nil, refusal.InvalidKey + } + if public := hex.EncodeToString(key.GetPublicKey()); !slices.Contains(admission, public) { + admission = append(admission, public) + } + } + return trust, admission, nil } +// workContextPurpose is a Work Context key's purpose in Keys. A key published +// with no purpose, by a host older than admission_keys, is one too. +const workContextPurpose = "work-context" + // 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 { @@ -571,7 +664,7 @@ func (v *Verifier) admit(ctx context.Context, method string) (context.Context, e } held, ok := ctx.Value(admissionKey{}).(*admission) if !ok || held.verifier != v || held.method != method { - // No tap handle reserved this call: it is reserved now, judged at now. + // No stats handler reserved this call: it is reserved now, judged at now. md, _ := metadata.FromIncomingContext(ctx) release, err := v.reserve(ctx, md, method, v.instant()) held = &admission{verifier: v, method: method, md: md, reserved: make(chan struct{}), release: release, err: err} @@ -610,9 +703,9 @@ func first(md metadata.MD, key string) string { return "" } -// TapHandle reserves each call's single-use admission when the call arrives, -// before its body is read, and the interceptors hold that reservation until -// the handler returns. +// StatsHandler reserves each call's single-use admission when the call +// arrives, before its body is read, and the interceptors hold that reservation +// until the handler returns. // // grpc-go decodes a unary request fully before it invokes the unary // interceptor, and the Connect and REST faces buffer the message too. Judging @@ -620,34 +713,51 @@ func first(md metadata.MD, key string) string { // longer than stamp.Lifetime to arrive; consuming its nonce there would let a // second delivery of the same admission, held back while its body arrived, // consume a nonce that had since been released, and run the handler twice. -// Installing this handle judges the window at the instant the stream was -// accepted and reserves the nonce then, so every delivery that arrives inside -// the window meets the reservation and is refused as stamp_replayed. Python's -// and TypeScript's verifiers run at the same point, before the body is read. +// Installing this handler judges the window at the instant the call reached +// the server and reserves the nonce then, so every delivery that arrives +// inside the window meets the reservation and is refused as stamp_replayed. +// Python's and TypeScript's verifiers run at the same point, before the body +// is read. // -// Install it beside the interceptors: +// It is a stats handler rather than a tap handle because gRPC tags every call +// with it, on every transport the server serves: its own listener, and the +// Connect listener that hands each call to the same server through +// grpc.Server.ServeHTTP, where a tap handle never runs. Install it beside the +// interceptors: // -// grpc.NewServer(grpc.InTapHandle(verifier.TapHandle()), +// grpc.NewServer(grpc.StatsHandler(verifier.StatsHandler()), // grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), // grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) // -// It runs on the connection's I/O goroutine, so it does no work there: it -// records the arrival instant and starts the check and the reservation beside -// the call, which the interceptor awaits and which refuses with the reason. A -// call that ends before its handler starts releases its hold; one whose handler -// started is released when the handler returns. -func (v *Verifier) TapHandle() tap.ServerInHandle { - return func(ctx context.Context, info *tap.Info) (context.Context, error) { - if strings.HasPrefix(info.FullMethodName, "/grpc.health.v1.Health/") { - return ctx, nil - } - arrival := v.instant() - held := &admission{verifier: v, method: info.FullMethodName, md: info.Header, reserved: make(chan struct{})} - go func() { - defer close(held.reserved) - held.release, held.err = v.reserve(ctx, info.Header, info.FullMethodName, arrival) - }() - context.AfterFunc(ctx, held.abandon) - return context.WithValue(ctx, admissionKey{}, held), nil +// It does no work on the call's path: it records the arrival instant and +// starts the check and the reservation beside the call, which the interceptor +// awaits and which refuses with the reason. A call that ends before its +// handler starts releases its hold; one whose handler started is released +// when the handler returns. +func (v *Verifier) StatsHandler() stats.Handler { return arrivals{v} } + +// arrivals is the Verifier's stats handler: it tags each call with its +// admission, and observes nothing else. +type arrivals struct{ v *Verifier } + +func (a arrivals) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + method := info.FullMethodName + if strings.HasPrefix(method, "/grpc.health.v1.Health/") { + return ctx } + md, _ := metadata.FromIncomingContext(ctx) + arrival := a.v.instant() + held := &admission{verifier: a.v, method: method, md: md, reserved: make(chan struct{})} + go func() { + defer close(held.reserved) + held.release, held.err = a.v.reserve(ctx, md, method, arrival) + }() + context.AfterFunc(ctx, held.abandon) + return context.WithValue(ctx, admissionKey{}, held) } + +func (arrivals) HandleRPC(context.Context, stats.RPCStats) {} + +func (arrivals) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context { return ctx } + +func (arrivals) HandleConn(context.Context, stats.ConnStats) {} diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index 90c31a60..b1d04fa4 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -27,7 +27,7 @@ var read = &basev0.WorkScopeV1{ResourceKind: "documents", Actions: []string{"rea func verifier(t *testing.T, a *hoptest.Authority, audience string) *hop.Verifier { t.Helper() - v, err := hop.NewVerifier(context.Background(), a, audience, hoptest.PublicKey(proof), hoptest.Replay()) + v, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), audience, hoptest.Replay()) if err != nil { t.Fatal(err) } @@ -133,17 +133,17 @@ func TestTheInterceptorRefusesByNameAndLetsHealthThrough(t *testing.T) { } } -func TestAVerifierNeedsPublishedKeysAnAudienceAndAProof(t *testing.T) { - a := hoptest.New(t) +func TestAVerifierNeedsPublishedKeysAnAudienceAndAReplayStore(t *testing.T) { + a := hoptest.New(t).PublishHop(proof) for name, build := range map[string]func() (*hop.Verifier, error){ "no keys": func() (*hop.Verifier, error) { - return hop.NewVerifier(context.Background(), nil, "documents", hoptest.PublicKey(proof), hoptest.Replay()) + return hop.NewVerifier(context.Background(), nil, "documents", hoptest.Replay()) }, "no audience": func() (*hop.Verifier, error) { - return hop.NewVerifier(context.Background(), a, "", hoptest.PublicKey(proof), hoptest.Replay()) + return hop.NewVerifier(context.Background(), a, "", hoptest.Replay()) }, - "no proof": func() (*hop.Verifier, error) { - return hop.NewVerifier(context.Background(), a, "documents", "", hoptest.Replay()) + "no replay store": func() (*hop.Verifier, error) { + return hop.NewVerifier(context.Background(), a, "documents", nil) }, } { if _, err := build(); err == nil { diff --git a/libraries/hop/go/hoptest/hoptest.go b/libraries/hop/go/hoptest/hoptest.go index 0e4ef566..e6787dd5 100644 --- a/libraries/hop/go/hoptest/hoptest.go +++ b/libraries/hop/go/hoptest/hoptest.go @@ -1,6 +1,6 @@ // 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 +// Contexts with a key of its own, answers Keys for them and for the hops it +// publishes, 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 @@ -11,27 +11,33 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "sync" "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + 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" + "google.golang.org/protobuf/proto" ) // 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. +// Authority signs with a fresh key; Rotate replaces it. Keys publishes that +// key and the admission key of every hop PublishHop names. type Authority struct { signer *core.Authority - keys *authorityv1.KeysResponse // Now is the signing clock; nil means time.Now. Now func() time.Time + + mu sync.Mutex + keys *authorityv1.KeysResponse } // New returns an authority with a fresh signing key. @@ -56,11 +62,54 @@ func (a *Authority) Rotate(t testing.TB) { } return time.Now() }} - a.keys = &authorityv1.KeysResponse{Issuer: Issuer, Keys: []*authorityv1.PublicKey{{KeyId: id, Algorithm: core.Algorithm, PublicKey: public}}} + a.mu.Lock() + defer a.mu.Unlock() + var hops []*authorityv1.PublicKey + if a.keys != nil { + hops = a.keys.AdmissionKeys + } + a.keys = &authorityv1.KeysResponse{Issuer: Issuer, Keys: []*authorityv1.PublicKey{{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: "work-context"}}, AdmissionKeys: hops} +} + +// PublishHop publishes, beside those already published, the admission key of +// each hop that signs as label (Signer(label)), as an authority publishes its +// hop's key: a Verifier reading this authority's Keys then admits what they +// sign. Publishing two is a rotation's overlap. +func (a *Authority) PublishHop(labels ...string) *Authority { + a.mu.Lock() + defer a.mu.Unlock() + keys := proto.Clone(a.keys).(*authorityv1.KeysResponse) + for _, label := range labels { + keys.AdmissionKeys = append(keys.AdmissionKeys, Published(label)) + } + a.keys = keys + return a +} + +// WithdrawHop stops publishing the admission key of each hop that signs as +// label, as an authority does once a rotation's overlap ends. +func (a *Authority) WithdrawHop(labels ...string) *Authority { + a.mu.Lock() + defer a.mu.Unlock() + keys := proto.Clone(a.keys).(*authorityv1.KeysResponse) + keys.AdmissionKeys = nil + for _, key := range a.keys.AdmissionKeys { + withdrawn := false + for _, label := range labels { + withdrawn = withdrawn || proto.Equal(key, Published(label)) + } + if !withdrawn { + keys.AdmissionKeys = append(keys.AdmissionKeys, key) + } + } + a.keys = keys + return a } // 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) { + a.mu.Lock() + defer a.mu.Unlock() return a.keys, nil } @@ -104,6 +153,7 @@ func Forward(t testing.TB, token, proof, method string) metadata.MD { } // Signer derives a public test key. Production seeds come only from Codefly. +// An Authority publishes its admission key once PublishHop names label. func Signer(label string) *stamp.Signer { sum := sha256.Sum256([]byte(label)) signer, _ := stamp.NewSigner(hex.EncodeToString(sum[:])) @@ -111,6 +161,13 @@ func Signer(label string) *stamp.Signer { } func PublicKey(label string) string { return Signer(label).PublicKey() } +// Published is the admission key of the hop that signs as label, as an +// authority's Keys carries it in admission_keys. +func Published(label string) *authorityv1.PublicKey { + id, public := Signer(label).Admission() + return &authorityv1.PublicKey{KeyId: id, Algorithm: core.Algorithm, PublicKey: public, Purpose: stamp.Purpose} +} + // Replay is for single-process tests only; deployments supply a shared store. // It declares no Shared(), so hop.NewVerifier refuses it outside a local run // rather than leaving that to this comment. diff --git a/libraries/hop/go/published_test.go b/libraries/hop/go/published_test.go new file mode 100644 index 00000000..6ee019a1 --- /dev/null +++ b/libraries/hop/go/published_test.go @@ -0,0 +1,121 @@ +package hop + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "errors" + "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/refusal" + core "github.com/codefly-dev/core/workcontext" +) + +// Review r3 B1: the hop's admission key is published by the authority's Keys, +// which every verifier already reads, so nothing about the hop is configured +// on a module: a verifier built from Keys alone admits a genuine stamp. +func TestAVerifierTakesTheHopsKeyFromTheAuthoritysKeysAlone(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("the-hop")}}} + v, err := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + if err != nil { + t.Fatal(err) + } + id, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "the-hop", ""), "/test.Service/Read") + if err != nil || id.Owner != core.FixturePrincipal { + t.Fatalf("a genuine stamp under the published key: %+v %v", id, err) + } +} + +// A stamp signed by a key the authority does not publish is refused as +// stamp_proof, and causes no read of the authority's keys: a caller cannot +// make the verifier re-read by presenting an unknown key. +func TestAStampSignedByAnUnpublishedKeyIsRefusedByName(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + settled(v) + reads := source.count() + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "an-unpublished-hop", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("want %s: %v", refusal.StampProof, err) + } + settled(v) + if source.count() != reads { + t.Fatalf("an unpublished key caused %d key reads", source.count()-reads) + } +} + +// While a hop's key rotates the authority publishes both, and the verifier +// admits stamps under either; once the retiring key is withdrawn, the next +// read stops admitting it. +func TestARotationsOverlapAdmitsBothKeysUntilTheRetiringOneIsWithdrawn(t *testing.T) { + authority := newSigner("published") + both := &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("retiring"), publishedHop("current")}} + source := &keys{response: both} + clock := time.Now() + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + v.now = func() time.Time { return clock } + v.fetched, v.tried = clock, clock + for _, hop := range []string{"retiring", "current"} { + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), hop, ""), "/test.Service/Read"); err != nil { + t.Fatalf("the overlap refused the %s key: %v", hop, err) + } + } + source.set(&authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("current")}}, nil, nil) + clock = clock.Add(keyMaxAge + time.Second) + v.Ready() // the periodic re-read + settled(v) + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "retiring", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("a withdrawn key still admits: %v", err) + } + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "current", ""), "/test.Service/Read"); err != nil { + t.Fatalf("the current key after the overlap: %v", err) + } +} + +// Each key is used for its own purpose only: an admission key published among +// the Work Context keys admits nothing, and an admission key never +// authenticates a Work Context. +func TestAKeyIsUsedForItsOwnPurposeOnly(t *testing.T) { + authority := newSigner("published") + misplaced := publishedHop("misplaced") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published(), misplaced}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + var reason refusal.Reason + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "misplaced", ""), "/test.Service/Read"); !errors.As(err, &reason) || reason != refusal.StampProof { + t.Fatalf("an admission key among the Work Context keys admitted: %v", err) + } + // A Work Context signed with the hop's admission key (the keys double + // publishes testSigner("proof")), under that entry's key id. + seed := sha256.Sum256([]byte("proof")) + forged := signer{id: publishedHop("proof").GetKeyId(), key: ed25519.NewKeyFromSeed(seed[:]), issuer: authority.issuer} + if _, err := v.VerifyToken(context.Background(), forged.issue(t, time.Now(), "documents")); !errors.As(err, &reason) || reason != refusal.UnknownKey { + t.Fatalf("a Work Context signed with the hop's admission key: %v", err) + } +} + +// An authority that publishes no admission key admits nothing, under a fixed +// reason, and says why in the module's logs. +func TestNoPublishedAdmissionKeyRefusesEveryCallAndSaysWhy(t *testing.T) { + authority := newSigner("published") + source := &keys{response: &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{{Algorithm: core.Algorithm, PublicKey: make([]byte, 32), Purpose: "work-context"}}}} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, time.Now(), "documents"), "proof", ""), "/test.Service/Read") + var reason refusal.Reason + if !errors.As(err, &reason) || reason != refusal.KeysUnavailable { + t.Fatalf("want %s: %v", refusal.KeysUnavailable, err) + } + if !errors.Is(v.KeysError(), ErrNoAdmissionKeys) { + t.Fatalf("the module's logs are not told why: %v", v.KeysError()) + } +} diff --git a/libraries/hop/go/reasons_test.go b/libraries/hop/go/reasons_test.go index 37fb670c..62aac9c1 100644 --- a/libraries/hop/go/reasons_test.go +++ b/libraries/hop/go/reasons_test.go @@ -14,7 +14,7 @@ import ( func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { s := newSigner("reasons") source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} - v, err := NewVerifier(t.Context(), source, "module", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } @@ -45,14 +45,14 @@ func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { } func TestConfigurationAndCancelledKeyWaitHaveNamedReasons(t *testing.T) { - _, err := NewVerifier(t.Context(), nil, "module", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + _, err := NewVerifier(t.Context(), nil, "module", core.NewMemoryReplayStore()) var reason Reason if !errors.As(err, &reason) || reason != ReasonConfiguration { t.Fatalf("configuration: %v", err) } s := newSigner("cancel-reason") source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} - v, err := NewVerifier(t.Context(), source, "module", testSigner("proof").PublicKey(), core.NewMemoryReplayStore()) + v, err := NewVerifier(t.Context(), source, "module", core.NewMemoryReplayStore()) if err != nil { t.Fatal(err) } diff --git a/libraries/hop/go/replaypg/replaypg.go b/libraries/hop/go/replaypg/replaypg.go index 45140520..6e983219 100644 --- a/libraries/hop/go/replaypg/replaypg.go +++ b/libraries/hop/go/replaypg/replaypg.go @@ -31,8 +31,8 @@ import ( // Migration creates the table, as the module's own migration. The primary key // is the reservation: Postgres decides one winner among concurrent inserts. -// active marks a reservation whose call is still running, which collection -// never removes. +// active marks a reservation whose call is still running, for diagnosis: +// collection does not read it. const Migration = `CREATE TABLE IF NOT EXISTS hop_admissions ( nonce TEXT PRIMARY KEY, retain_until TIMESTAMPTZ NOT NULL, @@ -90,11 +90,14 @@ func (s *Store) Consume(ctx context.Context, nonce string, retainUntil time.Time // own clock, and answers how many it removed. Run it on a schedule: without it // the table grows by one row per admitted call. // -// A reservation whose call is still running is never removed, however long -// the call runs. One whose retention has passed can be removed safely because -// Reserve refuses, on the same clock, any reservation past that retention. +// A reservation is removed once its retention has passed whether or not its +// call has finished: Reserve refuses, on the same clock, any reservation past +// that retention, and a delivery past it is refused on the window too, so +// removing it can never revive the nonce. A holder that stopped between +// Reserve and Finish (a crash, a failed or timed-out Finish, a Reserve that +// committed after its caller gave up) therefore leaves no permanent row. func (s *Store) Collect(ctx context.Context) (int64, error) { - tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE NOT active AND retain_until < now()`) + tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now()`) if err != nil { return 0, err } @@ -103,8 +106,8 @@ func (s *Store) Collect(ctx context.Context) (int64, error) { var _ hop.ReservationStore = (*Store)(nil) -// Reserve consumes nonce when a call arrives and holds it while the call runs, -// until Finish and retainUntil have both passed. It reports core.ErrReplayed +// Reserve consumes nonce when a call arrives and holds it, marked active until +// Finish, to retainUntil. It reports core.ErrReplayed // when the nonce is already held, and refusal.StampWindow when retainUntil has // already passed by the database's clock: a reservation delayed past its own // horizon must never revive a nonce that Collect removed. diff --git a/libraries/hop/go/replaypg/replaypg_test.go b/libraries/hop/go/replaypg/replaypg_test.go index d2f6d01c..6e3202ad 100644 --- a/libraries/hop/go/replaypg/replaypg_test.go +++ b/libraries/hop/go/replaypg/replaypg_test.go @@ -3,6 +3,8 @@ package replaypg import ( "context" "errors" + "fmt" + "os" "testing" "time" @@ -10,6 +12,7 @@ import ( core "github.com/codefly-dev/core/workcontext" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" ) // pool is a Postgres that holds one nonce per primary key, as the real table's @@ -36,10 +39,10 @@ func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandT return pgconn.NewCommandTag("UPDATE 1"), nil } if len(args) != 2 { - // The collection statement; remove what has aged out and is not running. + // The collection statement; remove what has aged out, running or not. removed := 0 for nonce, until := range p.held { - if until.Before(time.Now()) && !p.active[nonce] { + if until.Before(time.Now()) { delete(p.held, nonce) removed++ } @@ -170,11 +173,11 @@ func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { } } -// A reservation is held while its call runs, past its retention, and refused -// to anyone else; once the call finishes, collection removes it after its -// retention. A reservation whose retention has already passed is refused as -// the window, never inserted, so it cannot revive a collected nonce. -func TestAReservationIsHeldWhileItsCallRuns(t *testing.T) { +// A reservation is refused to anyone else while it is retained; collection +// removes it once its retention has passed, its call finished or not, and a +// reservation whose retention has already passed is refused as the window, +// never inserted, so it cannot revive a collected nonce. +func TestAReservationIsHeldToItsRetentionWhetherOrNotItsCallFinished(t *testing.T) { p := &pool{} store, _ := New(p) ctx := context.Background() @@ -184,15 +187,13 @@ func TestAReservationIsHeldWhileItsCallRuns(t *testing.T) { if err := store.Reserve(ctx, "running", time.Now().Add(time.Minute)); !errors.Is(err, core.ErrReplayed) { t.Fatalf("a second reservation was not refused: %v", err) } - time.Sleep(60 * time.Millisecond) if removed, err := store.Collect(ctx); err != nil || removed != 0 { - t.Fatalf("collection removed %d running reservations: %v", removed, err) - } - if err := store.Finish(ctx, "running"); err != nil { - t.Fatal(err) + t.Fatalf("collection removed %d reservations still retained: %v", removed, err) } + time.Sleep(60 * time.Millisecond) + // Never finished: its holder stopped between Reserve and Finish. if removed, err := store.Collect(ctx); err != nil || removed != 1 { - t.Fatalf("collected %d finished reservations: %v", removed, err) + t.Fatalf("collected %d reservations past their retention: %v", removed, err) } if err := store.Reserve(ctx, "running", time.Now().Add(-time.Second)); !errors.Is(err, refusal.StampWindow) { t.Fatalf("a reservation past its retention was not refused as the window: %v", err) @@ -227,3 +228,55 @@ func contains(haystack, needle string) bool { } return false } + +// Review r3 M2, on a real Postgres (HOP_TEST_POSTGRES): a holder that stops +// between Reserve and Finish leaves its row active for good, and collection +// still removes it once its retention has passed by the database's clock; +// the nonce is not revived, since a reservation past its retention is refused. +func TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock(t *testing.T) { + dsn := os.Getenv("HOP_TEST_POSTGRES") + if dsn == "" { + t.Skip("HOP_TEST_POSTGRES names no Postgres") + } + ctx := context.Background() + pg, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pg.Close() + if _, err := pg.Exec(ctx, Migration); err != nil { + t.Fatal(err) + } + store, _ := New(pg) + nonce := fmt.Sprintf("crashed-holder-%d", time.Now().UnixNano()) + until := time.Now().Add(time.Second) + if err := store.Reserve(ctx, nonce, until); err != nil { + t.Fatal(err) + } + // The holder stops here: no Finish, so the row stays active. + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } + if !held(t, pg, nonce) { + t.Fatal("collection removed a reservation still inside its retention") + } + time.Sleep(time.Until(until) + 200*time.Millisecond) + if _, err := store.Collect(ctx); err != nil { + t.Fatal(err) + } + if held(t, pg, nonce) { + t.Fatal("a crashed holder's reservation outlived its retention") + } + if err := store.Reserve(ctx, nonce, until); !errors.Is(err, refusal.StampWindow) { + t.Fatalf("the collected nonce was reserved again: %v", err) + } +} + +func held(t *testing.T, pg *pgxpool.Pool, nonce string) bool { + t.Helper() + var n int + if err := pg.QueryRow(context.Background(), `SELECT count(*) FROM hop_admissions WHERE nonce = $1`, nonce).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 +} diff --git a/libraries/hop/go/transport_postgres_test.go b/libraries/hop/go/transport_postgres_test.go index 8d882ae0..ad181bbc 100644 --- a/libraries/hop/go/transport_postgres_test.go +++ b/libraries/hop/go/transport_postgres_test.go @@ -18,7 +18,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" - "google.golang.org/grpc/tap" + "google.golang.org/grpc/stats" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/types/known/emptypb" ) @@ -82,16 +82,12 @@ func newTransport(t *testing.T, ctx context.Context, store *replaypg.Store) *tra tr.nonce = call.ReplayKey(hoptest.PublicKey("regression")) tr.expires = time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) - v, err := hop.NewVerifier(ctx, authority, "regression", hoptest.PublicKey("regression"), store) + v, err := hop.NewVerifier(ctx, authority.PublishHop("regression"), "regression", store) if err != nil { t.Fatal(err) } server := grpc.NewServer( - grpc.InTapHandle(func(ctx context.Context, info *tap.Info) (context.Context, error) { - admitted, err := v.TapHandle()(ctx, info) - tr.arrived <- struct{}{} - return admitted, err - }), + grpc.StatsHandler(arrivals{Handler: v.StatsHandler(), arrived: tr.arrived}), grpc.ChainUnaryInterceptor(v.UnaryServerInterceptor())) server.RegisterService(&grpc.ServiceDesc{ ServiceName: "regression.Service", @@ -122,6 +118,19 @@ func newTransport(t *testing.T, ctx context.Context, store *replaypg.Store) *tra return tr } +// arrivals is the verifier's stats handler, telling the test each call it has +// tagged on arrival. +type arrivals struct { + stats.Handler + arrived chan struct{} +} + +func (a arrivals) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + ctx = a.Handler.TagRPC(ctx, info) + a.arrived <- struct{}{} + return ctx +} + // deliver opens one delivery of the admission and returns once the server has // accepted its stream; its body is sent only when send is called. func (tr *transport) deliver(t *testing.T, ctx context.Context) (send func() error) { @@ -206,10 +215,11 @@ func TestASecondDeliveryHeldPastCollectionIsRefusedOverTheRealTransport(t *testi } } -// The reservation of a call whose handler is still running outlives its -// retention: collection never removes it while the call runs, and removes it -// once the call has returned. -func TestARunningCallKeepsItsReservationPastItsRetention(t *testing.T) { +// Review r3 M2: collection removes a reservation once its retention has +// passed, even while its call still runs (a crashed holder never finishes), +// and that reopens nothing: a delivery after the retention is refused on the +// window and by the database, and the running call completes. +func TestCollectingARunningCallsReservationReopensNothing(t *testing.T) { t.Setenv(resources.EnvironmentPrefix, "production") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -226,24 +236,21 @@ func TestARunningCallKeepsItsReservationPastItsRetention(t *testing.T) { t.Fatal(ctx.Err()) } tr.pastRetention(t, ctx, store) - if !held(t, ctx, pool, tr.nonce) { - t.Fatal("collection removed the reservation of a call that is still running") + if held(t, ctx, pool, tr.nonce) { + t.Fatal("a reservation past its retention outlived collection") } - if err := tr.deliver(t, ctx)(); err == nil || !strings.Contains(err.Error(), "stamp_") { - t.Errorf("a delivery after the retention was admitted: %v", err) + if err := tr.deliver(t, ctx)(); err == nil || !strings.Contains(err.Error(), "stamp_window") { + t.Errorf("a delivery after the retention was not refused on the window: %v", err) } close(tr.hold) if err := <-result; err != nil { t.Fatalf("the running call was refused: %v", err) } - if _, err := store.Collect(ctx); err != nil { - t.Fatal(err) - } if held(t, ctx, pool, tr.nonce) { - t.Fatal("a finished call's reservation outlived its retention") + t.Fatal("finishing the call revived its collected reservation") } if got := tr.reached.Load(); got != 1 { - t.Fatalf("the handler ran %d times", got) + t.Fatalf("SECURITY: the handler ran %d times", got) } } diff --git a/libraries/hop/testdata/stamped.json b/libraries/hop/testdata/stamped.json index 115b3897..950a4131 100644 --- a/libraries/hop/testdata/stamped.json +++ b/libraries/hop/testdata/stamped.json @@ -1,5 +1,4 @@ { - "public_key": "17053f7519568253029258eca5f61d05c76647ea0bd9143ad1bf5f36e37d9629", "audience": "documents", "now": 1792818000, "keys": { @@ -8,21 +7,30 @@ { "keyId": "hop-fixture", "algorithm": "Ed25519", - "publicKey": "CViKnk6aO0k+3rS1SPdYggLCjBPmMG6s4UU/Y95Smqc=" + "publicKey": "CViKnk6aO0k+3rS1SPdYggLCjBPmMG6s4UU/Y95Smqc=", + "purpose": "work-context" + } + ], + "admissionKeys": [ + { + "keyId": "hop-c39261b4607295e9", + "algorithm": "Ed25519", + "publicKey": "FwU/dRlWglMCkljspfYdBcdmR+oL2RQ60b9fNuN9lik=", + "purpose": "hop-admission" } ] }, "headers": { "x-codefly-hop-actors": "", - "x-codefly-hop-context": "c32facab31f07e892551222e5acdfba8225f5ee9a44ff18168639a029548709f", + "x-codefly-hop-context": "978f0ed23f4c5a96367cb2b8bda07368a1018b5717ec671104eba7e9328ebab4", "x-codefly-hop-expires": "1792818600", "x-codefly-hop-owner": "principal-owner", "x-codefly-hop-owner-kind": "human", - "x-codefly-hop-proof": "WyJjb2RlZmx5LWhvcC12MSIsIi90ZXN0LlNlcnZpY2UvUmVhZCIsImRvY3VtZW50cyIsImMzMmZhY2FiMzFmMDdlODkyNTUxMjIyZTVhY2RmYmE4MjI1ZjVlZTlhNDRmZjE4MTY4NjM5YTAyOTU0ODcwOWYiLCI3OWVjNDYzZDdiYjI0M2E2ZThjYzY0NDkwMTMxZGY5YzFiZjNmNWVkMTA2MzBjMDUxODllN2Y1OGMzM2ZjYzg2IiwxNzkyODE4MDAwLDE3OTI4MTgwMzBd.PkjjHEmoK7491qYH1TPs6sVOfUF-x7xdittoG2COw3gudr_d0TkqCFLg1djdcLU6DWX_e2K9pNWETB7Zb7pABQ", + "x-codefly-hop-proof": "WyJjb2RlZmx5LWhvcC12MSIsIi90ZXN0LlNlcnZpY2UvUmVhZCIsImRvY3VtZW50cyIsIjk3OGYwZWQyM2Y0YzVhOTYzNjdjYjJiOGJkYTA3MzY4YTEwMThiNTcxN2VjNjcxMTA0ZWJhN2U5MzI4ZWJhYjQiLCI1NzE5NTA4Yzc4ODMxODFhNjExNDk3ODg1YjBlNTA1OTE2NDcxNTI1Zjg4ZGZjNmFhYzExZTYxNzBlMjI0ZWZiIiwxNzkyODE4MDAwLDE3OTI4MTgwMzBd.G7tlrYUanR448OvpJ9KEDgL4XpaURINC0aefPXigdauI18NwMCeiexentKI1LuZHD7R0Dq_VR3Q1w2cr_ixYAA", "x-codefly-hop-scopes": "documents.collection:read", "x-codefly-hop-task": "task-conformance", "x-codefly-hop-tenant": "tenant-conformance", - "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGRiMTMyYmE1LTUwZTctNDAwYi1iYjk0LTkxNzVkNDY0YWFkZVAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokYWUzNzI0ZDAtNTU1MS00NmQ3LTg3NDMtODc3NmE1NGMzOGRiigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.zqmygW1AVYgFft6kBTBv6OzFekNqAJzKe50POaDTr5Q6uXV91DmI-RynvAGdUWfCeiAN6e4M5B0icRRijiG3Cw" + "x-codefly-work-context": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoLaG9wLWZpeHR1cmUiGmh0dHBzOi8vYXV0aG9yaXR5LmhvcC50ZXN0Kglkb2N1bWVudHMw0Pbw1gY40Pbw1gZAqPvw1gZKJGFkODI5MzU0LWNlM2UtNDdjMS1hNGMzLWZiMTA5NWU3NjNlZFAHWgpzaW5nbGUtdXNlYhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokMjMwZjMwZWUtZmU4Zi00OTYxLTgxOTYtNjM0YTBjN2Q2OWQzigEcChRkb2N1bWVudHMuY29sbGVjdGlvbhIEcmVhZIoBIgoUZG9jdW1lbnRzLmNvbGxlY3Rpb24SBXdyaXRlGgNjLTGyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.NYofoki9BRAE-lS0wgUgLdGDYEiLj12BxCBM9RoyK0ldoWaue4iA8m6EPF2-qSm9RtW2Z0F1lwoazJ79O2FdCQ" }, "identity": { "actors": null, 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 7319e401..523e79eb 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 @@ -113,7 +113,7 @@ func TestHTTPAndConnectIssueTheBindingObservedByTheListener(t *testing.T) { if err != nil { t.Fatal(err) } - verifier, err := hop.NewVerifier(context.Background(), a, "module", hoptest.PublicKey("binding-test"), hoptest.Replay()) + verifier, err := hop.NewVerifier(context.Background(), a.PublishHop("binding-test"), "module", hoptest.Replay()) if err != nil { t.Fatal(err) } 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 12bf8cdd..8fcecdb3 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 @@ -96,7 +96,7 @@ 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{Authority: hoptest.New(t), revoked: map[string]bool{}}, &[]string{} - verifier, err := hop.NewVerifier(context.Background(), a, "console", hoptest.PublicKey(proof), hoptest.Replay()) + verifier, err := hop.NewVerifier(context.Background(), a.PublishHop(proof), "console", hoptest.Replay()) if err != nil { t.Fatal(err) } diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 219acc78..366e165a 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,6 +7,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-20261026180113-c5b5e23960ab github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0 github.com/codefly-dev/authority/libraries/conformance/go v0.0.0 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 @@ -33,7 +34,6 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v1.0.0 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index 2f450b48..a2ead9db 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,8 +38,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-20261026135819-3d0a11cf77e4 h1:PkDtyzn6s9nShgB8vVVDIMXcW6MjVMe4wotdRObZ/Rc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026135819-3d0a11cf77e4/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/hopkey_test.go b/modules/authority/services/local/code/hopkey_test.go new file mode 100644 index 00000000..38e40fe4 --- /dev/null +++ b/modules/authority/services/local/code/hopkey_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "encoding/hex" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/core/resources" +) + +// Review r3 B1: the local authority publishes the hop's admission key in Keys, +// derived in code from the hop's own Codefly-generated seed (the variable the +// hop reads), so no consumer is configured with a hand-copied public key. +func TestTheHopsAdmissionKeyIsDerivedFromItsSeedAndPublished(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "local") + seed := strings.Repeat("07", 32) + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", seed) + keys, err := hopAdmissionKey(context.Background()) + if err != nil || len(keys) != 1 { + t.Fatalf("published %v: %v", keys, err) + } + hop, _ := stamp.NewSigner(seed) + if got := keys[0]; hex.EncodeToString(got.GetPublicKey()) != hop.PublicKey() || got.GetPurpose() != stamp.Purpose || got.GetKeyId() != keyID(hop) { + t.Fatalf("published %v, not the public half of the hop's seed", got) + } +} + +func keyID(s *stamp.Signer) string { + id, _ := s.Admission() + return id +} + +// A seed that is not one refuses to boot, as the hop refuses it. +func TestAMalformedHopSeedRefusesToBoot(t *testing.T) { + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "local") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", "not-a-seed") + if _, err := hopAdmissionKey(context.Background()); err == nil { + t.Fatal("a malformed hop seed was published") + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/issuer.go b/modules/authority/services/local/code/pkg/business/issuer/issuer.go index 0525d9b0..2ca09ff7 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -62,11 +62,12 @@ func classify(err error) error { // Issuer is the wire over one host and its core. type Issuer struct { - state *state.State - svc *authority.Service - issuer string - keyID string - public ed25519.PublicKey + state *state.State + svc *authority.Service + issuer string + keyID string + public ed25519.PublicKey + admission []*authorityv1.PublicKey } // New builds the issuer for a host whose core signs as issuer with keyID, its @@ -84,11 +85,19 @@ func Config(issuer, keyID string, key ed25519.PrivateKey, now func() time.Time) return authority.Config{Issuer: issuer, KeyID: keyID, Signer: key, Clock: now, MaxTTL: MaxTTL, Replay: replay} } -// Keys is what a verifier pins. +// PublishAdmission makes Keys publish the hop admission keys a module behind +// the hop verifies each call's admission by, and answers i. +func (i *Issuer) PublishAdmission(keys ...*authorityv1.PublicKey) *Issuer { + i.admission = append(i.admission, keys...) + return i +} + +// Keys is what a verifier pins: the Work Context key, and apart from it the +// hop's admission keys. func (i *Issuer) Keys() *authorityv1.KeysResponse { return &authorityv1.KeysResponse{Issuer: i.issuer, Keys: []*authorityv1.PublicKey{{ - KeyId: i.keyID, Algorithm: workcontext.Algorithm, PublicKey: []byte(i.public), - }}} + KeyId: i.keyID, Algorithm: workcontext.Algorithm, PublicKey: []byte(i.public), Purpose: "work-context", + }}, AdmissionKeys: i.admission} } // ListResources answers which resources of kind a checked capability may act diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index c8929322..e33cc72b 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/core/wool" "github.com/codefly-dev/core/workloadidentity" "github.com/codefly-dev/core/workloadidentity/native" @@ -18,6 +19,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "gateway/pkg/adapters" + authorityv1 "gateway/pkg/gen/host/authority/v1" "gateway/pkg/business/delivery" "gateway/pkg/business/issuer" "gateway/pkg/business/state" @@ -93,7 +95,11 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl if err != nil { return nil, err } - wiring.Set(st, issuer.New(st, issuerName, keyID, signing.Public().(ed25519.PublicKey))) + admission, err := hopAdmissionKey(ctx) + if err != nil { + return nil, err + } + wiring.Set(st, issuer.New(st, issuerName, keyID, signing.Public().(ed25519.PublicKey)).PublishAdmission(admission...)) // A delivery is verified against the policy the run wrote for its host, and // carried only by the module's own delivery workload, attested by the // anchor. With no policy the carrier path refuses by name and says so. @@ -125,6 +131,29 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl return clean, nil } +// hopAdmissionKey is the hop's admission key as Keys publishes it, derived +// from the hop's own signing seed (hop-signing/SEED, generated by Codefly for +// authority/hop): the public half is computed here, never configured, so it +// cannot drift from what the hop signs with. The seed never leaves this +// process. A run that composes no hop delivers no seed, and the authority +// publishes no admission key, saying so; a seed that is not one refuses to +// boot, as the hop refuses it. +func hopAdmissionKey(ctx context.Context) ([]*authorityv1.PublicKey, error) { + w := wool.Get(ctx).In("local") + seed, err := codefly.For(ctx).Service("hop").Secret("hop-signing", "SEED") + if err != nil || strings.TrimSpace(seed) == "" { + w.Warn("no hop signing seed (codefly config generate hop-signing SEED --service authority/hop): Keys publishes no hop admission key, so modules behind a hop admit no call") + return nil, nil + } + signer, err := stamp.NewSigner(strings.TrimSpace(seed)) + if err != nil { + return nil, fmt.Errorf("the hop's signing seed: %w", err) + } + keyID, public := signer.Admission() + w.Info("Keys publishes the hop's admission key " + keyID) + return []*authorityv1.PublicKey{{KeyId: keyID, Algorithm: "Ed25519", PublicKey: public, Purpose: stamp.Purpose}}, nil +} + // applyComposition adds the composition's part of the fixture: the workspace // configuration document fixture-composition, which a composing workspace // supplies for the modules and solutions it composes. The authority module diff --git a/modules/console/services/api/code/arrival_test.go b/modules/console/services/api/code/arrival_test.go new file mode 100644 index 00000000..d69d2c27 --- /dev/null +++ b/modules/console/services/api/code/arrival_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + hop "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + "github.com/codefly-dev/core/shared" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" + + "api/pkg/adapters" + gen "api/pkg/gen" +) + +const versionMethod = "/api.ApiService/Version" + +// counting is the console's API with its handler runs counted. +type counting struct { + gen.UnimplementedApiServiceServer + ran atomic.Int64 +} + +func (c *counting) Version(context.Context, *gen.VersionRequest) (*gen.VersionResponse, error) { + c.ran.Add(1) + return &gen.VersionResponse{Version: "0.1.0"}, nil +} + +// served is the console's generated server, both listeners, with its +// verifier installed exactly as answerFromTheStamp installs it, over keys +// that publish the hop's admission key and nothing configured beside them. +type served struct { + api *counting + grpc, connect string // listener addresses + md metadata.MD + retainedUntil time.Time // the admission's expiry plus the clock allowance +} + +func serve(t *testing.T) *served { + t.Helper() + t.Setenv("CODEFLY__ENVIRONMENT", "local") + authority := hoptest.New(t).PublishHop("console-hop") + token := authority.IssueSingleUse(t, "console") + s := &served{api: &counting{}, md: hoptest.Forward(t, token, "console-hop", versionMethod)} + // A one-second admission, so its window closes inside the test. + issued := time.Now() + proof, err := hoptest.Signer("console-hop").Sign(token, versionMethod, "console", issued, issued.Add(time.Second).Unix()) + if err != nil { + t.Fatal(err) + } + s.md.Set(hop.ProofKey, proof) + call, err := stamp.Check(s.md, hoptest.PublicKey("console-hop"), versionMethod, "console", issued) + if err != nil { + t.Fatal(err) + } + s.retainedUntil = time.Unix(call.Expires+int64(stamp.Skew/time.Second), 0) + + verifier, err := hop.NewVerifier(context.Background(), authority, "console", hoptest.Replay()) + if err != nil { + t.Fatal(err) + } + grpcPort, connectPort := freePort(t), freePort(t) + config := &adapters.Configuration{EndpointGrpcPort: grpcPort, EndpointConnectPort: shared.Pointer(connectPort)} + install(config, verifier) + config.Service = s.api + server, err := adapters.NewServer(config) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = server.Start(ctx) }() + t.Cleanup(func() { cancel(); server.Stop() }) + s.grpc, s.connect = fmt.Sprintf("127.0.0.1:%d", grpcPort), fmt.Sprintf("127.0.0.1:%d", connectPort) + for _, address := range []string{s.grpc, s.connect} { + for deadline := time.Now().Add(5 * time.Second); ; { + if conn, err := net.Dial("tcp", address); err == nil { + _ = conn.Close() + break + } else if time.Now().After(deadline) { + t.Fatalf("%s never listened: %v", address, err) + } + time.Sleep(10 * time.Millisecond) + } + } + return s +} + +func freePort(t *testing.T) uint16 { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return uint16(listener.Addr().(*net.TCPAddr).Port) +} + +// Review r3 M1: the hop routes the console's gRPC to its Connect listener, +// which hands each call to the gRPC server through ServeHTTP, where no tap +// handle runs, so a genuine call whose body arrived after the admission's +// window was refused as stamp_window. The verifier's stats handler judges the +// window when the call reaches the server, on that listener as on the gRPC +// one: a genuine call whose body arrives late is admitted, and its handler +// runs once. +func TestALateBodyIsJudgedAtArrivalOnBothListeners(t *testing.T) { + for _, listener := range []string{"grpc", "connect"} { + t.Run(listener, func(t *testing.T) { + s := serve(t) + address := s.grpc + if listener == "connect" { + address = s.connect + } + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + stream, err := conn.NewStream(metadata.NewOutgoingContext(ctx, s.md.Copy()), &grpc.StreamDesc{}, versionMethod) + if err != nil { + t.Fatal(err) + } + // The headers are sent now; the body only once the window has closed. + time.Sleep(time.Until(s.retainedUntil) + 300*time.Millisecond) + if err := stream.SendMsg(&gen.VersionRequest{}); err != nil { + t.Fatal(err) + } + if err := stream.CloseSend(); err != nil { + t.Fatal(err) + } + if err := stream.RecvMsg(&gen.VersionResponse{}); err != nil { + t.Fatalf("a genuine call whose body arrived late was refused: %v", err) + } + if got := s.api.ran.Load(); got != 1 { + t.Fatalf("the handler ran %d times", got) + } + }) + } +} + +// The same over the Connect protocol, as the hop's HTTP face forwards a +// Connect call: the unary body arrives after the window has closed. +func TestALateConnectBodyIsJudgedAtArrival(t *testing.T) { + s := serve(t) + body, _ := proto.Marshal(&gen.VersionRequest{}) + reader, writer := io.Pipe() + request, err := http.NewRequest(http.MethodPost, "http://"+s.connect+versionMethod, reader) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/proto") + request.Header.Set("Connect-Protocol-Version", "1") + for key, values := range s.md { + for _, value := range values { + request.Header.Add(key, value) + } + } + go func() { + time.Sleep(time.Until(s.retainedUntil) + 300*time.Millisecond) + _, _ = io.Copy(writer, bytes.NewReader(body)) + _ = writer.Close() + }() + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + answer, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusOK || strings.Contains(string(answer), "stamp_") { + t.Fatalf("a genuine Connect call whose body arrived late was refused: %d %s", response.StatusCode, answer) + } + if got := s.api.ran.Load(); got != 1 { + t.Fatalf("the handler ran %d times", got) + } +} + +// One admission delivered on both listeners at once runs the handler once: +// every other delivery meets the reservation made when the first arrived. +func TestOneAdmissionOnBothListenersRunsOnce(t *testing.T) { + s := serve(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var admitted atomic.Int64 + done := make(chan struct{}) + deliveries := 0 + for _, address := range []string{s.grpc, s.connect} { + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + for range 4 { + deliveries++ + go func() { + defer func() { done <- struct{}{} }() + var answer gen.VersionResponse + if conn.Invoke(metadata.NewOutgoingContext(ctx, s.md.Copy()), versionMethod, &gen.VersionRequest{}, &answer) == nil { + admitted.Add(1) + } + }() + } + } + for range deliveries { + <-done + } + if admitted.Load() != 1 || s.api.ran.Load() != 1 { + t.Fatalf("SECURITY: %d of %d deliveries admitted, handler ran %d times", admitted.Load(), deliveries, s.api.ran.Load()) + } +} diff --git a/modules/console/services/api/code/work.go b/modules/console/services/api/code/work.go index 5d5a4cbb..6dbaccd0 100644 --- a/modules/console/services/api/code/work.go +++ b/modules/console/services/api/code/work.go @@ -26,15 +26,13 @@ func init() { } // answerFromTheStamp is all the authority code the console carries: the hop's -// Verifier authenticates each forwarded Work Context against the authority's +// Verifier checks each call's admission by the hop keys the authority +// publishes, 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. +// did not issue for the console. It re-checks no permission the hop decided, +// and nothing about the hop is configured here: its key comes with the rest. func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Clean, error) { - publicKey, err := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "PUBLIC_KEY") - if err != nil || publicKey == "" { - return nil, fmt.Errorf("console: no hop verification public key delivered: %v", err) - } replay, closeReplay, err := reservations(ctx) if err != nil { return nil, err @@ -52,21 +50,28 @@ func answerFromTheStamp(ctx context.Context, config *adapters.Configuration) (Cl // It starts whether or not the authority serves its keys yet, and refuses // calls with the reason until it does. Outside a local run it also refuses // a replay store that is not the one every replica shares. - verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) + verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, replay) if err != nil { _ = conn.Close() closeReplay() return nil, fmt.Errorf("console: %w", err) } + install(config, verifier) + config.Service = &console{} + return func() { _ = conn.Close(); closeReplay() }, nil +} + +// install puts the verifier in front of every call the console serves. Its +// stats handler judges the admission window and reserves the single-use nonce +// when each call reaches the gRPC server, before a request body is read, on +// both listeners: the gRPC one, and the Connect one the hop routes to, which +// hands each call to the same server. The interceptors hold the reservation +// until the handler returns. +func install(config *adapters.Configuration, verifier *hop.Verifier) { config.GRPCServerOptions = append(config.GRPCServerOptions, - // TapHandle judges the admission window and reserves its single-use - // nonce when each call arrives, not after a request body has decoded; - // the interceptors hold the reservation until the handler returns. - grpc.InTapHandle(verifier.TapHandle()), + grpc.StatsHandler(verifier.StatsHandler()), grpc.ChainUnaryInterceptor(verifier.UnaryServerInterceptor()), grpc.ChainStreamInterceptor(verifier.StreamServerInterceptor())) - config.Service = &console{} - return func() { _ = conn.Close(); closeReplay() }, nil } // collectEvery is how often aged-out reservations are removed. Collection diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index 33b93934..717b48d8 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -43,10 +43,3 @@ service-dependencies: # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true -# The hop's public verification key, and nothing else: no signing secret, and no -# replay volume (the reservations live in this module's store). PUBLIC_KEY has -# no producer yet — see libraries/hop/README.md, "Delivering the verification -# key" — so this run is configured with it by hand until the authority -# publishes it. -workspace-configuration-dependencies: - - hop-verification diff --git a/playground/configurations/local/hop-verification.env b/playground/configurations/local/hop-verification.env deleted file mode 100644 index 9684628c..00000000 --- a/playground/configurations/local/hop-verification.env +++ /dev/null @@ -1,18 +0,0 @@ -# What a module behind the hop needs to check an admission: the public half of -# the hop's signing key. Public by design — a verifier must be able to hold it — -# so it is committed here with the playground's other local values. -# -# It is NOT generated, and that is the open half of security review r164 B1. -# `codefly config generate hop-signing SEED --service authority/hop` makes the -# hop's seed and keeps it inside Codefly; nothing derives the public half from -# it, so this value is written by hand for the playground and the two can drift -# silently (a wrong key refuses every call as stamp_proof). The sound producer -# is the authority publishing the hop's admission key through its own Keys path, -# which the verifier already trusts and already caches — an interfaces/authority -# change, proposed in the handbook before it is built. -# -# Until then: after generating the seed, derive its public half and set this key. -# libraries/hop/README.md, "Delivering the verification key", has the one -# command. The reservations are no longer configured here at all: they live in -# the console's own store (modules/console/services/store). -PUBLIC_KEY= From b184af796468c207ae17077f01d5a39f9a153566 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:08:54 -0400 Subject: [PATCH 14/19] fix(authority-client): Python and TypeScript verifiers take the hop's admission key from Keys Review r3 B1 and M3, the Go verifier's rules in both languages: no public_key/publicKey argument; the admission keys come from the Keys read the verifier already makes (admission_keys, purpose hop-admission), every published one is trusted while a key rotates, a stamp signed by none is refused as stamp_proof without starting a read, an authority publishing none refuses every call as keys_unavailable and keys_error/keysError says why (NoAdmissionKeys), and Work Context trust keeps only work-context keys. M3: Python treats a store as reservation-capable only when it has both reserve and finish (reserve_async and finish_async on the async path), as TypeScript does. Co-Authored-By: Claude Opus 5.5 --- .../python/authority_client/work_context.py | 6 +- .../python/codefly_hop/__init__.py | 122 +++++++++---- .../python/codefly_hop/call.py | 72 ++++++-- .../authority-client/python/tests/test_hop.py | 171 +++++++++++++++--- .../authority-client/typescript/hop-call.ts | 60 ++++-- .../typescript/tests/work-context.test.mjs | 140 ++++++++++---- .../typescript/work-context.ts | 75 ++++++-- 7 files changed, 502 insertions(+), 144 deletions(-) diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 9ee83e64..24608f7b 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -162,10 +162,12 @@ def __init__(self, issuer: str, keys: Mapping[str, bytes], *, now: Callable[[], @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.""" + Work Context keys are kept (purpose "work-context", or none from a host + older than the purpose); an empty or repeated key id is refused. The + hop's admission keys are never among them.""" keys: dict[str, bytes] = {} for key in response.keys: - if key.algorithm != ALGORITHM: + if key.algorithm != ALGORITHM or key.purpose not in ("", "work-context"): continue if not key.key_id or key.key_id in keys: raise Unauthenticated(Reason.INVALIDKEY) diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 3f6828b4..5f3f73d4 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -1,8 +1,10 @@ """Method-bound, single-use hop admission for Python services. -The hop alone holds a signing seed. Verifiers pin the delivered public key, -authenticate the Work Context and consume each signed nonce in a durable store -shared by all replicas. See libraries/hop/README.md for the wire contract. +The hop alone holds a signing seed. The authority publishes its public half in +Keys (admission_keys), which verifiers already read: they check each admission +by a published key, trusting every one while a key rotates, authenticate the +Work Context and consume each signed nonce in a durable store shared by all +replicas. See libraries/hop/README.md for the wire contract. """ from __future__ import annotations @@ -10,7 +12,6 @@ import asyncio import base64 import os -import re import threading import time from collections.abc import Callable, Iterable, Mapping @@ -19,12 +20,12 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from .call import SKEW, Expired, Replayed, ReplayStore, ReservationStore, check +from .call import SKEW, Expired, Replayed, ReplayStore, ReservationStore, admission_keys, check, signed_by 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, Reason, authenticate_token, bound_token, collect -__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of", "ReplayStore", "ReservationStore", "Replayed", "Expired"] +__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of", "ReplayStore", "ReservationStore", "Replayed", "Expired", "NoAdmissionKeys"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -116,30 +117,29 @@ 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; it never fails on an authority that is not - serving yet, and refuses calls, with the reason, until the keys are read. + anything with its ``Keys``): the published keys, the Work Context's and the + hop's admission keys, are read through it, never 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. Without ``replay`` it + authenticates bare tokens only and admits no call. """ - def __init__(self, keys, *, audience: str, public_key: str | None = None, replay: ReplayStore | None = None, + def __init__(self, keys, *, audience: str, replay: ReplayStore | None = None, now: Callable[[], float] | None = None, timeout: float = KEY_TIMEOUT) -> None: - admitting = public_key is not None or replay is not None + admitting = replay is not None if keys is None or not audience: raise Unauthenticated(Reason.CONFIGURATION) - if admitting and (replay is None or not isinstance(public_key, str) or not re.fullmatch(r"[0-9a-f]{64}", public_key)): - # Admission needs both the delivered hop public key and a store - # every replica shares; one without the other admits nothing. - raise Unauthenticated(Reason.CONFIGURATION) if admitting and os.environ.get("CODEFLY__ENVIRONMENT") != "local" and getattr(replay, "shared", False) is not True: # A process-local store answers only for its own process: a captured # admission would replay once per replica, and a restart would # forget every reservation. Only the run named exactly "local" may. raise Unauthenticated(Reason.STORENOTSHARED) self._source, self._audience, self._timeout = keys, audience, timeout - self._public_key, self._replay = public_key, replay + self._replay = replay self._now = now or time.time self._lock = threading.Lock() self._trust: Trust | None = None + self._admission: tuple[str, ...] = () # the published hop admission keys, hex self._fetched = 0.0 self._tried = float("-inf") self._inflight: futures.Future[None] | None = None # done when the running read ends @@ -165,9 +165,10 @@ def ready(self) -> bool: def verify(self, headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str) -> Identity: """Who the call is for, or ``Unauthenticated`` naming why it is refused. - The admission is checked first: nothing that is not signed for this exact - method and audience reaches the key read, and its single-use nonce is - reserved before the keys are read. It may wait up to the read's timeout + The admission is checked first, by the published hop admission keys + held: nothing that is not signed for this exact method and audience + reaches the Work Context's key read or starts one, and its single-use + nonce is reserved before that read. It may wait up to the read's timeout for a key it does not hold yet, or for keys past the stale window; an asyncio handler uses ``verify_async``. @@ -211,9 +212,13 @@ async def admit_async(self, headers: Mapping[str, str] | Iterable[tuple[str, str await finish() def _admit(self, headers, method): - pairs, token, call = self._bound(headers, method) arrival, mark = self._now(), time.monotonic() - managed = callable(getattr(self._replay, "reserve", None)) + pairs = self._pairs(headers) + wait = self._admission_read() + if wait is not None: + futures.wait((wait,), self._timeout) + pairs, token, call = self._bound(pairs, method, arrival) + managed = _managed(self._replay, "reserve", "finish") until = call.expires + SKEW try: if managed: @@ -241,9 +246,16 @@ def finish(): raise async def _admit_async(self, headers, method): - pairs, token, call = self._bound(headers, method) arrival, mark = self._now(), time.monotonic() - managed = callable(getattr(self._replay, "reserve_async", None)) + pairs = self._pairs(headers) + wait = self._admission_read() + if wait is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) + except asyncio.TimeoutError: + pass # judged below on the keys held: missing or stale ones are refused with the reason + pairs, token, call = self._bound(pairs, method, arrival) + managed = _managed(self._replay, "reserve_async", "finish_async") until = call.expires + SKEW try: if managed: @@ -292,13 +304,42 @@ async def verify_token_async(self, token: str) -> Identity: pass return self._token_identity(token) - def _bound(self, headers, method): - if self._public_key is None or self._replay is None: - # A token-only verifier holds neither the hop's public key nor a - # replay store, so it can establish no admission. + def _pairs(self, headers) -> list[tuple[str, str]]: + if self._replay is None: + # A token-only verifier holds no replay store, so it can establish + # no admission. raise Unauthenticated(Reason.CONFIGURATION) - pairs = list(headers.items() if isinstance(headers, Mapping) else headers) - call = check(pairs, self._public_key, method, self._audience, self._now()) + return list(headers.items() if isinstance(headers, Mapping) else headers) + + def _admission_read(self) -> futures.Future[None] | None: + """The read an admission must wait for, if any. An admission is judged on + the keys held: neither a stamp signed by no published key nor keys past + ``KEY_MAX_AGE`` start a read here, so a caller without an admission can + neither drive the authority's keys nor delay a genuine call's read. Only + when no trusted keys are held at all (never read, or past ``KEY_STALE``) + does it start one, at most every ``KEY_RETRY`` as readiness does.""" + with self._lock: + now = self._now() + trusted = self._trust is not None and now - self._fetched <= KEY_STALE + if not trusted and self._inflight is None and now - self._tried >= KEY_RETRY: + self._refresh() + return None if trusted else self._inflight + + def _bound(self, pairs, method, arrival): + """The admission judged at the instant the call arrived, by the published + hop admission key it is signed by.""" + with self._lock: + if self._trust is None or self._now() - self._fetched > KEY_STALE: + if isinstance(self.keys_error, Unauthenticated) and self.keys_error.reason == Reason.INVALIDKEY: + raise Unauthenticated(Reason.INVALIDKEY) + raise Unauthenticated(Reason.KEYSUNAVAILABLE) + published = self._admission + if not published: + raise Unauthenticated(Reason.KEYSUNAVAILABLE) + key = signed_by(pairs, published) + if not key: + raise Unauthenticated(Reason.STAMPPROOF) + call = check(pairs, key, method, self._audience, arrival) return pairs, bound_token(pairs), call def _start(self, key_id: str) -> futures.Future[None] | None: @@ -355,15 +396,18 @@ def _refresh(self) -> futures.Future[None]: self._inflight, self._tried = done, self._now() def read() -> None: - trust, error = None, None + trust, admission, 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)) + admission = admission_keys(response) + if self._replay is not None and not admission: + error = NoAdmissionKeys() 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._trust, self._admission, self._fetched = trust, admission, self._now() self.keys_error, self._inflight = error, None done.set_result(None) @@ -371,6 +415,22 @@ def read() -> None: return done +class NoAdmissionKeys(Exception): + """What ``keys_error`` reports, for the module's logs, while the authority's + keys read but publish no hop admission key: every call is refused as + keys_unavailable until the authority publishes its hop's key.""" + + def __init__(self) -> None: + super().__init__("the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted") + + +def _managed(store, reserve: str, finish: str) -> bool: + """Whether a store holds reservations: it has both operations, as + TypeScript requires. A store with only one is treated as a plain consumer, + never left holding a reservation nothing finishes.""" + return callable(getattr(store, reserve, None)) and callable(getattr(store, finish, None)) + + def _key_id(token: str) -> str: """The key a token names, read only to choose the keys it is judged by.""" if len(token) > 32 * 1024: diff --git a/libraries/authority-client/python/codefly_hop/call.py b/libraries/authority-client/python/codefly_hop/call.py index 550bb293..11e9a552 100644 --- a/libraries/authority-client/python/codefly_hop/call.py +++ b/libraries/authority-client/python/codefly_hop/call.py @@ -36,6 +36,60 @@ async def consume_async(self, nonce: str, retain_until: int) -> None: LIFETIME, SKEW = 30, 2 +# What a published admission key verifies: Keys carries the hop's keys in +# admission_keys with this purpose, never among the Work Context keys. +PURPOSE = "hop-admission" + + +def admission_keys(response) -> tuple[str, ...]: + """The hop admission keys a KeysResponse publishes, hex: Ed25519 keys in + ``admission_keys`` with purpose ``PURPOSE``; anything else there is not one.""" + out: list[str] = [] + for key in response.admission_keys: + if key.algorithm != "Ed25519" or key.purpose != PURPOSE: + continue + if len(key.public_key) != 32: + raise Unauthenticated(Reason.INVALIDKEY) + if key.public_key.hex() not in out: + out.append(key.public_key.hex()) + return tuple(out) + + +def _proof(values) -> tuple[bytes, bytes] | None: + """The one admission the headers carry, as its signed payload and signature, + each in canonical encoding; None for anything else.""" + items = values.get("x-codefly-hop-proof", []) + if len(items) != 1 or len(items[0]) > 8192: + return None + parts = items[0].split(".") + if len(parts) != 2: + return None + raw = [] + for value in parts: + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + return None + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value: + return None + raw.append(decoded) + return raw[0], raw[1] + + +def signed_by(headers, public_keys) -> str: + """Which published admission key (hex) the admission is signed by, or "" + for none: Go's stamp.SignedBy. ``check``, given that key, judges the rest.""" + proof = _proof(collect(headers)) + if proof is None: + return "" + for key in public_keys: + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(key)).verify(proof[1], proof[0]) + return key + except Exception: + continue + return "" + + @dataclass(frozen=True) class Call: nonce: str @@ -49,19 +103,11 @@ def one(key): items = values.get(key, []) return items[0] if len(items) == 1 else "" - def segment(value): - if not re.fullmatch(r"[A-Za-z0-9_-]+", value): - raise ValueError("encoding") - raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) - if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != value: - raise ValueError("encoding") - return raw - try: - encoded = one("x-codefly-hop-proof") - if len(encoded) > 8192: - raise ValueError("size") - payload, signature = (segment(part) for part in encoded.split(".")) + proof = _proof(values) + if proof is None: + raise ValueError("encoding") + payload, signature = proof Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key)).verify(signature, payload) fields = json.loads(payload) if not isinstance(fields, list) or len(fields) != 7: @@ -111,6 +157,8 @@ class ReservationStore(ReplayStore, Protocol): ``retain_until`` has already passed by the store's clock (``Expired``): a reservation delayed past its own retention must never revive a nonce that was collected. A ``finish`` that fails leaves the reservation held. + Because ``reserve`` refuses past ``retain_until`` on the store's clock, a + store may collect a reservation once that has passed, finished or not. """ def reserve(self, nonce: str, retain_until: int) -> None: ... def finish(self, nonce: str) -> None: ... diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 87513f64..2ec68690 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -32,9 +32,9 @@ def consume(self, nonce, until): async def consume_async(self, nonce, until): self.consume(nonce, until) -def fresh(now, *, method=METHOD, audience=FIXTURE["audience"], headers=None, issued=None, expires=None): +def fresh(now, *, method=METHOD, audience=FIXTURE["audience"], headers=None, issued=None, expires=None, hop=b"fixture-proof"): headers = dict(FIXTURE["headers"] if headers is None else headers) - key = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(b"fixture-proof").digest()) + key = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(hop).digest()) payload = json.dumps(["codefly-hop-v1", method, audience, hashlib.sha256(headers[WORK_CONTEXT_KEY].encode()).hexdigest(), secrets.token_hex(32), int(now) if issued is None else issued, int(now)+30 if expires is None else expires], separators=(",", ":")).encode() @@ -64,7 +64,7 @@ def published(): def verifier(clock=lambda: FIXTURE["now"], source=None): - return Verifier(source or Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=clock) + return Verifier(source or Keys(published()), audience=FIXTURE["audience"], replay=Replay(), now=clock) def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): @@ -103,7 +103,7 @@ def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_dige def test_another_audience_and_an_expired_context_are_refused(): with pytest.raises(Unauthenticated, match="stamp_audience"): - Verifier(Keys(published()), audience="records", public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"], METHOD) + Verifier(Keys(published()), audience="records", replay=Replay(), now=lambda: FIXTURE["now"]).verify(FIXTURE["headers"], METHOD) with pytest.raises(Unauthenticated, match="expired"): verifier(clock=lambda: FIXTURE["now"] + 3600, source=Keys(published())).verify(FIXTURE["headers"], METHOD) @@ -120,7 +120,7 @@ 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 = KeysResponse(issuer=published().issuer, admission_keys=published().admission_keys) rotated.keys.add(key_id="rotated", algorithm="Ed25519", public_key=bytes(32)) source.response = rotated source.hold = threading.Event() @@ -163,7 +163,7 @@ def test_a_call_on_keys_past_stale_is_refused_when_the_reread_fails(): import time as clock_time clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0], timeout=0.3) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0], timeout=0.3) source.error, source.hold = RuntimeError("dial authority.internal:4000: connection refused"), threading.Event() clock[0] = FIXTURE["now"] outcome = [] @@ -223,7 +223,7 @@ def test_a_hung_read_stalls_no_held_key(): 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"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0]) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) with pytest.raises(Unauthenticated, match="unavailable"): v.verify(fresh(v._now()), METHOD) source.error, source.response = None, published() @@ -245,10 +245,10 @@ def test_identity_narrows_by_every_actor_and_never_widens_a_resource_scope(): assert identity_of(context).scopes == () # Core's EffectiveScopes: the last actor's grants -def test_a_verifier_needs_keys_an_audience_and_a_proof(): - for args in [(None, "documents", "p"), (Keys(published()), "", "p"), (Keys(published()), "documents", "")]: +def test_a_verifier_needs_keys_and_an_audience(): + for args in [(None, "documents"), (Keys(published()), "")]: with pytest.raises(Unauthenticated): - Verifier(args[0], audience=args[1], public_key=args[2], replay=Replay()) + Verifier(args[0], audience=args[1], replay=Replay()) # Review r57 m-b: an asyncio handler's wait for the keys never blocks the loop. @@ -258,7 +258,7 @@ def test_verify_async_waits_for_keys_off_the_event_loop(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("not serving yet")) - v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0], timeout=1.0) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), 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 @@ -299,7 +299,7 @@ def submit(self, *args, **kwargs): clock = [FIXTURE["now"] - KEY_STALE - 60] source = Keys(published()) - v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0], timeout=1.0) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0], timeout=1.0) source.hold = threading.Event() clock[0] = FIXTURE["now"] @@ -344,7 +344,7 @@ def test_ready_holds_while_keys_are_trusted(): def test_an_unready_replica_becomes_ready_by_polling_alone(): clock = [FIXTURE["now"]] source = Keys(error=RuntimeError("not serving yet")) - v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=Replay(), now=lambda: clock[0]) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) ready = False for poll in range(30): if poll == 3: @@ -376,13 +376,13 @@ def test_an_idle_replica_polled_for_readiness_stays_ready(): # another replica, and a workload constructing the old shared-proof stamp. def test_review_retargeting_and_replay_across_replicas(): store = Replay() - first = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) - second = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) + first = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) + second = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) headers = FIXTURE["headers"] for method in ("/documents.v1.Service/Delete", "/datasets.v1.Service/Register", "Read", "", "POST " + METHOD): with pytest.raises(Unauthenticated, match="stamp_method"): first.verify(headers, method) - other = Verifier(Keys(published()), audience="records", public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) + other = Verifier(Keys(published()), audience="records", replay=store, now=lambda: FIXTURE["now"]) with pytest.raises(Unauthenticated, match="stamp_audience"): other.verify(headers, METHOD) assert first.verify(headers, METHOD).claims.context.replay_policy == "single-use" @@ -472,7 +472,7 @@ def test_duplicate_proof_and_token_are_refused_before_consumption(): def test_async_replay_has_one_winner_across_replicas(): import asyncio store = Replay() - replicas = [Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, now=lambda: FIXTURE["now"]) for _ in range(2)] + replicas = [Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) for _ in range(2)] async def run(): return await asyncio.gather(*(replicas[n % 2].verify_async(FIXTURE["headers"], METHOD) for n in range(32)), return_exceptions=True) results = asyncio.run(run()) @@ -591,7 +591,7 @@ def collect(self): def test_a_second_delivery_of_one_admission_is_refused_while_and_after_the_first_runs(): clock = [FIXTURE["now"]] store = Reservations(lambda: clock[0]) - v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: clock[0]) headers = fresh(FIXTURE["now"]) ran = 0 @@ -611,27 +611,36 @@ def test_a_second_delivery_of_one_admission_is_refused_while_and_after_the_first assert not store.held and ran == 1 -# The reservation is taken before the keys are read, so a delivery that arrives -# while the first one waits for the keys meets it; and a refusal after the -# reservation ends the hold rather than leaving it pinned. -def test_the_reservation_is_taken_before_the_key_read_and_released_on_refusal(): +# The reservation is taken before the Work Context is authenticated, so a +# refusal there ends the hold rather than leaving it pinned, and a second +# delivery still meets the reservation. An admission that cannot be judged, +# because no keys are held, reserves nothing. +def test_the_reservation_is_taken_before_the_work_context_and_released_on_refusal(): clock = [FIXTURE["now"]] store = Reservations(lambda: clock[0]) - source = Keys(error=RuntimeError("authority down")) - v = Verifier(source, audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, - now=lambda: clock[0], timeout=0.05) - headers = fresh(FIXTURE["now"]) - with pytest.raises(Unauthenticated, match="keys"): + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, + now=lambda: clock[0]) + token = FIXTURE["headers"][WORK_CONTEXT_KEY] + forged = token.split(".")[0] + "." + base64.urlsafe_b64encode(bytes(64)).rstrip(b"=").decode() + headers = fresh(FIXTURE["now"], headers={**FIXTURE["headers"], WORK_CONTEXT_KEY: forged, + CONTEXT_KEY: hashlib.sha256(forged.encode()).hexdigest()}) + with pytest.raises(Unauthenticated, match="signature"): v.verify(headers, METHOD) assert list(store.held) and not store.active, "a refused call kept its hold, or never reserved" with pytest.raises(Unauthenticated, match="stamp_replayed"): v.verify(headers, METHOD) + unread = Reservations(lambda: clock[0]) + down = Verifier(Keys(error=RuntimeError("authority down")), audience=FIXTURE["audience"], replay=unread, + now=lambda: clock[0], timeout=0.05) + with pytest.raises(Unauthenticated, match="keys"): + down.verify(fresh(FIXTURE["now"]), METHOD) + assert not unread.held, "an admission judged on no keys was reserved" def test_admit_releases_its_hold_however_the_handler_ends(): clock = [FIXTURE["now"]] store = Reservations(lambda: clock[0]) - v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: clock[0]) with pytest.raises(ZeroDivisionError): with v.admit(fresh(FIXTURE["now"]), METHOD): @@ -644,7 +653,7 @@ def test_admit_async_holds_the_reservation_until_the_handler_returns(): import asyncio clock = [FIXTURE["now"]] store = Reservations(lambda: clock[0]) - v = Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store, + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: clock[0]) headers = fresh(FIXTURE["now"]) @@ -697,5 +706,107 @@ def test_startup_requires_a_shared_store_outside_exactly_local(monkeypatch, envi assert refused.value.code == "replay_store_not_shared" store = Replay() store.shared = True - Verifier(Keys(published()), audience=FIXTURE["audience"], public_key=FIXTURE["public_key"], replay=store) + Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store) Verifier(Keys(published()), audience=FIXTURE["audience"]) # a token-only verifier holds no store + + +def hop_key(label: bytes): + """The admission key of the test hop that signs as label, as Keys publishes it.""" + from authority_client._gen.host.authority.v1.authority_pb2 import PublicKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + raw = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(label).digest()).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return PublicKey(key_id="hop-" + label.decode(), algorithm="Ed25519", public_key=raw, purpose="hop-admission") + + +def publishing(*labels: bytes) -> KeysResponse: + """The fixture's Work Context keys, and the admission keys of these hops.""" + response = published() + del response.admission_keys[:] + response.admission_keys.extend(hop_key(label) for label in labels) + return response + + +# Review r3 B1: the hop's admission key comes from the authority's Keys, the +# read the verifier already makes; nothing about the hop is configured. +def test_a_verifier_takes_the_hops_key_from_the_authoritys_keys_alone(): + v = Verifier(Keys(publishing(b"the-hop")), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + assert v.verify(fresh(FIXTURE["now"], hop=b"the-hop"), METHOD).owner == FIXTURE["identity"]["owner"] + + +# A stamp signed by a key the authority does not publish is refused by name, +# and causes no read of the authority's keys. +def test_a_stamp_signed_by_an_unpublished_key_is_refused_by_name(): + source = Keys(publishing(b"the-hop")) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + settle(v) + reads = source.reads + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"], hop=b"an-unpublished-hop"), METHOD) + assert refused.value.code == "stamp_proof" + settle(v) + assert source.reads == reads + + +# While a hop's key rotates both are published and admitted; once the retiring +# key is withdrawn, the next read stops admitting it. +def test_a_rotations_overlap_admits_both_keys_until_the_retiring_one_is_withdrawn(): + clock = [FIXTURE["now"]] + source = Keys(publishing(b"retiring", b"current")) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + for hop in (b"retiring", b"current"): + v.verify(fresh(clock[0], hop=hop), METHOD) + source.response = publishing(b"current") + clock[0] += 301 # past KEY_MAX_AGE: readiness re-reads + v.ready() + settle(v) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(clock[0], hop=b"retiring"), METHOD) + assert refused.value.code == "stamp_proof" + v.verify(fresh(clock[0], hop=b"current"), METHOD) + + +# Each key serves its own purpose only: an admission key published among the +# Work Context keys admits nothing, and an admission key never signs a Work +# Context. +def test_a_key_is_used_for_its_own_purpose_only(): + from authority_client.work_context import Trust + response = publishing() + response.keys.append(hop_key(b"misplaced")) + v = Verifier(Keys(response), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"], hop=b"misplaced"), METHOD) + assert refused.value.code == "keys_unavailable" # no admission key is published at all + assert "hop-misplaced" not in Trust.from_keys(response).keys + + +# An authority that publishes no admission key admits nothing, under a fixed +# reason, and says why in the module's logs. +def test_no_published_admission_key_refuses_every_call_and_says_why(): + from codefly_hop import NoAdmissionKeys + v = Verifier(Keys(publishing()), audience=FIXTURE["audience"], replay=Replay(), now=lambda: FIXTURE["now"]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(FIXTURE["now"]), METHOD) + assert refused.value.code == "keys_unavailable" + assert isinstance(v.keys_error, NoAdmissionKeys) + + +# Review r3 M3: a store with reserve and no finish is not a reservation store +# (TypeScript's rule): it is consumed, so no reservation is left that nothing +# finishes. +class HalfReservations(Replay): + def __init__(self): + super().__init__() + self.reserved = [] + def reserve(self, nonce, until): + self.reserved.append(nonce) + async def reserve_async(self, nonce, until): + self.reserved.append(nonce) + + +def test_a_store_with_reserve_but_no_finish_is_consumed_not_reserved(): + import asyncio + store = HalfReservations() + v = Verifier(Keys(published()), audience=FIXTURE["audience"], replay=store, now=lambda: FIXTURE["now"]) + v.verify(fresh(FIXTURE["now"]), METHOD) + asyncio.run(v.verify_async(fresh(FIXTURE["now"]), METHOD)) + assert not store.reserved and len(store.seen) == 2 diff --git a/libraries/authority-client/typescript/hop-call.ts b/libraries/authority-client/typescript/hop-call.ts index e39dd723..b272bea0 100644 --- a/libraries/authority-client/typescript/hop-call.ts +++ b/libraries/authority-client/typescript/hop-call.ts @@ -1,6 +1,49 @@ import { createHash, createPublicKey, verify } from "node:crypto"; +import type { KeysResponse } from "./src/gen/host/authority/v1/authority_pb.js"; import { Reason, Unauthenticated } from "./refusal.js"; +/** What a published admission key verifies: Keys carries the hop's keys in + * admission_keys with this purpose, never among the Work Context keys. */ +export const PURPOSE = "hop-admission"; + +/** The hop admission keys a KeysResponse publishes, hex: Ed25519 keys in + * admissionKeys with purpose PURPOSE; anything else there is not one. */ +export function admissionKeys(response: KeysResponse): string[] { + const out: string[] = []; + for (const key of response.admissionKeys) { + if (key.algorithm !== "Ed25519" || key.purpose !== PURPOSE) continue; + if (key.publicKey.length !== 32) throw new Unauthenticated(Reason.InvalidKey); + const hex = Buffer.from(key.publicKey).toString("hex"); + if (!out.includes(hex)) out.push(hex); + } + return out; +} + +/** The one admission the headers carry, as its signed payload and signature, + * each in canonical encoding; undefined for anything else. */ +function proof(pairs: readonly (readonly [string, string])[]): { payload: Buffer; signature: Buffer } | undefined { + const values = pairs.filter(([k]) => k === "x-codefly-hop-proof"); + if (values.length !== 1 || values[0][1].length > 8192) return undefined; + const parts = values[0][1].split("."); + if (parts.length !== 2) return undefined; + const raw = parts.map(part => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url") : undefined); + if (raw.some((r, i) => !r || r.toString("base64url") !== parts[i])) return undefined; + return { payload: raw[0]!, signature: raw[1]! }; +} + +const ed25519 = (hex: string) => createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(hex, "hex").toString("base64url") }, format: "jwk" }); + +/** Which published admission key (hex) the admission is signed by, or "" for + * none: Go's stamp.SignedBy. checkCall, given that key, judges the rest. */ +export function signedBy(pairs: readonly (readonly [string, string])[], publicKeys: readonly string[]): string { + const signed = proof(pairs); + if (!signed) return ""; + for (const key of publicKeys) { + if (/^[0-9a-f]{64}$/.test(key) && verify(null, signed.payload, ed25519(key), signed.signature)) return key; + } + return ""; +} + /** The hop admission lifetime and the clock allowance, as Go's stamp.Lifetime * and stamp.Skew define them, in seconds. An admission may be issued up to SKEW * ahead of this verifier's clock and stays admissible until SKEW past its @@ -25,7 +68,9 @@ export interface ReplayStore { * replica, a nonce already held (Replayed), including one held by a call still * running past its retention, and refuses a reservation whose retainUntil has * already passed by the store's clock (Expired), so a delayed reservation - * never revives a collected nonce. A finish that fails leaves it held. */ + * never revives a collected nonce. A finish that fails leaves it held. + * Because reserve refuses past retainUntil on the store's clock, a store may + * collect a reservation once that has passed, finished or not. */ reserve?(nonce: string, retainUntil: number): Promise; finish?(nonce: string): Promise; } @@ -33,16 +78,9 @@ export interface ReplayStore { export function checkCall(pairs: readonly (readonly [string, string])[], publicKey: string, method: string, audience: string, now: number): { nonce: string; expires: number } { const fail = (reason: Reason): never => { throw new Unauthenticated(reason); }; const one = (key: string): string => { const values = pairs.filter(([k]) => k === key); return values.length === 1 ? values[0][1] : ""; }; - const encoded = one("x-codefly-hop-proof"), parts = encoded.split("."); - if (!/^[0-9a-f]{64}$/.test(publicKey) || parts.length !== 2 || encoded.length > 8192) fail(Reason.StampProof); - const decode = (part: string): Buffer => { - const raw = Buffer.from(part, "base64url"); - if (!/^[A-Za-z0-9_-]+$/.test(part) || raw.toString("base64url") !== part) fail(Reason.StampProof); - return raw; - }; - const payload = decode(parts[0]), signature = decode(parts[1]); - const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(publicKey, "hex").toString("base64url") }, format: "jwk" }); - if (!verify(null, payload, key, signature)) fail(Reason.StampProof); + const signed = proof(pairs); + if (!/^[0-9a-f]{64}$/.test(publicKey) || !signed || !verify(null, signed.payload, ed25519(publicKey), signed.signature)) return fail(Reason.StampProof); + const { payload } = signed; let fields: unknown; try { fields = JSON.parse(payload.toString("utf8")); } catch { fail(Reason.StampProof); } if (!Array.isArray(fields) || fields.length !== 7) return fail(Reason.StampProof); diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index f878a611..0a095558 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -5,7 +5,7 @@ import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:cr import { test } from 'node:test'; import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Expired, Reason, SKEW } from '@codefly-dev/authority-client/work-context'; +import { authenticateToken, boundToken, identityOf, trustFromKeys, Unauthenticated, Verifier, Replayed, Expired, Reason, SKEW, NoAdmissionKeys } from '@codefly-dev/authority-client/work-context'; // Every verifier here runs in the local run, which alone may use a // process-local replay store; the startup guard's test sets its own. @@ -102,11 +102,16 @@ for (const v of vectors) { const fixture = JSON.parse(readFileSync(new URL('../../../hop/testdata/stamped.json',import.meta.url),'utf8')); const METHOD = "/test.Service/Read"; const replay = () => { const seen = new Set(); return { async consume(nonce) { if (seen.has(nonce)) throw new Replayed(); seen.add(nonce); } }; }; -const fresh = (now, method = METHOD, audience = fixture.audience) => { - const key = hopPrivateKey({key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), createHash("sha256").update("fixture-proof").digest()]), format: "der", type: "pkcs8"}); - const payload = Buffer.from(JSON.stringify(["codefly-hop-v1", method, audience, fixture.headers["x-codefly-hop-context"], hopRandom(32).toString("hex"), Math.floor(now/1000), Math.floor(now/1000)+30])); - return {...fixture.headers, "x-codefly-hop-proof": payload.toString("base64url")+"."+hopSign(null,payload,key).toString("base64url")}; +const hopKey = label => hopPrivateKey({key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), createHash("sha256").update(label).digest()]), format: "der", type: "pkcs8"}); +const fresh = (now, method = METHOD, audience = fixture.audience, headers = fixture.headers, hop = "fixture-proof") => { + const payload = Buffer.from(JSON.stringify(["codefly-hop-v1", method, audience, createHash("sha256").update(headers["x-codefly-work-context"]).digest("hex"), hopRandom(32).toString("hex"), Math.floor(now/1000), Math.floor(now/1000)+30])); + return {...headers, "x-codefly-hop-proof": payload.toString("base64url")+"."+hopSign(null,payload,hopKey(hop)).toString("base64url")}; }; +// The admission key of the test hop that signs as label, as Keys publishes it. +const hopPublished = label => create(PublicKeySchema, {keyId: `hop-${label}`, algorithm: 'Ed25519', purpose: 'hop-admission', + publicKey: Buffer.from(hopKey(label).export({format: 'jwk'}).x, 'base64url')}); +// The fixture's Work Context keys, and the admission keys of these hops. +const publishing = (...labels) => create(KeysResponseSchema, {issuer: fromJson(KeysResponseSchema, fixture.keys).issuer, keys: fromJson(KeysResponseSchema, fixture.keys).keys, admissionKeys: labels.map(hopPublished)}); const published = fromJson(KeysResponseSchema, fixture.keys); const source = (answer = () => Promise.resolve(published)) => { const s = {reads: 0, keys: () => { s.reads++; return answer(); }}; return s; }; @@ -114,7 +119,7 @@ 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.public_key, replay(), () => fixture.now*1000); + const v = new Verifier(keys, fixture.audience, replay(), () => fixture.now*1000); const id = await v.verify(fresh(v.now()), METHOD); assert.equal(id.owner, fixture.identity.owner); assert.equal(id.tenant, fixture.identity.tenant); @@ -133,7 +138,7 @@ test('a call without the proof never reads the keys, and a hung read stalls no h 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.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); assert.equal(keys.reads, 1); for (let i = 0; i < 20; i++) { @@ -153,7 +158,7 @@ test('a Verifier starts before the authority serves keys and refuses with a reas 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.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); await assert.rejects(v.verify(fresh(v.now()), METHOD), e => e instanceof Unauthenticated && /unavailable/.test(e.message) && !/4000/.test(e.message)); up = true; @@ -172,7 +177,7 @@ for (const mode of ['stamped', 'bare']) { test(mode + ': a call on keys past the stale window waits for the re-read', async () => { const keys = source(); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); clock = fixture.now*1000; assert.equal((await check(v)).owner, fixture.identity.owner); @@ -185,7 +190,7 @@ test(mode + ': a call on keys past the stale window is refused when the re-read let fail; const keys = source(() => keys.reads === 1 ? Promise.resolve(published) : new Promise((_, reject) => { fail = reject; })); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); clock = fixture.now*1000; let judged = false; @@ -204,7 +209,7 @@ test(mode + ': a call on keys past the stale window is refused when the re-read let hang = false; const keys = source(() => hang ? new Promise(() => {}) : Promise.resolve(published)); let clock = (fixture.now - 960)*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock, 100); + const v = new Verifier(keys, fixture.audience, replay(), () => clock, 100); await settle(); hang = true; clock = fixture.now*1000; @@ -230,7 +235,7 @@ test('readiness follows trust, not the last read', async () => { let failing = false; const keys = source(() => failing ? Promise.reject(new Error('authority down')) : Promise.resolve(published)); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); assert.equal(v.ready(), true); failing = true; @@ -248,7 +253,7 @@ test('an unready replica becomes ready by polling alone', async () => { let serving = false; const keys = source(() => serving ? Promise.resolve(published) : Promise.reject(new Error('not serving yet'))); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); let ready = false; for (let poll = 0; poll < 30 && !ready; poll++) { @@ -266,7 +271,7 @@ test('an unready replica becomes ready by polling alone', async () => { test('an idle replica polled for readiness stays ready', async () => { const keys = source(); let clock = fixture.now*1000; - const v = new Verifier(keys, fixture.audience, fixture.public_key, replay(), () => clock); + const v = new Verifier(keys, fixture.audience, replay(), () => clock); await settle(); for (let minute = 1; minute <= 60; minute++) { clock += 60_000; @@ -279,12 +284,12 @@ test('an idle replica polled for readiness stays ready', async () => { test('review probes: read stamp cannot retarget, cross audiences or replay across replicas', async () => { const store = replay(); - const first = new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000); - const second = new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000); + const first = new Verifier(source(), fixture.audience, store, () => fixture.now*1000); + const second = new Verifier(source(), fixture.audience, store, () => fixture.now*1000); for (const method of ['/documents.v1.Service/Delete','/datasets.v1.Service/Register','Read','','POST '+METHOD]) { await assert.rejects(first.verify(fixture.headers, method), /stamp_method/); } - const other = new Verifier(source(), 'records', fixture.public_key, store, () => fixture.now*1000); + const other = new Verifier(source(), 'records', store, () => fixture.now*1000); await assert.rejects(other.verify(fixture.headers, METHOD), /stamp_audience/); assert.equal((await first.verify(fixture.headers, METHOD)).claims.context.replayPolicy, "single-use"); await assert.rejects(first.verify(fixture.headers, METHOD), /stamp_replayed/); @@ -294,18 +299,18 @@ test('review probes: read stamp cannot retarget, cross audiences or replay acros test('concurrent replay has one winner and a failed store refuses admission', async () => { const store = replay(); - const replicas = [0,1].map(() => new Verifier(source(), fixture.audience, fixture.public_key, store, () => fixture.now*1000)); + const replicas = [0,1].map(() => new Verifier(source(), fixture.audience, store, () => fixture.now*1000)); const results = await Promise.allSettled(Array.from({length:32}, (_,n) => replicas[n%2].verify(fixture.headers, METHOD))); assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); - const v = new Verifier(source(), fixture.audience, fixture.public_key, {async consume(){throw Error('store down');}}, () => fixture.now*1000); + const v = new Verifier(source(), fixture.audience, {async consume(){throw Error('store down');}}, () => fixture.now*1000); await assert.rejects(v.verify(fixture.headers, METHOD), /stamp_store/); }); test('expired stamps and repeated binding headers refuse before consumption', async () => { - const expired = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now+30+SKEW)*1000); + const expired = new Verifier(source(), fixture.audience, replay(), () => (fixture.now+30+SKEW)*1000); await assert.rejects(expired.verify(fixture.headers, METHOD), /stamp_window/); for (const key of ['x-codefly-hop-proof', 'x-codefly-work-context','x-codefly-hop-context']) { - const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => fixture.now*1000); + const v = new Verifier(source(), fixture.audience, replay(), () => fixture.now*1000); await assert.rejects(v.verify([...Object.entries(fixture.headers),[key,fixture.headers[key]]], METHOD), Unauthenticated); await v.verify(fixture.headers, METHOD); } @@ -314,7 +319,7 @@ test('expired stamps and repeated binding headers refuse before consumption', as for (const row of vectors) { test(`bare token matches shared verdict: ${row.name}`, async () => { const response = create(KeysResponseSchema, {issuer: row.issuer, keys: Object.entries(row.keys).map(([keyId, raw]) => create(PublicKeySchema,{keyId,algorithm:'Ed25519',publicKey:Buffer.from(raw,'base64')}))}); - const v = new Verifier(source(()=>Promise.resolve(response)), row.audience, undefined, undefined, ()=>row.now*1000); + const v = new Verifier(source(()=>Promise.resolve(response)), row.audience, undefined, ()=>row.now*1000); if (row.accept) assert.equal((await v.verifyToken(row.token)).claims.audience,row.audience); else await assert.rejects(v.verifyToken(row.token), e=> e instanceof Unauthenticated && e.code === row.reason && !e.message.includes(row.token)); }); @@ -327,7 +332,7 @@ test('every named reason has distinct code and safe text', () => { }); test('stamp failures retain distinct safe codes', async () => { - const v=new Verifier(source(),fixture.audience,fixture.public_key,replay(),()=>fixture.now*1000); + const v=new Verifier(source(),fixture.audience,replay(),()=>fixture.now*1000); for (const [change,code] of [ [{'x-codefly-hop-proof':'private-proof'},Reason.StampProof], [{'x-codefly-work-context':''},Reason.StampToken], @@ -337,7 +342,7 @@ test('stamp failures retain distinct safe codes', async () => { }); test('bare-only process needs no admission keys and cannot accept stamps', async () => { - const v = new Verifier(source(),fixture.audience,undefined,undefined,()=>fixture.now*1000); + const v = new Verifier(source(),fixture.audience,undefined,()=>fixture.now*1000); assert.equal((await v.verifyToken(fixture.headers['x-codefly-work-context'])).owner,fixture.identity.owner); // Without the hop public key and a shared replay store it admits nothing. await assert.rejects(v.verify(fixture.headers, METHOD), e=>e.code===Reason.Configuration); @@ -348,7 +353,7 @@ test('invalid publication and unreadable keys have distinct safe codes', async ( [()=>Promise.resolve(create(KeysResponseSchema,{issuer:'issuer'})), Reason.InvalidKey], [()=>Promise.reject(new Error('private-source-detail')), Reason.KeysUnavailable], ]) { - const v=new Verifier(source(answer),fixture.audience,undefined,undefined,()=>fixture.now*1000); + const v=new Verifier(source(answer),fixture.audience,undefined,()=>fixture.now*1000); await assert.rejects(v.verifyToken(fixture.headers['x-codefly-work-context']), e=>e.code===code && !e.message.includes('private-')); } }); @@ -358,14 +363,14 @@ test('invalid publication and unreadable keys have distinct safe codes', async ( // reason that read as an expiry. Go, Python and TypeScript allow the same SKEW. for (const lag of [0, 1, SKEW]) { test(`a clock difference of ${lag}s inside the allowance still admits`, async () => { - const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now-lag)*1000); + const v = new Verifier(source(), fixture.audience, replay(), () => (fixture.now-lag)*1000); assert.equal((await v.verify(fresh(fixture.now*1000), METHOD)).owner, fixture.identity.owner); }); } for (const [lag, code] of [[SKEW+1, Reason.StampIssued], [-(30+SKEW), Reason.StampWindow]]) { test(`a clock difference of ${lag}s beyond the allowance is refused as ${code}`, async () => { - const v = new Verifier(source(), fixture.audience, fixture.public_key, replay(), () => (fixture.now-lag)*1000); + const v = new Verifier(source(), fixture.audience, replay(), () => (fixture.now-lag)*1000); await assert.rejects(v.verify(fresh(fixture.now*1000), METHOD), e => e instanceof Unauthenticated && e.code === code); }); } @@ -376,7 +381,7 @@ for (const [lag, code] of [[SKEW+1, Reason.StampIssued], [-(30+SKEW), Reason.Sta test('a reservation is retained past expiry by the allowance', async () => { const retained = []; const recording = { async consume(_nonce, retainUntil) { retained.push(retainUntil); } }; - const v = new Verifier(source(), fixture.audience, fixture.public_key, recording, () => fixture.now*1000); + const v = new Verifier(source(), fixture.audience, recording, () => fixture.now*1000); const headers = fresh(fixture.now*1000); const payload = JSON.parse(Buffer.from(headers['x-codefly-hop-proof'].split('.')[0], 'base64url').toString('utf8')); await v.verify(headers, METHOD); @@ -407,7 +412,7 @@ const reservations = clock => { test('a second delivery of one admission is refused while and after the first runs', async () => { let now = fixture.now * 1000; const store = reservations(() => now); - const v = new Verifier(source(), fixture.audience, fixture.public_key, store, () => now); + const v = new Verifier(source(), fixture.audience, store, () => now); const headers = fresh(now); let ran = 0; await v.admit(headers, METHOD, async identity => { @@ -424,23 +429,82 @@ test('a second delivery of one admission is refused while and after the first ru assert.equal(ran, 1); }); -// The reservation is taken before the keys are read, and a refusal after it -// ends the hold rather than leaving it pinned. -test('the reservation is taken before the key read and released on refusal', async () => { +// The reservation is taken before the Work Context is authenticated, and a +// refusal there ends the hold rather than leaving it pinned; a second delivery +// still meets it. An admission judged on no keys reserves nothing. +test('the reservation is taken before the work context and released on refusal', async () => { const now = fixture.now * 1000; const store = reservations(() => now); - const v = new Verifier(source(() => Promise.reject(new Error('authority down'))), fixture.audience, fixture.public_key, store, () => now, 50); - const headers = fresh(now); - await assert.rejects(v.verify(headers, METHOD), e => e instanceof Unauthenticated && /keys/.test(e.code)); + const v = new Verifier(source(), fixture.audience, store, () => now); + const token = fixture.headers['x-codefly-work-context']; + const forged = token.split('.')[0] + '.' + Buffer.alloc(64).toString('base64url'); + const headers = fresh(now, METHOD, fixture.audience, {...fixture.headers, 'x-codefly-work-context': forged, 'x-codefly-hop-context': createHash('sha256').update(forged).digest('hex')}); + await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.Signature); assert.equal(store.held.size, 1, 'the call never reserved'); assert.equal(store.active.size, 0, 'a refused call kept its hold'); await assert.rejects(v.verify(headers, METHOD), e => e.code === Reason.StampReplayed); + const unread = reservations(() => now); + const down = new Verifier(source(() => Promise.reject(new Error('authority down'))), fixture.audience, unread, () => now, 50); + await assert.rejects(down.verify(fresh(now), METHOD), e => e.code === Reason.KeysUnavailable); + assert.equal(unread.held.size, 0, 'an admission judged on no keys was reserved'); +}); + +// Review r3 B1: the hop's admission key comes from the authority's Keys, the +// read the verifier already makes; nothing about the hop is configured. +test('a verifier takes the hop key from the authority keys alone', async () => { + const v = new Verifier(source(() => Promise.resolve(publishing('the-hop'))), fixture.audience, replay(), () => fixture.now * 1000); + const id = await v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD); + assert.equal(id.owner, fixture.identity.owner); +}); + +// A stamp signed by a key the authority does not publish is refused by name, +// and causes no read of the authority's keys. +test('a stamp signed by an unpublished key is refused by name', async () => { + const keys = source(() => Promise.resolve(publishing('the-hop'))); + const v = new Verifier(keys, fixture.audience, replay(), () => fixture.now * 1000); + await settle(); + const reads = keys.reads; + await assert.rejects(v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'an-unpublished-hop'), METHOD), e => e.code === Reason.StampProof); + await settle(); + assert.equal(keys.reads, reads); +}); + +// While a hop's key rotates both are published and admitted; once the retiring +// key is withdrawn, the next read stops admitting it. +test('a rotation overlap admits both keys until the retiring one is withdrawn', async () => { + let now = fixture.now * 1000, answer = publishing('retiring', 'current'); + const v = new Verifier(source(() => Promise.resolve(answer)), fixture.audience, replay(), () => now); + for (const hop of ['retiring', 'current']) await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, hop), METHOD); + answer = publishing('current'); + now += 301_000; // past KEY_MAX_AGE: readiness re-reads + v.ready(); + await settle(); + await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'retiring'), METHOD), e => e.code === Reason.StampProof); + await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'current'), METHOD); +}); + +// Each key serves its own purpose only: an admission key published among the +// Work Context keys admits nothing, and is never trusted for a Work Context. +test('a key is used for its own purpose only', async () => { + const response = publishing(); + response.keys.push(hopPublished('misplaced')); + const v = new Verifier(source(() => Promise.resolve(response)), fixture.audience, replay(), () => fixture.now * 1000); + await assert.rejects(v.verify(fresh(fixture.now * 1000, METHOD, fixture.audience, fixture.headers, 'misplaced'), METHOD), e => e.code === Reason.KeysUnavailable); + assert.ok(!trustFromKeys(response).keys.has('hop-misplaced')); +}); + +// An authority that publishes no admission key admits nothing, under a fixed +// reason, and says why in the module's logs. +test('no published admission key refuses every call and says why', async () => { + const v = new Verifier(source(() => Promise.resolve(publishing())), fixture.audience, replay(), () => fixture.now * 1000); + await assert.rejects(v.verify(fresh(fixture.now * 1000), METHOD), e => e.code === Reason.KeysUnavailable); + assert.ok(v.keysError instanceof NoAdmissionKeys); }); test('admit releases its hold however the handler ends', async () => { const now = fixture.now * 1000; const store = reservations(() => now); - const v = new Verifier(source(), fixture.audience, fixture.public_key, store, () => now); + const v = new Verifier(source(), fixture.audience, store, () => now); await assert.rejects(v.admit(fresh(now), METHOD, async () => { assert.equal(store.active.size, 1); throw new Error('handler failed'); }), /handler failed/); assert.equal(store.held.size, 1); assert.equal(store.active.size, 0); @@ -454,7 +518,7 @@ test('a consumption completing past its retention is refused', async t => { const real = performance.now.bind(performance); t.mock.method(performance, 'now', () => real() + slow); const evicted = { async consume() { if (++calls === 2) slow = 60_000; } }; - const v = new Verifier(source(), fixture.audience, fixture.public_key, evicted, () => fixture.now * 1000); + const v = new Verifier(source(), fixture.audience, evicted, () => fixture.now * 1000); await v.verify(fresh(fixture.now * 1000), METHOD); await assert.rejects(v.verify(fresh(fixture.now * 1000), METHOD), e => e.code === Reason.StampWindow); }); @@ -466,7 +530,7 @@ test('startup requires a shared store outside exactly local', () => { for (const environment of [undefined, '', 'production', 'staging', 'local-production', 'local-docker', ' local', 'local ', 'local']) { if (environment === undefined) delete process.env.CODEFLY__ENVIRONMENT; else process.env.CODEFLY__ENVIRONMENT = environment; - const build = store => new Verifier(source(), fixture.audience, fixture.public_key, store); + const build = store => new Verifier(source(), fixture.audience, store); if (environment === 'local') build(replay()); else assert.throws(() => build(replay()), e => e.code === Reason.StoreNotShared); build({ ...replay(), shared: true }); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 7a003840..05e85d9f 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -1,5 +1,5 @@ -import { checkCall, Expired, Replayed, SKEW, type ReplayStore } from "./hop-call.js"; -export { Expired, Replayed, LIFETIME, SKEW } from "./hop-call.js"; +import { admissionKeys, checkCall, Expired, Replayed, signedBy, SKEW, type ReplayStore } from "./hop-call.js"; +export { Expired, Replayed, LIFETIME, SKEW, PURPOSE } from "./hop-call.js"; export type { ReplayStore } from "./hop-call.js"; /** Work Context identity readers and method-bound, single-use hop admission. * Verifier consumes signed call nonces in a required durable shared ReplayStore. @@ -32,11 +32,13 @@ export interface Trust { readonly now?: () => number; } -/** Trust from the authority interface's KeysResponse: Ed25519 keys only. */ +/** Trust from the authority interface's KeysResponse: Ed25519 Work Context + * keys only (purpose "work-context", or none from a host older than the + * purpose). The hop's admission keys are never among them. */ 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.algorithm !== "Ed25519" || (key.purpose !== "" && key.purpose !== "work-context")) continue; if (!key.keyId || keys.has(key.keyId) || key.publicKey.length !== 32) { throw new Unauthenticated(Reason.InvalidKey); } @@ -281,13 +283,25 @@ export interface KeySource { * 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; +/** What keysError reports, for the module's logs, while the authority's keys + * read but publish no hop admission key: every call is refused as + * keys_unavailable until the authority publishes its hop's key. */ +export class NoAdmissionKeys extends Error { + override name = "NoAdmissionKeys"; + constructor() { super("the authority's Keys publish no hop admission key (admission_keys), so no call can be admitted"); } +} + /** 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. */ + * the module depends on; the keys, the Work Context's and the hop's admission + * keys, are read through its Keys, never typed in. Without a replay store it + * authenticates bare tokens only and admits no call. 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; + /** The published hop admission keys, hex, read with trust. */ + private admission: string[] = []; private fetched = 0; private tried = Number.NEGATIVE_INFINITY; private inflight?: Promise; @@ -295,14 +309,9 @@ export class Verifier { keysError?: unknown; constructor(private readonly source: KeySource, private readonly audience: string, - private readonly publicKey?: string, private readonly replay?: ReplayStore, + private readonly replay?: ReplayStore, private readonly now: () => number = Date.now, private readonly timeoutMs: number = KEY_TIMEOUT_MS) { if (!source || !audience) throw new Unauthenticated(Reason.Configuration); - // Admission needs both the delivered hop public key and a store every - // replica shares; one without the other admits nothing. - if ((publicKey !== undefined || replay !== undefined) && (!replay || !/^[0-9a-f]{64}$/.test(publicKey ?? ""))) { - throw new Unauthenticated(Reason.Configuration); - } // A process-local store answers only for its own process: a captured // admission would replay once per replica, and a restart would forget every // reservation. Only the run named exactly "local" may use one. @@ -347,15 +356,16 @@ export class Verifier { private async begin(headers: StampHeaders, method: string): Promise<{ identity: Identity; finish: () => 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)); - if (this.publicKey === undefined || this.replay === undefined) { - // A token-only verifier holds neither the hop's public key nor a replay - // store, so it can establish no admission. + if (this.replay === undefined) { + // A token-only verifier holds no replay store, so it can establish no + // admission. throw new Unauthenticated(Reason.Configuration); } - // The admission first: nothing signed for another procedure, another - // audience or another token reaches the key read. + // The admission first, judged at arrival by the published hop admission + // keys held: nothing signed for another procedure, another audience or + // another token reaches the Work Context's key read or starts one. const arrival = this.now(), mark = performance.now(); - const call = checkCall(pairs, this.publicKey, method, this.audience, arrival); + const call = checkCall(pairs, await this.admissionKey(pairs), method, this.audience, arrival); const finish = await this.reserve(call.nonce, call.expires + SKEW, arrival, mark); try { const token = boundToken(pairs); @@ -380,6 +390,27 @@ export class Verifier { } } + /** The published hop admission key the admission is signed by. It is judged + * on the keys held: neither a stamp signed by no published key nor keys past + * KEY_MAX_AGE start a read here, so a caller without an admission can neither + * drive the authority's keys nor delay a genuine call's read. Only when no + * trusted keys are held at all (never read, or past KEY_STALE) does it start + * one, at most every KEY_RETRY as readiness does, and wait for it. */ + private async admissionKey(pairs: readonly (readonly [string, string])[]): Promise { + const now = this.now(); + const trusted = () => this.trust !== undefined && this.now() - this.fetched <= KEY_STALE_MS; + if (!trusted() && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + if (!trusted() && this.inflight) await this.inflight; + if (!trusted()) { + if (this.keysError instanceof Unauthenticated && this.keysError.code === Reason.InvalidKey) throw new Unauthenticated(Reason.InvalidKey); + throw new Unauthenticated(Reason.KeysUnavailable); + } + if (this.admission.length === 0) throw new Unauthenticated(Reason.KeysUnavailable); + const key = signedBy(pairs, this.admission); + if (!key) throw new Unauthenticated(Reason.StampProof); + return key; + } + /** Consume the admission's nonce, retained to its expiry plus the clock * allowance at least, and answer the release that ends its in-flight hold. */ private async reserve(nonce: string, until: number, arrival: number, mark: number): Promise<() => Promise> { @@ -437,7 +468,11 @@ export class Verifier { const signal = AbortSignal.timeout(this.timeoutMs); const deadline = new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })); this.inflight = Promise.race([this.source.keys({}, { timeoutMs: this.timeoutMs, signal }), deadline]) - .then(response => { this.trust = trustFromKeys(response); this.fetched = this.now(); this.keysError = undefined; }) + .then(response => { + const trust = trustFromKeys(response), admission = admissionKeys(response); + this.trust = trust; this.admission = admission; this.fetched = this.now(); + this.keysError = this.replay && admission.length === 0 ? new NoAdmissionKeys() : undefined; + }) .catch(error => { this.keysError = error; }) .finally(() => { this.inflight = undefined; }); return this.inflight; From 4984c5e86fdd04fd93c6ab0fef3226eca7673d7b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:09:51 -0400 Subject: [PATCH 15/19] docs: the hop's admission key is published in Keys; StatsHandler; collection The hop README's verification-key section states what is built (Keys' admission_keys, purpose, rotation overlap, local derivation, the accounts follow-up) and drops the open note; the Go, Python and TypeScript READMEs construct verifiers without a key; the playground README no longer asks for a hand-written one. Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/python/README.md | 12 +-- .../authority-client/typescript/README.md | 12 +-- libraries/hop/README.md | 79 +++++++++++-------- libraries/hop/go/README.md | 19 ++--- .../services/local/service.codefly.yaml | 3 + playground/configurations/README.md | 13 ++- 6 files changed, 74 insertions(+), 64 deletions(-) diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index a8786dc3..55b33243 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -19,7 +19,7 @@ for: 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"], public_key=public_key, replay=shared_replay_store) # once +verifier = Verifier(AuthorityServiceStub(channel), audience=os.environ["CODEFLY__MODULE"], replay=shared_replay_store) # once; the hop's key comes from Keys identity = verifier.verify(request.headers, full_procedure) # a sync handler identity = await verifier.verify_async(request.headers, full_procedure) # an asyncio handler: a key wait runs off the loop with verifier.admit(request.headers, full_procedure) as identity: ... # holds the single-use reservation while the handler runs @@ -139,8 +139,8 @@ admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, `stamp_store`. Each names no proof, token, digest, nonce or claim. Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit -`public_key` and `replay` when constructing the Python/TypeScript `Verifier`. -They still authenticate bare tokens against published Keys. They admit no -stamped call: without the hop's public key and a shared replay store there is -nothing to check an admission against, so `verify` refuses with -`configuration`. A process that receives hop calls supplies both. +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index c5aefa8d..9c393a7d 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -8,7 +8,7 @@ depends on: ```ts import { Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; -const verifier = new Verifier(createClient(AuthorityService, transport), audience, publicKey, sharedReplayStore); +const verifier = new Verifier(createClient(AuthorityService, transport), audience, sharedReplayStore); // the hop's key comes from Keys const id = await verifier.verify(requestHeaders, fullProcedure); // throws Unauthenticated, naming why await verifier.admit(requestHeaders, fullProcedure, async id => handle(id)); // holds the single-use reservation while handle runs id.tenant; id.scopes; id.claims.allows(resourceKind, action, exactResourceID); @@ -71,8 +71,8 @@ admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, `stamp_store`. Each names no proof, token, digest, nonce or claim. Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit -`public_key` and `replay` when constructing the Python/TypeScript `Verifier`. -They still authenticate bare tokens against published Keys. They admit no -stamped call: without the hop's public key and a shared replay store there is -nothing to check an admission against, so `verify` refuses with -`configuration`. A process that receives hop calls supplies both. +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/libraries/hop/README.md b/libraries/hop/README.md index 0121950f..3958f0db 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -91,13 +91,15 @@ the unary interceptor, and the Connect and REST faces buffer the message too. Judging the window there refused admissions the authority had granted to slow uploads; consuming the nonce there let a second delivery of one admission, held back while its body arrived, find the nonce collected after its retention and -run the handler a second time. `Verifier.TapHandle`, installed with -`grpc.InTapHandle`, records the arrival instant when the stream is accepted and -starts the admission check and the reservation beside the call, so neither -waits for the body; the interceptor awaits them and refuses with the reason. It -does no work on the connection's I/O goroutine itself, where it would serialise -every stream on that connection. A server that installs no handle judges and -reserves when the interceptor runs, so nothing is admitted by its absence. +run the handler a second time. `Verifier.StatsHandler`, installed with +`grpc.StatsHandler`, records the arrival instant when gRPC tags the call and +starts the admission check and the reservation beside it, so neither waits for +the body; the interceptor awaits them and refuses with the reason. gRPC tags +every call this way on every transport it serves: its own listener, and the +Connect listener a go-grpc module declares, which hands each call to the same +server through `grpc.Server.ServeHTTP`, where a tap handle never runs (review +r3, M1). A server that installs no handler judges and reserves when the +interceptor runs, so nothing is admitted by its absence. A single-use admission is consumed exactly once. Its reservation is retained at least to expiry plus the allowance, and for as long as a call holding it runs: a @@ -131,7 +133,10 @@ clock differences and prevent clock rollback. Synchronize deployment clocks. `INSERT ... ON CONFLICT (nonce) DO NOTHING` over a primary key is the atomic reservation, conditioned on the retention not having passed by the database's clock, and the database is already shared by every replica. Its `active` column -holds a running call's reservation; `Collect` never removes one. Codefly +marks a running call, for diagnosis; `Collect` removes every reservation past its +retention by the database's clock, running or not, because `Reserve` refuses any +reservation past it on that clock, so a holder that crashed before finishing +leaves no permanent row. Codefly delivers a store, so this needs nothing the platform cannot provide. `replaypg.Migration` is the table, `Collect` removes reservations whose retention has passed, and the console composes exactly this @@ -178,33 +183,37 @@ codefly config generate hop-signing SEED --service authority/hop ``` The hop reads `Secret("hop-signing", "SEED")`: a 32-byte hex seed, never the old -workspace `hop/PROOF`. Do not deliver the seed or derive it from a previously -shared proof. The key must come from trusted composition, never from a request -or an unauthenticated key advertisement. Key rotation changes the trusted public -key and immediately refuses stamps from the retired key. +workspace `hop/PROOF`. Do not deliver the seed to a consumer or derive it from a +previously shared proof. ### Delivering the verification key -**This is open.** A consumer needs the public half of that seed as -`hop-verification/PUBLIC_KEY`, and nothing produces it. `codefly config -generate` makes random bytes in hex or base64; it has no derived-value -generator, and a configuration value template assembles only its own producer's -primitives, so it cannot transform a seed either. Deriving the public half -therefore means reading the seed outside Codefly and writing the result in by -hand — the hand-written configuration the platform rules forbid — and a value -that drifts from the seed refuses every call as `stamp_proof` (security review -r164, B1). - -The agreed direction is for **the authority to publish the hop's admission key -through its own `Keys`**, which a verifier already trusts, already caches, and -already re-reads on rotation with a fail-closed staleness policy. A hop -admission key carried there is ignored by the Work Context trust set, which -filters on the algorithm it accepts, so it grants no Work Context signing -authority. `Keys` is part of `interfaces/authority`, so this is a contract -change: it is proposed in the handbook first and `accounts` implements it -alongside the local authority. Until that lands, a run configures -`PUBLIC_KEY` by hand and the playground documents it as such -(`playground/configurations/local/hop-verification.env`). +**The authority publishes it in `Keys`** (`codefly.dev/authority@0.3.2`): +`KeysResponse.admission_keys`, each with `purpose` `hop-admission`, beside the +Work Context keys in `keys` (purpose `work-context`). A verifier (Go, Python, +TypeScript) takes them from the `Keys` read it already makes, caches and +refreshes with a fail-closed staleness policy, so no consumer is configured with +a hop key and none can drift from the seed. They are a field of their own, so a +verifier that reads `keys` alone (an earlier release, or the JSON Web Key Set) +never trusts a hop key to sign a Work Context, and each verifier uses a key for +its own purpose only. The local authority derives the public half from the hop's +`hop-signing/SEED` in code (`modules/authority/services/local/code/work.go`, +`hopAdmissionKey`); the seed never leaves it. A run with no hop publishes none, +and an authority that publishes none leaves every admission refused as +`keys_unavailable`, with `ErrNoAdmissionKeys`/`NoAdmissionKeys` in the module's +key error saying why. + +Every published admission key is trusted, so a rotation overlaps: the authority +publishes the new key beside the retiring one, then the hop signs with the new +one, then the retiring key is withdrawn. A stamp signed by no published key is +refused as `stamp_proof` and starts no key read, so a caller without an +admission cannot drive the authority's keys; a verifier learns a new key on the +next read an admitted call or readiness starts, within the five-minute key age. +Publish the new key at least that long before the hop signs with it. + +`accounts` adopting this (publishing its hop's key from the hop's seed) is +follow-up work in the host; until it does, a cell's modules on this verifier +refuse every admission as `keys_unavailable`. `hop-verification/REPLAY_DIRECTORY` is gone. Reservations live in the consumer's own store (above), because Codefly delivers a store and does not deliver a @@ -228,9 +237,9 @@ nothing in this repository can sequence it: (`playground/workspace.codefly.yaml`, documents 0.1.13 and records 0.5.0) and both still verify the shared proof, so calls through the hop to them are refused until those releases exist and the playground repins them. -2. `accounts` publishes the hop's admission key through `Keys`, once that - contract change is agreed (above). Until then every consumer's - `hop-verification/PUBLIC_KEY` is configured by hand. +2. `accounts` publishes the hop's admission key through `Keys` + (`admission_keys`, above). Until it does, modules on a cell refuse every + admission as `keys_unavailable`; nothing is configured by hand. 3. This repository's hop and console upgrade together, and `hop/PROOF` delivery is removed. diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index c054c869..2df0be7c 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -13,18 +13,19 @@ import ( "github.com/codefly-dev/authority/libraries/hop/go/replaypg" ) -publicKey, _ := codefly.For(ctx).WorkspaceConfiguration("hop-verification", "PUBLIC_KEY") // The reservations: this module's own store. replaypg.Migration is its table. // NewVerifier refuses a store that is not the shared one outside a local run. pool, err := pgxpool.New(ctx, connection) // Service("store").Secret("postgres", "read-write-connection") replay, err := replaypg.New(pool) conn, _, err := host.Dial(ctx) // the local authority on a laptop, the host on a cell audience, err := hop.Audience() // the module the composition started this process as (CODEFLY__MODULE) -verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, publicKey, replay) +// The hop's admission keys come with the authority's Keys: nothing is configured. +verifier, err := hop.NewVerifier(ctx, authorityv1.NewAuthorityServiceClient(conn), audience, replay) config.GRPCServerOptions = append(config.GRPCServerOptions, // Judges the admission window and reserves its single-use nonce when each - // call arrives, not after a large request body has decoded. - grpc.InTapHandle(verifier.TapHandle()), + // call reaches the server, on the gRPC and the Connect listener alike, not + // after a large request body has decoded. + grpc.StatsHandler(verifier.StatsHandler()), 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) @@ -122,8 +123,8 @@ admission signed for this call), `stamp_token`, `stamp_digest`, `stamp_method`, `stamp_store`. Each names no proof, token, digest, nonce or claim. Processes that receive only Issue responses use Go `NewTokenVerifier`, or omit -`public_key` and `replay` when constructing the Python/TypeScript `Verifier`. -They still authenticate bare tokens against published Keys. They admit no -stamped call: without the hop's public key and a shared replay store there is -nothing to check an admission against, so `verify` refuses with -`configuration`. A process that receives hop calls supplies both. +`replay` when constructing the Python/TypeScript `Verifier`. They still +authenticate bare tokens against published Keys. They admit no stamped call: +without a shared replay store an admission cannot be consumed once, so `verify` +refuses with `configuration`. A process that receives hop calls supplies one; +the hop's admission keys come from the same Keys read, never from configuration. diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index ad2da266..ea0ee433 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -16,6 +16,9 @@ agent: # stand-in modules); this module ships an empty default under # configurations/local, which a composition's own document replaces whole # (code/work.go applyComposition). +# Keys publishes the hop's admission key, derived in code from the hop's own +# Codefly-generated hop-signing/SEED (code/work.go hopAdmissionKey); only the +# public half leaves this process, and a run with no hop publishes none. workspace-configuration-dependencies: - federation - fixture-composition diff --git a/playground/configurations/README.md b/playground/configurations/README.md index e21e6ef9..a76d62a7 100644 --- a/playground/configurations/README.md +++ b/playground/configurations/README.md @@ -13,14 +13,11 @@ codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN ``` -`hop-verification.env` carries the public half of that seed, which every module -behind the hop checks admissions with. It is committed (a public key) but it is -**written by hand**: nothing derives it from the seed, so the playground sets it -after generating the seed, as `hop-verification.env` and the -[hop admission migration](../../libraries/hop/README.md) describe. That is the -open half of security review r164 B1; the sound producer is the authority -publishing the hop's admission key through its own Keys path, which needs an -`interfaces/authority` change proposed in the handbook first. +Nothing else about the hop is configured here. The local authority derives the +public half of that seed and publishes it in `Keys` (`admission_keys`), which +every module behind the hop already reads, so no verification key is written by +hand ([hop admission migration](../../libraries/hop/README.md), "Delivering the +verification key"). No replay volume is configured here any more. Admission reservations live in the console's own Postgres (`modules/console/services/store`), because Codefly From 2552d5cb2fa7f016d41837c10f50c9aa56f70abf Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:13:30 -0400 Subject: [PATCH 16/19] chore: gofmt the local authority's imports Co-Authored-By: Claude Opus 5.5 --- modules/authority/services/local/code/work.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index e33cc72b..5908482f 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -19,10 +19,10 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "gateway/pkg/adapters" - authorityv1 "gateway/pkg/gen/host/authority/v1" "gateway/pkg/business/delivery" "gateway/pkg/business/issuer" "gateway/pkg/business/state" + authorityv1 "gateway/pkg/gen/host/authority/v1" "gateway/pkg/infra/postgres" "gateway/pkg/wiring" ) From e1966db4fe66aef7b08be058a76babcd31137760 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:41:08 -0400 Subject: [PATCH 17/19] feat(authority): the hop registers its admission key (authority 0.3.3) Review r4 (codefly-dev/authority#28 at 2552d5c) B1': the local authority derived the hop's admission key from hop-signing/SEED, a secret a composed run never delivers to it (it cannot depend on the hop, which depends on the authority), so the playground published no admission key. AuthorityService gains RegisterAdmissionKey: the hop presents its public key with its workload credential, the authority refuses any other workload, and Keys publishes the key in admission_keys. A registration lapses unless renewed, so a key no running hop signs with is withdrawn and a rotation overlaps. Additive: consumers' >=0.2.0 <0.4.0 admits 0.3.3. stamp.KeyID is the one derivation of the published key id, used by the signer and by the authority it registers with. Regenerated only through codefly v0.1.215: generate proto in the local service, then generate client --no-facade for authority-client; the no-proxy gate's manifest records the five regenerated Go files. Co-Authored-By: Claude Opus 5.5 --- interfaces/authority/interface.codefly.yaml | 4 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 396076 -> 397951 bytes .../go/gen/host/authority/v1/authority.pb.go | 315 ++++++++++++------ .../host/authority/v1/authority_grpc.pb.go | 78 ++++- libraries/authority-client/go/stamp/call.go | 10 +- .../authority-client/go/stamp/call_test.go | 5 +- .../authority-client/library.codefly.yaml | 4 +- .../_gen/host/authority/v1/authority_pb2.py | 46 +-- .../host/authority/v1/authority_pb2_grpc.py | 53 +++ .../src/gen/host/authority/v1/authority_pb.ts | 98 +++++- modules/authority/module.codefly.yaml | 2 +- .../pkg/gen/host/authority/v1/authority.pb.go | 315 ++++++++++++------ .../host/authority/v1/authority_grpc.pb.go | 78 ++++- .../authorityv1connect/authority.connect.go | 69 +++- .../proto/host/authority/v1/authority.proto | 30 +- tools/noproxygate/generated.json | 10 +- 17 files changed, 851 insertions(+), 268 deletions(-) diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index f3774030..f6c4ccd4 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -2,10 +2,10 @@ kind: interface publisher: codefly.dev name: authority description: What a module or solution calls on its host to be issued, checked and verified authority. Implemented by accounts and by the local authority. -version: 0.3.2 +version: 0.3.3 type: connect protobuf: package: host.authority.v1 services: - name: AuthorityService - methods: [Issue, Check, Keys, ListResources, AuthorizeTransfer, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] + methods: [Issue, Check, Keys, RegisterAdmissionKey, ListResources, AuthorizeTransfer, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index b75c1aa0..749f8d97 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:79c6ffc50eb6cb219d8ff789e893747bc4fabc06c4ba814b969bef94d19bd238", + "digest": "sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index 4d606a9b6bc6311321b7c3fdc235af9cbad7f1e6..64bdfd23ca4138b3e215d0400ed63df111e95e0d 100644 GIT binary patch delta 7173 zcmZu#eUMaDcJF)NyKlPR3^3gJq=A{Y1Iq$3JuAZi0sW8(gL{^dUu7S)TgSXGZZe4)QQmCO6t znXRi*xm>C}Gtci)3#N+MKOE`09i7Y4sg@-x{hn&>v9To!V?hgS>Q=iWuE$;I=Q`Rm zIbSARi+Idq>O_4nNaWiyInT&dfyiM z4|n>xe7r%MT$f(>*$nYV?*7kS<`zj2i9B2AzD1m5Y-M4_Oi^O@{*!QRC&Ddhjps|^ zZwc>ualBN{YisZF@@+nTJG{J~PUkdAcIMmKv#ETKMu93Gdg!I{u9r(K&*Z$8WIF97 zjjTDZE0u4X;N{vqsF$1NX-=-q&w=8lG6R+*cP`;IxB0I2;b$q0Fog+TN49-+Dzh9c zb#{&z**o1!W_rAKe9yXGS9^A4x;@!SjI94qXU=c+mi2g<cFcSkDg=awY%OFA>DZm%2YCtWR+?6vYnX>^^!^+)RJnabzaq;Mckm6t=VK(rU|0sV|!=bTjmpqv~Ep!_v;Li zS9?C_Z9C+o9#5d~YI~Qc=yij%;Bgu1hh&1ONIfR`bfXz2tqIpHaRm)ohB52J4dOt< zCmTfLdFEnw57(pHmR1A*QG6%&?ttu=n(dw07Lq)Z>+-XORkKAAYp~(3!6^$p*zng- zKGq=kYiOM7UM|u8gtX=q_S`O7odyBc9H%_A6JX7$7;g|*bDHM4-vn7W(wbkGJYW2b zH8^0+?`Jt+&F^P9V9f{XMOVivlGf6~hj)rGqM-<^rA}4oy$GzO)!#P=tfkYN-R(@X zqS9(nQ<}xLL=>bJXK-j11*v67jX@x_jB9l7)ts2LeD%N0;+}>WIKERETE)Qes}cr* z{WZ4h{zM1Br!{KBBC)uBO|h;F60)cbakY86g`Z5Z_}%jXBusS$RbW9faG!xa%&~ zY~-?msnQ2U^PC{Iqg#qAjX9icU{xaxgtHCIGpQ6I_$J1S+y&ZM5h1nzL2-MS3I-@z zqGchw2<%O)vMPj8u(wc0)@XK=kn$FbheI|-Dq3PDb`UZZzJ}uK*ktg;nZSL z%M#mItU8Pl18Ez>U;b<2d7IzOac;eD|fkl3${y8cBvgQRA4YcFh(_Zp)BRm00d#~EHOf=T- zWw9YTB{ag*UM2(eIcKQ+eyV)4ra;{N>i?FB`)2HCVYy*Lt)|?7@UY)V&N)Z=FH+Ej z+5-nt+ge0(?Tai{8V1G50XAjj?ybn)lFlI(DJb45X7(Op(LgLqXMdO+VfnNHr41C! z4JgJa8o@b2`3K=Jx$ReoGD=hM@fDTy#dmvOF*;i`ym?NcgDH8xWq38oez}GyT{=Ujr13rAB-O_t-$<0^DN*i94`{8SE^uDIMyrrJ-g^=QxYJsa|+U^e#HiqQRW8b+KMISpZ5KC@2;Xl6c(| z3lJV(H^s6!)?WJ!nr;sw2|ysVX1Vz3=r_!4LnA0}u#q+HUC2R5=Qk|!cZ|hv_0+RJ z5LK$IO*GE?4T}b|QRr+=nrr~24HRSpga{{1Hh^$_lCrr2t{v%|Vv)aB+uOvvJ5I5v zr$umRL$OaYUZTqds8P^&AUHhD1_iSY5Drf>tmN;LU`5h7!y@mfu_M%-MMfSlg{s1pU^Q^{l)g;6$OKJ+M3>c_t3;yyvdIrNpj;;9 zXG_&w@T&m{U%xI)e^~q#^S)+9 z!6FYUY9`W6=w*p?9_5iOT$Sa-l-fsmxbc+eLEguq*I2{{ly)Cishk*>=;NVaXoagj z9%@{o`?--L>Dx31{oKga-{!>ZhK)QHOh*`jvytQQL~@qsj&3H-bF{w#OM5d{lk(#E z5u16qN1zp?%^YhiQTXfC;xUGcRz8Ibt-ckRs|wu=b99Swy|LH3 zK##+%g%pD~jO1MdsZfHuctxNmz9N-fe9ZUIV{Q+T&htESh_m=;?=JlUF%S1Lb+B91 z)jZFm!GUjxR%s8Zlt8+G(%z%~t6O}(eveTJjqtn2s5C^EdLL2fGyy2>eQItGP80ih zc)&p;DEoLta7-DZD}8`t($d5Ml=cDjVvo2Xae#;0JQ_hcFhHRJeUPFYrdkZ4uLl)d zBVOt~XvPK_sg=eC-3&^l^Ae97LyvFnzh-~?K_-0fz}?@^;c}hx+B3m}ME~;w)c{)4 z`dW%^y~Lw|w557Vyv(sT&3Aik8(7+aweef%g@KBR8-M?^K}*(eMSxT?{jFz(fzJc4 z3SnL04$)rb;h7Q;-Flg0m!qneYQc|^diOwy(q2*=uqr-V2gm){o83CGsp-a>g6EJH zeIcg%`oPt@zl*`8dz#lc5l zg-fx1uQG2e^aPO1={FiY1$6z%GfgP)P{b?n=xE)?p<3YAI4?##+Xj?2P&wj}zWvvC z53=@VdnV}D0yAs86rL+^)9>iacC_bwuMXX=@9%J3*hBb4#MgL9uoeQMhp(AYJyeT) zoJ6JrFQBx6M#OK&8-$m)I00|LY$M?L;$qIoL*6X4<94-mwYJiPl!J!jt{BCY6v(gqqHpRA5NDu%1Z zM}&viH?zop)kPLYe7vdL|Uoe5d4#ebK&LFXnwX}_zE{9N4G zi2tyB*Juxj2=5y0%k_!&T(D{o5w_>ljlJTBAd>dy%!~&F@tm3QVx%S?il=s zacItd`DaJkn>W1$|=SxN2B_#={#*wf6Z1`J4}*fYJskqm4?PSs?1v z)jmvIw1f498MuJ(|AiU2)q3E5Y03g9?Jt9};6DLV7C;ccG-avQ>V9R^1(fzzfx7q) z;#WppKoGw&>Q)EmSEDW<&Z%16N%#-q*G64H`bO$fJ*(*!Xs@%--?Z*8R)=+#dSatk z4klfg*ID7}1PJCjD_otb^@VvoF{f!J{H(Xs*iE8oJidXs-U>%HAeied$WM2}Vba-T zMIN_U{0E-;;AT;#emYG!>bXr~+^AvTZL*@llh-g^jLj5+MkydpvX;8KNlcmq0MpG@ zEV!WqBE)8^IyhDi)8l(9F{fxIY`0pf`7tqZ48Ca;b_)>9t<Bt0TH3dY>r>r5tJ5F3@yVJt6CT&lEI1*dx%w{nfOv-krRcwM#wmU66BT)I`dcY`R zl0txZ7qiruEn-p=0GP@u?l%QfS>gT^*CXa>V*Zj2h``c*+EUxLh;0Z;W9DhAIJh(d t>W|@ZG_J?YZW4D#K*gZhtrl(-zo^7FFn3$A;LZ#P=I+90TgBww{|Ahl&z%4O delta 5457 zcmYjVZE#h^89uvr_vGGlLq0ai%_T~52@wTM5EBF>D8`S1u_6UDfC#9Oh(Z9Nd>Dhi zxY9A+zf%oMs8D$_y;45z4i;dQyV*gU0INJ_phaMn53TgB_+v>Gsz`1gFITX^60C}= zED2V{Rh9&+0<0~b8Ov3nNy+;s$o_J$3)UnzH&A!MniRjuBCsZnoai+%!%9)1D&0~g z*OjDzROO}xVJRS0rRQ4&Qq_P`Z<67JRcMNyF|3Cz)br^&W~bFvdQ z1cW*LiUJhElGX~(n`#C?SFQeOnw&PMHfk0LNrtE~@0uW9V1PxK!5MgmPL$E2u8lq;+ks6iSvfMtF37usLn@I`=U(Qp!`;87N zZMiO-Deo!sb*SE!hs`#EP70XIW7dr@n01)LQo}^O)#;5h<@{TGCRJTs#EuyTvkqhS zGHV}^N;EJ~-}J4E04B#iDhpUi1B=9MREVOm{yxcbm1tx<+uP(Xs0XOucucmGG_ssL z8w5@x>r)WGCl`)q}v!MH8YhJKqi#fz_8jA4TS;ex>@qU@(m1&O#X1rsFh)r z0|yz(2fE=W9 zlg#;>Ui!2gSD9pCe-BEN!%mw6pb|iS4nWY|X>$OC;7-cnJ_x$X-OZd%oi#^R-nW~D z6K3iz-5g>M!sgE&SAU7I1>^i0o&X*;{ z$84^M0p%F20&Pr><7O6LF`jPj96%sFXn}m7;47BlFB}>{`HCoaA@!(ozhTY={l^9J z&iro}UKu15HL0JX)CXW}KqXG;?gesC$tjyI8X>2q!D>-Wql&H!3tUXYooX(*0w3T=GBuJi5 z?pY*vvc!3o;-B`2qGlqEz+_XEdyzR;n0|e+99D4AE>xzgP|^ zxnzAqE2J)2t5eN%yD4dUXaFkF&2+^QdH3LM7V&o|TEXdNcsS8SQ_WF(nK&Eiq6Jps zGSfSj$W7N?wr3VvLAp$57UkSaiF*CaQaLiCp5wJJ98GD;UB#XCx^t-SPFDyMO}{nl`t$}1@$U_iZSnOs$|hDZHcKqIyC zoZe;+GqXd;Outpl)uH{@+emOm?3vnh*fs^=ya)>J2EE?QVqyWq)% zOA}ActzG;~Vs34srgmP57syiSHuVfjsWkB%-#Msa&?eshdd!WBbmhLzosGI|g&dOq zIuHA0m2M2sWIzcr0aQZkl`G^;#o8KxMhIzZK)PAQW}?uG0gzq{Csxplfd`KmG=kF1 zbNtspx>>{x9NUt%1|Yo{Mn8wGv4ICyIT}IP&_khGeTzo<3)!4by>IE}=j6KuZ}H%b zfJSQNSav#_GL+lG@kS`Dmv{EdKw2$4xOy_oG+RlH5&*>2%=NDeWKk&qLR)!oq5wj( zl?NwEhM8>}F=>ke(!Kq4y_^UlEpi(Vb|@f-Z9Lea8D`E~iAco`h)Y#Ju~Lo(kyN(w z;GqTx;#M9!Oft+aZ71RgLquo0KDts4y%Aj?w%dgPq&`}hU+9p}RBi`%wsV&EAC}Og zZ<9HCT!Z{qbtXt1JnR==CU;PL+o<^Ho&Yqc$W#;%Dry^#`j1{fP`B~k{?-Jsh!;Ytn^*d=gbjJYVPSv3oq69adYbtt7YYN_>IWDJa}vZLVPdBJvkBL z*~F&~W@lm7>)&2U!6^;xo#MJkB;AA0P?x7*L4=@OV`50qZW>z&K#t%{J~H zBnG()sKh~X_tt`g)>kxAEBX2$rka`5NK|E=F1*AUml@>2XuVZnVh)xI77@^4bvd~>??BUz?k`rIAe_m1oI3TPp{`(<$lkd zA7T7$34Pm0*;}7~MGokfi^0CDw)O^5G-Z2{>wmr~`d#Q&fG1hYOl_BDA+-TweF^4Q7% diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go index 75363d10..16f2a4ae 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go @@ -991,7 +991,8 @@ type KeysResponse struct { // them. More than one is published while a hop's key is rotated, so the // retiring and the new key overlap. They are never Work Context keys and are // kept out of keys, so no verifier that reads keys alone can trust one to - // sign a Work Context. Empty when the authority knows no hop. + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). AdmissionKeys []*PublicKey `protobuf:"bytes,3,rep,name=admission_keys,json=admissionKeys,proto3" json:"admission_keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1123,6 +1124,116 @@ func (x *PublicKey) GetPurpose() string { return "" } +// RegisterAdmissionKeyRequest presents one hop admission key. +type RegisterAdmissionKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The hop's workload credential; any other workload is refused. + Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyRequest) Reset() { + *x = RegisterAdmissionKeyRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyRequest) ProtoMessage() {} + +func (x *RegisterAdmissionKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyRequest.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} +} + +func (x *RegisterAdmissionKeyRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +type RegisterAdmissionKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The published entry, with its key id and purpose "hop-admission". + Key *PublicKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // When the registration lapses unless the hop registers the key again. + ExpiresAtUnix int64 `protobuf:"varint,2,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyResponse) Reset() { + *x = RegisterAdmissionKeyResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyResponse) ProtoMessage() {} + +func (x *RegisterAdmissionKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyResponse.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} +} + +func (x *RegisterAdmissionKeyResponse) GetKey() *PublicKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *RegisterAdmissionKeyResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + // ListResourcesRequest asks about one kind and one action. type ListResourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1140,7 +1251,7 @@ type ListResourcesRequest struct { func (x *ListResourcesRequest) Reset() { *x = ListResourcesRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1152,7 +1263,7 @@ func (x *ListResourcesRequest) String() string { func (*ListResourcesRequest) ProtoMessage() {} func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1165,7 +1276,7 @@ func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesRequest.ProtoReflect.Descriptor instead. func (*ListResourcesRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} } func (x *ListResourcesRequest) GetToken() string { @@ -1208,7 +1319,7 @@ type ListResourcesResponse struct { func (x *ListResourcesResponse) Reset() { *x = ListResourcesResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1220,7 +1331,7 @@ func (x *ListResourcesResponse) String() string { func (*ListResourcesResponse) ProtoMessage() {} func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1233,7 +1344,7 @@ func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesResponse.ProtoReflect.Descriptor instead. func (*ListResourcesResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} } func (x *ListResourcesResponse) GetGrants() []*ResourceGrant { @@ -1260,7 +1371,7 @@ type AttachResourceRequest struct { func (x *AttachResourceRequest) Reset() { *x = AttachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1272,7 +1383,7 @@ func (x *AttachResourceRequest) String() string { func (*AttachResourceRequest) ProtoMessage() {} func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1285,7 +1396,7 @@ func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceRequest.ProtoReflect.Descriptor instead. func (*AttachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} } func (x *AttachResourceRequest) GetToken() string { @@ -1327,7 +1438,7 @@ type AttachResourceResponse struct { func (x *AttachResourceResponse) Reset() { *x = AttachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1339,7 +1450,7 @@ func (x *AttachResourceResponse) String() string { func (*AttachResourceResponse) ProtoMessage() {} func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1352,7 +1463,7 @@ func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceResponse.ProtoReflect.Descriptor instead. func (*AttachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} } func (x *AttachResourceResponse) GetInstallationId() string { @@ -1379,7 +1490,7 @@ type DetachResourceRequest struct { func (x *DetachResourceRequest) Reset() { *x = DetachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1391,7 +1502,7 @@ func (x *DetachResourceRequest) String() string { func (*DetachResourceRequest) ProtoMessage() {} func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1404,7 +1515,7 @@ func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceRequest.ProtoReflect.Descriptor instead. func (*DetachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *DetachResourceRequest) GetActor() *WorkloadCredential { @@ -1446,7 +1557,7 @@ type DetachResourceResponse struct { func (x *DetachResourceResponse) Reset() { *x = DetachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1458,7 +1569,7 @@ func (x *DetachResourceResponse) String() string { func (*DetachResourceResponse) ProtoMessage() {} func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1471,7 +1582,7 @@ func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceResponse.ProtoReflect.Descriptor instead. func (*DetachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *DetachResourceResponse) GetInstallationId() string { @@ -1503,7 +1614,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1515,7 +1626,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1528,7 +1639,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1586,7 +1697,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1598,7 +1709,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1611,7 +1722,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{25} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1648,7 +1759,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1660,7 +1771,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1673,7 +1784,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{26} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1837,7 +1948,14 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + - "\apurpose\x18\x04 \x01(\tR\apurpose\"\x85\x01\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\x1bRegisterAdmissionKeyRequest\x12;\n" + + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\fR\tpublicKey\"v\n" + + "\x1cRegisterAdmissionKeyResponse\x12.\n" + + "\x03key\x18\x01 \x01(\v2\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n" + + "\x0fexpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n" + "\x14ListResourcesRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12#\n" + @@ -1878,11 +1996,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\xea\a\n" + + "\x05match2\xe3\b\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + - "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n" + + "\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12b\n" + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12e\n" + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + @@ -1904,80 +2023,86 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_host_authority_v1_authority_proto_goTypes = []any{ - (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest - (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef - (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest - (*Delegation)(nil), // 3: host.authority.v1.Delegation - (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest - (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest - (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse - (*PersonSession)(nil), // 7: host.authority.v1.PersonSession - (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential - (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef - (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse - (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest - (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse - (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest - (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse - (*PublicKey)(nil), // 15: host.authority.v1.PublicKey - (*ListResourcesRequest)(nil), // 16: host.authority.v1.ListResourcesRequest - (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse - (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest - (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse - (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest - (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse - (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 26: host.authority.v1.WorkContextV1 + (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest + (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef + (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest + (*Delegation)(nil), // 3: host.authority.v1.Delegation + (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest + (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest + (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse + (*PersonSession)(nil), // 7: host.authority.v1.PersonSession + (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential + (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef + (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse + (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest + (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse + (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest + (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse + (*PublicKey)(nil), // 15: host.authority.v1.PublicKey + (*RegisterAdmissionKeyRequest)(nil), // 16: host.authority.v1.RegisterAdmissionKeyRequest + (*RegisterAdmissionKeyResponse)(nil), // 17: host.authority.v1.RegisterAdmissionKeyResponse + (*ListResourcesRequest)(nil), // 18: host.authority.v1.ListResourcesRequest + (*ListResourcesResponse)(nil), // 19: host.authority.v1.ListResourcesResponse + (*AttachResourceRequest)(nil), // 20: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 21: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 22: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 23: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 24: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 25: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 26: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 27: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 28: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 25, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 25, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey - 24, // 14: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 15: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 17: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 18: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 19: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 20: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 21: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 22: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 23: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 24: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 25: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 26: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 27: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 28: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 29: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 30: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 31: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 32: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 33: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 34: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 35: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 36: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 37: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 28, // [28:38] is the sub-list for method output_type - 18, // [18:28] is the sub-list for method input_type - 18, // [18:18] is the sub-list for extension type_name - 18, // [18:18] is the sub-list for extension extendee - 0, // [0:18] is the sub-list for field type_name + 8, // 14: host.authority.v1.RegisterAdmissionKeyRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 15, // 15: host.authority.v1.RegisterAdmissionKeyResponse.key:type_name -> host.authority.v1.PublicKey + 26, // 16: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 17: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 18: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 19: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 20: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 21: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 22: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 23: host.authority.v1.AuthorityService.RegisterAdmissionKey:input_type -> host.authority.v1.RegisterAdmissionKeyRequest + 18, // 24: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 20, // 25: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 22, // 26: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 24, // 27: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 28: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 29: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 30: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 31: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 32: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 33: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 34: host.authority.v1.AuthorityService.RegisterAdmissionKey:output_type -> host.authority.v1.RegisterAdmissionKeyResponse + 19, // 35: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 21, // 36: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 23, // 37: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 25, // 38: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 39: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 40: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 41: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 31, // [31:42] is the sub-list for method output_type + 20, // [20:31] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1993,7 +2118,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[24].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[26].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -2005,7 +2130,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index 40b9d3d6..02afed32 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -26,16 +26,17 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" - AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" - AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" - AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" - AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" - AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" - AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" - AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" - AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" - AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" + AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" + AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" + AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" + AuthorityService_RegisterAdmissionKey_FullMethodName = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" + AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" + AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" + AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" + AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" + AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" ) // AuthorityServiceClient is the client API for AuthorityService service. @@ -57,6 +58,17 @@ type AuthorityServiceClient interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -161,6 +173,16 @@ func (c *authorityServiceClient) Keys(ctx context.Context, in *KeysRequest, opts return out, nil } +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterAdmissionKeyResponse) + err := c.cc.Invoke(ctx, AuthorityService_RegisterAdmissionKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListResourcesResponse) @@ -250,6 +272,17 @@ type AuthorityServiceServer interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -333,6 +366,9 @@ func (UnimplementedAuthorityServiceServer) Check(context.Context, *CheckRequest) func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { return nil, status.Error(codes.Unimplemented, "method Keys not implemented") } +func (UnimplementedAuthorityServiceServer) RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterAdmissionKey not implemented") +} func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } @@ -429,6 +465,24 @@ func _AuthorityService_Keys_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _AuthorityService_RegisterAdmissionKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterAdmissionKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_RegisterAdmissionKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, req.(*RegisterAdmissionKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListResourcesRequest) if err := dec(in); err != nil { @@ -574,6 +628,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Keys", Handler: _AuthorityService_Keys_Handler, }, + { + MethodName: "RegisterAdmissionKey", + Handler: _AuthorityService_RegisterAdmissionKey_Handler, + }, { MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, diff --git a/libraries/authority-client/go/stamp/call.go b/libraries/authority-client/go/stamp/call.go index ce238fff..9543d6d3 100644 --- a/libraries/authority-client/go/stamp/call.go +++ b/libraries/authority-client/go/stamp/call.go @@ -205,8 +205,14 @@ const Purpose = "hop-admission" // builds the entry in them. func (s *Signer) Admission() (keyID string, public []byte) { key := s.key.Public().(ed25519.PublicKey) - sum := sha256.Sum256(key) - return "hop-" + hex.EncodeToString(sum[:8]), []byte(key) + return KeyID(key), []byte(key) +} + +// KeyID is the key id an admission key is published under, derived from the +// key alone: the hop and the authority it registers with name it alike. +func KeyID(public ed25519.PublicKey) string { + sum := sha256.Sum256(public) + return "hop-" + hex.EncodeToString(sum[:8]) } // ReplayKey is globally namespaced by signing key and random nonce, not by method: diff --git a/libraries/authority-client/go/stamp/call_test.go b/libraries/authority-client/go/stamp/call_test.go index 8dd0935e..87ddc44f 100644 --- a/libraries/authority-client/go/stamp/call_test.go +++ b/libraries/authority-client/go/stamp/call_test.go @@ -110,13 +110,14 @@ func TestSignedByNamesThePublishedKeyAProofIsSignedBy(t *testing.T) { } // The authority publishes a seed's public half as one stable entry: the same -// seed always yields the same key id beside its public key. +// seed always yields the same key id beside its public key, and the authority +// a hop registers the key with derives that id from the key alone (KeyID). func TestAdmissionIsTheSignersStablePublishedEntry(t *testing.T) { signer, _ := NewSigner(strings.Repeat("01", 32)) again, _ := NewSigner(strings.Repeat("01", 32)) other, _ := NewSigner(strings.Repeat("02", 32)) id, public := signer.Admission() - if hex.EncodeToString(public) != signer.PublicKey() || id == "" { + if hex.EncodeToString(public) != signer.PublicKey() || id == "" || KeyID(public) != id { t.Fatalf("entry %q %x", id, public) } if againID, _ := again.Admission(); againID != id { diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 5405d355..71aa1236 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,10 +20,10 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:79c6ffc50eb6cb219d8ff789e893747bc4fabc06c4ba814b969bef94d19bd238 + contract-digest: sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb services: - LocalService generated-by: - codefly: development + codefly: 0.1.215 companion: ghcr.io/codefly-dev/proto:0.0.18 facade: false diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py index c03ff343..2441c455 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py @@ -25,7 +25,7 @@ from authority_client._gen.host.authority.v1 import work_context_pb2 as host_dot_authority_dot_v1_dot_work__context__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"\x9d\x01\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\x12\x43\n\x0e\x61\x64mission_keys\x18\x03 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\x12\x18\n\x07purpose\x18\x04 \x01(\tR\x07purpose\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xaa\x07\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"\x9d\x01\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\x12\x43\n\x0e\x61\x64mission_keys\x18\x03 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\radmissionKeys\"y\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\x12\x18\n\x07purpose\x18\x04 \x01(\tR\x07purpose\"y\n\x1bRegisterAdmissionKeyRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1d\n\npublic_key\x18\x02 \x01(\x0cR\tpublicKey\"v\n\x1cRegisterAdmissionKeyResponse\x12.\n\x03key\x18\x01 \x01(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xa3\x08\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n\rGetDelegation\x12\'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -65,24 +65,28 @@ _globals['_KEYSRESPONSE']._serialized_end=2168 _globals['_PUBLICKEY']._serialized_start=2170 _globals['_PUBLICKEY']._serialized_end=2291 - _globals['_LISTRESOURCESREQUEST']._serialized_start=2294 - _globals['_LISTRESOURCESREQUEST']._serialized_end=2427 - _globals['_LISTRESOURCESRESPONSE']._serialized_start=2429 - _globals['_LISTRESOURCESRESPONSE']._serialized_end=2510 - _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2513 - _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2689 - _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2691 - _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2756 - _globals['_DETACHRESOURCEREQUEST']._serialized_start=2759 - _globals['_DETACHRESOURCEREQUEST']._serialized_end=2954 - _globals['_DETACHRESOURCERESPONSE']._serialized_start=2956 - _globals['_DETACHRESOURCERESPONSE']._serialized_end=3021 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=3024 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3279 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3281 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3380 - _globals['_RESOURCEGRANT']._serialized_start=3383 - _globals['_RESOURCEGRANT']._serialized_end=3543 - _globals['_AUTHORITYSERVICE']._serialized_start=3546 - _globals['_AUTHORITYSERVICE']._serialized_end=4484 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_start=2293 + _globals['_REGISTERADMISSIONKEYREQUEST']._serialized_end=2414 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_start=2416 + _globals['_REGISTERADMISSIONKEYRESPONSE']._serialized_end=2534 + _globals['_LISTRESOURCESREQUEST']._serialized_start=2537 + _globals['_LISTRESOURCESREQUEST']._serialized_end=2670 + _globals['_LISTRESOURCESRESPONSE']._serialized_start=2672 + _globals['_LISTRESOURCESRESPONSE']._serialized_end=2753 + _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2756 + _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2932 + _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2934 + _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2999 + _globals['_DETACHRESOURCEREQUEST']._serialized_start=3002 + _globals['_DETACHRESOURCEREQUEST']._serialized_end=3197 + _globals['_DETACHRESOURCERESPONSE']._serialized_start=3199 + _globals['_DETACHRESOURCERESPONSE']._serialized_end=3264 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=3267 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3522 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3524 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3623 + _globals['_RESOURCEGRANT']._serialized_start=3626 + _globals['_RESOURCEGRANT']._serialized_end=3786 + _globals['_AUTHORITYSERVICE']._serialized_start=3789 + _globals['_AUTHORITYSERVICE']._serialized_end=4848 # @@protoc_insertion_point(module_scope) diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py index ed3e8777..1823c757 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py @@ -50,6 +50,11 @@ def __init__(self, channel): request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysRequest.SerializeToString, response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysResponse.FromString, _registered_method=True) + self.RegisterAdmissionKey = channel.unary_unary( + '/host.authority.v1.AuthorityService/RegisterAdmissionKey', + request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.SerializeToString, + response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.FromString, + _registered_method=True) self.ListResources = channel.unary_unary( '/host.authority.v1.AuthorityService/ListResources', request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.SerializeToString, @@ -119,6 +124,22 @@ def Keys(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def RegisterAdmissionKey(self, request, context): + """RegisterAdmissionKey is how the hop tells the authority the public key it + signs call admissions with, so Keys publishes it in admission_keys. The + caller is the hop, proving it with its workload credential; any other + workload is refused by name. Only the public half travels: the hop's seed + never leaves it. Registering the same key again is idempotent and renews + it, and a registration lapses at expires_at_unix unless renewed, so a hop + renews while it runs (a restarted authority learns the key again at the + next renewal) and a key no running hop signs with any more is withdrawn + without being named. While a rotation overlaps, the retiring and the new + key are both registered, and both published. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def ListResources(self, request, context): """ListResources answers which resources of one kind a checked capability may act on with one action, decided by the authority at the time of the call @@ -236,6 +257,11 @@ def add_AuthorityServiceServicer_to_server(servicer, server): request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysRequest.FromString, response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.KeysResponse.SerializeToString, ), + 'RegisterAdmissionKey': grpc.unary_unary_rpc_method_handler( + servicer.RegisterAdmissionKey, + request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.FromString, + response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.SerializeToString, + ), 'ListResources': grpc.unary_unary_rpc_method_handler( servicer.ListResources, request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.FromString, @@ -364,6 +390,33 @@ def Keys(request, metadata, _registered_method=True) + @staticmethod + def RegisterAdmissionKey(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/host.authority.v1.AuthorityService/RegisterAdmissionKey', + host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyRequest.SerializeToString, + host_dot_authority_dot_v1_dot_authority__pb2.RegisterAdmissionKeyResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def ListResources(request, target, diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index 4bd5e819..c910b471 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMuoHChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJuChFBdXRob3JpemVUcmFuc2ZlchIrLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBosLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJnChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDCJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -548,7 +548,8 @@ export type KeysResponse = Message<"host.authority.v1.KeysResponse"> & { * them. More than one is published while a hop's key is rotated, so the * retiring and the new key overlap. They are never Work Context keys and are * kept out of keys, so no verifier that reads keys alone can trust one to - * sign a Work Context. Empty when the authority knows no hop. + * sign a Work Context. Empty until a hop registers its key + * (RegisterAdmissionKey). * * @generated from field: repeated host.authority.v1.PublicKey admission_keys = 3; */ @@ -606,6 +607,62 @@ export type PublicKey = Message<"host.authority.v1.PublicKey"> & { export const PublicKeySchema: GenMessage = /*@__PURE__*/ messageDesc(file_host_authority_v1_authority, 15); +/** + * RegisterAdmissionKeyRequest presents one hop admission key. + * + * @generated from message host.authority.v1.RegisterAdmissionKeyRequest + */ +export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdmissionKeyRequest"> & { + /** + * The hop's workload credential; any other workload is refused. + * + * @generated from field: host.authority.v1.WorkloadCredential actor = 1; + */ + actor?: WorkloadCredential; + + /** + * The raw Ed25519 public key the hop signs admissions with: 32 bytes. + * + * @generated from field: bytes public_key = 2; + */ + publicKey: Uint8Array; +}; + +/** + * Describes the message host.authority.v1.RegisterAdmissionKeyRequest. + * Use `create(RegisterAdmissionKeyRequestSchema)` to create a new message. + */ +export const RegisterAdmissionKeyRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 16); + +/** + * RegisterAdmissionKeyResponse is the key as Keys now publishes it. + * + * @generated from message host.authority.v1.RegisterAdmissionKeyResponse + */ +export type RegisterAdmissionKeyResponse = Message<"host.authority.v1.RegisterAdmissionKeyResponse"> & { + /** + * The published entry, with its key id and purpose "hop-admission". + * + * @generated from field: host.authority.v1.PublicKey key = 1; + */ + key?: PublicKey; + + /** + * When the registration lapses unless the hop registers the key again. + * + * @generated from field: int64 expires_at_unix = 2; + */ + expiresAtUnix: bigint; +}; + +/** + * Describes the message host.authority.v1.RegisterAdmissionKeyResponse. + * Use `create(RegisterAdmissionKeyResponseSchema)` to create a new message. + */ +export const RegisterAdmissionKeyResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 17); + /** * ListResourcesRequest asks about one kind and one action. * @@ -646,7 +703,7 @@ export type ListResourcesRequest = Message<"host.authority.v1.ListResourcesReque * Use `create(ListResourcesRequestSchema)` to create a new message. */ export const ListResourcesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 16); + messageDesc(file_host_authority_v1_authority, 18); /** * ListResourcesResponse is every grant the capability holds on the kind for the @@ -667,7 +724,7 @@ export type ListResourcesResponse = Message<"host.authority.v1.ListResourcesResp * Use `create(ListResourcesResponseSchema)` to create a new message. */ export const ListResourcesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 17); + messageDesc(file_host_authority_v1_authority, 19); /** * AttachResourceRequest names one resource and the capability it was created under. @@ -709,7 +766,7 @@ export type AttachResourceRequest = Message<"host.authority.v1.AttachResourceReq * Use `create(AttachResourceRequestSchema)` to create a new message. */ export const AttachResourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 18); + messageDesc(file_host_authority_v1_authority, 20); /** * AttachResourceResponse names the installation that now owns the resource. @@ -730,7 +787,7 @@ export type AttachResourceResponse = Message<"host.authority.v1.AttachResourceRe * Use `create(AttachResourceResponseSchema)` to create a new message. */ export const AttachResourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 19); + messageDesc(file_host_authority_v1_authority, 21); /** * DetachResourceRequest names one resource its serving module deleted. @@ -772,7 +829,7 @@ export type DetachResourceRequest = Message<"host.authority.v1.DetachResourceReq * Use `create(DetachResourceRequestSchema)` to create a new message. */ export const DetachResourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 20); + messageDesc(file_host_authority_v1_authority, 22); /** * DetachResourceResponse names the installation that owned the resource. @@ -793,7 +850,7 @@ export type DetachResourceResponse = Message<"host.authority.v1.DetachResourceRe * Use `create(DetachResourceResponseSchema)` to create a new message. */ export const DetachResourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 21); + messageDesc(file_host_authority_v1_authority, 23); /** * AuthorizeTransferRequest names one existing resource and where it moves. @@ -850,7 +907,7 @@ export type AuthorizeTransferRequest = Message<"host.authority.v1.AuthorizeTrans * Use `create(AuthorizeTransferRequestSchema)` to create a new message. */ export const AuthorizeTransferRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 22); + messageDesc(file_host_authority_v1_authority, 24); /** * AuthorizeTransferResponse is the decision the store records. @@ -878,7 +935,7 @@ export type AuthorizeTransferResponse = Message<"host.authority.v1.AuthorizeTran * Use `create(AuthorizeTransferResponseSchema)` to create a new message. */ export const AuthorizeTransferResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 23); + messageDesc(file_host_authority_v1_authority, 25); /** * ResourceGrant is one way of naming covered resources. Exactly one match is @@ -939,7 +996,7 @@ export type ResourceGrant = Message<"host.authority.v1.ResourceGrant"> & { * Use `create(ResourceGrantSchema)` to create a new message. */ export const ResourceGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 24); + messageDesc(file_host_authority_v1_authority, 26); /** * AuthorityService is implemented by the host (accounts) and by the local authority. @@ -984,6 +1041,25 @@ export const AuthorityService: GenService<{ input: typeof KeysRequestSchema; output: typeof KeysResponseSchema; }, + /** + * RegisterAdmissionKey is how the hop tells the authority the public key it + * signs call admissions with, so Keys publishes it in admission_keys. The + * caller is the hop, proving it with its workload credential; any other + * workload is refused by name. Only the public half travels: the hop's seed + * never leaves it. Registering the same key again is idempotent and renews + * it, and a registration lapses at expires_at_unix unless renewed, so a hop + * renews while it runs (a restarted authority learns the key again at the + * next renewal) and a key no running hop signs with any more is withdrawn + * without being named. While a rotation overlaps, the retiring and the new + * key are both registered, and both published. + * + * @generated from rpc host.authority.v1.AuthorityService.RegisterAdmissionKey + */ + registerAdmissionKey: { + methodKind: "unary"; + input: typeof RegisterAdmissionKeyRequestSchema; + output: typeof RegisterAdmissionKeyResponseSchema; + }, /** * ListResources answers which resources of one kind a checked capability may * act on with one action, decided by the authority at the time of the call diff --git a/modules/authority/module.codefly.yaml b/modules/authority/module.codefly.yaml index f82816d0..ff165f99 100644 --- a/modules/authority/module.codefly.yaml +++ b/modules/authority/module.codefly.yaml @@ -8,7 +8,7 @@ interface: - service: local endpoint: grpc implements: - - codefly.dev/authority@0.3.2 + - codefly.dev/authority@0.3.3 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.2.0 - codefly.dev/session@0.2.0 diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go index 5468ff9b..d580a1dd 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go @@ -991,7 +991,8 @@ type KeysResponse struct { // them. More than one is published while a hop's key is rotated, so the // retiring and the new key overlap. They are never Work Context keys and are // kept out of keys, so no verifier that reads keys alone can trust one to - // sign a Work Context. Empty when the authority knows no hop. + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). AdmissionKeys []*PublicKey `protobuf:"bytes,3,rep,name=admission_keys,json=admissionKeys,proto3" json:"admission_keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1123,6 +1124,116 @@ func (x *PublicKey) GetPurpose() string { return "" } +// RegisterAdmissionKeyRequest presents one hop admission key. +type RegisterAdmissionKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The hop's workload credential; any other workload is refused. + Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyRequest) Reset() { + *x = RegisterAdmissionKeyRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyRequest) ProtoMessage() {} + +func (x *RegisterAdmissionKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyRequest.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} +} + +func (x *RegisterAdmissionKeyRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +type RegisterAdmissionKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The published entry, with its key id and purpose "hop-admission". + Key *PublicKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // When the registration lapses unless the hop registers the key again. + ExpiresAtUnix int64 `protobuf:"varint,2,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterAdmissionKeyResponse) Reset() { + *x = RegisterAdmissionKeyResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterAdmissionKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterAdmissionKeyResponse) ProtoMessage() {} + +func (x *RegisterAdmissionKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterAdmissionKeyResponse.ProtoReflect.Descriptor instead. +func (*RegisterAdmissionKeyResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} +} + +func (x *RegisterAdmissionKeyResponse) GetKey() *PublicKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *RegisterAdmissionKeyResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + // ListResourcesRequest asks about one kind and one action. type ListResourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1140,7 +1251,7 @@ type ListResourcesRequest struct { func (x *ListResourcesRequest) Reset() { *x = ListResourcesRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1152,7 +1263,7 @@ func (x *ListResourcesRequest) String() string { func (*ListResourcesRequest) ProtoMessage() {} func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[16] + mi := &file_host_authority_v1_authority_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1165,7 +1276,7 @@ func (x *ListResourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesRequest.ProtoReflect.Descriptor instead. func (*ListResourcesRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{16} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} } func (x *ListResourcesRequest) GetToken() string { @@ -1208,7 +1319,7 @@ type ListResourcesResponse struct { func (x *ListResourcesResponse) Reset() { *x = ListResourcesResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1220,7 +1331,7 @@ func (x *ListResourcesResponse) String() string { func (*ListResourcesResponse) ProtoMessage() {} func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[17] + mi := &file_host_authority_v1_authority_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1233,7 +1344,7 @@ func (x *ListResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResourcesResponse.ProtoReflect.Descriptor instead. func (*ListResourcesResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{17} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} } func (x *ListResourcesResponse) GetGrants() []*ResourceGrant { @@ -1260,7 +1371,7 @@ type AttachResourceRequest struct { func (x *AttachResourceRequest) Reset() { *x = AttachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1272,7 +1383,7 @@ func (x *AttachResourceRequest) String() string { func (*AttachResourceRequest) ProtoMessage() {} func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1285,7 +1396,7 @@ func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceRequest.ProtoReflect.Descriptor instead. func (*AttachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} } func (x *AttachResourceRequest) GetToken() string { @@ -1327,7 +1438,7 @@ type AttachResourceResponse struct { func (x *AttachResourceResponse) Reset() { *x = AttachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1339,7 +1450,7 @@ func (x *AttachResourceResponse) String() string { func (*AttachResourceResponse) ProtoMessage() {} func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1352,7 +1463,7 @@ func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachResourceResponse.ProtoReflect.Descriptor instead. func (*AttachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} } func (x *AttachResourceResponse) GetInstallationId() string { @@ -1379,7 +1490,7 @@ type DetachResourceRequest struct { func (x *DetachResourceRequest) Reset() { *x = DetachResourceRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1391,7 +1502,7 @@ func (x *DetachResourceRequest) String() string { func (*DetachResourceRequest) ProtoMessage() {} func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1404,7 +1515,7 @@ func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceRequest.ProtoReflect.Descriptor instead. func (*DetachResourceRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *DetachResourceRequest) GetActor() *WorkloadCredential { @@ -1446,7 +1557,7 @@ type DetachResourceResponse struct { func (x *DetachResourceResponse) Reset() { *x = DetachResourceResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1458,7 +1569,7 @@ func (x *DetachResourceResponse) String() string { func (*DetachResourceResponse) ProtoMessage() {} func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[21] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1471,7 +1582,7 @@ func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachResourceResponse.ProtoReflect.Descriptor instead. func (*DetachResourceResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *DetachResourceResponse) GetInstallationId() string { @@ -1503,7 +1614,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1515,7 +1626,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[22] + mi := &file_host_authority_v1_authority_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1528,7 +1639,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1586,7 +1697,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1598,7 +1709,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[23] + mi := &file_host_authority_v1_authority_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1611,7 +1722,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{25} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1648,7 +1759,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1660,7 +1771,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[24] + mi := &file_host_authority_v1_authority_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1673,7 +1784,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{24} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{26} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1837,7 +1948,14 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + - "\apurpose\x18\x04 \x01(\tR\apurpose\"\x85\x01\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\x1bRegisterAdmissionKeyRequest\x12;\n" + + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\fR\tpublicKey\"v\n" + + "\x1cRegisterAdmissionKeyResponse\x12.\n" + + "\x03key\x18\x01 \x01(\v2\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n" + + "\x0fexpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n" + "\x14ListResourcesRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12#\n" + @@ -1878,11 +1996,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\xea\a\n" + + "\x05match2\xe3\b\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + - "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12w\n" + + "\x14RegisterAdmissionKey\x12..host.authority.v1.RegisterAdmissionKeyRequest\x1a/.host.authority.v1.RegisterAdmissionKeyResponse\x12b\n" + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12e\n" + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + @@ -1903,80 +2022,86 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_host_authority_v1_authority_proto_goTypes = []any{ - (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest - (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef - (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest - (*Delegation)(nil), // 3: host.authority.v1.Delegation - (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest - (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest - (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse - (*PersonSession)(nil), // 7: host.authority.v1.PersonSession - (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential - (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef - (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse - (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest - (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse - (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest - (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse - (*PublicKey)(nil), // 15: host.authority.v1.PublicKey - (*ListResourcesRequest)(nil), // 16: host.authority.v1.ListResourcesRequest - (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse - (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest - (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse - (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest - (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse - (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 26: host.authority.v1.WorkContextV1 + (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest + (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef + (*DelegateRequest)(nil), // 2: host.authority.v1.DelegateRequest + (*Delegation)(nil), // 3: host.authority.v1.Delegation + (*GetDelegationRequest)(nil), // 4: host.authority.v1.GetDelegationRequest + (*RevokeDelegationRequest)(nil), // 5: host.authority.v1.RevokeDelegationRequest + (*RevokeDelegationResponse)(nil), // 6: host.authority.v1.RevokeDelegationResponse + (*PersonSession)(nil), // 7: host.authority.v1.PersonSession + (*WorkloadCredential)(nil), // 8: host.authority.v1.WorkloadCredential + (*InstallationRef)(nil), // 9: host.authority.v1.InstallationRef + (*IssueResponse)(nil), // 10: host.authority.v1.IssueResponse + (*CheckRequest)(nil), // 11: host.authority.v1.CheckRequest + (*CheckResponse)(nil), // 12: host.authority.v1.CheckResponse + (*KeysRequest)(nil), // 13: host.authority.v1.KeysRequest + (*KeysResponse)(nil), // 14: host.authority.v1.KeysResponse + (*PublicKey)(nil), // 15: host.authority.v1.PublicKey + (*RegisterAdmissionKeyRequest)(nil), // 16: host.authority.v1.RegisterAdmissionKeyRequest + (*RegisterAdmissionKeyResponse)(nil), // 17: host.authority.v1.RegisterAdmissionKeyResponse + (*ListResourcesRequest)(nil), // 18: host.authority.v1.ListResourcesRequest + (*ListResourcesResponse)(nil), // 19: host.authority.v1.ListResourcesResponse + (*AttachResourceRequest)(nil), // 20: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 21: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 22: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 23: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 24: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 25: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 26: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 27: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 28: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 25, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 25, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 27, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 26, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 28, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey 15, // 13: host.authority.v1.KeysResponse.admission_keys:type_name -> host.authority.v1.PublicKey - 24, // 14: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 8, // 15: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 16: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 8, // 17: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 18: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 19: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 20: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 21: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 22: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 20, // 23: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest - 22, // 24: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 2, // 25: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 26: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 27: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 28: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 29: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 30: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 31: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 32: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 21, // 33: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse - 23, // 34: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 3, // 35: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 36: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 37: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 28, // [28:38] is the sub-list for method output_type - 18, // [18:28] is the sub-list for method input_type - 18, // [18:18] is the sub-list for extension type_name - 18, // [18:18] is the sub-list for extension extendee - 0, // [0:18] is the sub-list for field type_name + 8, // 14: host.authority.v1.RegisterAdmissionKeyRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 15, // 15: host.authority.v1.RegisterAdmissionKeyResponse.key:type_name -> host.authority.v1.PublicKey + 26, // 16: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 17: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 18: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 19: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 20: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 21: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 22: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 23: host.authority.v1.AuthorityService.RegisterAdmissionKey:input_type -> host.authority.v1.RegisterAdmissionKeyRequest + 18, // 24: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 20, // 25: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 22, // 26: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 24, // 27: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 28: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 29: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 30: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 31: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 32: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 33: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 34: host.authority.v1.AuthorityService.RegisterAdmissionKey:output_type -> host.authority.v1.RegisterAdmissionKeyResponse + 19, // 35: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 21, // 36: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 23, // 37: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 25, // 38: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 39: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 40: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 41: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 31, // [31:42] is the sub-list for method output_type + 20, // [20:31] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1992,7 +2117,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[24].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[26].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -2004,7 +2129,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index 40b9d3d6..02afed32 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -26,16 +26,17 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" - AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" - AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" - AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" - AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" - AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" - AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" - AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" - AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" - AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" + AuthorityService_Issue_FullMethodName = "/host.authority.v1.AuthorityService/Issue" + AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" + AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" + AuthorityService_RegisterAdmissionKey_FullMethodName = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" + AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" + AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" + AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" + AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" + AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" ) // AuthorityServiceClient is the client API for AuthorityService service. @@ -57,6 +58,17 @@ type AuthorityServiceClient interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -161,6 +173,16 @@ func (c *authorityServiceClient) Keys(ctx context.Context, in *KeysRequest, opts return out, nil } +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterAdmissionKeyResponse) + err := c.cc.Invoke(ctx, AuthorityService_RegisterAdmissionKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListResourcesResponse) @@ -250,6 +272,17 @@ type AuthorityServiceServer interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(context.Context, *KeysRequest) (*KeysResponse, error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -333,6 +366,9 @@ func (UnimplementedAuthorityServiceServer) Check(context.Context, *CheckRequest) func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { return nil, status.Error(codes.Unimplemented, "method Keys not implemented") } +func (UnimplementedAuthorityServiceServer) RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterAdmissionKey not implemented") +} func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } @@ -429,6 +465,24 @@ func _AuthorityService_Keys_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _AuthorityService_RegisterAdmissionKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterAdmissionKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_RegisterAdmissionKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).RegisterAdmissionKey(ctx, req.(*RegisterAdmissionKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListResourcesRequest) if err := dec(in); err != nil { @@ -574,6 +628,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Keys", Handler: _AuthorityService_Keys_Handler, }, + { + MethodName: "RegisterAdmissionKey", + Handler: _AuthorityService_RegisterAdmissionKey_Handler, + }, { MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index 52e66ca7..ebd0b27e 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -45,6 +45,9 @@ const ( AuthorityServiceCheckProcedure = "/host.authority.v1.AuthorityService/Check" // AuthorityServiceKeysProcedure is the fully-qualified name of the AuthorityService's Keys RPC. AuthorityServiceKeysProcedure = "/host.authority.v1.AuthorityService/Keys" + // AuthorityServiceRegisterAdmissionKeyProcedure is the fully-qualified name of the + // AuthorityService's RegisterAdmissionKey RPC. + AuthorityServiceRegisterAdmissionKeyProcedure = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" // AuthorityServiceListResourcesProcedure is the fully-qualified name of the AuthorityService's // ListResources RPC. AuthorityServiceListResourcesProcedure = "/host.authority.v1.AuthorityService/ListResources" @@ -83,6 +86,17 @@ type AuthorityServiceClient interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -178,6 +192,12 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op connect.WithSchema(authorityServiceMethods.ByName("Keys")), connect.WithClientOptions(opts...), ), + registerAdmissionKey: connect.NewClient[v1.RegisterAdmissionKeyRequest, v1.RegisterAdmissionKeyResponse]( + httpClient, + baseURL+AuthorityServiceRegisterAdmissionKeyProcedure, + connect.WithSchema(authorityServiceMethods.ByName("RegisterAdmissionKey")), + connect.WithClientOptions(opts...), + ), listResources: connect.NewClient[v1.ListResourcesRequest, v1.ListResourcesResponse]( httpClient, baseURL+AuthorityServiceListResourcesProcedure, @@ -225,16 +245,17 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op // authorityServiceClient implements AuthorityServiceClient. type authorityServiceClient struct { - issue *connect.Client[v1.IssueRequest, v1.IssueResponse] - check *connect.Client[v1.CheckRequest, v1.CheckResponse] - keys *connect.Client[v1.KeysRequest, v1.KeysResponse] - listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] - attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] - detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] - authorizeTransfer *connect.Client[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse] - delegate *connect.Client[v1.DelegateRequest, v1.Delegation] - revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] - getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] + issue *connect.Client[v1.IssueRequest, v1.IssueResponse] + check *connect.Client[v1.CheckRequest, v1.CheckResponse] + keys *connect.Client[v1.KeysRequest, v1.KeysResponse] + registerAdmissionKey *connect.Client[v1.RegisterAdmissionKeyRequest, v1.RegisterAdmissionKeyResponse] + listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] + attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] + detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] + authorizeTransfer *connect.Client[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse] + delegate *connect.Client[v1.DelegateRequest, v1.Delegation] + revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] + getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] } // Issue calls host.authority.v1.AuthorityService.Issue. @@ -252,6 +273,11 @@ func (c *authorityServiceClient) Keys(ctx context.Context, req *connect.Request[ return c.keys.CallUnary(ctx, req) } +// RegisterAdmissionKey calls host.authority.v1.AuthorityService.RegisterAdmissionKey. +func (c *authorityServiceClient) RegisterAdmissionKey(ctx context.Context, req *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) { + return c.registerAdmissionKey.CallUnary(ctx, req) +} + // ListResources calls host.authority.v1.AuthorityService.ListResources. func (c *authorityServiceClient) ListResources(ctx context.Context, req *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) { return c.listResources.CallUnary(ctx, req) @@ -302,6 +328,17 @@ type AuthorityServiceHandler interface { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -393,6 +430,12 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han connect.WithSchema(authorityServiceMethods.ByName("Keys")), connect.WithHandlerOptions(opts...), ) + authorityServiceRegisterAdmissionKeyHandler := connect.NewUnaryHandler( + AuthorityServiceRegisterAdmissionKeyProcedure, + svc.RegisterAdmissionKey, + connect.WithSchema(authorityServiceMethods.ByName("RegisterAdmissionKey")), + connect.WithHandlerOptions(opts...), + ) authorityServiceListResourcesHandler := connect.NewUnaryHandler( AuthorityServiceListResourcesProcedure, svc.ListResources, @@ -443,6 +486,8 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han authorityServiceCheckHandler.ServeHTTP(w, r) case AuthorityServiceKeysProcedure: authorityServiceKeysHandler.ServeHTTP(w, r) + case AuthorityServiceRegisterAdmissionKeyProcedure: + authorityServiceRegisterAdmissionKeyHandler.ServeHTTP(w, r) case AuthorityServiceListResourcesProcedure: authorityServiceListResourcesHandler.ServeHTTP(w, r) case AuthorityServiceAttachResourceProcedure: @@ -478,6 +523,10 @@ func (UnimplementedAuthorityServiceHandler) Keys(context.Context, *connect.Reque return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.Keys is not implemented")) } +func (UnimplementedAuthorityServiceHandler) RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.RegisterAdmissionKey is not implemented")) +} + func (UnimplementedAuthorityServiceHandler) ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.ListResources is not implemented")) } diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 82b4d258..104828ea 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -30,6 +30,17 @@ service AuthorityService { // Context keys, and the hop admission keys a module behind the hop checks // each call's admission by. rpc Keys(KeysRequest) returns (KeysResponse); + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call // rather than carried in the capability. A module maps the answer onto its @@ -270,7 +281,8 @@ message KeysResponse { // them. More than one is published while a hop's key is rotated, so the // retiring and the new key overlap. They are never Work Context keys and are // kept out of keys, so no verifier that reads keys alone can trust one to - // sign a Work Context. Empty when the authority knows no hop. + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). repeated PublicKey admission_keys = 3; } @@ -288,6 +300,22 @@ message PublicKey { string purpose = 4; } +// RegisterAdmissionKeyRequest presents one hop admission key. +message RegisterAdmissionKeyRequest { + // The hop's workload credential; any other workload is refused. + WorkloadCredential actor = 1; + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + bytes public_key = 2; +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +message RegisterAdmissionKeyResponse { + // The published entry, with its key id and purpose "hop-admission". + PublicKey key = 1; + // When the registration lapses unless the hop registers the key again. + int64 expires_at_unix = 2; +} + // ListResourcesRequest asks about one kind and one action. message ListResourcesRequest { // The capability presented. diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 41e85008..485efeb0 100644 --- a/tools/noproxygate/generated.json +++ b/tools/noproxygate/generated.json @@ -3,8 +3,8 @@ "libraries/authority-client/go/gen/api_grpc.pb.go": "71617cc6107e9332fa88dc1889892378cf36e0887a5d05b87e7af8089a158f5a", "libraries/authority-client/go/gen/host/artifact/v1/artifact.pb.go": "8f032d82c2a6921e47ec08cde4c797fa75ec05e6c7cccb5b5a1be744805e8f4e", "libraries/authority-client/go/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", - "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "435f959a8826ae58052fb48e8f43a9975de548cb65fea620a2c482f242707306", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "5a1f16daacb02f2811b5d58b878cf95cd1ab7996b1c0a953d2cdec730724cc93", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "c1624bcd92bee470ee22ad4a868f4fc2ba53fc5d5250fec4b7c3f9c0d076da1e", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "4e0519a8f4d9d337b67f71cece2ab68978ca3ba63a1b840cb7d13dcd3aceb080", "libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go": "c53b6b47355ee9a502441173931b3b809e510054168aa0cc2984b5152e771bf1", "libraries/authority-client/go/gen/host/composition/v1/composition.pb.go": "8fc4657b7b207b2fd80db74b238b10e5b0afd65b25851d56817cb0b960b1c750", "libraries/authority-client/go/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", @@ -39,10 +39,10 @@ "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact.pb.go": "13863e4c49e9b038ea946ac6c2a373dc222f5b1fb8ddc437109c9bd2afa7bb3f", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifactv1connect/artifact.connect.go": "8d5ab439f1293c76d3b5d2fe0e2a4845694a08b007f13c62f19c2de6c6e2b20f", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "76ee9672455652f0649d51e43028e00456484b37e58d3588267d34251f757faa", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "cb634089e403795182aec0c193a8f16decf894cf98cab020712b86f8a3fb1696", "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.gw.go": "37a1508e28e8bdb5310471401c9f78a5b14faa646e826bc5592ef64684fb7ea2", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "5a1f16daacb02f2811b5d58b878cf95cd1ab7996b1c0a953d2cdec730724cc93", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "3034a760140acba69c15b85395e5afc4c392629dc32abd74e9a910f00511a46e", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "4e0519a8f4d9d337b67f71cece2ab68978ca3ba63a1b840cb7d13dcd3aceb080", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "8a76ce6fa7434f15a98e1375cbd058468e842864bfb94c9873d997bbd438c470", "modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go": "30da6150c9d3567ba0552590dd4ae3736f313097d4f4df8fcbaf242fca8af029", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go": "c7a1220dc4bfeb3419485a4b41b5a8a5f8a9a35654180402b46010345f5094e4", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", From b9b716bbedc8019098ae586c4f8033f24dec4cbf Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:00 -0400 Subject: [PATCH 18/19] fix(hop): the hop registers its admission key; the local authority derives nothing Review r4 (codefly-dev/authority#28 at 2552d5c) B1' and N1. B1'. The local authority read hop-signing/SEED for the hop, which no composed run delivers to it, so the playground published no admission key and the console refused every call as keys_unavailable. That derivation is deleted. The hop declares module-identity, so the run vouches for it, and registers its public key over its workload credential (hop.Registration.Keep): at boot, then renewing at a quarter of each lease and every 5s after a failure, reporting each change of outcome in its log. The local authority attests the caller, refuses any workload but authority/hop by name, and publishes the key in Keys until its lease (AdmissionLease, 2 min) lapses: re-registration renews one entry, a rotation publishes both keys while both are renewed, and a key no running hop renews is withdrawn. At most 8 keys are published at once. A verifier whose last read published no admission key (it read before the hop registered) reads again on the next call or readiness check, at most once per keyRetry, instead of waiting out keyMaxAge: Go, Python and TypeScript alike. N1. replaypg.Collect removes only rows CollectMargin (1 min) past their retention, so a collection committing between Reserve's clock check and its insert can no longer revive a nonce near expiry. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- .../python/codefly_hop/__init__.py | 14 +- .../authority-client/python/tests/test_hop.py | 32 ++++ .../typescript/tests/work-context.test.mjs | 29 ++++ .../typescript/work-context.ts | 17 +- libraries/hop/README.md | 43 +++-- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 +- libraries/hop/go/hop.go | 17 +- libraries/hop/go/published_test.go | 50 ++++++ libraries/hop/go/register.go | 102 +++++++++++ libraries/hop/go/register_test.go | 115 ++++++++++++ libraries/hop/go/replaypg/replaypg.go | 28 +-- libraries/hop/go/replaypg/replaypg_test.go | 140 +++++++++++---- libraries/hop/go/transport_postgres_test.go | 17 +- modules/authority/services/hop/code/work.go | 19 +- .../services/hop/service.codefly.yaml | 4 + modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 4 +- .../services/local/code/hopkey_test.go | 44 ----- .../code/pkg/business/issuer/admission.go | 88 ++++++++++ .../pkg/business/issuer/admission_test.go | 164 ++++++++++++++++++ .../code/pkg/business/issuer/export_test.go | 6 + .../local/code/pkg/business/issuer/issuer.go | 31 ++-- .../local/code/pkg/business/state/state.go | 12 ++ .../code/pkg/business/testkit/testkit.go | 17 ++ .../local/code/plugins/authority/plugin.go | 13 +- .../registry_test/hop_admission_test.go | 159 +++++++++++++++++ modules/authority/services/local/code/work.go | 33 +--- .../services/local/service.codefly.yaml | 6 +- playground/configurations/README.md | 8 +- 31 files changed, 1041 insertions(+), 193 deletions(-) create mode 100644 libraries/hop/go/register.go create mode 100644 libraries/hop/go/register_test.go delete mode 100644 modules/authority/services/local/code/hopkey_test.go create mode 100644 modules/authority/services/local/code/pkg/business/issuer/admission.go create mode 100644 modules/authority/services/local/code/pkg/business/issuer/admission_test.go create mode 100644 modules/authority/services/local/code/pkg/business/issuer/export_test.go create mode 100644 modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go diff --git a/README.md b/README.md index e2eed9e0..6fec267b 100644 --- a/README.md +++ b/README.md @@ -1028,12 +1028,22 @@ read it already caches and refreshes, so no consumer is configured with a hop public key. The admission keys are a field of their own, never entries of `keys`, so a verifier that reads `keys` alone (an earlier release, or the JSON Web Key Set) can never trust a hop key to sign a Work Context. More than one is -published while a hop key rotates. The local authority derives the public half -from the hop's own `hop-signing/SEED`. It is additive, so `>=0.2.0 <0.4.0` +published while a hop key rotates. It is additive, so `>=0.2.0 <0.4.0` consumers admit it; a host that publishes no admission key leaves every hop admission refused (`keys_unavailable`), so `accounts` must publish its hop's key before a cell's modules adopt this verifier. +`codefly.dev/authority@0.3.3` adds `RegisterAdmissionKey`: the hop presents its +public key under its own workload credential (it declares `module-identity`), +the authority refuses any other workload by name, and `Keys` publishes the key +until its registration lapses (`AdmissionLease`, two minutes) unless the hop +renews it, which it does while it runs. A restarted authority therefore learns +the key again at the next renewal, a restarted hop re-registers the same entry, +and a key no running hop signs with is withdrawn without being named. The seed +never leaves the hop; nothing about the hop is configured on the authority. It +is additive too. The local authority implements it; `accounts` implementing the +same registration is follow-up work in the host. + The 0.3.1 authority version is an additive pre-stable revision selected for this refresh so existing `>=0.2.0 <0.4.0` consumers admit it. It retains 0.3.0's `AuthorizeTransfer` and adds attachment operations. Composition remains 0.2.0. diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 5f3f73d4..8db92ac1 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -154,12 +154,13 @@ def ready(self) -> bool: succeeded or once the keys are stale. Readiness uses this, never ``keys_error``: a brief authority outage must not take a replica out of rotation while its keys are still trusted. It also starts the read a call - would (keys missing or older than 5 min, at most every 10 s, never waiting), - so an unready replica, which gets no calls, recovers, and an idle one never + would (keys missing or older than 5 min, or no admission key published, at + most every 10 s, never waiting), so an unready replica, which gets no calls, recovers, and an idle one never goes stale.""" with self._lock: now = self._now() - if (self._trust is None or now - self._fetched > KEY_MAX_AGE) and self._inflight is None and now - self._tried >= KEY_RETRY: + unadmitting = self._replay is not None and not self._admission + if (self._trust is None or now - self._fetched > KEY_MAX_AGE or unadmitting) and self._inflight is None and now - self._tried >= KEY_RETRY: self._refresh() return self._trust is not None and now - self._fetched <= KEY_STALE @@ -316,11 +317,12 @@ def _admission_read(self) -> futures.Future[None] | None: the keys held: neither a stamp signed by no published key nor keys past ``KEY_MAX_AGE`` start a read here, so a caller without an admission can neither drive the authority's keys nor delay a genuine call's read. Only - when no trusted keys are held at all (never read, or past ``KEY_STALE``) - does it start one, at most every ``KEY_RETRY`` as readiness does.""" + when no trusted keys are held at all (never read, or past ``KEY_STALE``), + or the last read published no admission key (the hop had not registered + yet), does it start one, at most every ``KEY_RETRY`` as readiness does.""" with self._lock: now = self._now() - trusted = self._trust is not None and now - self._fetched <= KEY_STALE + trusted = self._trust is not None and now - self._fetched <= KEY_STALE and bool(self._admission) if not trusted and self._inflight is None and now - self._tried >= KEY_RETRY: self._refresh() return None if trusted else self._inflight diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 2ec68690..4745d1d1 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -790,6 +790,38 @@ def test_no_published_admission_key_refuses_every_call_and_says_why(): assert isinstance(v.keys_error, NoAdmissionKeys) +# Review r5: a verifier that first read the keys before the hop registered its +# key holds no admission key; once the authority publishes one, the next call +# or readiness check after KEY_RETRY reads it, without waiting out KEY_MAX_AGE, +# and callers inside KEY_RETRY drive no read. +def test_a_verifier_that_read_before_the_hop_registered_learns_its_key_within_key_retry(): + clock = [FIXTURE["now"]] + source = Keys(publishing()) + v = Verifier(source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + with pytest.raises(Unauthenticated) as refused: + v.verify(fresh(clock[0], hop=b"the-hop"), METHOD) + assert refused.value.code == "keys_unavailable" + settle(v) + reads = source.reads + for _ in range(5): + with pytest.raises(Unauthenticated): + v.verify(fresh(clock[0], hop=b"the-hop"), METHOD) + settle(v) + assert source.reads == reads + source.response = publishing(b"the-hop") + clock[0] += KEY_RETRY + assert v.verify(fresh(clock[0], hop=b"the-hop"), METHOD).owner == FIXTURE["identity"]["owner"] + idle_source = Keys(publishing()) + idle = Verifier(idle_source, audience=FIXTURE["audience"], replay=Replay(), now=lambda: clock[0]) + idle.ready() + settle(idle) + idle_source.response = publishing(b"the-hop") + clock[0] += KEY_RETRY + idle.ready() + settle(idle) + assert idle.keys_error is None + + # Review r3 M3: a store with reserve and no finish is not a reservation store # (TypeScript's rule): it is consumed, so no reservation is left that nothing # finishes. diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 0a095558..8e22def9 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -501,6 +501,35 @@ test('no published admission key refuses every call and says why', async () => { assert.ok(v.keysError instanceof NoAdmissionKeys); }); +// Review r5: a verifier that first read the keys before the hop registered its +// key holds no admission key; once the authority publishes one, the next call +// or readiness check after KEY_RETRY reads it, without waiting out +// KEY_MAX_AGE, and callers inside KEY_RETRY drive no read. +test('a verifier that read before the hop registered learns its key within KEY_RETRY', async () => { + let now = fixture.now * 1000, answer = publishing(); + const keys = source(() => Promise.resolve(answer)); + const v = new Verifier(keys, fixture.audience, replay(), () => now); + await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD), e => e.code === Reason.KeysUnavailable); + await settle(); + const reads = keys.reads; + for (let n = 0; n < 5; n++) await assert.rejects(v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD), e => e.code === Reason.KeysUnavailable); + await settle(); + assert.equal(keys.reads, reads, 'callers inside KEY_RETRY drove a read'); + answer = publishing('the-hop'); + now += 10_000; + const id = await v.verify(fresh(now, METHOD, fixture.audience, fixture.headers, 'the-hop'), METHOD); + assert.equal(id.owner, fixture.identity.owner); + let idleAnswer = publishing(); + const idle = new Verifier(source(() => Promise.resolve(idleAnswer)), fixture.audience, replay(), () => now); + idle.ready(); + await settle(); + idleAnswer = publishing('the-hop'); + now += 10_000; + idle.ready(); + await settle(); + assert.equal(idle.keysError, undefined); +}); + test('admit releases its hold however the handler ends', async () => { const now = fixture.now * 1000; const store = reservations(() => now); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 05e85d9f..e9e95b1b 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -324,12 +324,13 @@ export class Verifier { /** Whether trusted keys are held: true from the first successful read and * through failed re-reads inside the 15-minute window; false before any read * succeeded or once the keys are stale. Readiness uses this, never keysError. - * It also starts the read a call would (keys missing or older than 5 min, at - * most every 10 s, never waiting), so an unready replica, which gets no calls, + * It also starts the read a call would (keys missing or older than 5 min, or + * no admission key published, at most every 10 s, never waiting), so an unready replica, which gets no calls, * recovers, and an idle one never goes stale. */ ready(): boolean { const now = this.now(); - if ((!this.trust || now - this.fetched > KEY_MAX_AGE_MS) && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + const unadmitting = this.replay !== undefined && this.admission.length === 0; + if ((!this.trust || now - this.fetched > KEY_MAX_AGE_MS || unadmitting) && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); return this.trust !== undefined && now - this.fetched <= KEY_STALE_MS; } @@ -394,13 +395,15 @@ export class Verifier { * on the keys held: neither a stamp signed by no published key nor keys past * KEY_MAX_AGE start a read here, so a caller without an admission can neither * drive the authority's keys nor delay a genuine call's read. Only when no - * trusted keys are held at all (never read, or past KEY_STALE) does it start - * one, at most every KEY_RETRY as readiness does, and wait for it. */ + * trusted keys are held at all (never read, or past KEY_STALE), or the last + * read published no admission key (the hop had not registered yet), does it + * start one, at most every KEY_RETRY as readiness does, and wait for it. */ private async admissionKey(pairs: readonly (readonly [string, string])[]): Promise { const now = this.now(); const trusted = () => this.trust !== undefined && this.now() - this.fetched <= KEY_STALE_MS; - if (!trusted() && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); - if (!trusted() && this.inflight) await this.inflight; + const judgeable = () => trusted() && this.admission.length > 0; + if (!judgeable() && !this.inflight && now - this.tried >= KEY_RETRY_MS) void this.refresh(); + if (!judgeable() && this.inflight) await this.inflight; if (!trusted()) { if (this.keysError instanceof Unauthenticated && this.keysError.code === Reason.InvalidKey) throw new Unauthenticated(Reason.InvalidKey); throw new Unauthenticated(Reason.KeysUnavailable); diff --git a/libraries/hop/README.md b/libraries/hop/README.md index 3958f0db..fe65119f 100644 --- a/libraries/hop/README.md +++ b/libraries/hop/README.md @@ -133,13 +133,17 @@ clock differences and prevent clock rollback. Synchronize deployment clocks. `INSERT ... ON CONFLICT (nonce) DO NOTHING` over a primary key is the atomic reservation, conditioned on the retention not having passed by the database's clock, and the database is already shared by every replica. Its `active` column -marks a running call, for diagnosis; `Collect` removes every reservation past its -retention by the database's clock, running or not, because `Reserve` refuses any -reservation past it on that clock, so a holder that crashed before finishing -leaves no permanent row. Codefly +marks a running call, for diagnosis; `Collect` removes every reservation more +than `replaypg.CollectMargin` (a minute) past its retention by the database's +clock, running or not, because `Reserve` refuses any reservation past it on that +clock, so a holder that crashed before finishing leaves no permanent row. The +margin is what makes that refusal hold under concurrency: `Reserve`'s clock +check and its insert are one statement but not one instant, and a collection +removing a row the moment its retention passed could commit between them and +let the insert revive the nonce. Codefly delivers a store, so this needs nothing the platform cannot provide. -`replaypg.Migration` is the table, `Collect` removes reservations whose -retention has passed, and the console composes exactly this +`replaypg.Migration` is the table, `Collect` removes reservations a margin past +their retention, and the console composes exactly this (`modules/console/services/store`). Outside a local run, `NewVerifier` now **refuses** a store that does not declare @@ -196,12 +200,19 @@ refreshes with a fail-closed staleness policy, so no consumer is configured with a hop key and none can drift from the seed. They are a field of their own, so a verifier that reads `keys` alone (an earlier release, or the JSON Web Key Set) never trusts a hop key to sign a Work Context, and each verifier uses a key for -its own purpose only. The local authority derives the public half from the hop's -`hop-signing/SEED` in code (`modules/authority/services/local/code/work.go`, -`hopAdmissionKey`); the seed never leaves it. A run with no hop publishes none, -and an authority that publishes none leaves every admission refused as -`keys_unavailable`, with `ErrNoAdmissionKeys`/`NoAdmissionKeys` in the module's -key error saying why. +its own purpose only. **The hop registers its key** with the authority +(`RegisterAdmissionKey`, `codefly.dev/authority@0.3.3`, through +`hop.Registration`): it presents the public half under its own workload +credential, and the authority refuses any other workload by name. A registration +lapses after `AdmissionLease` unless renewed, and the hop renews it while it runs, +so a restarted authority learns the key again at the next renewal and a key no +running hop signs with is withdrawn. The seed never leaves the hop. A run with no +hop publishes none, and an authority that publishes none leaves every admission +refused as `keys_unavailable`, with `ErrNoAdmissionKeys`/`NoAdmissionKeys` in the +module's key error saying why. A verifier whose last read published no admission +key (it read before the hop registered) reads again on the next call or +readiness check, at most once per `keyRetry`, so it admits within seconds of the +registration rather than after the five-minute key age. Every published admission key is trusted, so a rotation overlaps: the authority publishes the new key beside the retiring one, then the hop signs with the new @@ -209,9 +220,13 @@ one, then the retiring key is withdrawn. A stamp signed by no published key is refused as `stamp_proof` and starts no key read, so a caller without an admission cannot drive the authority's keys; a verifier learns a new key on the next read an admitted call or readiness starts, within the five-minute key age. -Publish the new key at least that long before the hop signs with it. +Publish the new key at least that long before the hop signs with it: start a +hop with the new seed (it registers its key at boot) that long before calls are +routed to it, while a hop with the retiring seed keeps renewing the retiring +key; the retiring key lapses `AdmissionLease` after the last hop signing with +it stops. -`accounts` adopting this (publishing its hop's key from the hop's seed) is +`accounts` adopting this (implementing `RegisterAdmissionKey` and publishing what its hop registers) is follow-up work in the host; until it does, a cell's modules on this verifier refuse every admission as `keys_unavailable`. diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 32575924..0d9e4ca1 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-20261026180113-c5b5e23960ab + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/jackc/pgx/v5 v5.11.0 google.golang.org/grpc v1.84.0 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index ef7c731c..9a44f054 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/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-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 h1:hUN9kDZaqaTDJROIfQHzXOawIwKtH7mWm4dTwA0P81Q= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 5b32d992..94d867f0 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -357,14 +357,15 @@ func (v *Verifier) KeysError() error { // replica out of rotation while its keys are still trusted. // // It also starts the read a call would (keys missing or older than keyMaxAge, -// at most once per keyRetry, never waiting for it): an unready replica gets no -// calls, so without this it would never read again and never become ready, and -// an idle one would go stale. +// or no admission key published, at most once per keyRetry, never waiting for +// it): an unready replica gets no calls, so without this it would never read +// again and never become ready, and an idle one would go stale. func (v *Verifier) Ready() bool { v.mu.Lock() defer v.mu.Unlock() now := v.now() - if (v.fetched.IsZero() || now.Sub(v.fetched) > keyMaxAge) && v.inflight == nil && now.Sub(v.tried) >= keyRetry { + unadmitting := v.replay != nil && len(v.admission) == 0 + if (v.fetched.IsZero() || now.Sub(v.fetched) > keyMaxAge || unadmitting) && v.inflight == nil && now.Sub(v.tried) >= keyRetry { v.refresh() } return !v.fetched.IsZero() && now.Sub(v.fetched) <= keyStale @@ -464,14 +465,18 @@ func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trus // keyMaxAge start a read here, and a caller without an admission can neither // drive the authority's keys nor delay the read a genuine call needs. Only // when no trusted keys are held at all (never read, or past keyStale, as on an -// idle replica's first call) does it start a read, at most once per keyRetry +// idle replica's first call), or the last read published no admission key (the +// hop had not registered yet), does it start a read, at most once per keyRetry // as readiness does, and wait for it. A hop's new key is therefore learnt on // the next read an admitted call or readiness starts, within keyMaxAge, so an // authority publishes it that long before its hop signs with it, beside the // retiring key. func (v *Verifier) admissionKey(ctx context.Context, md metadata.MD) (string, error) { v.mu.Lock() - untrusted := v.trusted() != nil + // Holding no published admission key is holding no key to judge by: the + // authority publishes the hop's once the hop registers it, which may be + // after this verifier first read Keys. + untrusted := v.trusted() != nil || len(v.admission) == 0 if untrusted && v.inflight == nil && v.now().Sub(v.tried) >= keyRetry { v.refresh() } diff --git a/libraries/hop/go/published_test.go b/libraries/hop/go/published_test.go index 6ee019a1..8f82ea98 100644 --- a/libraries/hop/go/published_test.go +++ b/libraries/hop/go/published_test.go @@ -119,3 +119,53 @@ func TestNoPublishedAdmissionKeyRefusesEveryCallAndSaysWhy(t *testing.T) { t.Fatalf("the module's logs are not told why: %v", v.KeysError()) } } + +// Review r5: a verifier that first read Keys before the hop registered its +// key holds no admission key; once the authority publishes one, the next call +// or readiness check after keyRetry reads it, so the module admits a genuine +// stamp without waiting out keyMaxAge. Until then every call is refused as +// keys_unavailable, and an unknown caller drives at most one read per +// keyRetry. +func TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry(t *testing.T) { + authority := newSigner("published") + // No admission key published (the double publishes one when the field is + // empty, so it carries only an entry of another purpose, which is dropped). + unregistered := func() *authorityv1.KeysResponse { + return &authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{{Algorithm: core.Algorithm, PublicKey: make([]byte, 32), Purpose: "work-context"}}} + } + source := &keys{response: unregistered()} + v, _ := NewVerifier(context.Background(), source, "documents", core.NewMemoryReplayStore()) + settled(v) + clock := time.Now() + v.now = func() time.Time { return clock } + var reason refusal.Reason + if _, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read"); !errors.As(err, &reason) || reason != refusal.KeysUnavailable { + t.Fatalf("before the hop registered: %v", err) + } + reads := source.count() + for range 5 { + _, _ = v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read") + } + settled(v) + if source.count() != reads { + t.Fatalf("callers inside keyRetry drove %d key reads", source.count()-reads) + } + source.set(&authorityv1.KeysResponse{Issuer: authority.issuer, Keys: []*authorityv1.PublicKey{authority.published()}, + AdmissionKeys: []*authorityv1.PublicKey{publishedHop("the-hop")}}, nil, nil) + clock = clock.Add(keyRetry) + if id, err := v.Verify(context.Background(), forwarded(t, authority.issue(t, clock, "documents"), "the-hop", ""), "/test.Service/Read"); err != nil || id.Owner != core.FixturePrincipal { + t.Fatalf("after the hop registered: %+v %v", id, err) + } + // Readiness reads too, for a replica that gets no call meanwhile. + idle, _ := NewVerifier(context.Background(), &keys{response: unregistered()}, "documents", core.NewMemoryReplayStore()) + idle.now = func() time.Time { return clock } + settled(idle) + idle.source.(*keys).set(source.response, nil, nil) + clock = clock.Add(keyRetry) + idle.Ready() + settled(idle) + if idle.KeysError() != nil { + t.Fatalf("readiness did not learn the registered key: %v", idle.KeysError()) + } +} diff --git a/libraries/hop/go/register.go b/libraries/hop/go/register.go new file mode 100644 index 00000000..cf805278 --- /dev/null +++ b/libraries/hop/go/register.go @@ -0,0 +1,102 @@ +package hop + +import ( + "context" + "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/stamp" + "google.golang.org/grpc" +) + +// Registrar is the authority interface's RegisterAdmissionKey, as its +// generated client serves it. +type Registrar interface { + RegisterAdmissionKey(ctx context.Context, in *authorityv1.RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) +} + +// registerRetry is how soon a registration that failed is tried again. +const registerRetry = 5 * time.Second + +// Registration keeps the hop's admission key registered with the authority, +// which publishes it in Keys for every module behind the hop: the hop presents +// the public half with its workload credential, never its seed. A registration +// lapses unless renewed, so the hop renews it while it runs, and a restarted +// authority learns the key again at the next renewal. +type Registration struct { + Authority Registrar + Signer *stamp.Signer + // Credential returns the hop's workload credential, read fresh on every + // registration so a rotation takes effect (RunCredential). + Credential func() (string, error) + // Report is told each time the outcome changes: the published key and + // when it lapses, or why the authority did not publish it. + Report func(key *authorityv1.PublicKey, expires time.Time, err error) +} + +// Register registers the key once and answers the published entry and when +// it lapses unless registered again. +func (r Registration) Register(ctx context.Context) (*authorityv1.PublicKey, time.Time, error) { + credential, err := r.Credential() + if err != nil { + return nil, time.Time{}, err + } + _, public := r.Signer.Admission() + out, err := r.Authority.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, + }) + if err != nil { + return nil, time.Time{}, err + } + return out.GetKey(), time.Unix(out.GetExpiresAtUnix(), 0), nil +} + +// Keep registers the key now, then renews it at a quarter of each lease's +// remaining time, or every registerRetry after a failure, until stop is +// called. The first outcome is reported before Keep returns, so the hop's +// boot says whether its key is published; a failure does not stop renewal, +// since the authority may answer later. +func (r Registration) Keep(ctx context.Context) (stop func()) { + ctx, cancel := context.WithCancel(ctx) + var last string + attempt := func() time.Duration { + call, done := context.WithTimeout(ctx, registerRetry) + defer done() + key, expires, err := r.Register(call) + outcome := "ok " + key.GetKeyId() + if err != nil { + outcome = err.Error() + } + if outcome != last && ctx.Err() == nil { + last = outcome + if r.Report != nil { + r.Report(key, expires, err) + } + } + if err != nil { + return registerRetry + } + return max(time.Until(expires)/4, time.Second) + } + next := attempt() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + timer := time.NewTimer(next) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + timer.Reset(attempt()) + } + } + }() + return func() { + cancel() + wg.Wait() + } +} diff --git a/libraries/hop/go/register_test.go b/libraries/hop/go/register_test.go new file mode 100644 index 00000000..1dfd824e --- /dev/null +++ b/libraries/hop/go/register_test.go @@ -0,0 +1,115 @@ +package hop + +import ( + "context" + "errors" + "sync" + "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/stamp" + "google.golang.org/grpc" +) + +// registrar is an authority answering RegisterAdmissionKey with a lease, or +// refusing while refuse is set; it records what it was presented. +type registrar struct { + mu sync.Mutex + lease time.Duration + refuse error + credentials []string + keys [][]byte +} + +func (r *registrar) RegisterAdmissionKey(_ context.Context, in *authorityv1.RegisterAdmissionKeyRequest, _ ...grpc.CallOption) (*authorityv1.RegisterAdmissionKeyResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.credentials = append(r.credentials, in.GetActor().GetCredential()) + r.keys = append(r.keys, in.GetPublicKey()) + if r.refuse != nil { + return nil, r.refuse + } + return &authorityv1.RegisterAdmissionKeyResponse{ + Key: &authorityv1.PublicKey{KeyId: stamp.KeyID(in.GetPublicKey()), PublicKey: in.GetPublicKey(), Purpose: stamp.Purpose}, + ExpiresAtUnix: time.Now().Add(r.lease).Unix(), + }, nil +} + +func (r *registrar) calls() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.keys) +} + +// The hop presents its public key and its workload credential, read fresh on +// every registration, never its seed; the first outcome is reported before +// Keep returns, and the registration is renewed while the hop runs. +func TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential(t *testing.T) { + authority := ®istrar{lease: 4 * time.Second} + signer := testSigner("the-hop") + rotation := 0 + var reports []string + var mu sync.Mutex + stop := Registration{Authority: authority, Signer: signer, + Credential: func() (string, error) { rotation++; return "credential-" + string(rune('0'+rotation)), nil }, + Report: func(key *authorityv1.PublicKey, _ time.Time, err error) { + mu.Lock() + defer mu.Unlock() + if err != nil { + reports = append(reports, err.Error()) + return + } + reports = append(reports, key.GetKeyId()) + }}.Keep(context.Background()) + mu.Lock() + id, public := signer.Admission() + if len(reports) != 1 || reports[0] != id { + t.Fatalf("Keep returned before reporting the first registration: %v", reports) + } + mu.Unlock() + deadline := time.Now().Add(5 * time.Second) + for authority.calls() < 2 && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + stop() + authority.mu.Lock() + defer authority.mu.Unlock() + if len(authority.keys) < 2 { + t.Fatalf("the registration was not renewed inside its lease: %d registrations", len(authority.keys)) + } + for n, key := range authority.keys { + if string(key) != string(public) { + t.Fatalf("registration %d presented %x, not the hop's public key", n, key) + } + } + if authority.credentials[0] == authority.credentials[1] { + t.Fatal("the credential was not read fresh for the renewal") + } + mu.Lock() + defer mu.Unlock() + if len(reports) != 1 { + t.Fatalf("an unchanged outcome was reported again: %v", reports) + } +} + +// An authority that refuses (or is not up yet) is reported once and retried; +// a hop with no credential presents nothing. +func TestARefusedRegistrationIsReportedAndRetried(t *testing.T) { + refused := errors.New("rpc error: code = PermissionDenied desc = the caller is console/api, not the hop") + authority := ®istrar{lease: time.Minute, refuse: refused} + var got error + stop := Registration{Authority: authority, Signer: testSigner("the-hop"), + Credential: func() (string, error) { return "credential", nil }, + Report: func(_ *authorityv1.PublicKey, _ time.Time, err error) { got = err }}.Keep(context.Background()) + stop() + if !errors.Is(got, refused) { + t.Fatalf("the refusal was not reported: %v", got) + } + none := ®istrar{lease: time.Minute} + _, _, err := Registration{Authority: none, Signer: testSigner("the-hop"), + Credential: func() (string, error) { return "", errors.New("CODEFLY__WORKLOAD_TOKEN_FILE is not set") }}.Register(context.Background()) + if err == nil || none.calls() != 0 { + t.Fatalf("a hop with no credential registered: %v (%d calls)", err, none.calls()) + } +} diff --git a/libraries/hop/go/replaypg/replaypg.go b/libraries/hop/go/replaypg/replaypg.go index 6e983219..0ab05495 100644 --- a/libraries/hop/go/replaypg/replaypg.go +++ b/libraries/hop/go/replaypg/replaypg.go @@ -86,18 +86,26 @@ func (s *Store) Consume(ctx context.Context, nonce string, retainUntil time.Time return nil } -// Collect removes reservations whose retention has passed, by the database's -// own clock, and answers how many it removed. Run it on a schedule: without it -// the table grows by one row per admitted call. +// CollectMargin is how long past its retention a reservation is kept before +// Collect removes it. Reserve's check that the retention has not passed and +// its insert are one statement but not one instant: a Collect that removed a +// row the moment its retention passed could commit between them and let the +// insert revive the nonce. Collecting only rows a whole margin past retention +// leaves no such instant. +const CollectMargin = time.Minute + +// Collect removes reservations more than CollectMargin past their retention, +// by the database's own clock, and answers how many it removed. Run it on a +// schedule: without it the table grows by one row per admitted call. // -// A reservation is removed once its retention has passed whether or not its -// call has finished: Reserve refuses, on the same clock, any reservation past -// that retention, and a delivery past it is refused on the window too, so -// removing it can never revive the nonce. A holder that stopped between -// Reserve and Finish (a crash, a failed or timed-out Finish, a Reserve that -// committed after its caller gave up) therefore leaves no permanent row. +// A reservation is removed then whether or not its call has finished: Reserve +// refuses, on the same clock, any reservation past that retention, and a +// delivery past it is refused on the window too, so removing it can never +// revive the nonce. A holder that stopped between Reserve and Finish (a crash, +// a failed or timed-out Finish, a Reserve that committed after its caller gave +// up) therefore leaves no permanent row. func (s *Store) Collect(ctx context.Context) (int64, error) { - tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now()`) + tag, err := s.pool.Exec(ctx, `DELETE FROM hop_admissions WHERE retain_until < now() - make_interval(secs => $1)`, CollectMargin.Seconds()) if err != nil { return 0, err } diff --git a/libraries/hop/go/replaypg/replaypg_test.go b/libraries/hop/go/replaypg/replaypg_test.go index 6e3202ad..4864f857 100644 --- a/libraries/hop/go/replaypg/replaypg_test.go +++ b/libraries/hop/go/replaypg/replaypg_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "testing" "time" @@ -23,8 +24,12 @@ type pool struct { fail error sql []string args [][]any + // ahead is how far the database's clock runs ahead of this process's. + ahead time.Duration } +func (p *pool) now() time.Time { return time.Now().Add(p.ahead) } + func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { p.sql, p.args = append(p.sql, sql), append(p.args, args) if p.fail != nil { @@ -33,16 +38,18 @@ func (p *pool) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandT if p.held == nil { p.held = map[string]time.Time{} } - if len(args) == 1 { + switch strings.Fields(sql)[0] { + case "UPDATE": // Finish: the call has returned. delete(p.active, args[0].(string)) return pgconn.NewCommandTag("UPDATE 1"), nil - } - if len(args) != 2 { - // The collection statement; remove what has aged out, running or not. + case "DELETE": + // The collection statement; remove what is a margin past its + // retention, running or not. + margin := time.Duration(args[0].(float64) * float64(time.Second)) removed := 0 for nonce, until := range p.held { - if until.Before(time.Now()) { + if until.Before(p.now().Add(-margin)) { delete(p.held, nonce) removed++ } @@ -70,7 +77,7 @@ func (p *pool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { nonce, until := args[0].(string), args[1].(time.Time) _, taken := p.held[nonce] switch { - case !until.After(time.Now()): + case !until.After(p.now()): return row{outcome: "expired"} case taken: return row{outcome: "replayed"} @@ -158,7 +165,10 @@ func TestAnUnavailableStoreIsNeitherAdmissionNorReplay(t *testing.T) { func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { p := &pool{} store, _ := New(p) - if err := store.Consume(context.Background(), "aged", time.Now().Add(-time.Minute)); err != nil { + if err := store.Consume(context.Background(), "aged", time.Now().Add(-CollectMargin-time.Second)); err != nil { + t.Fatal(err) + } + if err := store.Consume(context.Background(), "inside-the-margin", time.Now().Add(-time.Second)); err != nil { t.Fatal(err) } if err := store.Consume(context.Background(), "current", time.Now().Add(time.Minute)); err != nil { @@ -168,8 +178,10 @@ func TestCollectRemovesOnlyReservationsPastTheirRetention(t *testing.T) { if err != nil || removed != 1 { t.Fatalf("collected %d: %v", removed, err) } - if _, held := p.held["current"]; !held { - t.Fatal("collection removed a reservation still retained") + for _, kept := range []string{"current", "inside-the-margin"} { + if _, held := p.held[kept]; !held { + t.Fatalf("collection removed %s", kept) + } } } @@ -191,6 +203,11 @@ func TestAReservationIsHeldToItsRetentionWhetherOrNotItsCallFinished(t *testing. t.Fatalf("collection removed %d reservations still retained: %v", removed, err) } time.Sleep(60 * time.Millisecond) + // Past its retention but inside the margin: still held. + if removed, err := store.Collect(ctx); err != nil || removed != 0 { + t.Fatalf("collection removed %d reservations inside the margin: %v", removed, err) + } + p.ahead = CollectMargin + time.Second // Never finished: its holder stopped between Reserve and Finish. if removed, err := store.Collect(ctx); err != nil || removed != 1 { t.Fatalf("collected %d reservations past their retention: %v", removed, err) @@ -231,47 +248,96 @@ func contains(haystack, needle string) bool { // Review r3 M2, on a real Postgres (HOP_TEST_POSTGRES): a holder that stops // between Reserve and Finish leaves its row active for good, and collection -// still removes it once its retention has passed by the database's clock; -// the nonce is not revived, since a reservation past its retention is refused. +// still removes it once it is a margin past its retention by the database's +// clock; the nonce is not revived, since a reservation past its retention is +// refused. A row past its retention but inside the margin is kept. func TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock(t *testing.T) { - dsn := os.Getenv("HOP_TEST_POSTGRES") - if dsn == "" { - t.Skip("HOP_TEST_POSTGRES names no Postgres") - } + pg := postgres(t) ctx := context.Background() - pg, err := pgxpool.New(ctx, dsn) - if err != nil { - t.Fatal(err) - } - defer pg.Close() - if _, err := pg.Exec(ctx, Migration); err != nil { - t.Fatal(err) - } store, _ := New(pg) - nonce := fmt.Sprintf("crashed-holder-%d", time.Now().UnixNano()) - until := time.Now().Add(time.Second) - if err := store.Reserve(ctx, nonce, until); err != nil { - t.Fatal(err) + crashed := fmt.Sprintf("crashed-holder-%d", time.Now().UnixNano()) + recent := crashed + "-recent" + // Rows as a crashed holder leaves them, aged by the database's own clock. + for nonce, age := range map[string]string{crashed: "61 seconds", recent: "30 seconds"} { + if _, err := pg.Exec(ctx, `INSERT INTO hop_admissions (nonce, retain_until, active) VALUES ($1, now() - $2::interval, TRUE)`, nonce, age); err != nil { + t.Fatal(err) + } } - // The holder stops here: no Finish, so the row stays active. if _, err := store.Collect(ctx); err != nil { t.Fatal(err) } - if !held(t, pg, nonce) { - t.Fatal("collection removed a reservation still inside its retention") + if held(t, pg, crashed) { + t.Fatal("a crashed holder's reservation outlived its retention and the margin") } - time.Sleep(time.Until(until) + 200*time.Millisecond) - if _, err := store.Collect(ctx); err != nil { - t.Fatal(err) + if !held(t, pg, recent) { + t.Fatal("collection removed a reservation inside the margin past its retention") } - if held(t, pg, nonce) { - t.Fatal("a crashed holder's reservation outlived its retention") - } - if err := store.Reserve(ctx, nonce, until); !errors.Is(err, refusal.StampWindow) { + if err := store.Reserve(ctx, crashed, time.Now().Add(-time.Second)); !errors.Is(err, refusal.StampWindow) { t.Fatalf("the collected nonce was reserved again: %v", err) } } +// Review r4 N1, on a real Postgres: Reserve checks the retention and inserts +// in one statement but not one instant, so a Collect that removed a row the +// moment its retention passed could commit between the two and let the insert +// revive the nonce. With the margin, concurrent collection and reservation of +// a nonce near its expiry never admit it a second time. +func TestConcurrentCollectionNeverRevivesANonceNearItsExpiry(t *testing.T) { + pg := postgres(t) + ctx := context.Background() + store, _ := New(pg) + for round := range 20 { + nonce := fmt.Sprintf("near-expiry-%d-%d", time.Now().UnixNano(), round) + until := time.Now().Add(150 * time.Millisecond) + if err := store.Reserve(ctx, nonce, until); err != nil { + t.Fatal(err) + } + stop := time.Now().Add(400 * time.Millisecond) + done := make(chan error, 1) + go func() { + for time.Now().Before(stop) { + if _, err := store.Collect(ctx); err != nil { + done <- err + return + } + } + done <- nil + }() + for time.Now().Before(stop) { + switch err := store.Reserve(ctx, nonce, until); { + case err == nil: + t.Fatalf("round %d: a replay of a nonce near its expiry was admitted", round) + case errors.Is(err, core.ErrReplayed), errors.Is(err, refusal.StampWindow): + default: + t.Fatal(err) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } + if !held(t, pg, nonce) { + t.Fatalf("round %d: the reservation was collected inside the margin", round) + } + } +} + +func postgres(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("HOP_TEST_POSTGRES") + if dsn == "" { + t.Skip("HOP_TEST_POSTGRES names no Postgres") + } + pg, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pg.Close) + if _, err := pg.Exec(context.Background(), Migration); err != nil { + t.Fatal(err) + } + return pg +} + func held(t *testing.T, pg *pgxpool.Pool, nonce string) bool { t.Helper() var n int diff --git a/libraries/hop/go/transport_postgres_test.go b/libraries/hop/go/transport_postgres_test.go index ad181bbc..8cb46c18 100644 --- a/libraries/hop/go/transport_postgres_test.go +++ b/libraries/hop/go/transport_postgres_test.go @@ -155,15 +155,20 @@ func (tr *transport) deliver(t *testing.T, ctx context.Context) (send func() err } } -// pastRetention waits until the admission's retention has passed and runs -// collection, as the console's schedule does. -func (tr *transport) pastRetention(t *testing.T, ctx context.Context, store *replaypg.Store) { +// pastRetention waits until the admission's retention has passed, ages its +// reservation by replaypg.CollectMargin as if that much more time had passed +// (a database's clock cannot be advanced), and runs collection, as the +// console's schedule does. +func (tr *transport) pastRetention(t *testing.T, ctx context.Context, pool *pgxpool.Pool, store *replaypg.Store) { t.Helper() select { case <-time.After(time.Until(tr.expires) + 200*time.Millisecond): case <-ctx.Done(): t.Fatal(ctx.Err()) } + if _, err := pool.Exec(ctx, `UPDATE hop_admissions SET retain_until = retain_until - make_interval(secs => $2) WHERE nonce = $1`, tr.nonce, replaypg.CollectMargin.Seconds()); err != nil { + t.Fatal(err) + } if _, err := store.Collect(ctx); err != nil { t.Fatal(err) } @@ -205,7 +210,7 @@ func TestASecondDeliveryHeldPastCollectionIsRefusedOverTheRealTransport(t *testi if err := first(); err != nil { t.Fatalf("the first delivery was refused: %v", err) } - tr.pastRetention(t, ctx, store) + tr.pastRetention(t, ctx, pool, store) err := second() if err == nil || !strings.Contains(err.Error(), "stamp_replayed") { t.Errorf("the second delivery was not refused as stamp_replayed: %v", err) @@ -235,7 +240,7 @@ func TestCollectingARunningCallsReservationReopensNothing(t *testing.T) { case <-ctx.Done(): t.Fatal(ctx.Err()) } - tr.pastRetention(t, ctx, store) + tr.pastRetention(t, ctx, pool, store) if held(t, ctx, pool, tr.nonce) { t.Fatal("a reservation past its retention outlived collection") } @@ -273,7 +278,7 @@ func TestACallCancelledBeforeItsBodyReleasesItsHold(t *testing.T) { t.Errorf("a second delivery inside the window was not refused as stamp_replayed: %v", err) } for { - tr.pastRetention(t, ctx, store) + tr.pastRetention(t, ctx, pool, store) if !held(t, ctx, pool, tr.nonce) { break } diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index c7d5ad2b..5925a9a8 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + hop "github.com/codefly-dev/authority/libraries/hop/go" "github.com/codefly-dev/authority/libraries/hop/go/routing" "hop/pkg/adapters" @@ -126,7 +127,22 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e if err := requireEnforcement(enforcement); err != nil { return nil, err } - decider := decide.Decider{Routes: routes, Authority: authorityv1.NewAuthorityServiceClient(authority), Modules: modules} + client := authorityv1.NewAuthorityServiceClient(authority) + // The authority publishes this hop's admission key once the hop registers + // it, proving itself with its workload credential: the seed stays here. + unregister := hop.Registration{Authority: client, Signer: signer, Credential: hop.RunCredential, Report: func(key *authorityv1.PublicKey, expires time.Time, err error) { + if err != nil { + w.Warn("the authority does not publish this hop's admission key, so modules behind it admit no call; retrying", wool.Field("reason", err.Error())) + return + } + w.Info("the authority publishes this hop's admission key", wool.Field("key", key.GetKeyId()), wool.Field("renewed-before", expires.UTC().Format(time.RFC3339))) + }}.Keep(context.WithoutCancel(ctx)) + defer func() { + if !configured { + unregister() + } + }() + decider := decide.Decider{Routes: routes, Authority: client, Modules: modules} decision := decider.Decide p := &proxy.Proxy{Decide: decision, Upstreams: upstreams, Signer: signer, Refused: refused} config.GRPCServerOptions = append(config.GRPCServerOptions, p.ServerOptions()...) @@ -141,6 +157,7 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e configured = true return func() { stopWeb() + unregister() _ = authority.Close() for _, conn := range upstreams { _ = conn.Close() diff --git a/modules/authority/services/hop/service.codefly.yaml b/modules/authority/services/hop/service.codefly.yaml index 10cb3c5d..2d5059ce 100644 --- a/modules/authority/services/hop/service.codefly.yaml +++ b/modules/authority/services/hop/service.codefly.yaml @@ -36,6 +36,10 @@ spec: # accounts, say), and the local authority when it names none (code/host.go). service-dependencies: - interface: codefly.dev/authority@>=0.2.0 <0.4.0 +# The hop is a workload of its own: the run vouches for it, and it registers +# its admission key with the authority under that credential +# (RegisterAdmissionKey), so Keys publishes it to every module behind the hop. +module-identity: true # The hop group names the modules this hop fronts (MODULES). Its signing seed # is service-scoped hop-signing/SEED, never a workspace secret. hop-routes is generated # by Codefly from their published API catalogs, resolved only for this hop. diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 366e165a..ff78cb89 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,7 +7,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-20261026180113-c5b5e23960ab + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0 github.com/codefly-dev/authority/libraries/conformance/go v0.0.0 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index a2ead9db..daa295ed 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,8 +38,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-20261026180113-c5b5e23960ab h1:2c2+mvN0H0AvfH0gSLR8Iyi/WqP31TX8uE3IV64mpDk= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026180113-c5b5e23960ab/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66 h1:hUN9kDZaqaTDJROIfQHzXOawIwKtH7mWm4dTwA0P81Q= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026184108-e1966db4fe66/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/hopkey_test.go b/modules/authority/services/local/code/hopkey_test.go deleted file mode 100644 index 38e40fe4..00000000 --- a/modules/authority/services/local/code/hopkey_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "context" - "encoding/hex" - "strings" - "testing" - - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" - "github.com/codefly-dev/core/resources" -) - -// Review r3 B1: the local authority publishes the hop's admission key in Keys, -// derived in code from the hop's own Codefly-generated seed (the variable the -// hop reads), so no consumer is configured with a hand-copied public key. -func TestTheHopsAdmissionKeyIsDerivedFromItsSeedAndPublished(t *testing.T) { - t.Setenv(resources.ModulePrefix, "authority") - t.Setenv(resources.ServicePrefix, "local") - seed := strings.Repeat("07", 32) - t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", seed) - keys, err := hopAdmissionKey(context.Background()) - if err != nil || len(keys) != 1 { - t.Fatalf("published %v: %v", keys, err) - } - hop, _ := stamp.NewSigner(seed) - if got := keys[0]; hex.EncodeToString(got.GetPublicKey()) != hop.PublicKey() || got.GetPurpose() != stamp.Purpose || got.GetKeyId() != keyID(hop) { - t.Fatalf("published %v, not the public half of the hop's seed", got) - } -} - -func keyID(s *stamp.Signer) string { - id, _ := s.Admission() - return id -} - -// A seed that is not one refuses to boot, as the hop refuses it. -func TestAMalformedHopSeedRefusesToBoot(t *testing.T) { - t.Setenv(resources.ModulePrefix, "authority") - t.Setenv(resources.ServicePrefix, "local") - t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", "not-a-seed") - if _, err := hopAdmissionKey(context.Background()); err == nil { - t.Fatal("a malformed hop seed was published") - } -} diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission.go b/modules/authority/services/local/code/pkg/business/issuer/admission.go new file mode 100644 index 00000000..3bea01f6 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/admission.go @@ -0,0 +1,88 @@ +package issuer + +import ( + "context" + "crypto/ed25519" + "fmt" + "sort" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/core/workcontext" + + authorityv1 "gateway/pkg/gen/host/authority/v1" +) + +// AdmissionLease is how long a registered admission key stays published +// without being registered again. The hop renews well inside it, so a +// restarted authority learns the key again within one renewal, and a key no +// running hop signs with any more is withdrawn once it lapses. +const AdmissionLease = 2 * time.Minute + +// maxAdmissionKeys bounds the keys published at once: a rotation needs two, +// and a few more absorb rotations closer together than one lease. +const maxAdmissionKeys = 8 + +// HopModule and HopService name the one workload whose admission keys this +// authority publishes: the hop service of the authority module. +const ( + HopModule = "authority" + HopService = "hop" +) + +type admission struct { + key *authorityv1.PublicKey + until time.Time +} + +// RegisterAdmissionKey records the public key the hop signs call admissions +// with, attesting that the caller is the hop, and publishes it in Keys until +// the answered time; registering it again renews it. Any other workload is +// refused by name, and only the public half is ever presented. +func (i *Issuer) RegisterAdmissionKey(ctx context.Context, credential string, public []byte) (*authorityv1.PublicKey, time.Time, error) { + workload, err := i.state.AttestWorkload(ctx, credential) + if err != nil { + return nil, time.Time{}, fmt.Errorf("an admission key is registered by the hop: %w", err) + } + if workload.Module != HopModule || workload.Service != HopService { + return nil, time.Time{}, domain.Refuse("the caller is %s/%s, not the hop (%s/%s): only the hop registers an admission key", + workload.Module, workload.Service, HopModule, HopService) + } + if len(public) != ed25519.PublicKeySize { + return nil, time.Time{}, domain.Malformed("an admission key is a %d-byte Ed25519 public key, not %d bytes", ed25519.PublicKeySize, len(public)) + } + key := &authorityv1.PublicKey{KeyId: stamp.KeyID(public), Algorithm: workcontext.Algorithm, PublicKey: append([]byte(nil), public...), Purpose: stamp.Purpose} + now := i.now() + until := now.Add(AdmissionLease) + i.mu.Lock() + defer i.mu.Unlock() + i.lapse(now) + if _, renewed := i.admission[key.KeyId]; !renewed && len(i.admission) >= maxAdmissionKeys { + return nil, time.Time{}, domain.NotNow("%d admission keys are already published; one must lapse before %s is registered", maxAdmissionKeys, key.KeyId) + } + i.admission[key.KeyId] = admission{key: key, until: until} + return key, until, nil +} + +// admissionKeys are the registered keys still within their lease, by key id. +func (i *Issuer) admissionKeys() []*authorityv1.PublicKey { + i.mu.Lock() + defer i.mu.Unlock() + i.lapse(i.now()) + out := make([]*authorityv1.PublicKey, 0, len(i.admission)) + for _, a := range i.admission { + out = append(out, a.key) + } + sort.Slice(out, func(a, b int) bool { return out[a].GetKeyId() < out[b].GetKeyId() }) + return out +} + +// lapse withdraws every registration past its lease. Called with mu held. +func (i *Issuer) lapse(now time.Time) { + for id, a := range i.admission { + if !now.Before(a.until) { + delete(i.admission, id) + } + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/admission_test.go b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go new file mode 100644 index 00000000..9894be4e --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/admission_test.go @@ -0,0 +1,164 @@ +package issuer_test + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + + "gateway/pkg/business/issuer" + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" +) + +func hopSigner(t *testing.T, label string) *stamp.Signer { + t.Helper() + seed := sha256.Sum256([]byte(label)) + signer, err := stamp.NewSigner(hex.EncodeToString(seed[:])) + if err != nil { + t.Fatal(err) + } + return signer +} + +func published(k *testkit.Kit) []string { + var ids []string + for _, key := range k.Issuer.Keys().GetAdmissionKeys() { + ids = append(ids, key.GetKeyId()) + } + return ids +} + +// Review r4 B1': the hop registers its admission key with the authority under +// its own workload credential, and Keys publishes exactly that public key, as +// an admission key and never a Work Context key. Nothing about the hop is +// configured on the authority. +func TestTheHopsRegisteredKeyIsPublishedInKeys(t *testing.T) { + k := testkit.Bare(t) + if keys := k.Issuer.Keys().GetAdmissionKeys(); len(keys) != 0 { + t.Fatalf("an authority no hop registered with publishes %v", keys) + } + hop := hopSigner(t, "the-hop") + id, public := hop.Admission() + key, until, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil { + t.Fatal(err) + } + if key.GetKeyId() != id || key.GetPurpose() != stamp.Purpose || key.GetAlgorithm() != "Ed25519" || hex.EncodeToString(key.GetPublicKey()) != hop.PublicKey() { + t.Fatalf("registered %v, not the hop's public key", key) + } + if lease := time.Until(until); lease <= 0 || lease > issuer.AdmissionLease { + t.Fatalf("the registration lapses in %s", lease) + } + keys := k.Issuer.Keys() + if got := keys.GetAdmissionKeys(); len(got) != 1 || got[0].GetKeyId() != id { + t.Fatalf("Keys publishes %v", got) + } + for _, workContextKey := range keys.GetKeys() { + if workContextKey.GetKeyId() == id { + t.Fatal("the hop's admission key is published as a Work Context key") + } + } +} + +// Only the hop registers an admission key: another composed workload is +// refused by name, and so is a credential the launcher did not sign. +func TestANonHopCallerIsRefusedByName(t *testing.T) { + k := testkit.Bare(t) + _, public := hopSigner(t, "impostor").Admission() + _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.Credential(t, "documents"), public) + if code, _ := domain.CodeOf(err); code != domain.Denied || !strings.Contains(err.Error(), "the caller is documents/documents, not the hop (authority/hop)") { + t.Fatalf("a module registered an admission key: %v", err) + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), "not-a-credential", public); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("an unattested caller registered an admission key: %v", err) + } + if ids := published(k); len(ids) != 0 { + t.Fatalf("a refused registration published %v", ids) + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public[:16]); err == nil { + t.Fatal("a truncated key was registered") + } +} + +// A hop that restarts registers the same key again: one entry, renewed, never +// a second one. +func TestReRegistrationAtRestartIsIdempotent(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + _, public := hopSigner(t, "the-hop").Admission() + _, first, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil { + t.Fatal(err) + } + clock = clock.Add(issuer.AdmissionLease / 2) + _, renewed, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err != nil || !renewed.After(first) { + t.Fatalf("the restart renewed to %s (was %s): %v", renewed, first, err) + } + clock = first.Add(time.Second) // the first lease alone would have lapsed + if ids := published(k); len(ids) != 1 { + t.Fatalf("Keys publishes %v after a re-registration", ids) + } +} + +// A rotation overlaps: while the retiring and the new key are both +// registered, both are published; once no hop renews the retiring key its +// lease lapses and it is withdrawn, and the new key stays. +func TestARotationOverlapsUntilTheRetiringKeyLapses(t *testing.T) { + k := testkit.Bare(t) + clock := time.Now() + issuer.SetClock(k.Issuer, func() time.Time { return clock }) + retiringID, retiring := hopSigner(t, "retiring").Admission() + nextID, next := hopSigner(t, "next").Admission() + for _, public := range [][]byte{retiring, next} { + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public); err != nil { + t.Fatal(err) + } + } + if ids := published(k); len(ids) != 2 { + t.Fatalf("the overlap publishes %v", ids) + } + // Only the new key's hops still run and renew. + for range 3 { + clock = clock.Add(issuer.AdmissionLease / 2) + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), next); err != nil { + t.Fatal(err) + } + } + if ids := published(k); len(ids) != 1 || ids[0] != nextID { + t.Fatalf("after the overlap Keys publishes %v, want only %s (withdrawn: %s)", ids, nextID, retiringID) + } + clock = clock.Add(issuer.AdmissionLease) + if ids := published(k); len(ids) != 0 { + t.Fatalf("a key no hop renews is still published: %v", ids) + } +} + +// The published set is bounded: past it a new key waits for one to lapse, +// refused by name, while a registered key still renews. +func TestThePublishedAdmissionKeysAreBounded(t *testing.T) { + k := testkit.Bare(t) + var last []byte + for n := 0; ; n++ { + _, public := hopSigner(t, "key-"+string(rune('a'+n))).Admission() + _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), public) + if err == nil { + last = public + continue + } + if code, _ := domain.CodeOf(err); code != domain.Precondition || !strings.Contains(err.Error(), "already published") || n < 2 { + t.Fatalf("key %d: %v", n, err) + } + break + } + if _, _, err := k.Issuer.RegisterAdmissionKey(t.Context(), k.HopCredential(t), last); err != nil { + t.Fatalf("a registered key no longer renews at the bound: %v", err) + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/export_test.go b/modules/authority/services/local/code/pkg/business/issuer/export_test.go new file mode 100644 index 00000000..70429953 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/export_test.go @@ -0,0 +1,6 @@ +package issuer + +import "time" + +// SetClock moves the clock admission leases are judged by, for a test. +func SetClock(i *Issuer, now func() time.Time) { i.now = now } diff --git a/modules/authority/services/local/code/pkg/business/issuer/issuer.go b/modules/authority/services/local/code/pkg/business/issuer/issuer.go index 2ca09ff7..3b9f5a0b 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -11,6 +11,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "sync" "time" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" @@ -62,18 +63,23 @@ func classify(err error) error { // Issuer is the wire over one host and its core. type Issuer struct { - state *state.State - svc *authority.Service - issuer string - keyID string - public ed25519.PublicKey - admission []*authorityv1.PublicKey + state *state.State + svc *authority.Service + issuer string + keyID string + public ed25519.PublicKey + now func() time.Time + + mu sync.Mutex + // admission are the hop admission keys registered and not yet lapsed, by + // key id (RegisterAdmissionKey). + admission map[string]admission } // New builds the issuer for a host whose core signs as issuer with keyID, its // public half published by Keys. func New(st *state.State, issuer, keyID string, public ed25519.PublicKey) *Issuer { - return &Issuer{state: st, svc: st.Service(), issuer: issuer, keyID: keyID, public: public} + return &Issuer{state: st, svc: st.Service(), issuer: issuer, keyID: keyID, public: public, now: time.Now, admission: map[string]admission{}} } // Config is the core configuration for one signing key: what state.New @@ -85,19 +91,12 @@ func Config(issuer, keyID string, key ed25519.PrivateKey, now func() time.Time) return authority.Config{Issuer: issuer, KeyID: keyID, Signer: key, Clock: now, MaxTTL: MaxTTL, Replay: replay} } -// PublishAdmission makes Keys publish the hop admission keys a module behind -// the hop verifies each call's admission by, and answers i. -func (i *Issuer) PublishAdmission(keys ...*authorityv1.PublicKey) *Issuer { - i.admission = append(i.admission, keys...) - return i -} - // Keys is what a verifier pins: the Work Context key, and apart from it the -// hop's admission keys. +// admission keys the hop registered (RegisterAdmissionKey). func (i *Issuer) Keys() *authorityv1.KeysResponse { return &authorityv1.KeysResponse{Issuer: i.issuer, Keys: []*authorityv1.PublicKey{{ KeyId: i.keyID, Algorithm: workcontext.Algorithm, PublicKey: []byte(i.public), Purpose: "work-context", - }}, AdmissionKeys: i.admission} + }}, AdmissionKeys: i.admissionKeys()} } // ListResources answers which resources of kind a checked capability may act diff --git a/modules/authority/services/local/code/pkg/business/state/state.go b/modules/authority/services/local/code/pkg/business/state/state.go index 534041fc..0902cde7 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -23,6 +23,7 @@ import ( "github.com/codefly-dev/authority/libraries/hop/go/routing" resources "github.com/codefly-dev/authority/libraries/resources/go" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workloadidentity" "github.com/codefly-dev/core/workloadidentity/native" "github.com/google/uuid" "gopkg.in/yaml.v3" @@ -709,6 +710,17 @@ func (s *State) GrantRole(g domain.RoleGrant) error { return s.write(func(tx authority.Tx) error { return tx.PutRoleGrant(g) }) } +// AttestWorkload attests a native launcher credential with core's attester +// and answers the workload it names, composed module or not: for a caller the +// host knows by workload alone, as the hop registering its admission key. +func (s *State) AttestWorkload(ctx context.Context, credential string) (workloadidentity.Workload, error) { + attestation, err := s.attester.Attest(ctx, credential, s.audience) + if err != nil { + return workloadidentity.Workload{}, fmt.Errorf("%w: workload credential: %v", ErrForbidden, err) + } + return attestation.Workload, nil +} + // VerifyWorkload attests a native launcher credential with core's attester // and resolves the module it names to a composed module; for a signed // delivery the service must be one of its current declared workloads. diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 05887187..4a17f4f0 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -56,6 +56,22 @@ func (k *Kit) Credential(t *testing.T, module string) string { return token } +// HopWorkload is the hop as the launcher starts it: the authority module's +// hop service, the one workload that registers admission keys. +func HopWorkload() workloadidentity.Workload { + return workloadidentity.Workload{TrustDomain: "local.test", Module: issuer.HopModule, Service: issuer.HopService, Instance: "hop-1"} +} + +// HopCredential is what the launcher signed for the hop's process. +func (k *Kit) HopCredential(t *testing.T) string { + t.Helper() + token, err := k.Launcher.Issue(native.Subject{Workload: HopWorkload(), Container: issuer.HopService}, Audience) + if err != nil { + t.Fatal(err) + } + return token +} + // Scope builds one scope. func Scope(kind string, actions ...string) *basev0.WorkScopeV1 { return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} @@ -282,6 +298,7 @@ func Bare(t *testing.T) *Kit { for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store", "conformance-owner-a", "conformance-owner-b", "conformance-store"} { launcher.Declare(Workload(module)) } + launcher.Declare(HopWorkload()) _, signing, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index 12179578..d1e5af3c 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -1,4 +1,5 @@ -// Package authority serves codefly.dev/authority: Issue, Check, Keys. +// Package authority serves codefly.dev/authority: Issue, Check, Keys, and +// the hop's admission key registration. package authority import ( @@ -220,6 +221,16 @@ func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.K return p.Issuer.Keys(), nil } +// RegisterAdmissionKey publishes the hop's admission key in Keys, for the hop +// alone, until its registration lapses. +func (p *Plugin) RegisterAdmissionKey(ctx context.Context, req *authorityv1.RegisterAdmissionKeyRequest) (*authorityv1.RegisterAdmissionKeyResponse, error) { + key, until, err := p.Issuer.RegisterAdmissionKey(ctx, req.GetActor().GetCredential(), req.GetPublicKey()) + if err != nil { + return nil, Status(err) + } + return &authorityv1.RegisterAdmissionKeyResponse{Key: key, ExpiresAtUnix: until.Unix()}, nil +} + // Status maps a refusal to a gRPC status that names its reason: every refusal // reaches the caller, none stays only in a log. The core's refusals carry // their code; this host's own and core's Work Context errors map by kind. diff --git a/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go b/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go new file mode 100644 index 00000000..3124bc7d --- /dev/null +++ b/modules/authority/services/local/code/plugins/registry_test/hop_admission_test.go @@ -0,0 +1,159 @@ +package registry_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" + "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" + + "gateway/pkg/business/testkit" + authorityv1 "gateway/pkg/gen/host/authority/v1" + "gateway/plugins/authority" +) + +// Review r4 B1', under the playground's identity. The playground run starts +// the hop as authority/hop in workspace authority-playground with its own +// service-scoped hop-signing/SEED and its projected workload credential, and +// hands the local authority neither. The authority used to derive the key from +// that seed, so this run published no admission key and the console refused +// every call (keys_unavailable). Now the hop presents its public key over the +// wire under its workload credential, Keys publishes it, and a stamp the hop +// signs is admitted by the published key alone (the check every module's +// verifier makes first), while one signed by any other key is not. +// +// The module side's verifier (libraries/hop/go) links the authority-client +// bindings of the same protos, which cannot share a binary with this +// service's own, so it is driven from Keys in its own module +// (TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry) and +// in the playground run; here the wire is this service's generated client. +func TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(t *testing.T) { + k := testkit.New(t) + client := authorityv1.NewAuthorityServiceClient(serveAuthority(t, k)) + ctx := context.Background() + + // The hop's process, as the playground run starts it. + seed := sha256.Sum256([]byte("the playground hop")) + projected := filepath.Join(t.TempDir(), "credential") + if err := os.WriteFile(projected, []byte(k.HopCredential(t)), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(resources.WorkspacePrefix, "authority-playground") + t.Setenv(resources.EnvironmentPrefix, "local") + t.Setenv(resources.ModulePrefix, "authority") + t.Setenv(resources.ServicePrefix, "hop") + t.Setenv(resources.ServiceSecretConfigurationPrefix+"__AUTHORITY__HOP__HOP_SIGNING__SEED", hex.EncodeToString(seed[:])) + delivered, err := codefly.For(ctx).Secret("hop-signing", "SEED") + if err != nil { + t.Fatalf("the hop reads its own seed under the playground's identity: %v", err) + } + signer, err := stamp.NewSigner(delivered) + if err != nil { + t.Fatal(err) + } + keys, err := client.Keys(ctx, &authorityv1.KeysRequest{}) + if err != nil || len(keys.GetAdmissionKeys()) != 0 { + t.Fatalf("before the hop registered Keys publishes %v: %v", keys.GetAdmissionKeys(), err) + } + + // The hop registers, presenting its public half and its credential. + credential, err := os.ReadFile(projected) + if err != nil { + t.Fatal(err) + } + id, public := signer.Admission() + register := func(credential string, public []byte) (*authorityv1.RegisterAdmissionKeyResponse, error) { + return client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public}) + } + first, err := register(string(credential), public) + if err != nil || first.GetKey().GetKeyId() != id || first.GetExpiresAtUnix() <= time.Now().Unix() { + t.Fatalf("the hop's registration: %v %v", first, err) + } + // A restart registers again: still one key, renewed. + again, err := register(string(credential), public) + if err != nil || again.GetExpiresAtUnix() < first.GetExpiresAtUnix() { + t.Fatalf("the hop's re-registration: %v %v", again, err) + } + + keys, err = client.Keys(ctx, &authorityv1.KeysRequest{}) + if err != nil || len(keys.GetAdmissionKeys()) != 1 { + t.Fatalf("after the hop registered Keys publishes %v: %v", keys.GetAdmissionKeys(), err) + } + var published []string + for _, key := range keys.GetAdmissionKeys() { + if key.GetPurpose() != stamp.Purpose { + t.Fatalf("an admission key with purpose %q", key.GetPurpose()) + } + published = append(published, hex.EncodeToString(key.GetPublicKey())) + } + + issued, err := k.PersonIssue(t, "maya", "documents") + if err != nil { + t.Fatal(err) + } + // The run's clock: the testkit's starts at a fixed instant. + now := time.Unix(issued.GetContext().GetNotBeforeUnix(), 0) + stamped := func(by *stamp.Signer) metadata.MD { + proof, err := by.Sign(issued.GetToken(), "/test.Service/Read", "documents", now, issued.GetExpiresAtUnix()) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(issued.GetToken())) + return metadata.Pairs("x-codefly-work-context", issued.GetToken(), "x-codefly-hop-context", hex.EncodeToString(sum[:]), "x-codefly-hop-proof", proof) + } + md := stamped(signer) + key := stamp.SignedBy(md, published) + if key == "" { + t.Fatal("a genuine stamp is signed by no published key") + } + if _, err := stamp.Check(md, key, "/test.Service/Read", "documents", now); err != nil { + t.Fatalf("a genuine stamp under the published key: %v", err) + } + other := sha256.Sum256([]byte("not the hop")) + impostor, _ := stamp.NewSigner(hex.EncodeToString(other[:])) + if stamp.SignedBy(stamped(impostor), published) != "" { + t.Fatal("a stamp by an unregistered key is signed by a published one") + } + + // Another workload presenting its key is refused by name, and publishes nothing. + _, impostorKey := impostor.Admission() + if _, err := register(k.Credential(t, "documents"), impostorKey); status.Code(err) != codes.PermissionDenied || + !strings.Contains(err.Error(), "the caller is documents/documents, not the hop (authority/hop)") { + t.Fatalf("a module registered an admission key: %v", err) + } + if keys, _ := client.Keys(ctx, &authorityv1.KeysRequest{}); len(keys.GetAdmissionKeys()) != 1 { + t.Fatalf("a refused registration changed Keys: %v", keys.GetAdmissionKeys()) + } +} + +func serveAuthority(t *testing.T, k *testkit.Kit) *grpc.ClientConn { + t.Helper() + server := grpc.NewServer() + (&authority.Plugin{Issuer: k.Issuer}).RegisterGRPC(server) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index 5908482f..ccdf091b 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" "github.com/codefly-dev/core/wool" "github.com/codefly-dev/core/workloadidentity" "github.com/codefly-dev/core/workloadidentity/native" @@ -22,7 +21,6 @@ import ( "gateway/pkg/business/delivery" "gateway/pkg/business/issuer" "gateway/pkg/business/state" - authorityv1 "gateway/pkg/gen/host/authority/v1" "gateway/pkg/infra/postgres" "gateway/pkg/wiring" ) @@ -95,11 +93,9 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl if err != nil { return nil, err } - admission, err := hopAdmissionKey(ctx) - if err != nil { - return nil, err - } - wiring.Set(st, issuer.New(st, issuerName, keyID, signing.Public().(ed25519.PublicKey)).PublishAdmission(admission...)) + // Keys publishes the hop's admission key once the hop registers it + // (RegisterAdmissionKey): nothing about the hop is configured here. + wiring.Set(st, issuer.New(st, issuerName, keyID, signing.Public().(ed25519.PublicKey))) // A delivery is verified against the policy the run wrote for its host, and // carried only by the module's own delivery workload, attested by the // anchor. With no policy the carrier path refuses by name and says so. @@ -131,29 +127,6 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl return clean, nil } -// hopAdmissionKey is the hop's admission key as Keys publishes it, derived -// from the hop's own signing seed (hop-signing/SEED, generated by Codefly for -// authority/hop): the public half is computed here, never configured, so it -// cannot drift from what the hop signs with. The seed never leaves this -// process. A run that composes no hop delivers no seed, and the authority -// publishes no admission key, saying so; a seed that is not one refuses to -// boot, as the hop refuses it. -func hopAdmissionKey(ctx context.Context) ([]*authorityv1.PublicKey, error) { - w := wool.Get(ctx).In("local") - seed, err := codefly.For(ctx).Service("hop").Secret("hop-signing", "SEED") - if err != nil || strings.TrimSpace(seed) == "" { - w.Warn("no hop signing seed (codefly config generate hop-signing SEED --service authority/hop): Keys publishes no hop admission key, so modules behind a hop admit no call") - return nil, nil - } - signer, err := stamp.NewSigner(strings.TrimSpace(seed)) - if err != nil { - return nil, fmt.Errorf("the hop's signing seed: %w", err) - } - keyID, public := signer.Admission() - w.Info("Keys publishes the hop's admission key " + keyID) - return []*authorityv1.PublicKey{{KeyId: keyID, Algorithm: "Ed25519", PublicKey: public, Purpose: stamp.Purpose}}, nil -} - // applyComposition adds the composition's part of the fixture: the workspace // configuration document fixture-composition, which a composing workspace // supplies for the modules and solutions it composes. The authority module diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index ea0ee433..c4899c82 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -16,9 +16,9 @@ agent: # stand-in modules); this module ships an empty default under # configurations/local, which a composition's own document replaces whole # (code/work.go applyComposition). -# Keys publishes the hop's admission key, derived in code from the hop's own -# Codefly-generated hop-signing/SEED (code/work.go hopAdmissionKey); only the -# public half leaves this process, and a run with no hop publishes none. +# Keys publishes the hop's admission key once the hop registers it over its +# workload credential (RegisterAdmissionKey): nothing about the hop is +# configured here, its seed never leaves it, and a run with no hop publishes none. workspace-configuration-dependencies: - federation - fixture-composition diff --git a/playground/configurations/README.md b/playground/configurations/README.md index a76d62a7..881deb90 100644 --- a/playground/configurations/README.md +++ b/playground/configurations/README.md @@ -13,10 +13,10 @@ codefly config generate hop-signing SEED --service authority/hop codefly config generate internal-auth CODEFLY_INTERNAL_TOKEN ``` -Nothing else about the hop is configured here. The local authority derives the -public half of that seed and publishes it in `Keys` (`admission_keys`), which -every module behind the hop already reads, so no verification key is written by -hand ([hop admission migration](../../libraries/hop/README.md), "Delivering the +Nothing else about the hop is configured here. The hop registers the public +half of that seed with the local authority under its own workload credential, +and the authority publishes it in `Keys` (`admission_keys`), which every module +behind the hop already reads, so no verification key is written by hand ([hop admission migration](../../libraries/hop/README.md), "Delivering the verification key"). No replay volume is configured here any more. Admission reservations live in the From 655fe4f29c8b22efaab8c214194ca30071e2deae Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:03:17 -0400 Subject: [PATCH 19/19] fix(hop): name the published admission key in the hop's log The logger masks a field named key, so the boot line said key=****; it now names the admission key id and when its registration lapses. Co-Authored-By: Claude Opus 5.5 --- modules/authority/services/hop/code/work.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index 5925a9a8..40fed309 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -135,7 +135,7 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e w.Warn("the authority does not publish this hop's admission key, so modules behind it admit no call; retrying", wool.Field("reason", err.Error())) return } - w.Info("the authority publishes this hop's admission key", wool.Field("key", key.GetKeyId()), wool.Field("renewed-before", expires.UTC().Format(time.RFC3339))) + w.Info("the authority publishes this hop's admission key", wool.Field("admission", key.GetKeyId()), wool.Field("lapses", expires.UTC().Format(time.RFC3339))) }}.Keep(context.WithoutCancel(ctx)) defer func() { if !configured {