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