From 3acb2c3acc46cabd45e398f920c1667e88530ec6 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:22:58 -0400 Subject: [PATCH 1/5] Route gRPC to Connect listeners and share safe bare-token verification Co-Authored-By: Claude Opus 5.5 --- .../authority-client/go/refusal/reason.go | 75 ++++++++++ .../authority-client/go/stamp/context.go | 11 +- .../authority-client/go/workcontext/claims.go | 50 ++++++- .../go/workcontext/claims_test.go | 6 +- .../go/workcontext/vectors_test.go | 30 ++-- libraries/authority-client/python/README.md | 16 +++ .../python/authority_client/work_context.py | 123 +++++++++++----- .../python/codefly_hop/__init__.py | 41 ++++-- .../authority-client/python/tests/test_hop.py | 58 +++++++- .../python/tests/test_work_context.py | 12 +- .../authority-client/testdata/vectors.json | 38 +++-- .../authority-client/typescript/README.md | 16 +++ .../typescript/tests/work-context.test.mjs | 51 +++++-- .../typescript/work-context.ts | 135 +++++++++++++----- libraries/conformance/go/forwarded_context.go | 2 +- libraries/hop/go/README.md | 16 +++ libraries/hop/go/bare_test.go | 87 +++++++++++ libraries/hop/go/hop.go | 35 +++-- libraries/hop/go/hop_test.go | 6 +- libraries/hop/go/reasons.go | 29 ++++ libraries/hop/go/reasons_test.go | 71 +++++++++ libraries/hop/go/routing/README.md | 8 ++ libraries/hop/go/routing/routes.go | 11 +- libraries/hop/go/routing/routes_test.go | 12 +- .../pkg/infra/proxy/connect_listener_test.go | 85 +++++++++++ modules/authority/services/hop/code/work.go | 9 +- 26 files changed, 874 insertions(+), 159 deletions(-) create mode 100644 libraries/authority-client/go/refusal/reason.go create mode 100644 libraries/hop/go/bare_test.go create mode 100644 libraries/hop/go/reasons.go create mode 100644 libraries/hop/go/reasons_test.go create mode 100644 modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go 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..2771494c 100644 --- a/libraries/authority-client/go/workcontext/vectors_test.go +++ b/libraries/authority-client/go/workcontext/vectors_test.go @@ -63,17 +63,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 +82,19 @@ 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+"." + return append(out, forged, unsigned, emptySignature, elsewhere, expired, early, issuer, unknown) } // Every verdict in ../testdata/vectors.json is Go's, reached by the one check a diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index fa524119..4cd7c3c9 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -109,3 +109,19 @@ 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. 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..5130ec2e 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-" @@ -110,7 +110,7 @@ class Verifier: def __init__(self, keys, *, audience: str, proof: str, now: Callable[[], float] | None = None, timeout: float = KEY_TIMEOUT) -> None: if keys is None or not audience or not proof: - raise Unauthenticated("a verifier needs the authority's Keys, this module's audience and the run's hop proof") + raise Unauthenticated(Reason.CONFIGURATION) self._source, self._audience, self._proof, self._timeout = keys, audience, proof, timeout self._now = now or time.time self._lock = threading.Lock() @@ -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,18 @@ 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") + 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 +218,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 +252,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 0184c04d..81a404de 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"]) @@ -336,3 +336,51 @@ 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) 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/vectors.json b/libraries/authority-client/testdata/vectors.json index 32656c1c..8e4debf5 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,6 @@ "rotated": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=" }, "accept": false, - "reason": "no verification key" + "reason": "unknown_key" } ] diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index cd543a2a..c9704a5f 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -41,3 +41,19 @@ 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. diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 3ede50e3..d56d2561 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,28 @@ 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-')); +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 25fc7b91..5e436b5b 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); } @@ -294,7 +349,7 @@ export class Verifier { 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(); } @@ -315,8 +370,7 @@ export class Verifier { 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 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,7 @@ 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"); + throw new Unauthenticated(Reason.KeysUnavailable); } return { ...this.trust, now: this.now }; } @@ -363,6 +423,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/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/hop/go/README.md b/libraries/hop/go/README.md index 5dc4aa9f..0bd97c8e 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -91,3 +91,19 @@ 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. diff --git a/libraries/hop/go/bare_test.go b/libraries/hop/go/bare_test.go new file mode 100644 index 00000000..1ff4dfe8 --- /dev/null +++ b/libraries/hop/go/bare_test.go @@ -0,0 +1,87 @@ +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 := NewVerifier(t.Context(), source, "module", "proof") + if err != nil { + t.Fatal(err) + } + token := signer.issue(t, now, "module") + 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/hop.go b/libraries/hop/go/hop.go index 1965a2a8..137cc9e7 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. @@ -259,11 +260,11 @@ type Verifier struct { 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") + return nil, refusal.Configuration case audience == "": - return nil, errors.New("hop: a verifier needs this module's audience") + return nil, refusal.Configuration case proof == "": - return nil, errors.New("hop: a verifier needs the run's hop proof (codefly config generate hop PROOF)") + return nil, refusal.Configuration } v := &Verifier{source: keys, audience: audience, proof: proof, now: time.Now, timeout: keyTimeout} v.mu.Lock() @@ -313,19 +314,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,7 +361,7 @@ 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() @@ -428,7 +441,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..0f1ffea6 --- /dev/null +++ b/libraries/hop/go/reasons_test.go @@ -0,0 +1,71 @@ +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) + } +} 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/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..a2a90644 --- /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(proxy.DialOptions(), host.DialOptions(options)...)...) + 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 2d50ed07..38f5679f 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) } From 54d009157cdeb561cb974e8b77c2309e74b60964 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:26:54 -0400 Subject: [PATCH 2/5] Pin the published caller-safe authority client Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 2 ++ libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 2 ++ modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 2 ++ 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index ed296dd7..3959effd 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/conformance/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 google.golang.org/grpc v1.84.0 diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index d2fa6c9c..d48af268 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -30,6 +30,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ 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.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index 7ff6aec2..b762d5cf 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 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 e9f8f31a..1fcac961 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -30,6 +30,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-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.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index abc042d4..32b3aaa8 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -30,7 +30,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-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 47b4a8b3..32b83537 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,6 +38,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-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.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 2173c1da9ab1c607f053316d2ce80d30a44eef50 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:32:57 -0400 Subject: [PATCH 3/5] Compose bare-token verifiers without a hop proof Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/python/README.md | 5 +++++ .../python/codefly_hop/__init__.py | 6 +++--- .../authority-client/python/tests/test_hop.py | 7 +++++++ .../authority-client/typescript/README.md | 5 +++++ .../typescript/tests/work-context.test.mjs | 6 ++++++ .../typescript/work-context.ts | 6 +++--- libraries/hop/go/README.md | 5 +++++ libraries/hop/go/bare_test.go | 5 ++++- libraries/hop/go/hop.go | 19 ++++++++++++++----- .../pkg/infra/proxy/connect_listener_test.go | 2 +- 10 files changed, 53 insertions(+), 13 deletions(-) diff --git a/libraries/authority-client/python/README.md b/libraries/authority-client/python/README.md index 4cd7c3c9..93fc9ac9 100644 --- a/libraries/authority-client/python/README.md +++ b/libraries/authority-client/python/README.md @@ -125,3 +125,8 @@ 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/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 5130ec2e..505cca1a 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -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: + if keys is None or not audience or proof == "": raise Unauthenticated(Reason.CONFIGURATION) - self._source, self._audience, self._proof, self._timeout = keys, audience, proof, timeout + 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 diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 81a404de..9bad0e6b 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -384,3 +384,10 @@ def test_stamp_failure_codes_are_safe_and_distinct(change, code): 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"]) diff --git a/libraries/authority-client/typescript/README.md b/libraries/authority-client/typescript/README.md index c9704a5f..68f41a72 100644 --- a/libraries/authority-client/typescript/README.md +++ b/libraries/authority-client/typescript/README.md @@ -57,3 +57,8 @@ 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 d56d2561..6b47c70a 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -287,3 +287,9 @@ test('stamp failures retain distinct safe codes', async () => { [{'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); +}); diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 5e436b5b..44ea84d2 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -347,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(Reason.Configuration); + if (!source || !audience || proof === "") throw new Unauthenticated(Reason.Configuration); void this.refresh(); } @@ -369,7 +369,7 @@ 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 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, diff --git a/libraries/hop/go/README.md b/libraries/hop/go/README.md index 0bd97c8e..42d243c8 100644 --- a/libraries/hop/go/README.md +++ b/libraries/hop/go/README.md @@ -107,3 +107,8 @@ 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 index 1ff4dfe8..2a4bdbed 100644 --- a/libraries/hop/go/bare_test.go +++ b/libraries/hop/go/bare_test.go @@ -14,11 +14,14 @@ 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 := NewVerifier(t.Context(), source, "module", "proof") + 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) diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index 137cc9e7..b6a0f614 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -258,12 +258,21 @@ 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: + if proof == "" { return nil, refusal.Configuration - case audience == "": - return nil, refusal.Configuration - case proof == "": + } + 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} 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 index 9c4c70e5..7b548033 100644 --- 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 @@ -43,7 +43,7 @@ func TestGRPCAndConnectReachOnePublishedConnectListener(t *testing.T) { if err != nil { t.Fatal(err) } - upstream, err := grpc.NewClient(target, host.DialOptions(options)...) + upstream, err := grpc.NewClient(target, append(host.DialOptions(options), grpc.WithDefaultCallOptions(proxy.CallOptions()...))...) if err != nil { t.Fatal(err) } From 3e34690346e890e72940081d67d5ae551cc01b48 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:43:38 -0400 Subject: [PATCH 4/5] Distinguish unusable key publications from read outages Co-Authored-By: Claude Opus 5.5 --- .../python/codefly_hop/__init__.py | 2 ++ .../authority-client/python/tests/test_hop.py | 10 ++++++++ .../typescript/tests/work-context.test.mjs | 10 ++++++++ .../typescript/work-context.ts | 1 + libraries/hop/go/hop.go | 7 ++++-- libraries/hop/go/reasons_test.go | 24 +++++++++++++++++++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 505cca1a..1e3d16e1 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -200,6 +200,8 @@ def _start(self, key_id: str) -> futures.Future[None] | None: def _token_identity(self, token: str) -> Identity: with self._lock: if self._trust is None or self._now() - self._fetched > KEY_STALE: + if isinstance(self.keys_error, Unauthenticated) and self.keys_error.reason == Reason.INVALIDKEY: + raise Unauthenticated(Reason.INVALIDKEY) raise Unauthenticated(Reason.KEYSUNAVAILABLE) trust = self._trust claims = authenticate_token(token, self._audience, trust) diff --git a/libraries/authority-client/python/tests/test_hop.py b/libraries/authority-client/python/tests/test_hop.py index 9bad0e6b..06cddb2a 100644 --- a/libraries/authority-client/python/tests/test_hop.py +++ b/libraries/authority-client/python/tests/test_hop.py @@ -391,3 +391,13 @@ def test_bare_only_process_needs_no_proof_and_cannot_accept_stamps(): 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/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index 6b47c70a..3688ce61 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -293,3 +293,13 @@ test('bare-only process needs no proof and cannot accept stamps', async () => { 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 44ea84d2..45addf34 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -401,6 +401,7 @@ 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) { + 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 }; diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index b6a0f614..40804254 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -376,6 +376,9 @@ func (v *Verifier) trustFor(ctx context.Context, keyID string) (workcontext.Trus 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 @@ -416,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 } diff --git a/libraries/hop/go/reasons_test.go b/libraries/hop/go/reasons_test.go index 0f1ffea6..6da86b81 100644 --- a/libraries/hop/go/reasons_test.go +++ b/libraries/hop/go/reasons_test.go @@ -69,3 +69,27 @@ func TestConfigurationAndCancelledKeyWaitHaveNamedReasons(t *testing.T) { 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) + } + } +} From f0ad8b431e4c360a11d294e93af92b392f7e559c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:23:58 -0400 Subject: [PATCH 5/5] fix: pin canonical core decoding and share encoding conformance cases Run 17 encoding variants through Go stamped and bare authentication and the public hop verifier, sharing the existing Python and TypeScript fixture. Pin core PR #850 and synchronize its resource-owner vectors. Retain the saved merge of main after PR #25. Co-Authored-By: Claude Opus 5.5 --- libraries/authority-client/go/go.mod | 2 +- libraries/authority-client/go/go.sum | 4 +- .../go/workcontext/vectors_test.go | 95 +++++++- .../testdata/resource_owner_vectors.json | 4 + .../authority-client/testdata/vectors.json | 204 ++++++++++++++++++ libraries/authority-core/go/go.mod | 2 +- libraries/authority-core/go/go.sum | 4 +- libraries/conformance/go/go.mod | 2 +- libraries/conformance/go/go.sum | 6 +- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 8 +- libraries/hop/go/vectors_test.go | 63 ++++++ modules/authority/services/edge/code/go.mod | 2 +- modules/authority/services/edge/code/go.sum | 4 +- modules/authority/services/hop/code/go.mod | 2 +- modules/authority/services/hop/code/go.sum | 4 +- modules/authority/services/local/code/go.mod | 5 +- modules/authority/services/local/code/go.sum | 8 +- modules/console/services/api/code/go.mod | 2 +- modules/console/services/api/code/go.sum | 4 +- tools/noproxygate/go.mod | 2 +- tools/noproxygate/go.sum | 4 +- 22 files changed, 387 insertions(+), 46 deletions(-) create mode 100644 libraries/hop/go/vectors_test.go 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/workcontext/vectors_test.go b/libraries/authority-client/go/workcontext/vectors_test.go index 2771494c..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") @@ -94,7 +97,71 @@ func vectors(t *testing.T) []vector { unknown.Keys = map[string]string{"rotated": unknown.Keys[body(t, "session").GetKeyId()]} emptySignature := unsigned emptySignature.Name, emptySignature.Token = "empty signature", payload+"." - return append(out, forged, unsigned, emptySignature, elsewhere, expired, early, issuer, unknown) + 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 @@ -123,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/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 8e4debf5..2e1a3533 100644 --- a/libraries/authority-client/testdata/vectors.json +++ b/libraries/authority-client/testdata/vectors.json @@ -303,5 +303,209 @@ }, "accept": false, "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-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/go.mod b/libraries/conformance/go/go.mod index b279b09e..64081225 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -5,7 +5,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 github.com/codefly-dev/authority/libraries/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 1f07e274..2c0a19dd 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -28,14 +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/go.mod b/libraries/hop/go/go.mod index 7334f688..1c175c1a 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 - 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/hop/go/go.sum b/libraries/hop/go/go.sum index 9bd5b817..f78729a1 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,14 +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/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.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= -github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= -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/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/local/code/go.mod b/modules/authority/services/local/code/go.mod index 1c3b37b7..95c7bffd 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -7,13 +7,12 @@ require ( buf.build/go/protovalidate v1.4.0 connectrpc.com/connect v1.21.0 connectrpc.com/vanguard v0.4.0 - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026082258-3acb2c3acc46 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0 github.com/codefly-dev/authority/libraries/conformance/go v0.0.0 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 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 @@ -32,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 2240c8d8..205ba4f8 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,14 +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/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.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= -github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= -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/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=