diff --git a/libraries/authority-client/go/go.mod b/libraries/authority-client/go/go.mod index 6fa4755b..4553a9e1 100644 --- a/libraries/authority-client/go/go.mod +++ b/libraries/authority-client/go/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b google.golang.org/grpc v1.84.0 diff --git a/libraries/authority-client/go/go.sum b/libraries/authority-client/go/go.sum index 4b511f53..399a959a 100644 --- a/libraries/authority-client/go/go.sum +++ b/libraries/authority-client/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/libraries/authority-client/go/refusal/reason.go b/libraries/authority-client/go/refusal/reason.go new file mode 100644 index 00000000..dc86801f --- /dev/null +++ b/libraries/authority-client/go/refusal/reason.go @@ -0,0 +1,75 @@ +// Package refusal defines caller-safe verifier failures. No reason retains a token, +// claim, key, transport error, or underlying error message. +package refusal + +// Reason is a stable failure code with fixed, safe text. +type Reason string + +const ( + Configuration Reason = "configuration" + StampProof Reason = "stamp_proof" + StampToken Reason = "stamp_token" + StampDigest Reason = "stamp_digest" + StampIdentity Reason = "stamp_identity" + KeysUnavailable Reason = "keys_unavailable" + Cancelled Reason = "cancelled" + Malformed Reason = "malformed" + Lifetime Reason = "lifetime" + Issuer Reason = "issuer" + UnknownKey Reason = "unknown_key" + InvalidKey Reason = "invalid_key" + FixtureKey Reason = "fixture_key" + Signature Reason = "signature" + Audience Reason = "audience" + NotYetValid Reason = "not_yet_valid" + Expired Reason = "expired" + Attenuation Reason = "attenuation" + Grant Reason = "grant" +) + +func (r Reason) Code() string { return string(r) } +func (r Reason) Error() string { return r.Code() + ": " + r.Text() } +func (r Reason) Text() string { + switch r { + case Configuration: + return "the verifier configuration is incomplete" + case StampProof: + return "the call requires this run's hop proof" + case StampToken: + return "the stamp binds no single Work Context" + case StampDigest: + return "the Work Context does not match the hop stamp" + case StampIdentity: + return "the stamp does not match the authenticated Work Context" + case KeysUnavailable: + return "the authority's published keys are unavailable" + case Cancelled: + return "the call ended while the authority's keys were being read" + case Malformed: + return "the Work Context encoding or claims are invalid" + case Lifetime: + return "the capability lifetime exceeds the allowed bound" + case Issuer: + return "the Work Context was issued by an untrusted issuer" + case UnknownKey: + return "no verification key is published for this Work Context" + case InvalidKey: + return "the authority's verification key is malformed" + case FixtureKey: + return "the conformance fixture key is refused" + case Signature: + return "the signature does not verify under the published key" + case Audience: + return "the Work Context was minted for another audience" + case NotYetValid: + return "the Work Context is not valid before its not-before instant" + case Expired: + return "the Work Context has expired" + case Attenuation: + return "the actor chain widens authority" + case Grant: + return "the grant structure is invalid" + default: + return "the Work Context was refused" + } +} diff --git a/libraries/authority-client/go/stamp/context.go b/libraries/authority-client/go/stamp/context.go index 6df35234..238bfc8f 100644 --- a/libraries/authority-client/go/stamp/context.go +++ b/libraries/authority-client/go/stamp/context.go @@ -5,19 +5,22 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" - "errors" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "google.golang.org/grpc/metadata" ) // Context returns only the token whose digest the authenticated hop stamped. func Context(md metadata.MD, proof string) (string, error) { proofs, tokens, digests := md.Get("x-codefly-hop-proof"), md.Get("x-codefly-work-context"), md.Get("x-codefly-hop-context") - if proof == "" || len(proofs) != 1 || subtle.ConstantTimeCompare([]byte(proofs[0]), []byte(proof)) != 1 || len(tokens) != 1 || tokens[0] == "" || len(digests) != 1 { - return "", errors.New("authority: context requires this run's hop proof and a single stamped token") + if proof == "" || len(proofs) != 1 || subtle.ConstantTimeCompare([]byte(proofs[0]), []byte(proof)) != 1 { + return "", refusal.StampProof + } + if len(tokens) != 1 || tokens[0] == "" || len(digests) != 1 { + return "", refusal.StampToken } sum := sha256.Sum256([]byte(tokens[0])) if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(digests[0])) != 1 { - return "", errors.New("authority: context does not match the hop stamp") + return "", refusal.StampDigest } return tokens[0], nil } diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index 6af5ebcb..939d44c1 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -7,6 +7,8 @@ import ( "crypto/ed25519" "errors" "fmt" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "strings" "time" "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" @@ -57,20 +59,29 @@ type Claims struct { // 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", ErrUnauthenticated, err) + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, safeReason(err)) + } + return AuthenticateToken(token, audience, trust) +} + +// AuthenticateToken authenticates an unstamped token without consuming it or +// checking live revocation. Callers still enforce their operation's policy. +func AuthenticateToken(token, audience string, trust Trust) (*Claims, error) { + if audience == "" { + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, refusal.Configuration) + } + if trust.Issuer == "" || len(trust.Keys) == 0 { + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, refusal.InvalidKey) } 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) + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, safeReason(err)) } decoded, err := core.Decode(token) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, safeReason(err)) } context := decoded.Context() scopes := context.GetAuthorityScopes() @@ -138,3 +149,30 @@ func (c *Claims) AllowsOwned(kind, action, resourceID, ownerID string) bool { } return false } + +// Core's pinned Inspector exposes ErrInvalid with diagnostic text. Translate +// only its fixed leading categories; never expose or retain the diagnostic. +// Unknown future failures stay refused as Malformed. Verification remains Core's. +func safeReason(err error) refusal.Reason { + var reason refusal.Reason + if errors.As(err, &reason) { + return reason + } + message := strings.TrimPrefix(err.Error(), core.ErrInvalid.Error()+": ") + for _, entry := range []struct { + prefix string + reason refusal.Reason + }{ + {"issued by ", refusal.Issuer}, {"no verification key ", refusal.UnknownKey}, + {"verification key ", refusal.InvalidKey}, {"key ", refusal.FixtureKey}, + {"signature does not verify ", refusal.Signature}, {"minted for audience ", refusal.Audience}, + {"not valid before ", refusal.NotYetValid}, {"expired at ", refusal.Expired}, + {"actor hop ", refusal.Attenuation}, {"grant ", refusal.Grant}, + {"the capability's lifetime ", refusal.Lifetime}, + } { + if strings.HasPrefix(message, entry.prefix) { + return entry.reason + } + } + return refusal.Malformed +} diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index fea07c19..02b9c555 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -206,12 +206,12 @@ func TestAForgedOrMisaddressedContextIsRefusedByName(t *testing.T) { "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}, + "wrong audience": {tokenFor(t, session), "another-module", "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}, + "no trust": {tokenFor(t, session), session.GetAudience(), "invalid_key", reader.Trust{}}, + "configuration": {tokenFor(t, session), "", "configuration", trust}, } { t.Run(name, func(t *testing.T) { _, err := reader.Authenticate(stampedToken(c.token), "test-proof", c.audience, c.trust) diff --git a/libraries/authority-client/go/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go index 991f472b..6206d474 100644 --- a/libraries/authority-client/go/workcontext/vectors_test.go +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -1,6 +1,7 @@ package workcontext_test import ( + "bytes" "crypto/ed25519" "encoding/base64" "encoding/json" @@ -11,9 +12,11 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" 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" + "google.golang.org/protobuf/encoding/protojson" ) var updateVectors = flag.Bool("update-vectors", false, "rewrite ../testdata/vectors.json") @@ -63,17 +66,17 @@ func vectors(t *testing.T) []vector { 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("an actor widens the owner", "delegated", widen, nil, "attenuation"), + build("no seal", "session", func(c *basev0.WorkContextV1) { c.Seal = nil }, nil, "malformed"), + build("seal epoch zero", "session", func(c *basev0.WorkContextV1) { c.Seal.PrincipalEpoch = 0 }, nil, "malformed"), + build("empty tenant", "session", func(c *basev0.WorkContextV1) { c.TenantId = "" }, nil, "malformed"), + build("wrong type", "session", func(c *basev0.WorkContextV1) { c.Typ = "codefly.work-context/v0" }, nil, "malformed"), + build("unknown replay policy", "session", func(c *basev0.WorkContextV1) { c.ReplayPolicy = "forever" }, nil, "malformed"), + build("actor without an epoch", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].PrincipalEpoch = 0 }, nil, "malformed"), 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 without single use", "grant", func(c *basev0.WorkContextV1) { c.ReplayPolicy = core.ReplayIdempotent }, nil, "grant"), 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"), + build("signed with Core's public conformance key", "session", nil, fixturePrivate, "fixture_key"), } forged := build("forged signature", "session", nil, nil, "signature") other := ed25519.NewKeyFromSeed([]byte("a forger, not the authority, 32b")) @@ -82,17 +85,83 @@ func vectors(t *testing.T) []vector { 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 := build("another audience", "session", nil, nil, "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 := build("not yet valid", "session", nil, nil, "not_yet_valid") early.Now = body(t, "session").GetNotBeforeUnix() - 3600 - issuer := build("another issuer", "session", nil, nil, "issued by") + issuer := build("another issuer", "session", nil, nil, "issuer") issuer.Issuer = "https://forger.example" - unknown := build("a key the authority does not publish", "session", nil, nil, "no verification key") + unknown := build("a key the authority does not publish", "session", nil, nil, "unknown_key") unknown.Keys = map[string]string{"rotated": unknown.Keys[body(t, "session").GetKeyId()]} - return append(out, forged, unsigned, elsewhere, expired, early, issuer, unknown) + emptySignature := unsigned + emptySignature.Name, emptySignature.Token = "empty signature", payload+"." + out = append(out, forged, unsigned, emptySignature, elsewhere, expired, early, issuer, unknown) + return append(out, encodingVectors(t, out[0])...) +} + +// Encoding variants share a valid signed control. Text variants preserve the +// decoded bytes; protobuf variants are signed over the altered payload so a +// signature failure cannot disguise a missing canonical-encoding check. +func encodingVectors(t *testing.T, control vector) []vector { + t.Helper() + var out []vector + add := func(name, token string) { + v := control + v.Name, v.Token, v.Accept, v.Reason = name, token, false, "malformed" + out = append(out, v) + } + payload, signature, _ := strings.Cut(control.Token, ".") + for _, segment := range []struct{ name, text string }{{"payload", payload}, {"signature", signature}} { + join := func(text string) string { + if segment.name == "payload" { + return text + "." + signature + } + return payload + "." + text + } + for _, variant := range []struct{ name, text string }{ + {"padding", segment.text + "="}, + {"double padding", segment.text + "=="}, + {"CR", segment.text[:1] + "\r" + segment.text[1:]}, + {"LF", segment.text[:1] + "\n" + segment.text[1:]}, + {"CRLF", segment.text[:1] + "\r\n" + segment.text[1:]}, + } { + add(segment.name+" "+variant.name, join(variant.text)) + } + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + if len(segment.text)%4 == 0 { + t.Fatalf("%s fixture needs unused base64 bits", segment.name) + } + last := len(segment.text) - 1 + changed := segment.text[:last] + string(alphabet[strings.IndexByte(alphabet, segment.text[last])^1]) + add(segment.name+" unused bit", join(changed)) + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + t.Fatal(err) + } + signed := func(name string, altered []byte) { + add(name, base64.RawURLEncoding.EncodeToString(altered)+"."+base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, altered))) + } + // The canonical first field is the type string (tag 1, length-delimited). + end := 2 + int(raw[1]) + signed("protobuf duplicate field", append(append([]byte{}, raw...), raw[:end]...)) + signed("protobuf reordered field", append(append([]byte{}, raw[end:]...), raw[:end]...)) + signed("protobuf overlong varint", append([]byte{raw[0], raw[1] | 0x80, 0}, raw[2:]...)) + signed("protobuf unknown field", append(append([]byte{}, raw...), 0xf8, 0x07, 0x01)) + jsonPayload, err := protojson.Marshal(body(t, "session")) + if err != nil { + t.Fatal(err) + } + // protojson may vary insignificant whitespace; keep the fixture reproducible. + var compact bytes.Buffer + if err := json.Compact(&compact, jsonPayload); err != nil { + t.Fatal(err) + } + // Work Context accepts protobuf only, including when JSON contains the same claims. + signed("JSON claims", compact.Bytes()) + return out } // Every verdict in ../testdata/vectors.json is Go's, reached by the one check a @@ -121,12 +190,26 @@ func TestTheSharedVectorsAreGosVerdicts(t *testing.T) { keys[id] = raw } at := time.Unix(v.Now, 0) - _, err := reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, reader.Trust{Issuer: v.Issuer, Keys: keys, Now: func() time.Time { return at }}) - 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) + trust := reader.Trust{Issuer: v.Issuer, Keys: keys, Now: func() time.Time { return at }} + for _, mode := range []string{"stamped", "bare"} { + t.Run(mode, func(t *testing.T) { + var err error + if mode == "stamped" { + _, err = reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, trust) + } else { + _, err = reader.AuthenticateToken(v.Token, v.Audience, trust) + } + if v.Accept { + if err != nil { + t.Fatalf("Go refuses a vector it should accept: %v", err) + } + } else { + var reason refusal.Reason + if !errors.Is(err, reader.ErrUnauthenticated) || !errors.As(err, &reason) || reason.Code() != v.Reason { + t.Fatalf("want a refusal naming %q: %v", v.Reason, err) + } + } + }) } }) } diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index fa524119..93fc9ac9 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -109,3 +109,24 @@ name-based one (`https_proxy` / `http_proxy` / `grpc_proxy`, with `grpc.enable_http_proxy=0`) and the address-based one (`GRPC_ADDRESS_HTTP_PROXY`, with an empty `grpc.address_http_proxy_enabled_addresses`), as Go's `host.DialOptions` (`grpc.WithNoProxy()`) does. A caller can set neither. + + +Bare tokens returned by Issue can be authenticated without manufacturing a hop +stamp: Go `Verifier.VerifyToken(ctx, token)`, Python `verify_token(token)` / +`verify_token_async(token)`, TypeScript `verifyToken(token)`. These reuse the +stamped path's published-key cache, including waiting for stale-key refresh and +refusing failed refreshes. They check signature, issuer, audience, validity and +claim structure. They do not prove hop admission, live revocation, method policy, +or single-use consumption; retain those checks where required. + +Verifier failures expose fixed caller-safe reason codes and text. Go callers use +`errors.As(err, &reason)` with `hop.Reason` (`Code()` / `Text()`); Python and +TypeScript `Unauthenticated` expose `code` and `text`, with exported `Reason` +values. These errors never retain underlying key-source errors or claim values. +`KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a +caller response. Unknown future Core diagnostic categories remain refused. + +Processes that receive only Issue responses use Go `NewTokenVerifier` or omit +`proof` when constructing Python/TypeScript `Verifier`. They still authenticate +bare tokens against published Keys; stamped verification is refused without a +configured run proof. A process receiving hop calls continues to require it. diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index f045f31d..85313874 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -68,8 +68,61 @@ def _contract_class(): _CONTRACT = _contract_class() +from enum import Enum + +class Reason(str, Enum): + """Stable caller-safe refusal categories.""" + CONFIGURATION = "configuration" + STAMPPROOF = "stamp_proof" + STAMPTOKEN = "stamp_token" + STAMPDIGEST = "stamp_digest" + STAMPIDENTITY = "stamp_identity" + KEYSUNAVAILABLE = "keys_unavailable" + CANCELLED = "cancelled" + MALFORMED = "malformed" + LIFETIME = "lifetime" + ISSUER = "issuer" + UNKNOWNKEY = "unknown_key" + INVALIDKEY = "invalid_key" + FIXTUREKEY = "fixture_key" + SIGNATURE = "signature" + AUDIENCE = "audience" + NOTYETVALID = "not_yet_valid" + EXPIRED = "expired" + ATTENUATION = "attenuation" + GRANT = "grant" + + +_REASON_TEXT = { + Reason.CONFIGURATION: 'the verifier configuration is incomplete', + Reason.STAMPPROOF: "the call requires this run's hop proof", + Reason.STAMPTOKEN: 'the stamp binds no single Work Context', + Reason.STAMPDIGEST: 'the Work Context does not match the hop stamp', + Reason.STAMPIDENTITY: 'the stamp does not match the authenticated Work Context', + Reason.KEYSUNAVAILABLE: "the authority's published keys are unavailable", + Reason.CANCELLED: "the call ended while the authority's keys were being read", + Reason.MALFORMED: 'the Work Context encoding or claims are invalid', + Reason.LIFETIME: 'the capability lifetime exceeds the allowed bound', + Reason.ISSUER: 'the Work Context was issued by an untrusted issuer', + Reason.UNKNOWNKEY: 'no verification key is published for this Work Context', + Reason.INVALIDKEY: "the authority's verification key is malformed", + Reason.FIXTUREKEY: 'the conformance fixture key is refused', + Reason.SIGNATURE: 'the signature does not verify under the published key', + Reason.AUDIENCE: 'the Work Context was minted for another audience', + Reason.NOTYETVALID: 'the Work Context is not valid before its not-before instant', + Reason.EXPIRED: 'the Work Context has expired', + Reason.ATTENUATION: 'the actor chain widens authority', + Reason.GRANT: 'the grant structure is invalid', +} + class Unauthenticated(ValueError): - """The forwarded Work Context must not be believed; the message names why.""" + """Contains only a code and fixed safe text, never diagnostic causes.""" + def __init__(self, reason: Reason): + self.reason = reason + self.code = reason.value + self.text = _REASON_TEXT[reason] + super().__init__(f"{self.code}: {self.text}") + class Trust: @@ -79,16 +132,16 @@ class Trust: 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") + raise Unauthenticated(Reason.INVALIDKEY) if not keys: - raise Unauthenticated("the authority publishes no verification key") + raise Unauthenticated(Reason.INVALIDKEY) 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 + raise Unauthenticated(Reason.INVALIDKEY) from None @classmethod def from_keys(cls, response, *, now: Callable[[], datetime] | None = None) -> "Trust": @@ -99,7 +152,7 @@ def from_keys(cls, response, *, now: Callable[[], datetime] | None = None) -> "T 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") + raise Unauthenticated(Reason.INVALIDKEY) keys[key.key_id] = bytes(key.public_key) return cls(response.issuer, keys, now=now) @@ -154,44 +207,50 @@ def authenticate( """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) + raise Unauthenticated(Reason.CONFIGURATION) + return authenticate_token(bound_token(headers, proof), audience, trust) + + +def authenticate_token(token: str, audience: str, trust: Trust) -> Claims: + """Authenticate bare bytes; no hop admission, live checks or consumption.""" + if not audience: + raise Unauthenticated(Reason.CONFIGURATION) if len(token) > MAX_TOKEN_BYTES or not token.isascii() or token.count(".") != 1: - raise Unauthenticated("expected a bounded . context") + raise Unauthenticated(Reason.MALFORMED) payload_segment, signature_segment = token.split(".") payload, signature = _canonical(payload_segment), _canonical(signature_segment) context = WorkContextV1() try: context.ParseFromString(payload) except DecodeError as error: - raise Unauthenticated("payload is not a WorkContextV1") from error + raise Unauthenticated(Reason.MALFORMED) from None try: protovalidate.validate(_parsed(_CONTRACT, payload)) except protovalidate.ValidationError as error: - raise Unauthenticated(f"invalid claims: {error}") from error + raise Unauthenticated(Reason.MALFORMED) from None context.DiscardUnknownFields() if context.SerializeToString(deterministic=True) != payload: - raise Unauthenticated("payload is noncanonical or carries a field this reader does not know") + raise Unauthenticated(Reason.MALFORMED) if context.expires_at_unix - context.not_before_unix > MAX_LIFETIME.total_seconds(): - raise Unauthenticated(f"the capability's lifetime exceeds {MAX_LIFETIME}") + raise Unauthenticated(Reason.LIFETIME) if context.issuer != trust.issuer: - raise Unauthenticated(f"issued by {context.issuer!r}, not {trust.issuer!r}") + raise Unauthenticated(Reason.ISSUER) 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") + raise Unauthenticated(Reason.UNKNOWNKEY) 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") + raise Unauthenticated(Reason.FIXTUREKEY) try: key.verify(signature, payload) except InvalidSignature: - raise Unauthenticated("signature does not verify under the authority's published key") from None + raise Unauthenticated(Reason.SIGNATURE) from None if context.audience != audience: - raise Unauthenticated(f"minted for audience {context.audience!r}, presented to {audience!r}") + raise Unauthenticated(Reason.AUDIENCE) now = trust.now().timestamp() if now + SKEW.total_seconds() < context.not_before_unix: - raise Unauthenticated("not valid before its not-before instant") + raise Unauthenticated(Reason.NOTYETVALID) if not now - SKEW.total_seconds() < context.expires_at_unix: - raise Unauthenticated("expired") + raise Unauthenticated(Reason.EXPIRED) _check_structure(context) scopes = context.actor_chain[-1].granted_scopes if context.actor_chain else context.authority_scopes return Claims( @@ -239,28 +298,28 @@ def _check_structure(context) -> None: 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") + raise Unauthenticated(Reason.ATTENUATION) 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") + raise Unauthenticated(Reason.GRANT) if not context.HasField("parent_session_id"): - raise Unauthenticated(f"grant {name!r} is not a child session") + raise Unauthenticated(Reason.GRANT) if context.replay_policy != REPLAY_SINGLE_USE: - raise Unauthenticated(f"grant {name!r} is replayable; a grant authorizes one call") + raise Unauthenticated(Reason.GRANT) 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") + raise Unauthenticated(Reason.GRANT) 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") + raise Unauthenticated(Reason.GRANT) if last.delegation_id != name: - raise Unauthenticated(f"grant {name!r} elevates a hop delegated by {last.delegation_id!r}") + raise Unauthenticated(Reason.GRANT) 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") + raise Unauthenticated(Reason.GRANT) def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: str) -> str: @@ -269,12 +328,12 @@ def bound_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], proof: s 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") + raise Unauthenticated(Reason.STAMPPROOF) if len(tokens) != 1 or not tokens[0] or len(digests) != 1: - raise Unauthenticated("the stamp binds no single Work Context") + raise Unauthenticated(Reason.STAMPTOKEN) 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") + raise Unauthenticated(Reason.STAMPDIGEST) return tokens[0] @@ -292,7 +351,7 @@ def _canonical(segment: str) -> bytes: try: decoded = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) except ValueError as error: - raise Unauthenticated("context segment is not base64url") from error + raise Unauthenticated(Reason.MALFORMED) from None if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != segment: - raise Unauthenticated("context segment is not canonical base64url") + raise Unauthenticated(Reason.MALFORMED) return decoded diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 793087be..1e3d16e1 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -28,9 +28,9 @@ 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 +from authority_client.work_context import Claims, Trust, Unauthenticated, Reason, authenticate_token, bound_token, collect -__all__ = ["Identity", "Verifier", "Unauthenticated", "identity_of"] +__all__ = ["Identity", "Verifier", "Unauthenticated", "Reason", "identity_of"] WORK_CONTEXT_KEY = "x-codefly-work-context" STAMP_PREFIX = "x-codefly-hop-" @@ -107,11 +107,11 @@ class Verifier: serving yet, and refuses calls, with the reason, until the keys are read. """ - def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] | None = None, + def __init__(self, keys, *, audience: str, proof: str | None = None, now: Callable[[], float] | None = None, timeout: float = KEY_TIMEOUT) -> None: - if keys is None or not audience or not proof: - raise Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof") - self._source, self._audience, self._proof, self._timeout = keys, audience, proof, timeout + if keys is None or not audience or proof == "": + raise Unauthenticated(Reason.CONFIGURATION) + self._source, self._audience, self._proof, self._timeout = keys, audience, proof or "", timeout self._now = now or time.time self._lock = threading.Lock() self._trust: Trust | None = None @@ -162,6 +162,23 @@ async def verify_async(self, headers: Mapping[str, str] | Iterable[tuple[str, st pass # judged below on the keys held: stale ones are refused with the reason return self._finish(pairs, token) + def verify_token(self, token: str) -> Identity: + """Authenticate a bare token with the same fail-closed key cache.""" + wait = self._start(_key_id(token)) + if wait is not None: + futures.wait((wait,), self._timeout) + return self._token_identity(token) + + async def verify_token_async(self, token: str) -> Identity: + """Async bare-token verification; never blocks the event loop.""" + wait = self._start(_key_id(token)) + if wait is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(wait), self._timeout) + except asyncio.TimeoutError: + pass + return self._token_identity(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) @@ -180,14 +197,20 @@ def _start(self, key_id: str) -> futures.Future[None] | None: judgeable = held and now - self._fetched <= KEY_STALE return None if judgeable else self._inflight - def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: + def _token_identity(self, 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") + if isinstance(self.keys_error, Unauthenticated) and self.keys_error.reason == Reason.INVALIDKEY: + raise Unauthenticated(Reason.INVALIDKEY) + raise Unauthenticated(Reason.KEYSUNAVAILABLE) trust = self._trust - claims = authenticate(pairs, self._proof, self._audience, trust) - values = collect(pairs) + claims = authenticate_token(token, self._audience, trust) identity = identity_of(claims.context) + return Identity(**{**identity.__dict__, "context": token, "claims": claims}) + + def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: + identity = self._token_identity(token) + values = collect(pairs) 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), @@ -197,9 +220,9 @@ def _finish(self, pairs: list[tuple[str, str]], token: str) -> Identity: 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") + raise Unauthenticated(Reason.STAMPIDENTITY) advisory = (values.get(ADVISORY_KEY) or [""])[0] - return Identity(**{**identity.__dict__, "context": token, "advisory": advisory, "claims": claims}) + return Identity(**{**identity.__dict__, "context": token, "advisory": advisory}) def _refresh(self) -> futures.Future[None]: """Start the one read, on its own thread and timeout; the lock is held. The @@ -231,6 +254,8 @@ def read() -> None: def _key_id(token: str) -> str: """The key a token names, read only to choose the keys it is judged by.""" + if len(token) > 32 * 1024: + return "" try: context = WorkContextV1() context.ParseFromString(base64.urlsafe_b64decode(token.split(".")[0] + "==")) diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 998efd4d..574e0cdb 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -53,11 +53,11 @@ def test_the_hop_fixture_authenticates_and_reads_as_go_reads_it(): # 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"), + ({TENANT_KEY: "globex"}, "stamp_identity"), + ({SCOPES_KEY: "documents.collection:read,documents.collection:admin"}, "stamp_identity"), + ({ACTORS_KEY: "robin"}, "stamp_identity"), ({"x-codefly-hop-proof": "another-run"}, "proof"), - ({CONTEXT_KEY: "0" * 64}, "stamp binds"), + ({CONTEXT_KEY: "0" * 64}, "stamp_digest"), ]) def test_a_stamp_the_token_does_not_bear_out_is_refused(change, reason): with pytest.raises(Unauthenticated, match=reason): @@ -74,7 +74,7 @@ def test_a_token_the_authority_did_not_sign_is_refused_even_with_a_matching_dige def test_another_audience_and_an_expired_context_are_refused(): - with pytest.raises(Unauthenticated, match="minted for audience"): + with pytest.raises(Unauthenticated, match="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"]) @@ -342,3 +342,68 @@ def test_an_idle_replica_polled_for_readiness_stays_ready(): settle(v) assert v.ready(), f"an idle replica went unready at minute {minute + 1}" assert 10 <= source.reads <= 14 + + +@pytest.mark.parametrize("row", json.loads((Path(__file__).parents[2] / "testdata/vectors.json").read_text()), ids=lambda r: r["name"]) +def test_bare_token_matches_shared_verdict(row): + response = KeysResponse(issuer=row["issuer"]) + for kid, raw in row["keys"].items(): + response.keys.add(key_id=kid, algorithm="Ed25519", public_key=base64.b64decode(raw)) + v = Verifier(Keys(response), audience=row["audience"], proof="proof", now=lambda: row["now"]) + if row["accept"]: + assert v.verify_token(row["token"]).claims.audience == row["audience"] + else: + with pytest.raises(Unauthenticated) as refused: + v.verify_token(row["token"]) + assert refused.value.code == row["reason"] + assert row["token"] not in str(refused.value) + + +def test_bare_token_stale_wait_async_and_safe_reasons(): + import asyncio + from codefly_hop import Reason + codes = [r.value for r in Reason] + assert len(set(codes)) == len(codes) + assert len({Unauthenticated(r).text for r in Reason}) == len(codes) + clock = [FIXTURE["now"] - KEY_STALE - 60] + source = Keys(published()) + v = verifier(clock=lambda: clock[0], source=source) + clock[0] = FIXTURE["now"] + token = FIXTURE["headers"][WORK_CONTEXT_KEY] + assert asyncio.run(v.verify_token_async(token)).owner == FIXTURE["identity"]["owner"] + assert source.reads == 2 + clock[0] += KEY_STALE + 1 + source.error = RuntimeError("secret-key-source") + with pytest.raises(Unauthenticated) as refused: + v.verify_token(token) + assert refused.value.code == Reason.KEYSUNAVAILABLE.value + assert "secret" not in str(refused.value) + +@pytest.mark.parametrize("change, code", [ + ({"x-codefly-hop-proof": "private-proof"}, "stamp_proof"), + ({WORK_CONTEXT_KEY: ""}, "stamp_token"), + ({CONTEXT_KEY: "private-digest"}, "stamp_digest"), + ({TENANT_KEY: "private-tenant"}, "stamp_identity"), +]) +def test_stamp_failure_codes_are_safe_and_distinct(change, code): + with pytest.raises(Unauthenticated) as refused: + verifier().verify({**FIXTURE["headers"], **change}) + assert refused.value.code == code + assert "private-" not in str(refused.value) + + +def test_bare_only_process_needs_no_proof_and_cannot_accept_stamps(): + v = Verifier(Keys(published()), audience=FIXTURE["audience"], now=lambda: FIXTURE["now"]) + assert v.verify_token(FIXTURE["headers"][WORK_CONTEXT_KEY]).owner == FIXTURE["identity"]["owner"] + with pytest.raises(Unauthenticated): + v.verify(FIXTURE["headers"]) + + +def test_invalid_publication_and_unreadable_keys_have_distinct_safe_codes(): + for source, code in [(Keys(KeysResponse(issuer="issuer")), "invalid_key"), + (Keys(error=RuntimeError("private-source-detail")), "keys_unavailable")]: + v = verifier(source=source) + with pytest.raises(Unauthenticated) as refused: + v.verify_token(FIXTURE["headers"][WORK_CONTEXT_KEY]) + assert refused.value.code == code + assert "private-" 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 24f6fea5..623e8911 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -104,10 +104,10 @@ def test_a_forged_or_misaddressed_context_is_refused_by_name(): (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), "another-module", trust, "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"), + (sign(context), "", trust, "configuration"), ]: with pytest.raises(Unauthenticated, match=reason): authenticate(stamped(token), PROOF, audience, use) @@ -137,13 +137,13 @@ def test_the_reader_refuses_a_token_the_stamp_does_not_bind(change): def test_trust_comes_only_from_published_ed25519_keys(): - with pytest.raises(Unauthenticated, match="no issuer"): + with pytest.raises(Unauthenticated, match="invalid_key"): Trust.from_keys(KeysResponse(keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=PUBLIC)])) - with pytest.raises(Unauthenticated, match="no verification key"): + with pytest.raises(Unauthenticated, match="invalid_key"): Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="RSA", public_key=PUBLIC)])) - with pytest.raises(Unauthenticated, match="repeated"): + with pytest.raises(Unauthenticated, match="invalid_key"): Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=PUBLIC)] * 2)) - with pytest.raises(Unauthenticated, match="unusable"): + with pytest.raises(Unauthenticated, match="invalid_key"): Trust.from_keys(KeysResponse(issuer="i", keys=[PublicKey(key_id="k", algorithm="Ed25519", public_key=b"short")])) diff --git a/libraries/authority-client/testdata/resource_owner_vectors.json b/libraries/authority-client/testdata/resource_owner_vectors.json index 27e8e1c5..cd86f5d3 100644 --- a/libraries/authority-client/testdata/resource_owner_vectors.json +++ b/libraries/authority-client/testdata/resource_owner_vectors.json @@ -1,6 +1,10 @@ { "_comment": "resource_owner_id vectors: every protovalidate implementation must accept the first list and refuse the second. Read by workcontext/owner_test.go.", "accepted": [ + { + "name": "no owner", + "owner": "" + }, { "name": "512 ASCII bytes", "owner": "u", diff --git a/libraries/authority-client/testdata/vectors.json b/libraries/authority-client/testdata/vectors.json index 32656c1c..2e1a3533 100644 --- a/libraries/authority-client/testdata/vectors.json +++ b/libraries/authority-client/testdata/vectors.json @@ -86,7 +86,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "widens authority" + "reason": "attenuation" }, { "name": "no seal", @@ -98,7 +98,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "seal" + "reason": "malformed" }, { "name": "seal epoch zero", @@ -110,7 +110,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "principal_epoch" + "reason": "malformed" }, { "name": "empty tenant", @@ -122,7 +122,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "tenant_id" + "reason": "malformed" }, { "name": "wrong type", @@ -134,7 +134,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "typ" + "reason": "malformed" }, { "name": "unknown replay policy", @@ -146,7 +146,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "replay_policy" + "reason": "malformed" }, { "name": "actor without an epoch", @@ -158,7 +158,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "principal_epoch" + "reason": "malformed" }, { "name": "lifetime beyond a day", @@ -182,7 +182,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "replayable" + "reason": "grant" }, { "name": "grant to another hop", @@ -206,7 +206,7 @@ "conformance-1": "zUk2IHGYpgzQIg62XX3M2E4ljwn34sgdGreGOMl9Efs=" }, "accept": false, - "reason": "conformance fixture key" + "reason": "fixture_key" }, { "name": "forged signature", @@ -232,6 +232,18 @@ "accept": false, "reason": "signature" }, + { + "name": "empty signature", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.", + "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", @@ -242,7 +254,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "minted for audience" + "reason": "audience" }, { "name": "expired", @@ -266,7 +278,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "not valid before" + "reason": "not_yet_valid" }, { "name": "another issuer", @@ -278,7 +290,7 @@ "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "issued by" + "reason": "issuer" }, { "name": "a key the authority does not publish", @@ -290,6 +302,210 @@ "rotated": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "no verification key" + "reason": "unknown_key" + }, + { + "name": "payload padding", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw=.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "payload double padding", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw==.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "payload CR", + "token": "C\rhdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "payload LF", + "token": "C\nhdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "payload CRLF", + "token": "C\r\nhdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "payload unused bit", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAx.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature padding", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw=", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature double padding", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw==", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature CR", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.c\rcIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature LF", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.c\ncIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature CRLF", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.c\r\ncIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "signature unused bit", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAw.ccIoRo1Phc6RcTq3N_mL0z1r5EbZfw-v-Z8WXu4FGlJ6kprieciqWuU4kSPwplc0Itwdfk3mTSGIhQBbPeR7Dx", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "protobuf duplicate field", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYAwoXY29kZWZseS53b3JrLWNvbnRleHQvdjE.mbWqDB0EbkwfSwsKCB0logAx2kWefmj0Q_1kqv1LtiZxzyv3m4vK6V9CTtgqJhEQ4UopsMHto3C2JKd4lNJkAA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "protobuf reordered field", + "token": "EgdFZDI1NTE5Gg1jb25mb3JtYW5jZS0xIh5odHRwczovL2F1dGhvcml0eS5jb2RlZmx5LnRlc3QqGGNvZGVmbHkudGVzdC9jb25mb3JtYW5jZTCgxfXWBjigxfXWBkCw4fXWBkokYTIwYWVmZjctODQwYy00NjkzLThjYTMtMDRhNTQwNGJkOTFlUAdaCmlkZW1wb3RlbnRiEnRlbmFudC1jb25mb3JtYW5jZWoPcHJpbmNpcGFsLW93bmVychB0YXNrLWNvbmZvcm1hbmNleiRmN2QyM2ZhZC1lMjUxLTQzYzMtYWYzMi0zYzUzZTljYzBlYzKKAQ4KBnJlY29yZBIEcmVhZLIBBWh1bWFuygEYb3JnYW5pemF0aW9uLWNvbmZvcm1hbmNl0gEeCAISGGluc3RhbGxhdGlvbi1jb25mb3JtYW5jZRgDChdjb2RlZmx5LndvcmstY29udGV4dC92MQ.A1P7JAM3lj_Ihp-7ppvJv9Hbw-p1c9zVIvVpFvghcpoXQCqiMNPK63rIDgmzbQZicS6Q0oQdWMQV-Fr4xTBvDw", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "protobuf overlong varint", + "token": "CpcAY29kZWZseS53b3JrLWNvbnRleHQvdjESB0VkMjU1MTkaDWNvbmZvcm1hbmNlLTEiHmh0dHBzOi8vYXV0aG9yaXR5LmNvZGVmbHkudGVzdCoYY29kZWZseS50ZXN0L2NvbmZvcm1hbmNlMKDF9dYGOKDF9dYGQLDh9dYGSiRhMjBhZWZmNy04NDBjLTQ2OTMtOGNhMy0wNGE1NDA0YmQ5MWVQB1oKaWRlbXBvdGVudGISdGVuYW50LWNvbmZvcm1hbmNlag9wcmluY2lwYWwtb3duZXJyEHRhc2stY29uZm9ybWFuY2V6JGY3ZDIzZmFkLWUyNTEtNDNjMy1hZjMyLTNjNTNlOWNjMGVjMooBDgoGcmVjb3JkEgRyZWFksgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.7TCLQhlkbfwJuSAT1-NKUfSvFzjapGTIdqK95dUepPve3bFGFLbABTqUVua0xeR15OrCn30sRfDvQ8e8Pfx3CQ", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "protobuf unknown field", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJGEyMGFlZmY3LTg0MGMtNDY5My04Y2EzLTA0YTU0MDRiZDkxZVAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokZjdkMjNmYWQtZTI1MS00M2MzLWFmMzItM2M1M2U5Y2MwZWMyigEOCgZyZWNvcmQSBHJlYWSyAQVodW1hbsoBGG9yZ2FuaXphdGlvbi1jb25mb3JtYW5jZdIBHggCEhhpbnN0YWxsYXRpb24tY29uZm9ybWFuY2UYA_gHAQ.8s0wuPqDpmaHMNMm3h7KR66vsepWUlYvvbhvQR3W3j3GYUEbo6EYcQs9TbR-OA4tAdM2M_DslMGBwsoWcfuhCA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" + }, + { + "name": "JSON claims", + "token": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlJZCI6ImNvbmZvcm1hbmNlLTEiLCJpc3N1ZXIiOiJodHRwczovL2F1dGhvcml0eS5jb2RlZmx5LnRlc3QiLCJhdWRpZW5jZSI6ImNvZGVmbHkudGVzdC9jb25mb3JtYW5jZSIsIm5vdEJlZm9yZVVuaXgiOiIxNzkyODkzNjAwIiwiaXNzdWVkQXRVbml4IjoiMTc5Mjg5MzYwMCIsImV4cGlyZXNBdFVuaXgiOiIxNzkyODk3MjAwIiwibm9uY2UiOiJhMjBhZWZmNy04NDBjLTQ2OTMtOGNhMy0wNGE1NDA0YmQ5MWUiLCJhdXRob3JpemF0aW9uUmV2aXNpb24iOiI3IiwicmVwbGF5UG9saWN5IjoiaWRlbXBvdGVudCIsInRlbmFudElkIjoidGVuYW50LWNvbmZvcm1hbmNlIiwib3duZXJQcmluY2lwYWxJZCI6InByaW5jaXBhbC1vd25lciIsInRhc2tJZCI6InRhc2stY29uZm9ybWFuY2UiLCJzZXNzaW9uSWQiOiJmN2QyM2ZhZC1lMjUxLTQzYzMtYWYzMi0zYzUzZTljYzBlYzIiLCJhdXRob3JpdHlTY29wZXMiOlt7InJlc291cmNlS2luZCI6InJlY29yZCIsImFjdGlvbnMiOlsicmVhZCJdfV0sIm93bmVyUHJpbmNpcGFsS2luZCI6Imh1bWFuIiwib3JnYW5pemF0aW9uSWQiOiJvcmdhbml6YXRpb24tY29uZm9ybWFuY2UiLCJzZWFsIjp7InByaW5jaXBhbEVwb2NoIjoiMiIsImluc3RhbGxhdGlvbklkIjoiaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlIiwiaW5zdGFsbGF0aW9uUmV2aXNpb24iOiIzIn19.S6df_jmaUBkPx--yz8zXsfoICAvC8kuT-J22uk3IIrhJTap28b6oMONUu4Wg_LklK77bInZA8-Lc-Tle-ZEtDA", + "audience": "codefly.test/conformance", + "issuer": "https://authority.codefly.test", + "now": 1792893601, + "keys": { + "conformance-1": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" + }, + "accept": false, + "reason": "malformed" } ] diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index cd543a2a..68f41a72 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -41,3 +41,24 @@ needed. Test signatures and fixed-clock fixtures are not live credentials. The generator owns `src/`, including its index. The helper lives at the package root; preserved `package.json`/`tsconfig.json` scaffolds export and compile it alongside generated bindings. Never edit the generated index to add this helper. + + +Bare tokens returned by Issue can be authenticated without manufacturing a hop +stamp: Go `Verifier.VerifyToken(ctx, token)`, Python `verify_token(token)` / +`verify_token_async(token)`, TypeScript `verifyToken(token)`. These reuse the +stamped path's published-key cache, including waiting for stale-key refresh and +refusing failed refreshes. They check signature, issuer, audience, validity and +claim structure. They do not prove hop admission, live revocation, method policy, +or single-use consumption; retain those checks where required. + +Verifier failures expose fixed caller-safe reason codes and text. Go callers use +`errors.As(err, &reason)` with `hop.Reason` (`Code()` / `Text()`); Python and +TypeScript `Unauthenticated` expose `code` and `text`, with exported `Reason` +values. These errors never retain underlying key-source errors or claim values. +`KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a +caller response. Unknown future Core diagnostic categories remain refused. + +Processes that receive only Issue responses use Go `NewTokenVerifier` or omit +`proof` when constructing Python/TypeScript `Verifier`. They still authenticate +bare tokens against published Keys; stamped verification is refused without a +configured run proof. A process receiving hop calls continues to require it. diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 3ede50e3..3688ce61 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -4,7 +4,7 @@ import { createHash, createPrivateKey, generateKeyPairSync, sign } from 'node:cr import { test } from 'node:test'; import { create, fromBinary, fromJson, toBinary } from '@bufbuild/protobuf'; import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; -import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier } from '@codefly-dev/authority-client/work-context'; +import { authenticateClaims, identityOf, trustFromKeys, Unauthenticated, Verifier, Reason } 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'); @@ -49,11 +49,11 @@ test('a forged, unsigned, misaddressed or expired context is refused by name', ( [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), 'another-module', trust, /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/], + [signed(context), '', trust, /configuration/], ]) { assert.throws(()=>authenticateClaims(stamped(token),'test-proof',audience,use), e => e instanceof Unauthenticated && reason.test(e.message)); } @@ -108,7 +108,7 @@ test('the Verifier reads the hop fixture as Go reads it', async () => { 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/]]) { + for (const [key, value, reason] of [['x-codefly-hop-tenant','globex',/stamp_identity/],['x-codefly-hop-scopes','documents.collection:admin',/stamp_identity/],['x-codefly-hop-proof','another-run',/proof/]]) { await assert.rejects(v.verify({...fixture.headers, [key]: value}), e => e instanceof Unauthenticated && reason.test(e.message)); } }); @@ -153,19 +153,21 @@ test('a Verifier starts before the authority serves keys and refuses with a reas // started succeeded in the background. Held keys past the stale window cannot // judge a call, so the call waits for the read, as it does for a key not held. // The keys are read before the fixture's token is minted, so the token is live. -test('a call on keys past the stale window waits for the re-read', async () => { +for (const mode of ['stamped', 'bare']) { + const check = v => mode === 'bare' ? v.verifyToken(fixture.headers['x-codefly-work-context']) : v.verify(fixture.headers); +test(mode + ': a call on keys past the stale window waits for the re-read', async () => { const keys = source(); let clock = (fixture.now - 960)*1000; const v = new Verifier(keys, fixture.audience, fixture.proof, () => clock); await settle(); clock = fixture.now*1000; - assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); + assert.equal((await check(v)).owner, fixture.identity.owner); assert.equal(keys.reads, 2); }); // The wait fails closed: a re-read that fails refuses the call with the fixed // reason, and only once the read has ended. -test('a call on keys past the stale window is refused when the re-read fails', async () => { +test(mode + ': a call on keys past the stale window is refused when the re-read fails', async () => { let fail; const keys = source(() => keys.reads === 1 ? Promise.resolve(published) : new Promise((_, reject) => { fail = reject; })); let clock = (fixture.now - 960)*1000; @@ -173,7 +175,7 @@ test('a call on keys past the stale window is refused when the re-read fails', a await settle(); clock = fixture.now*1000; let judged = false; - const call = v.verify(fixture.headers).finally(() => { judged = true; }); + const call = check(v).finally(() => { judged = true; }); for (let i = 0; i < 5; i++) await settle(); assert.equal(judged, false, 'the call was judged before its re-read ended'); fail(new Error('dial authority.internal:4000: connection refused')); @@ -184,7 +186,7 @@ test('a call on keys past the stale window is refused when the re-read fails', a // Review r159 f1: the wait bounds itself. A re-read that never answers, from a // source that ignores its timeout and signal, refuses the call with the fixed // reason once the verifier's own timeout passes, and holds no later read. -test('a call on keys past the stale window is refused when the re-read hangs', {timeout: 3_000}, async () => { +test(mode + ': a call on keys past the stale window is refused when the re-read hangs', {timeout: 3_000}, async () => { let hang = false; const keys = source(() => hang ? new Promise(() => {}) : Promise.resolve(published)); let clock = (fixture.now - 960)*1000; @@ -193,15 +195,17 @@ test('a call on keys past the stale window is refused when the re-read hangs', { hang = true; clock = fixture.now*1000; const started = performance.now(); - await assert.rejects(v.verify(fixture.headers), e => e instanceof Unauthenticated && /unavailable/.test(e.message)); + await assert.rejects(check(v), e => e instanceof Unauthenticated && /unavailable/.test(e.message)); assert.ok(performance.now() - started >= 90, 'refused before the read timed out'); assert.equal(v.keysError?.name, 'TimeoutError'); hang = false; clock += 11_000; - assert.equal((await v.verify(fixture.headers)).owner, fixture.identity.owner); + assert.equal((await check(v)).owner, fixture.identity.owner); assert.equal(keys.reads, 3); }); +} + test('an actor granted nothing narrows the identity to nothing', () => { const context = body('delegated'); context.actorChain[0].grantedScopes = []; @@ -258,3 +262,44 @@ test('an idle replica polled for readiness stays ready', async () => { } assert.ok(keys.reads >= 10 && keys.reads <= 14, `reads=${keys.reads}`); }); + +for (const row of vectors) { + test(`bare token matches shared verdict: ${row.name}`, async () => { + const response = create(KeysResponseSchema, {issuer: row.issuer, keys: Object.entries(row.keys).map(([keyId, raw]) => create(PublicKeySchema,{keyId,algorithm:'Ed25519',publicKey:Buffer.from(raw,'base64')}))}); + const v = new Verifier(source(()=>Promise.resolve(response)), row.audience, 'proof', ()=>row.now*1000); + if (row.accept) assert.equal((await v.verifyToken(row.token)).claims.audience,row.audience); + else await assert.rejects(v.verifyToken(row.token), e=> e instanceof Unauthenticated && e.code === row.reason && !e.message.includes(row.token)); + }); +} +test('every named reason has distinct code and safe text', () => { + const reasons=Object.values(Reason); + assert.equal(new Set(reasons).size,reasons.length); + const messages=reasons.map(r=>new Unauthenticated(r).text); + assert.equal(new Set(messages).size,reasons.length); +}); + +test('stamp failures retain distinct safe codes', async () => { + const v=new Verifier(source(),fixture.audience,fixture.proof,()=>fixture.now*1000); + for (const [change,code] of [ + [{'x-codefly-hop-proof':'private-proof'},Reason.StampProof], + [{'x-codefly-work-context':''},Reason.StampToken], + [{'x-codefly-hop-context':'private-digest'},Reason.StampDigest], + [{'x-codefly-hop-tenant':'private-tenant'},Reason.StampIdentity], + ]) await assert.rejects(v.verify({...fixture.headers,...change}), e=>e.code===code && !e.message.includes('private-')); +}); + +test('bare-only process needs no proof and cannot accept stamps', async () => { + const v = new Verifier(source(),fixture.audience,undefined,()=>fixture.now*1000); + assert.equal((await v.verifyToken(fixture.headers['x-codefly-work-context'])).owner,fixture.identity.owner); + await assert.rejects(v.verify(fixture.headers),Unauthenticated); +}); + +test('invalid publication and unreadable keys have distinct safe codes', async () => { + for (const [answer, code] of [ + [()=>Promise.resolve(create(KeysResponseSchema,{issuer:'issuer'})), Reason.InvalidKey], + [()=>Promise.reject(new Error('private-source-detail')), Reason.KeysUnavailable], + ]) { + const v=new Verifier(source(answer),fixture.audience,fixture.proof,()=>fixture.now*1000); + await assert.rejects(v.verifyToken(fixture.headers['x-codefly-work-context']), e=>e.code===code && !e.message.includes('private-')); + } +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 25fc7b91..45addf34 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -26,8 +26,56 @@ const FIXTURE_PUBLIC = Buffer.from(createPrivateKey({ format: "der", type: "pkcs8", }).export({ format: "jwk" }).x as string, "base64url"); +export enum Reason { + Configuration = "configuration", + StampProof = "stamp_proof", + StampToken = "stamp_token", + StampDigest = "stamp_digest", + StampIdentity = "stamp_identity", + KeysUnavailable = "keys_unavailable", + Cancelled = "cancelled", + Malformed = "malformed", + Lifetime = "lifetime", + Issuer = "issuer", + UnknownKey = "unknown_key", + InvalidKey = "invalid_key", + FixtureKey = "fixture_key", + Signature = "signature", + Audience = "audience", + NotYetValid = "not_yet_valid", + Expired = "expired", + Attenuation = "attenuation", + Grant = "grant", +} +const reasonText: Record = { + [Reason.Configuration]: "the verifier configuration is incomplete", + [Reason.StampProof]: "the call requires this run's hop proof", + [Reason.StampToken]: "the stamp binds no single Work Context", + [Reason.StampDigest]: "the Work Context does not match the hop stamp", + [Reason.StampIdentity]: "the stamp does not match the authenticated Work Context", + [Reason.KeysUnavailable]: "the authority's published keys are unavailable", + [Reason.Cancelled]: "the call ended while the authority's keys were being read", + [Reason.Malformed]: "the Work Context encoding or claims are invalid", + [Reason.Lifetime]: "the capability lifetime exceeds the allowed bound", + [Reason.Issuer]: "the Work Context was issued by an untrusted issuer", + [Reason.UnknownKey]: "no verification key is published for this Work Context", + [Reason.InvalidKey]: "the authority's verification key is malformed", + [Reason.FixtureKey]: "the conformance fixture key is refused", + [Reason.Signature]: "the signature does not verify under the published key", + [Reason.Audience]: "the Work Context was minted for another audience", + [Reason.NotYetValid]: "the Work Context is not valid before its not-before instant", + [Reason.Expired]: "the Work Context has expired", + [Reason.Attenuation]: "the actor chain widens authority", + [Reason.Grant]: "the grant structure is invalid", +}; +/** Fixed caller-safe code and text; never retains a diagnostic cause. */ export class Unauthenticated extends Error { override name = "Unauthenticated"; + readonly text: string; + constructor(readonly code: Reason) { + super(`${code}: ${reasonText[code]}`); + this.text = reasonText[code]; + } } /** The authority a module believes: its issuer and published keys, from Keys. */ @@ -45,11 +93,11 @@ export function trustFromKeys(response: KeysResponse, now?: () => number): Trust 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"); + throw new Unauthenticated(Reason.InvalidKey); } 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"); + if (!response.issuer || keys.size === 0) throw new Unauthenticated(Reason.InvalidKey); return { issuer: response.issuer, keys, now }; } @@ -79,43 +127,49 @@ export type StampHeaders = Readonly> /** The claims of the Work Context the hop forwarded to this module (audience), or * Unauthenticated naming why it must not be believed. */ export function authenticateClaims(headers: StampHeaders, proof: string, audience: string, trust: Trust): Claims { - if (!audience) throw new Unauthenticated("no audience: a module authenticates a context for itself"); - const token = boundToken(headers, proof); + if (!audience) throw new Unauthenticated(Reason.Configuration); + return authenticateToken(boundToken(headers, proof), audience, trust); +} + +/** Authenticate bare bytes without claiming hop admission or live checks. */ +export function authenticateToken(token: string, audience: string, trust: Trust): Claims { + if (!audience) throw new Unauthenticated(Reason.Configuration); if (token.length > MAX_TOKEN_BYTES || token.split(".").length !== 2) { - throw new Unauthenticated("expected a bounded payload.signature context"); + throw new Unauthenticated(Reason.Malformed); } const [payloadSegment, signatureSegment] = token.split("."); - const payload = decodeSegment(payloadSegment), signature = decodeSegment(signatureSegment); + const payload = decodeSegment(payloadSegment), signature = signatureSegment === "" ? new Uint8Array() : decodeSegment(signatureSegment); let context: WorkContextV1; try { context = fromBinary(WorkContextV1Schema, payload, { readUnknownFields: false }); } catch { - throw new Unauthenticated("payload is not a WorkContextV1"); + throw new Unauthenticated(Reason.Malformed); } 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}`); + throw new Unauthenticated(Reason.Malformed); } const canonical = toBinary(WorkContextV1Schema, context); if (canonical.length !== payload.length || canonical.some((byte, index) => byte !== payload[index])) { - throw new Unauthenticated("payload is noncanonical or carries a field this reader does not know"); + throw new Unauthenticated(Reason.Malformed); } 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}`); + if (expires - notBefore > MAX_LIFETIME_SECONDS) throw new Unauthenticated(Reason.Lifetime); + if (context.issuer !== trust.issuer) throw new Unauthenticated(Reason.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 (!raw) throw new Unauthenticated(Reason.UnknownKey); + if (raw.length !== 32) throw new Unauthenticated(Reason.InvalidKey); 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`); + throw new Unauthenticated(Reason.FixtureKey); } 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"); + throw new Unauthenticated(Reason.Signature); } - if (context.audience !== audience) throw new Unauthenticated(`minted for audience ${context.audience}, presented to ${audience}`); + if (context.audience !== audience) throw new Unauthenticated(Reason.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"); + if (now + SKEW_SECONDS < notBefore) throw new Unauthenticated(Reason.NotYetValid); + if (!(now - SKEW_SECONDS < expires)) throw new Unauthenticated(Reason.Expired); checkStructure(context); const effective = context.actorChain.at(-1)?.grantedScopes ?? context.authorityScopes; const scopes = Object.freeze(effective.map(scope => Object.freeze({ @@ -170,33 +224,33 @@ function checkStructure(context: WorkContextV1): void { 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`); + throw new Unauthenticated(Reason.Attenuation); } 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`); + if (chain.length === 0) throw new Unauthenticated(Reason.Grant); + if (context.parentSessionId === undefined) throw new Unauthenticated(Reason.Grant); + if (context.replayPolicy !== "single-use") throw new Unauthenticated(Reason.Grant); 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`); + throw new Unauthenticated(Reason.Grant); } 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`); + throw new Unauthenticated(Reason.Grant); } - if (last.delegationId !== name) throw new Unauthenticated(`grant ${name} elevates a hop delegated by ${last.delegationId}`); + if (last.delegationId !== name) throw new Unauthenticated(Reason.Grant); 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`); + if (identityKey(context, last) !== actor) throw new Unauthenticated(Reason.Grant); } /** 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"); + throw new Unauthenticated(Reason.Configuration); } const values = new Map(); const pairs = Symbol.iterator in Object(headers) ? headers as Iterable : Object.entries(headers); @@ -211,18 +265,19 @@ export function boundToken(headers: StampHeaders, proof: string): string { }; 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"); + throw new Unauthenticated(Reason.StampProof); } - 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"); + if (!token || values.get("x-codefly-hop-context")?.length !== 1) throw new Unauthenticated(Reason.StampToken); + if (!equal(createHash("sha256").update(token).digest("hex"), one("x-codefly-hop-context"))) { + throw new Unauthenticated(Reason.StampDigest); } return token; } function decodeSegment(segment: string): Uint8Array { - if (!/^[A-Za-z0-9_-]+$/.test(segment)) throw new Unauthenticated("context segment is not base64url"); + if (!/^[A-Za-z0-9_-]+$/.test(segment)) throw new Unauthenticated(Reason.Malformed); const decoded = Buffer.from(segment, "base64url"); - if (decoded.toString("base64url") !== segment) throw new Unauthenticated("context segment is not canonical base64url"); + if (decoded.toString("base64url") !== segment) throw new Unauthenticated(Reason.Malformed); return new Uint8Array(decoded); } @@ -292,9 +347,9 @@ export class Verifier { /** The last key-read failure, for the module's own logs; never sent to a caller. */ keysError?: unknown; - constructor(private readonly source: KeySource, private readonly audience: string, private readonly proof: string, + constructor(private readonly source: KeySource, private readonly audience: string, private readonly proof?: string, private readonly now: () => number = Date.now, private readonly timeoutMs: number = KEY_TIMEOUT_MS) { - if (!source || !audience || !proof) throw new Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof"); + if (!source || !audience || proof === "") throw new Unauthenticated(Reason.Configuration); void this.refresh(); } @@ -314,9 +369,8 @@ export class Verifier { 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 token = boundToken(pairs, this.proof ?? ""); // the stamp first: nothing unbound reaches the key read + const id = await this.verifyToken(token); const stamped: Record = { "x-codefly-hop-owner": id.owner, "x-codefly-hop-owner-kind": id.ownerKind, "x-codefly-hop-tenant": id.tenant, "x-codefly-hop-actors": id.actors.join(","), "x-codefly-hop-scopes": id.scopes.join(","), @@ -325,11 +379,17 @@ export class Verifier { 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`); + throw new Unauthenticated(Reason.StampIdentity); } } const advisory = pairs.find(([name]) => name === "x-codefly-hop-advisory")?.[1] ?? ""; - return Object.freeze({ ...id, context: token, advisory, claims }); + return Object.freeze({ ...id, advisory }); + } + + /** Authenticate a bare token with the stamped path's fail-closed key cache. */ + async verifyToken(token: string): Promise { + const claims = authenticateToken(token, this.audience, await this.trustFor(keyIdOf(token))); + return Object.freeze({ ...identityOf(claims.context), context: token, claims, advisory: "" }); } private async trustFor(keyId: string): Promise { @@ -341,7 +401,8 @@ export class Verifier { // idle replica's first call is judged on fresh keys, not refused while they arrive. if ((!held || now - this.fetched > KEY_STALE_MS) && 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"); + if (this.keysError instanceof Unauthenticated && this.keysError.code === Reason.InvalidKey) throw new Unauthenticated(Reason.InvalidKey); + throw new Unauthenticated(Reason.KeysUnavailable); } return { ...this.trust, now: this.now }; } @@ -363,6 +424,7 @@ export class Verifier { } function keyIdOf(token: string): string { + if (token.length > MAX_TOKEN_BYTES) return ""; try { return fromBinary(WorkContextV1Schema, Buffer.from(token.split(".")[0], "base64url")).keyId; } catch { diff --git a/libraries/authority-core/go/go.mod b/libraries/authority-core/go/go.mod index 5bb7baf5..9ad50f8f 100644 --- a/libraries/authority-core/go/go.mod +++ b/libraries/authority-core/go/go.mod @@ -5,7 +5,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/google/uuid v1.6.0 google.golang.org/protobuf v1.36.12 ) diff --git a/libraries/authority-core/go/go.sum b/libraries/authority-core/go/go.sum index b536cab0..dff267a3 100644 --- a/libraries/authority-core/go/go.sum +++ b/libraries/authority-core/go/go.sum @@ -14,8 +14,8 @@ github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/libraries/conformance/go/forwarded_context.go b/libraries/conformance/go/forwarded_context.go index 9af2575e..96b2ed00 100644 --- a/libraries/conformance/go/forwarded_context.go +++ b/libraries/conformance/go/forwarded_context.go @@ -67,7 +67,7 @@ func (c *client) forwardedContext(t *testing.T, ctx context.Context, f Fixture) }{ "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}, + "wrong audience": {token, f.Elsewhere, "audience", trust}, "expired": {token, f.Audience, "expired", later}, } { t.Run(name+" is refused by name", func(t *testing.T) { diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 1d9bcafb..64081225 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -3,9 +3,9 @@ module github.com/codefly-dev/authority/libraries/conformance/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index 0d8a5270..2c0a19dd 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,12 +28,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 h1:7vdTj6aLQVyFf0MWWgUnR/otX1v9xN6AbFKC2cHrQjE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 5dc4aa9f..42d243c8 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -91,3 +91,24 @@ 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`. + + +Bare tokens returned by Issue can be authenticated without manufacturing a hop +stamp: Go `Verifier.VerifyToken(ctx, token)`, Python `verify_token(token)` / +`verify_token_async(token)`, TypeScript `verifyToken(token)`. These reuse the +stamped path's published-key cache, including waiting for stale-key refresh and +refusing failed refreshes. They check signature, issuer, audience, validity and +claim structure. They do not prove hop admission, live revocation, method policy, +or single-use consumption; retain those checks where required. + +Verifier failures expose fixed caller-safe reason codes and text. Go callers use +`errors.As(err, &reason)` with `hop.Reason` (`Code()` / `Text()`); Python and +TypeScript `Unauthenticated` expose `code` and `text`, with exported `Reason` +values. These errors never retain underlying key-source errors or claim values. +`KeysError` / `keys_error` / `keysError` is a separate internal diagnostic, not a +caller response. Unknown future Core diagnostic categories remain refused. + +Processes that receive only Issue responses use Go `NewTokenVerifier` or omit +`proof` when constructing Python/TypeScript `Verifier`. They still authenticate +bare tokens against published Keys; stamped verification is refused without a +configured run proof. A process receiving hop calls continues to require it. diff --git a/libraries/hop/go/bare_test.go b/libraries/hop/go/bare_test.go new file mode 100644 index 00000000..2a4bdbed --- /dev/null +++ b/libraries/hop/go/bare_test.go @@ -0,0 +1,90 @@ +package hop + +import ( + "context" + "encoding/base64" + "errors" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "strings" + "testing" + "time" +) + +func TestVerifyTokenUsesPublishedTrustAndSafeReasons(t *testing.T) { + now := time.Now() + signer := newSigner("bare") + source := &keys{response: &authorityv1.KeysResponse{Issuer: signer.issuer, Keys: []*authorityv1.PublicKey{signer.published()}}} + v, err := NewTokenVerifier(t.Context(), source, "module") + if err != nil { + t.Fatal(err) + } + token := signer.issue(t, now, "module") + if _, err := v.Verify(t.Context(), forwarded(t, token, "proof", "")); err == nil { + t.Fatal("bare-only verifier accepted a stamp") + } + id, err := v.VerifyToken(t.Context(), token) + if err != nil || id.Claims == nil || id.Context != token || id.Advisory != "" { + t.Fatalf("bare token: %v", err) + } + for _, tc := range []struct { + name, token string + reason Reason + }{ + {"forged", strings.Split(token, ".")[0] + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64)), ReasonSignature}, + {"unsigned", strings.Split(token, ".")[0] + ".", ReasonSignature}, + {"expired", signer.issue(t, now.Add(-time.Hour), "module"), ReasonExpired}, + {"audience", signer.issue(t, now, "other"), ReasonAudience}, + {"malformed", "secret-token", ReasonMalformed}, + {"early", signer.issue(t, now.Add(time.Hour), "module"), ReasonNotYetValid}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := v.VerifyToken(t.Context(), tc.token) + var reason Reason + if !errors.As(err, &reason) || reason != tc.reason { + t.Fatalf("want %s, got %v", tc.reason, err) + } + if strings.Contains(err.Error(), tc.token) || strings.Contains(err.Error(), signer.id) { + t.Fatal("unsafe refusal") + } + }) + } +} + +func TestBareTokenWaitsForStaleRefreshAndRefusesFailure(t *testing.T) { + now := time.Now() + signer := newSigner("bare-refresh") + source := &keys{response: &authorityv1.KeysResponse{Issuer: signer.issuer, Keys: []*authorityv1.PublicKey{signer.published()}}} + v, err := NewVerifier(t.Context(), source, "module", "proof") + if err != nil { + t.Fatal(err) + } + token := signer.issue(t, now, "module") + // Age only the cache: the freshly issued token remains valid. + v.mu.Lock() + v.fetched = now.Add(-keyStale - time.Second) + v.tried = v.fetched + v.mu.Unlock() + hold := make(chan struct{}) + source.set(source.response, nil, hold) + result := make(chan error, 1) + go func() { _, err := v.VerifyToken(context.Background(), token); result <- err }() + select { + case err := <-result: + t.Fatalf("did not wait: %v", err) + case <-time.After(30 * time.Millisecond): + } + close(hold) + if err := <-result; err != nil { + t.Fatal(err) + } + v.mu.Lock() + v.fetched = now.Add(-keyStale - time.Second) + v.tried = v.fetched + v.mu.Unlock() + source.set(nil, errors.New("private-key-source-secret"), nil) + _, err = v.VerifyToken(t.Context(), token) + var reason Reason + if !errors.As(err, &reason) || reason != ReasonKeysUnavailable || strings.Contains(err.Error(), "secret") { + t.Fatalf("stale failure: %v", err) + } +} diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index d93cc6ce..1c175c1a 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,8 +3,8 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 944ab148..f78729a1 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,10 +28,10 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 1965a2a8..40804254 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -35,6 +35,7 @@ import ( "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" "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" @@ -228,7 +229,7 @@ const ( // 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) +var errKeysUnavailable = fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.KeysUnavailable) // Verifier is the one module-side entry point: it authenticates every call the // hop forwards before anything reads it. Build it once, at startup. @@ -257,13 +258,22 @@ type Verifier struct { // 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)") + if proof == "" { + return nil, refusal.Configuration + } + return newVerifier(ctx, keys, audience, proof) +} + +// NewTokenVerifier builds a verifier for Issue responses in a process that does +// not receive hop calls. It needs no hop proof. Verify always refuses stamps; +// VerifyToken uses the same key cache and validation as NewVerifier. +func NewTokenVerifier(ctx context.Context, keys KeySource, audience string) (*Verifier, error) { + return newVerifier(ctx, keys, audience, "") +} + +func newVerifier(ctx context.Context, keys KeySource, audience, proof string) (*Verifier, error) { + if keys == nil || audience == "" { + return nil, refusal.Configuration } v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now, timeout: keyTimeout} v.mu.Lock() @@ -313,19 +323,31 @@ func (v *Verifier) Verify(ctx context.Context, md metadata.MD) (Identity, error) if err != nil { return Identity{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, err) } - trust, err := v.trustFor(ctx, keyIDOf(token)) + id, err := v.VerifyToken(ctx, token) if err != nil { return Identity{}, err } - claims, err := workcontext.Authenticate(md, v.proof, v.audience, trust) + if err := sameAsStamped(md, id); err != nil { + return Identity{}, err + } + id.Advisory = first(md, AdvisoryKey) + return id, nil +} + +// VerifyToken authenticates a bare Work Context using the same published keys +// and fail-closed refresh policy as Verify. It does not establish hop admission, +// consume a single-use nonce, or check live revocation or method permissions. +func (v *Verifier) VerifyToken(ctx context.Context, token string) (Identity, error) { + trust, err := v.trustFor(ctx, keyIDOf(token)) if err != nil { return Identity{}, err } - id := IdentityOf(claims.Context()) - if err := sameAsStamped(md, id); err != nil { + claims, err := workcontext.AuthenticateToken(token, v.audience, trust) + if err != nil { return Identity{}, err } - id.Context, id.Claims, id.Advisory = token, claims, first(md, AdvisoryKey) + id := IdentityOf(claims.Context()) + id.Context, id.Claims = token, claims return id, nil } @@ -348,12 +370,15 @@ func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trus 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) + return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.Cancelled) } } v.mu.Lock() defer v.mu.Unlock() if v.fetched.IsZero() || v.now().Sub(v.fetched) > keyStale { + if errors.Is(v.failure, refusal.InvalidKey) { + return workcontext.Trust{}, fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.InvalidKey) + } return workcontext.Trust{}, errKeysUnavailable } return workcontext.Trust{Issuer: v.trust.Issuer, Keys: v.trust.Keys, Now: v.now}, nil @@ -394,12 +419,12 @@ func (v *Verifier) read(ctx context.Context) (workcontext.Trust, error) { 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") + return workcontext.Trust{}, refusal.InvalidKey } 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 workcontext.Trust{}, refusal.InvalidKey } return trust, nil } @@ -428,7 +453,7 @@ func sameAsStamped(md metadata.MD, id Identity) error { {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 fmt.Errorf("%w: %w", workcontext.ErrUnauthenticated, refusal.StampIdentity) } } return nil diff --git a/libraries/hop/go/hop_test.go b/libraries/hop/go/hop_test.go index bba7f65c..df4bdc64 100644 --- a/libraries/hop/go/hop_test.go +++ b/libraries/hop/go/hop_test.go @@ -66,12 +66,12 @@ func TestAModuleHoldingTheProofCannotForgeAContext(t *testing.T) { }{ "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"}, + "another audience": {hoptest.Forward(t, elsewhere, proof), "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"}, + "stamped tenant lie": {with(hoptest.Forward(t, genuine, proof), hop.TenantKey, "globex"), "stamp_identity"}, + "stamped scope lie": {with(hoptest.Forward(t, genuine, proof), hop.ScopesKey, "documents:read,documents:admin"), "stamp_identity"}, } for name, c := range cases { t.Run(name, func(t *testing.T) { diff --git a/libraries/hop/go/reasons.go b/libraries/hop/go/reasons.go new file mode 100644 index 00000000..2a44dedb --- /dev/null +++ b/libraries/hop/go/reasons.go @@ -0,0 +1,29 @@ +package hop + +import "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + +// Reason is a caller-safe refusal. Use errors.As to extract its Code and Text. +// Verifier errors retain no unsafe diagnostic cause. +type Reason = refusal.Reason + +const ( + ReasonConfiguration = refusal.Configuration + ReasonStampProof = refusal.StampProof + ReasonStampToken = refusal.StampToken + ReasonStampDigest = refusal.StampDigest + ReasonStampIdentity = refusal.StampIdentity + ReasonKeysUnavailable = refusal.KeysUnavailable + ReasonCancelled = refusal.Cancelled + ReasonMalformed = refusal.Malformed + ReasonLifetime = refusal.Lifetime + ReasonIssuer = refusal.Issuer + ReasonUnknownKey = refusal.UnknownKey + ReasonInvalidKey = refusal.InvalidKey + ReasonFixtureKey = refusal.FixtureKey + ReasonSignature = refusal.Signature + ReasonAudience = refusal.Audience + ReasonNotYetValid = refusal.NotYetValid + ReasonExpired = refusal.Expired + ReasonAttenuation = refusal.Attenuation + ReasonGrant = refusal.Grant +) diff --git a/libraries/hop/go/reasons_test.go b/libraries/hop/go/reasons_test.go new file mode 100644 index 00000000..6da86b81 --- /dev/null +++ b/libraries/hop/go/reasons_test.go @@ -0,0 +1,95 @@ +package hop + +import ( + "context" + "errors" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "google.golang.org/grpc/metadata" + "strings" + "testing" + "time" +) + +func TestStampFailureCodesAreDistinctAndSafe(t *testing.T) { + s := newSigner("reasons") + source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} + v, err := NewVerifier(t.Context(), source, "module", "proof") + if err != nil { + t.Fatal(err) + } + token := s.issue(t, v.now(), "module") + cases := []struct { + want Reason + change func(metadata.MD) + }{ + {ReasonStampProof, func(md metadata.MD) { md.Set(ProofKey, "private-proof") }}, + {ReasonStampToken, func(md metadata.MD) { md.Append(WorkContextKey, "private-token") }}, + {ReasonStampDigest, func(md metadata.MD) { md.Set(ContextKey, "private-digest") }}, + {ReasonStampIdentity, func(md metadata.MD) { md.Set(TenantKey, "private-tenant") }}, + } + seen := map[string]bool{} + for _, tc := range cases { + md := forwarded(t, token, "proof", "") + tc.change(md) + _, err := v.Verify(t.Context(), md) + var reason Reason + if !errors.As(err, &reason) || reason != tc.want { + t.Fatalf("want %s: %v", tc.want, err) + } + if seen[reason.Code()] || strings.Contains(err.Error(), "private-") { + t.Fatalf("unsafe or repeated code: %v", err) + } + seen[reason.Code()] = true + } +} + +func TestConfigurationAndCancelledKeyWaitHaveNamedReasons(t *testing.T) { + _, err := NewVerifier(t.Context(), nil, "module", "proof") + var reason Reason + if !errors.As(err, &reason) || reason != ReasonConfiguration { + t.Fatalf("configuration: %v", err) + } + s := newSigner("cancel-reason") + source := &keys{response: &authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{s.published()}}} + v, err := NewVerifier(t.Context(), source, "module", "proof") + if err != nil { + t.Fatal(err) + } + hold := make(chan struct{}) + source.set(source.response, nil, hold) + defer close(hold) + v.mu.Lock() + v.fetched = v.now().Add(-keyStale - time.Second) + v.tried = v.fetched + v.mu.Unlock() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = v.VerifyToken(ctx, s.issue(t, v.now(), "module")) + if !errors.As(err, &reason) || reason != ReasonCancelled { + t.Fatalf("cancelled: %v", err) + } +} + +func TestInvalidPublicationIsDistinctFromUnreadableKeys(t *testing.T) { + s := newSigner("publication") + token := s.issue(t, time.Now(), "module") + for _, tc := range []struct { + response *authorityv1.KeysResponse + failure error + want Reason + }{ + {&authorityv1.KeysResponse{Issuer: s.issuer}, nil, ReasonInvalidKey}, + {&authorityv1.KeysResponse{Issuer: s.issuer, Keys: []*authorityv1.PublicKey{{KeyId: "secret-key-id", Algorithm: "Ed25519", PublicKey: []byte("secret-key")}}}, nil, ReasonInvalidKey}, + {nil, errors.New("secret-key-source"), ReasonKeysUnavailable}, + } { + v, err := NewTokenVerifier(t.Context(), &keys{response: tc.response, err: tc.failure}, "module") + if err != nil { + t.Fatal(err) + } + _, err = v.VerifyToken(t.Context(), token) + var reason Reason + if !errors.As(err, &reason) || reason != tc.want || strings.Contains(err.Error(), "secret-") { + t.Fatalf("publication classification: %v", err) + } + } +} diff --git a/libraries/hop/go/routing/README.md b/libraries/hop/go/routing/README.md index ab52d3c1..4a60fde3 100644 --- a/libraries/hop/go/routing/README.md +++ b/libraries/hop/go/routing/README.md @@ -10,3 +10,11 @@ proxy also checks the original escaped path before deciding: dot segments, repeated separators, escaped separators/dots and nested escapes are refused, never normalized and forwarded under another operation's policy. HTTP upstreams must be origins; they cannot add a path prefix after the authority check. + + +A declared Connect endpoint serves both Connect HTTP requests and gRPC calls at +its one resolved address. Both use the declared protobuf method's policy. A gRPC +and Connect publication for the same audience/method is ambiguous and refused; +remove the separate gRPC endpoint when adopting one Connect listener. Unknown +methods, undeclared audiences, unsafe destinations and transport refusals remain +closed. The hop dials using the shared host transport policy. diff --git a/libraries/hop/go/routing/routes.go b/libraries/hop/go/routing/routes.go index 52c319eb..7a5e4d6c 100644 --- a/libraries/hop/go/routing/routes.go +++ b/libraries/hop/go/routing/routes.go @@ -98,6 +98,13 @@ func (r *Routing) Validate() error { return fmt.Errorf("hop routes: ambiguous destination for %s %s", e.Audience, method) } seen[key] = true + if e.Transport == "connect" { + grpcKey := e.Audience + "\x00grpc\x00" + method + if seen[grpcKey] { + return fmt.Errorf("hop routes: ambiguous destination for %s %s", e.Audience, method) + } + seen[grpcKey] = true + } } } return nil @@ -134,12 +141,12 @@ func (r *Routing) Resolve(audience, transport, method string) (RouteEndpoint, st } var matched []match for _, endpoint := range r.Endpoints { - if endpoint.Audience != audience || (endpoint.Transport != transport && !(transport == "http" && endpoint.Transport == "connect")) { + if endpoint.Audience != audience || (endpoint.Transport != transport && !((transport == "http" || transport == "grpc") && endpoint.Transport == "connect")) { continue } for _, declared := range endpoint.Methods { policy := method - if endpoint.Transport == "connect" { + if endpoint.Transport == "connect" && transport == "http" { policy = declared declared = "POST " + declared } diff --git a/libraries/hop/go/routing/routes_test.go b/libraries/hop/go/routing/routes_test.go index 790f9007..26943b68 100644 --- a/libraries/hop/go/routing/routes_test.go +++ b/libraries/hop/go/routing/routes_test.go @@ -14,7 +14,6 @@ func endpoint(service, transport string, methods ...string) RouteEndpoint { func TestPublishedListenersStayDistinct(t *testing.T) { routes := Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{ - endpoint("first", "grpc", "/first.Service/Call"), endpoint("second", "grpc", "/second.Service/Call"), endpoint("first", "connect", "/first.Service/Call"), endpoint("web", "http", "GET /first.Service/Call", "POST /ordinary.Service/Call", "POST /items/{id}:publish", "GET /files/{name=**}:read"), @@ -53,6 +52,7 @@ func TestPublishedListenersStayDistinct(t *testing.T) { func TestAmbiguousPublicationAndTemplatesRefuse(t *testing.T) { for _, second := range []RouteEndpoint{ endpoint("second", "grpc", "/first.Service/Call"), + endpoint("second", "connect", "/first.Service/Call"), endpoint("first", "grpc", "/other.Service/Call"), } { routes := Routing{Schema: RoutingSchema, Endpoints: []RouteEndpoint{endpoint("first", "grpc", "/first.Service/Call"), second}} @@ -115,3 +115,13 @@ func TestNonCanonicalPathsNeverMatchEvenAnExactInventory(t *testing.T) { } } } + +func TestConnectGRPCOverlapRefusesInEitherOrder(t *testing.T) { + a, b := endpoint("a", "grpc", "/a.Service/Call"), endpoint("b", "connect", "/a.Service/Call") + for _, endpoints := range [][]RouteEndpoint{{a, b}, {b, a}} { + r := Routing{Schema: RoutingSchema, Endpoints: endpoints} + if err := r.Validate(); err == nil { + t.Fatal("ambiguous protocol publication accepted") + } + } +} diff --git a/libraries/hop/go/vectors_test.go b/libraries/hop/go/vectors_test.go new file mode 100644 index 00000000..74c93306 --- /dev/null +++ b/libraries/hop/go/vectors_test.go @@ -0,0 +1,63 @@ +package hop + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/authority/libraries/authority-client/go/refusal" + "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" +) + +// Run the same wire bytes through the public bare-token API as Python and TS. +func TestBareTokenSharedVectors(t *testing.T) { + data, err := os.ReadFile("../../authority-client/testdata/vectors.json") + if err != nil { + t.Fatal(err) + } + var rows []struct { + Name, Token, Audience, Issuer, Reason string + Now int64 + Keys map[string]string + Accept bool + } + if err := json.Unmarshal(data, &rows); err != nil { + t.Fatal(err) + } + for _, row := range rows { + t.Run(row.Name, func(t *testing.T) { + response := &authorityv1.KeysResponse{Issuer: row.Issuer} + for id, encoded := range row.Keys { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + response.Keys = append(response.Keys, &authorityv1.PublicKey{KeyId: id, Algorithm: "Ed25519", PublicKey: raw}) + } + v, err := NewTokenVerifier(context.Background(), &keys{response: response}, row.Audience) + if err != nil { + t.Fatal(err) + } + v.now = func() time.Time { return time.Unix(row.Now, 0) } + id, err := v.VerifyToken(context.Background(), row.Token) + if row.Accept { + if err != nil { + t.Fatalf("valid token refused: %v", err) + } + if id.Claims.Audience() != row.Audience { + t.Fatal("wrong audience") + } + } else { + var reason refusal.Reason + if !errors.Is(err, workcontext.ErrUnauthenticated) || !errors.As(err, &reason) || reason.Code() != row.Reason { + t.Fatalf("want %s, got %v", row.Reason, err) + } + } + }) + } +} diff --git a/modules/authority/services/edge/code/go.mod b/modules/authority/services/edge/code/go.mod index 97c43163..fc89884b 100644 --- a/modules/authority/services/edge/code/go.mod +++ b/modules/authority/services/edge/code/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024163421-423f79d08aa1 - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 google.golang.org/grpc v1.84.0 ) diff --git a/modules/authority/services/edge/code/go.sum b/modules/authority/services/edge/code/go.sum index 4b511f53..399a959a 100644 --- a/modules/authority/services/edge/code/go.sum +++ b/modules/authority/services/edge/code/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/authority/services/hop/code/go.mod b/modules/authority/services/hop/code/go.mod index 86c8cf29..3bb95833 100644 --- a/modules/authority/services/hop/code/go.mod +++ b/modules/authority/services/hop/code/go.mod @@ -8,7 +8,7 @@ require ( 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.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/rs/cors v1.11.1 diff --git a/modules/authority/services/hop/code/go.sum b/modules/authority/services/hop/code/go.sum index 1c032116..d6554232 100644 --- a/modules/authority/services/hop/code/go.sum +++ b/modules/authority/services/hop/code/go.sum @@ -36,8 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go b/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go new file mode 100644 index 00000000..7b548033 --- /dev/null +++ b/modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go @@ -0,0 +1,85 @@ +package proxy_test + +import ( + "connectrpc.com/connect" + "context" + "github.com/codefly-dev/authority/libraries/authority-client/go/host" + "github.com/codefly-dev/authority/libraries/hop/go" + "github.com/codefly-dev/authority/libraries/hop/go/routing" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "hop/pkg/business/decide" + "hop/pkg/gen" + "hop/pkg/gen/genconnect" + "hop/pkg/infra/proxy" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" +) + +type connectModule struct{ calls atomic.Int32 } + +func (m *connectModule) Version(_ context.Context, req *connect.Request[gen.VersionRequest]) (*connect.Response[gen.VersionResponse], error) { + if req.Header().Get(hop.ProofKey) != proof { + return nil, connect.NewError(connect.CodeUnauthenticated, hop.ReasonStampProof) + } + m.calls.Add(1) + return connect.NewResponse(&gen.VersionResponse{Version: "one-listener"}), nil +} + +func TestGRPCAndConnectReachOnePublishedConnectListener(t *testing.T) { + t.Setenv("CODEFLY__ENVIRONMENT", "local") + m := &connectModule{} + _, handler := genconnect.NewHopServiceHandler(m) + backend := httptest.NewServer(h2c.NewHandler(handler, &http2.Server{})) + defer backend.Close() + target, options, err := host.Endpoint(t.Context(), backend.URL) + if err != nil { + t.Fatal(err) + } + upstream, err := grpc.NewClient(target, append(host.DialOptions(options), grpc.WithDefaultCallOptions(proxy.CallOptions()...))...) + if err != nil { + t.Fatal(err) + } + defer upstream.Close() + routes := &routing.Routing{Schema: routing.RoutingSchema, Endpoints: []routing.RouteEndpoint{routeEndpoint("module", "connect", backend.URL, genconnect.HopServiceVersionProcedure)}} + if err := routes.Validate(); err != nil { + t.Fatal(err) + } + checker := &routeChecker{} + decider := decide.Decider{Routes: routes, Authority: checker, Modules: map[string]bool{"module": true}, AudienceOf: func(string) (string, error) { return "module", nil }} + p := &proxy.Proxy{Decide: decider.Decide, Proof: proof, Upstreams: map[string]*grpc.ClientConn{backend.URL: upstream}} + conn := serve(t, p.ServerOptions(), func(*grpc.Server) {}) + grpcClient := gen.NewHopServiceClient(conn) + ctx := metadata.AppendToOutgoingContext(t.Context(), hop.WorkContextKey, "context") + got, err := grpcClient.Version(ctx, &gen.VersionRequest{}) + if err != nil || got.GetVersion() != "one-listener" { + t.Fatalf("grpc: %v %v", got, err) + } + u, _ := url.Parse(backend.URL) + face := httptest.NewServer(&proxy.HTTP{Decide: decider.Decide, Proof: proof, Upstreams: map[string]*url.URL{backend.URL: u}}) + defer face.Close() + client := genconnect.NewHopServiceClient(&http.Client{Transport: host.HTTPTransport()}, face.URL) + req := connect.NewRequest(&gen.VersionRequest{}) + req.Header().Set(hop.WorkContextKey, "context") + answer, err := client.Version(t.Context(), req) + if err != nil || answer.Msg.Version != "one-listener" { + t.Fatalf("connect: %v %v", answer, err) + } + checker.denied = genconnect.HopServiceVersionProcedure + if _, err := grpcClient.Version(ctx, &gen.VersionRequest{}); status.Code(err) != codes.PermissionDenied { + t.Fatalf("grpc refusal: %v", err) + } + if _, err := client.Version(t.Context(), req); connect.CodeOf(err) != connect.CodePermissionDenied { + t.Fatalf("connect refusal: %v", err) + } + if m.calls.Load() != 2 { + t.Fatalf("refusal reached backend: %d", m.calls.Load()) + } +} diff --git a/modules/authority/services/hop/code/work.go b/modules/authority/services/hop/code/work.go index a14b47d0..8dec4dc8 100644 --- a/modules/authority/services/hop/code/work.go +++ b/modules/authority/services/hop/code/work.go @@ -80,7 +80,14 @@ func standInFront(ctx context.Context, config *adapters.Configuration) (Clean, e } covered[route.Audience] = true switch route.Transport { - case "grpc": + case "grpc", "connect": + if route.Transport == "connect" { + target, urlErr := route.ResolvedURL() + if urlErr != nil { + return nil, urlErr + } + webs[route.Address] = target + } if strings.Contains(route.Address, "${") { return nil, fmt.Errorf("hop routes: unresolved endpoint for %s/%s", route.Audience, route.Service) } diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index acb0109f..95c7bffd 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -12,7 +12,7 @@ require ( github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 github.com/codefly-dev/authority/libraries/resources/go v0.0.0 - github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 @@ -31,7 +31,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index c3b89701..205ba4f8 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,10 +36,10 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 h1:QK5HW356hmRNwZJ1LmoDcKGxB0Jslx+EdBfXMHuJlvo= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/console/services/api/code/go.mod b/modules/console/services/api/code/go.mod index 33de746a..2a373136 100644 --- a/modules/console/services/api/code/go.mod +++ b/modules/console/services/api/code/go.mod @@ -8,7 +8,7 @@ require ( 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.20261026034911-b57347f09515 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/rs/cors v1.11.1 diff --git a/modules/console/services/api/code/go.sum b/modules/console/services/api/code/go.sum index 1c032116..d6554232 100644 --- a/modules/console/services/api/code/go.sum +++ b/modules/console/services/api/code/go.sum @@ -36,8 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/tools/noproxygate/go.mod b/tools/noproxygate/go.mod index d42b9f36..e591a385 100644 --- a/tools/noproxygate/go.mod +++ b/tools/noproxygate/go.mod @@ -16,7 +16,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/core v0.19.7-0.20261026034911-b57347f09515 // indirect + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 // indirect github.com/codefly-dev/sdk-go v0.4.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect diff --git a/tools/noproxygate/go.sum b/tools/noproxygate/go.sum index 6a6fb917..d7d51a65 100644 --- a/tools/noproxygate/go.sum +++ b/tools/noproxygate/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515 h1:u6dYtxKTnEYeOwlgRx3sfJbxDl6UWI4uhpvUqOR4Ho0= -github.com/codefly-dev/core v0.19.7-0.20261026034911-b57347f09515/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY=