Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libraries/authority-client/go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions libraries/authority-client/go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
75 changes: 75 additions & 0 deletions libraries/authority-client/go/refusal/reason.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 7 additions & 4 deletions libraries/authority-client/go/stamp/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
50 changes: 44 additions & 6 deletions libraries/authority-client/go/workcontext/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions libraries/authority-client/go/workcontext/claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 103 additions & 20 deletions libraries/authority-client/go/workcontext/vectors_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workcontext_test

import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
Expand All @@ -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")
Expand Down Expand Up @@ -63,17 +66,17 @@ func vectors(t *testing.T) []vector {
c.AuthorityScopes = []*basev0.WorkScopeV1{{ResourceKind: "record", Actions: []string{"read"}, ResourceIds: []string{"a"}}, {ResourceKind: "record", Actions: []string{"write"}, ResourceIds: []string{"b"}}}
}, nil, ""),
build("an actor granted nothing", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].GrantedScopes = nil }, nil, ""),
build("an actor widens the owner", "delegated", widen, nil, "widens authority"),
build("no seal", "session", func(c *basev0.WorkContextV1) { c.Seal = nil }, nil, "seal"),
build("seal epoch zero", "session", func(c *basev0.WorkContextV1) { c.Seal.PrincipalEpoch = 0 }, nil, "principal_epoch"),
build("empty tenant", "session", func(c *basev0.WorkContextV1) { c.TenantId = "" }, nil, "tenant_id"),
build("wrong type", "session", func(c *basev0.WorkContextV1) { c.Typ = "codefly.work-context/v0" }, nil, "typ"),
build("unknown replay policy", "session", func(c *basev0.WorkContextV1) { c.ReplayPolicy = "forever" }, nil, "replay_policy"),
build("actor without an epoch", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].PrincipalEpoch = 0 }, nil, "principal_epoch"),
build("an actor widens the owner", "delegated", widen, nil, "attenuation"),
build("no seal", "session", func(c *basev0.WorkContextV1) { c.Seal = nil }, nil, "malformed"),
build("seal epoch zero", "session", func(c *basev0.WorkContextV1) { c.Seal.PrincipalEpoch = 0 }, nil, "malformed"),
build("empty tenant", "session", func(c *basev0.WorkContextV1) { c.TenantId = "" }, nil, "malformed"),
build("wrong type", "session", func(c *basev0.WorkContextV1) { c.Typ = "codefly.work-context/v0" }, nil, "malformed"),
build("unknown replay policy", "session", func(c *basev0.WorkContextV1) { c.ReplayPolicy = "forever" }, nil, "malformed"),
build("actor without an epoch", "delegated", func(c *basev0.WorkContextV1) { c.ActorChain[0].PrincipalEpoch = 0 }, nil, "malformed"),
build("lifetime beyond a day", "session", func(c *basev0.WorkContextV1) { c.ExpiresAtUnix = c.NotBeforeUnix + 25*3600 }, nil, "lifetime"),
build("grant without single use", "grant", func(c *basev0.WorkContextV1) { c.ReplayPolicy = core.ReplayIdempotent }, nil, "replayable"),
build("grant without single use", "grant", func(c *basev0.WorkContextV1) { c.ReplayPolicy = core.ReplayIdempotent }, nil, "grant"),
build("grant to another hop", "grant", func(c *basev0.WorkContextV1) { c.GrantHop.GrantId = "another-grant" }, nil, "grant"),
build("signed with Core's public conformance key", "session", nil, fixturePrivate, "conformance fixture key"),
build("signed with Core's public conformance key", "session", nil, fixturePrivate, "fixture_key"),
}
forged := build("forged signature", "session", nil, nil, "signature")
other := ed25519.NewKeyFromSeed([]byte("a forger, not the authority, 32b"))
Expand All @@ -82,17 +85,83 @@ func vectors(t *testing.T) []vector {
forged.Token = payload + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(other, raw))
unsigned := build("unsigned", "session", nil, nil, "signature")
unsigned.Token = payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64))
elsewhere := build("another audience", "session", nil, nil, "minted for audience")
elsewhere := build("another audience", "session", nil, nil, "audience")
elsewhere.Audience = "another-module"
expired := build("expired", "session", nil, nil, "expired")
expired.Now = body(t, "session").GetExpiresAtUnix() + 3600
early := build("not yet valid", "session", nil, nil, "not valid before")
early := build("not yet valid", "session", nil, nil, "not_yet_valid")
early.Now = body(t, "session").GetNotBeforeUnix() - 3600
issuer := build("another issuer", "session", nil, nil, "issued by")
issuer := build("another issuer", "session", nil, nil, "issuer")
issuer.Issuer = "https://forger.example"
unknown := build("a key the authority does not publish", "session", nil, nil, "no verification key")
unknown := build("a key the authority does not publish", "session", nil, nil, "unknown_key")
unknown.Keys = map[string]string{"rotated": unknown.Keys[body(t, "session").GetKeyId()]}
return append(out, forged, unsigned, elsewhere, expired, early, issuer, unknown)
emptySignature := unsigned
emptySignature.Name, emptySignature.Token = "empty signature", payload+"."
out = append(out, forged, unsigned, emptySignature, elsewhere, expired, early, issuer, unknown)
return append(out, encodingVectors(t, out[0])...)
}

// Encoding variants share a valid signed control. Text variants preserve the
// decoded bytes; protobuf variants are signed over the altered payload so a
// signature failure cannot disguise a missing canonical-encoding check.
func encodingVectors(t *testing.T, control vector) []vector {
t.Helper()
var out []vector
add := func(name, token string) {
v := control
v.Name, v.Token, v.Accept, v.Reason = name, token, false, "malformed"
out = append(out, v)
}
payload, signature, _ := strings.Cut(control.Token, ".")
for _, segment := range []struct{ name, text string }{{"payload", payload}, {"signature", signature}} {
join := func(text string) string {
if segment.name == "payload" {
return text + "." + signature
}
return payload + "." + text
}
for _, variant := range []struct{ name, text string }{
{"padding", segment.text + "="},
{"double padding", segment.text + "=="},
{"CR", segment.text[:1] + "\r" + segment.text[1:]},
{"LF", segment.text[:1] + "\n" + segment.text[1:]},
{"CRLF", segment.text[:1] + "\r\n" + segment.text[1:]},
} {
add(segment.name+" "+variant.name, join(variant.text))
}
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
if len(segment.text)%4 == 0 {
t.Fatalf("%s fixture needs unused base64 bits", segment.name)
}
last := len(segment.text) - 1
changed := segment.text[:last] + string(alphabet[strings.IndexByte(alphabet, segment.text[last])^1])
add(segment.name+" unused bit", join(changed))
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
t.Fatal(err)
}
signed := func(name string, altered []byte) {
add(name, base64.RawURLEncoding.EncodeToString(altered)+"."+base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer, altered)))
}
// The canonical first field is the type string (tag 1, length-delimited).
end := 2 + int(raw[1])
signed("protobuf duplicate field", append(append([]byte{}, raw...), raw[:end]...))
signed("protobuf reordered field", append(append([]byte{}, raw[end:]...), raw[:end]...))
signed("protobuf overlong varint", append([]byte{raw[0], raw[1] | 0x80, 0}, raw[2:]...))
signed("protobuf unknown field", append(append([]byte{}, raw...), 0xf8, 0x07, 0x01))
jsonPayload, err := protojson.Marshal(body(t, "session"))
if err != nil {
t.Fatal(err)
}
// protojson may vary insignificant whitespace; keep the fixture reproducible.
var compact bytes.Buffer
if err := json.Compact(&compact, jsonPayload); err != nil {
t.Fatal(err)
}
// Work Context accepts protobuf only, including when JSON contains the same claims.
signed("JSON claims", compact.Bytes())
return out
}

// Every verdict in ../testdata/vectors.json is Go's, reached by the one check a
Expand Down Expand Up @@ -121,12 +190,26 @@ func TestTheSharedVectorsAreGosVerdicts(t *testing.T) {
keys[id] = raw
}
at := time.Unix(v.Now, 0)
_, err := reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, reader.Trust{Issuer: v.Issuer, Keys: keys, Now: func() time.Time { return at }})
if v.Accept && err != nil {
t.Fatalf("Go refuses a vector it should accept: %v", err)
}
if !v.Accept && (!errors.Is(err, reader.ErrUnauthenticated) || !strings.Contains(err.Error(), v.Reason)) {
t.Fatalf("want a refusal naming %q: %v", v.Reason, err)
trust := reader.Trust{Issuer: v.Issuer, Keys: keys, Now: func() time.Time { return at }}
for _, mode := range []string{"stamped", "bare"} {
t.Run(mode, func(t *testing.T) {
var err error
if mode == "stamped" {
_, err = reader.Authenticate(stampedToken(v.Token), "test-proof", v.Audience, trust)
} else {
_, err = reader.AuthenticateToken(v.Token, v.Audience, trust)
}
if v.Accept {
if err != nil {
t.Fatalf("Go refuses a vector it should accept: %v", err)
}
} else {
var reason refusal.Reason
if !errors.Is(err, reader.ErrUnauthenticated) || !errors.As(err, &reason) || reason.Code() != v.Reason {
t.Fatalf("want a refusal naming %q: %v", v.Reason, err)
}
}
})
}
})
}
Expand Down
Loading
Loading