From f3e271d9e9c949d3be8ff127f752ffcd2f42609e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:54:35 -0400 Subject: [PATCH 01/35] feat(workcontext)!: mint once per execution and seal the credential to one installation and build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module process now obtains its credential once per execution, sealed to the principal and epoch the host authenticated, the installation and revision it serves, and the build incarnation it is running. It never announces itself again. Nothing runs on a timer that is not the credential's own expiry. A fifteen-minute credential renewed three minutes before expiry costs one mint and five renewals in an hour; the heartbeat this replaces burned a single-use token per surface every fifteen seconds, 240 mints an hour, each one an audit event recording that nothing had changed. The process self-reports nothing. A mint request carries the audience it wants and the audience its projection was minted for, and no identity at all: a field for the build or the installation would be a field a process could lie in. Verification is one path and the seal is required. VerifyWorkContext replaces Verify on both verifiers and returns a VerifiedWorkContext, which is now the only thing that produces verified claims — so claims that were never verified, or mutated after verification, can no longer reach an authorization decision. An operation context resolves its sealed binding identity exactly: a credential sealed to binding X is refused as binding Y even when Y's scopes are a superset, because scope containment is not binding identity. ErrWorkContextSuperseded is new and separate on purpose. A credential whose principal epoch, installation revision or binding revision has moved was sound when minted and is re-mintable now; mapped onto the retryable-outage path every revision bump becomes a retry storm, and mapped onto a plain denial a condition one mint fixes becomes a user-visible error. Cache partitions now carry the sealed installation at its sealed revision, so an answer computed under revision N is not served under N+1. The key prefix moves to wc2 so a shared cache outliving the deploy cannot be reached under the keys it still holds. Authority-bearing configuration stops at boot. ReadAuthority freezes the principal, binding and audience a process runs under, refuses a partial read, and refuses a drift rather than reloading one; the mint client rechecks before every renewal, and WorkspaceValue answers from the pin so the ordinary accessor is not a way around it. Breaking: the signed payload gains required fields, so the cutover is ordered — the decoder rejects unknown fields, and verifiers must upgrade before minters or every call fails closed in the window. The cross-language wire goldens change with it. Co-Authored-By: Claude Opus 5 (1M context) --- .golangci.yaml | 4 +- AGENTS.md | 14 +- README.md | 29 +- authority.go | 189 +++++++ authority_test.go | 166 ++++++ for.go | 22 + workcontext/README.md | 278 ++++++++++ workcontext/cache_partition.go | 119 +++-- workcontext/cache_partition_test.go | 108 +++- workcontext/doc.go | 23 +- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- .../grpctransport/execution_context_grpc.go | 94 +++- .../execution_context_grpc_test.go | 107 +++- workcontext/grpctransport/two_service_test.go | 35 +- workcontext/mint.go | 464 +++++++++++++++++ workcontext/mint_test.go | 361 +++++++++++++ workcontext/seal.go | 245 +++++++++ workcontext/seal_test.go | 371 ++++++++++++++ workcontext/stream.go | 131 +++++ workcontext/stream_test.go | 123 +++++ workcontext/work_context.go | 482 +++++++++++++++--- workcontext/work_context_jwks.go | 16 +- workcontext/work_context_jwks_test.go | 62 +-- workcontext/work_context_test.go | 140 +++-- 25 files changed, 3365 insertions(+), 224 deletions(-) create mode 100644 authority.go create mode 100644 authority_test.go create mode 100644 workcontext/README.md create mode 100644 workcontext/mint.go create mode 100644 workcontext/mint_test.go create mode 100644 workcontext/seal.go create mode 100644 workcontext/seal_test.go create mode 100644 workcontext/stream.go create mode 100644 workcontext/stream_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 7cf1232..30c518b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -17,9 +17,11 @@ linters: exclusions: generated: lax rules: + # ST1005 forbids a capitalized error string, which it cannot tell from a + # proper noun: every sentinel in these files begins with "Codefly". - linters: - staticcheck - path: (^|/)(runtime_environment_file|work_context)\.go$ + path: (^|/)(runtime_environment_file|work_context|mint|stream)\.go$ text: "ST1005:" paths: - .*\.pb\.go diff --git a/AGENTS.md b/AGENTS.md index 6260834..7590f49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,13 +89,14 @@ package line. | --- | --- | | `codefly.go` | `Init`, the immutable env snapshot, `Inject*`, process-level accessors | | `for.go` | the `For(ctx)` query: endpoints, configuration, secrets, workspace values | +| `authority.go` | authority-bearing values, read once at boot; a drift is refused, never reloaded | | `runtime_value.go` | `RuntimeValue`, for values the runtime injects directly | | `runtime_environment_file.go` | loading a runtime-written env file | | `fixture.go` | the selected fixture, and resolving its principals by role | | `tls.go` | workload leaf certificates, reloaded on rotation | | `receipts/` | effect receipts: the store, the digest, the replay/conflict interceptor | | `receipts/grpctransport/`, `receipts/connecttransport/` | the two transport adapters, split so neither drags the other's dependency in | -| `workcontext/` | **separate leaf module**: Work Context signing and verification | +| `workcontext/` | **separate leaf module**: the mint-once client, the seal, signing and verification | | `workcontext/grpctransport/` | the gRPC carrier, so verify-only consumers never compile grpc | `workcontext` exists to keep a verify-only consumer's `go.sum` small. Adding a @@ -113,6 +114,17 @@ dependency to it is a design change, not a detail — see the skill below. - **An empty injected value is not a value.** A composition templating an unset variable ships the name with an empty string; `RuntimeValue` reports `false` so it cannot shadow the configuration a caller falls back to. Keep that. +- **A credential is sealed or it is not a credential.** Every field of + `workcontext.Seal` is required, at sign time and at verify time, and + `AttachWorkContext` refuses an unsealed token. A claim added to the wire must + be added to the payload struct pair in `work_context.go`: the signed bytes are + hand-written JSON, so a field present only in the proto is dropped at mint and + absent at verify. `codefly-dev/core` has a second `workcontext` package that + signs protobuf; it is wire-incompatible and no live path uses it. +- **An authority-bearing value is read once.** A principal, binding or audience + comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for + a pinned name, so a drift is an error rather than a reload — the process has + already minted a credential sealed to the old value. - **A receipt is written inside the transaction that commits its effect.** A receipt written after the commit leaves a window where the effect exists and the receipt does not, and a recovery landing there reads "no receipt" for an diff --git a/README.md b/README.md index a7f6299..80cf99c 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,28 @@ # codefly + go = sdk-go -## Work Context verifier - -Services that only need to verify Codefly Work Contexts can import the leaf -module `github.com/codefly-dev/sdk-go/workcontext` instead of the full SDK. It -depends only on the shared `codefly/base/v0` proto types, so a consumer's -`go.sum` stays a handful of entries rather than inheriting core's transitive -tail. +## Work Context: mint once, sealed, verified exactly + +A module process obtains its credential once per execution, sealed to the build +it is and the installation it serves, and never announces itself again. The leaf +module `github.com/codefly-dev/sdk-go/workcontext` holds the mint client, the +signer and the verifier; it depends only on the shared `codefly/base/v0` proto +types, so a consumer that only verifies keeps a `go.sum` of a handful of entries +rather than inheriting core's transitive tail. + +See [`workcontext/README.md`](workcontext/README.md) for the model, the sealed +fields, the four refusals a caller must tell apart, and what a consumer has to +stop doing — there is no heartbeat, no registration call, and no self-reported +build or installation. + +## Authority-bearing configuration + +A principal, binding or audience is read once, at boot, through +`codefly.ReadAuthority`, and the SDK refuses a runtime change to one: the host +is the authority for these values, and a value that drifts under a running +process is an error rather than a reload — the process has already minted a +credential sealed to the old one. `For(ctx).WorkspaceValue` answers from that +boot read for a pinned name, so the ordinary accessor is not a way around it. ## Tenant-scoped effect receipts diff --git a/authority.go b/authority.go new file mode 100644 index 0000000..ef194d9 --- /dev/null +++ b/authority.go @@ -0,0 +1,189 @@ +package codefly + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// Errors a caller distinguishes when it reads an authority-bearing value. +var ( + // ErrAuthorityValueMissing reports that a declared authority-bearing value + // is not configured. It is a boot error: the process has no authority to + // run under and must not serve. + ErrAuthorityValueMissing = errors.New("codefly: authority-bearing value is not configured") + + // ErrAuthorityValueChanged reports that an authority-bearing value resolves + // differently now than it did when the process read it. The host is the + // authority for these values, so a value that drifts under a running + // process is an error and never a reload: the process that read the old + // value already minted a credential against it. + ErrAuthorityValueChanged = errors.New("codefly: authority-bearing value changed under a running process") + + // ErrAuthorityValueUnread reports a lookup for a value the process did not + // declare when it read its authority. Declaring the set up front is what + // makes "read once" checkable. + ErrAuthorityValueUnread = errors.New("codefly: authority-bearing value was not read at boot") +) + +// AuthorityValueName addresses one workspace value by the name and key the +// workspace accessors take, never by its carrier. +type AuthorityValueName struct { + Name string + Key string +} + +func (n AuthorityValueName) String() string { + return n.Name + "/" + n.Key +} + +func (n AuthorityValueName) canonical() AuthorityValueName { + return AuthorityValueName{ + Name: strings.TrimSpace(n.Name), + Key: strings.TrimSpace(n.Key), + } +} + +// Authority is the set of authority-bearing workspace values a process read +// once, at boot: the principal it runs as, the binding it serves, the audience +// it mints against. Nothing in the set can be re-read into a different value, +// because the credential the process already holds is sealed to the values it +// was minted under. +// +// Read it once during startup, before serving, and treat a non-nil error as +// fatal. Hand the resolved strings to whatever mints the process credential, +// and call Recheck before every renewal so a drifted value refuses the renewal +// instead of silently re-sealing to something the host never approved. +type Authority struct { + readAt time.Time + values map[AuthorityValueName]string +} + +// authorityPins is the process-wide record of what has been pinned. It exists +// so a later WorkspaceValue for a pinned name cannot answer with a value that +// drifted after boot: without it, reading the authority through Authority and +// reading the same name through the ordinary accessor would disagree, and the +// ordinary accessor is the one product code reaches for by habit. +var ( + authorityPinsMu sync.RWMutex + authorityPins = map[AuthorityValueName]string{} +) + +// ReadAuthority resolves every named value once and freezes the result. A name +// that resolves to nothing fails the whole read: a partially-known authority is +// not an authority, and continuing with the rest would let a process serve +// under an identity it could not fully establish. +// +// Calling it twice with the same name is allowed and must agree — the second +// read is checked against the pin exactly as Recheck would check it. +func ReadAuthority(ctx context.Context, names ...AuthorityValueName) (*Authority, error) { + if len(names) == 0 { + return nil, fmt.Errorf("%w: no names given", ErrAuthorityValueMissing) + } + query := For(ctx) + values := make(map[AuthorityValueName]string, len(names)) + for _, raw := range names { + name := raw.canonical() + if name.Name == "" || name.Key == "" { + return nil, fmt.Errorf("%w: a name and a key are both required", ErrAuthorityValueMissing) + } + if _, duplicate := values[name]; duplicate { + continue + } + value, err := query.workspaceValueLive(name.Name, name.Key) + if err != nil || strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%w: %s", ErrAuthorityValueMissing, name) + } + if pinned, ok := authorityPin(name); ok && pinned != value { + return nil, fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) + } + values[name] = value + } + authority := &Authority{readAt: time.Now().UTC(), values: values} + pinAuthorityValues(values) + return authority, nil +} + +// Value answers from the frozen set. It performs no lookup, so it cannot +// observe a change; Recheck is what observes one. +func (a *Authority) Value(name string, key string) (string, error) { + if a == nil { + return "", fmt.Errorf("%w: authority was never read", ErrAuthorityValueUnread) + } + addressed := AuthorityValueName{Name: name, Key: key}.canonical() + value, ok := a.values[addressed] + if !ok { + return "", fmt.Errorf("%w: %s", ErrAuthorityValueUnread, addressed) + } + return value, nil +} + +// Names returns the declared set, sorted, for a boot log that records what the +// process established its identity from. +func (a *Authority) Names() []AuthorityValueName { + if a == nil { + return nil + } + names := make([]AuthorityValueName, 0, len(a.values)) + for name := range a.values { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + if names[i].Name != names[j].Name { + return names[i].Name < names[j].Name + } + return names[i].Key < names[j].Key + }) + return names +} + +// ReadAt reports when the set was frozen. +func (a *Authority) ReadAt() time.Time { + if a == nil { + return time.Time{} + } + return a.readAt +} + +// Recheck re-resolves every value in the set and refuses on the first that +// moved. A value that has disappeared counts as moved: the host withdrawing an +// authority-bearing value is a withdrawal, not an absence to tolerate. +// +// It is deliberately a check and not a reload. Nothing here updates the frozen +// set, because a credential already sealed to the old value cannot be made +// correct by forgetting what it was sealed to. +func (a *Authority) Recheck(ctx context.Context) error { + if a == nil { + return fmt.Errorf("%w: authority was never read", ErrAuthorityValueUnread) + } + query := For(ctx) + for _, name := range a.Names() { + value, err := query.workspaceValueLive(name.Name, name.Key) + if err != nil || strings.TrimSpace(value) == "" { + return fmt.Errorf("%w: %s is no longer configured", ErrAuthorityValueChanged, name) + } + if value != a.values[name] { + return fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) + } + } + return nil +} + +func authorityPin(name AuthorityValueName) (string, bool) { + authorityPinsMu.RLock() + defer authorityPinsMu.RUnlock() + value, ok := authorityPins[name] + return value, ok +} + +func pinAuthorityValues(values map[AuthorityValueName]string) { + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + for name, value := range values { + authorityPins[name] = value + } +} diff --git a/authority_test.go b/authority_test.go new file mode 100644 index 0000000..b8b41cc --- /dev/null +++ b/authority_test.go @@ -0,0 +1,166 @@ +package codefly_test + +import ( + "testing" + + codefly "github.com/codefly-dev/sdk-go" + "github.com/stretchr/testify/require" +) + +// Deliverable 5: a value that carries authority is read once, at boot, and the +// SDK refuses a change to it under a running process. The host is the authority +// for these values; a process that already minted a credential against the old +// one cannot be made correct by noticing the new one. +func TestAuthorityValuesAreReadOnceAndADriftIsRefused(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "warden.evidence") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-one", Key: "audience"}) + require.NoError(t, err) + value, err := authority.Value("mint-one", "audience") + require.NoError(t, err) + require.Equal(t, "warden.evidence", value) + require.NoError(t, authority.Recheck(t.Context()), "nothing has moved yet") + + // The host changes the value under the running process. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "warden.something-else") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + // The frozen read is unchanged — that is what "read once" means. + value, err = authority.Value("mint-one", "audience") + require.NoError(t, err) + require.Equal(t, "warden.evidence", value) + + // And the drift is an error, not a reload. + err = authority.Recheck(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + require.ErrorContains(t, err, "mint-one/audience") +} + +// An authority-bearing value withdrawn is a withdrawal, not an absence to +// tolerate. A process whose audience has been removed must not keep serving on +// the one it remembers. +func TestAuthorityValueWithdrawnIsAChange(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "warden.evidence") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-gone", Key: "audience"}) + require.NoError(t, err) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "") + require.NoError(t, codefly.LoadEnvironmentVariables()) + err = authority.Recheck(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + require.ErrorContains(t, err, "no longer configured") +} + +// The ordinary accessor is the one product code reaches for by habit, so it +// must not be the way around the pin. A pinned name answers from the boot read, +// and a drifted live value is refused there too. +func TestWorkspaceValueAnswersFromThePinAndRefusesADrift(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PINNED__BINDING", "binding-0a9f") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-pinned", Key: "binding"}) + require.NoError(t, err) + + value, err := codefly.For(t.Context()).WorkspaceValue("mint-pinned", "binding") + require.NoError(t, err) + require.Equal(t, "binding-0a9f", value) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PINNED__BINDING", "binding-somebody-elses") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err = codefly.For(t.Context()).WorkspaceValue("mint-pinned", "binding") + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + + // A name nobody pinned is untouched by any of this. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_FREE__LABEL", "first") + require.NoError(t, codefly.LoadEnvironmentVariables()) + label, err := codefly.For(t.Context()).WorkspaceValue("mint-free", "label") + require.NoError(t, err) + require.Equal(t, "first", label) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_FREE__LABEL", "second") + require.NoError(t, codefly.LoadEnvironmentVariables()) + label, err = codefly.For(t.Context()).WorkspaceValue("mint-free", "label") + require.NoError(t, err) + require.Equal(t, "second", label, "only authority-bearing values are frozen") +} + +// A partially-known authority is not an authority. Continuing with the names +// that did resolve would let a process serve under an identity it could not +// fully establish, which is the quiet half of a missing credential. +func TestReadAuthorityFailsWholeWhenOneNameIsMissing(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "warden.evidence") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + _, err := codefly.ReadAuthority(t.Context(), + codefly.AuthorityValueName{Name: "mint-partial", Key: "audience"}, + codefly.AuthorityValueName{Name: "mint-partial", Key: "binding"}, + ) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) + require.ErrorContains(t, err, "mint-partial/binding") + + // Nothing was pinned, so the ordinary accessor still answers live. + value, err := codefly.For(t.Context()).WorkspaceValue("mint-partial", "audience") + require.NoError(t, err) + require.Equal(t, "warden.evidence", value) +} + +// A name the process never declared is not answerable from a frozen set. +// Declaring the set up front is what makes "read once" checkable at all. +func TestAuthorityRefusesANameItNeverRead(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_DECLARED__AUDIENCE", "warden.evidence") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-declared", Key: "audience"}) + require.NoError(t, err) + + _, err = authority.Value("mint-declared", "binding") + require.ErrorIs(t, err, codefly.ErrAuthorityValueUnread) + + var never *codefly.Authority + _, err = never.Value("mint-declared", "audience") + require.ErrorIs(t, err, codefly.ErrAuthorityValueUnread) + require.ErrorIs(t, never.Recheck(t.Context()), codefly.ErrAuthorityValueUnread) + + _, err = codefly.ReadAuthority(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) + _, err = codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: " ", Key: "audience"}) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) +} + +// Reading the same name twice is allowed and must agree. The second read is +// held to the pin exactly as Recheck would hold it, so a process cannot obtain +// a second, differently-valued authority by asking again. +func TestReadingTheAuthorityTwiceMustAgree(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "warden.evidence") + require.NoError(t, codefly.LoadEnvironmentVariables()) + name := codefly.AuthorityValueName{Name: "mint-twice", Key: "audience"} + first, err := codefly.ReadAuthority(t.Context(), name) + require.NoError(t, err) + second, err := codefly.ReadAuthority(t.Context(), name) + require.NoError(t, err) + firstValue, err := first.Value(name.Name, name.Key) + require.NoError(t, err) + secondValue, err := second.Value(name.Name, name.Key) + require.NoError(t, err) + require.Equal(t, firstValue, secondValue) + require.Equal(t, []codefly.AuthorityValueName{name}, second.Names()) + require.False(t, second.ReadAt().IsZero()) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "warden.other") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err = codefly.ReadAuthority(t.Context(), name) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) +} + +// A secret-namespace value is authority-bearing in the same way, so the +// two-namespace lookup WorkspaceValue performs has to be the one ReadAuthority +// performs too — otherwise a binding delivered as a secret would read as +// missing at boot and live afterwards. +func TestAuthorityReadsTheSecretNamespaceToo(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__MINT_SECRET__BINDING", "binding-secret") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-secret", Key: "binding"}) + require.NoError(t, err) + value, err := authority.Value("mint-secret", "binding") + require.NoError(t, err) + require.Equal(t, "binding-secret", value) +} diff --git a/for.go b/for.go index d4d74e6..6100edb 100644 --- a/for.go +++ b/for.go @@ -279,7 +279,29 @@ func (q *Query) WorkspaceSecret(name string, key string) (string, error) { // WorkspaceValue resolves a workspace value from the public namespace first, // then the secret namespace. It is intended for settings whose sensitivity is // deployment-defined while preserving a single SDK-only lookup boundary. +// +// A value this process read as authority-bearing (see ReadAuthority) answers +// from what was read at boot, and a live value that has since drifted is +// refused with ErrAuthorityValueChanged rather than returned. Without that, a +// process could seal its credential to the boot value and then authorize +// against a different one through the ordinary accessor. func (q *Query) WorkspaceValue(name string, key string) (string, error) { + addressed := AuthorityValueName{Name: name, Key: key}.canonical() + pinned, isPinned := authorityPin(addressed) + value, err := q.workspaceValueLive(name, key) + if !isPinned { + return value, err + } + if err != nil || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%w: %s is no longer configured", ErrAuthorityValueChanged, addressed) + } + if value != pinned { + return "", fmt.Errorf("%w: %s", ErrAuthorityValueChanged, addressed) + } + return pinned, nil +} + +func (q *Query) workspaceValueLive(name string, key string) (string, error) { if value, err := q.WorkspaceConfiguration(name, key); err == nil && value != "" { return value, nil } diff --git a/workcontext/README.md b/workcontext/README.md new file mode 100644 index 0000000..054193e --- /dev/null +++ b/workcontext/README.md @@ -0,0 +1,278 @@ +# `workcontext` + +Signing and verification of Codefly Work Contexts, and the client a module +process uses to obtain the one credential it runs on. + +It is a separate Go module so a service whose only need is to verify can import +`github.com/codefly-dev/sdk-go/workcontext` without inheriting the root SDK's +transitive tail. Three direct dependencies: `codefly-dev/core` (for the +`codefly/base/v0` proto types), `testify`, `grpc`. The gRPC carrier lives in +`workcontext/grpctransport` so a verify-only consumer never compiles grpc. + +## The model: mint once, sealed, verified exactly + +A module process obtains its credential **once per execution**, bound to the +build it is and the installation it serves, and never again announces itself. + +``` +boot ──▶ read the projected service-account token + ──▶ present it to the host's mint endpoint + ──▶ receive a credential sealed to + (principal id + epoch, installation id + revision, build incarnation) + ──▶ serve + ──▶ at expiry: re-read the rotated projection, re-check the boot authority, mint again +``` + +Three properties follow, and each replaces something that used to be true: + +**Nothing runs on a timer that is not the credential's own expiry.** There is no +heartbeat and no registration call. A fifteen-minute credential renewed three +minutes before expiry costs **one mint and five renewals in an hour**. The +heartbeat this replaces burned a single-use token per surface every fifteen +seconds — 240 mints an hour, each one an audit event recording that nothing had +changed. + +**The process self-reports nothing.** The mint request carries the audience it +wants and the audience its projection was minted for. That is all. Principal, +installation and build are the host's to establish, from the projected token and +its own records; a field for any of them would be a field a process could lie +in. There is no self-reported upstream and no manifest. + +**A credential is sealed or it is not a credential.** Every sealed field is +required. A token missing one does not verify, and `AttachWorkContext` refuses +to put it on a request at all — there is no request on which an unsealed +credential is better than no credential. + +## Obtaining the credential + +```go +// At boot, in the root SDK: read the authority-bearing values once. +authority, err := codefly.ReadAuthority(ctx, + codefly.AuthorityValueName{Name: "platform", Key: "work-context-audience"}, + codefly.AuthorityValueName{Name: "platform", Key: "mint-url"}, +) +if err != nil { + return err // a process that cannot establish its identity must not serve +} +audience, _ := authority.Value("platform", "work-context-audience") +mintURL, _ := authority.Value("platform", "mint-url") + +client, err := workcontext.NewMintClient(workcontext.MintOptions{ + URL: mintURL, + Audience: audience, + ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), + ProjectionAudience: projectionAudience, + Authority: authority, // rechecked before every renewal +}) +``` + +Then, on every outbound request: + +```go +credential, err := client.Credential(ctx) // mints once; renews only at expiry +if err != nil { + return err +} +if err := credential.Attach(request); err != nil { + return err +} +``` + +`Credential` is the only way to reach the credential, so there is no path on +which a stale one is used. It re-reads the projected token and rechecks the +boot-read authority before each renewal: a projection rotated under the running +process is picked up without a restart, and an authority value that has drifted +refuses the renewal instead of being sealed into a new credential. + +`Attach` sets `x-codefly-work-context` and, beside it, +`x-codefly-installation-id` / `x-codefly-installation-revision`. The gRPC +transport sets the same names as metadata. Those two carriers are a **pre-check +the host may refuse on cheaply, and never authority**: the installation that +governs a call is the sealed one. On the incoming side the SDK refuses when a +carrier disagrees with the sealed claim rather than preferring either side. + +## Verifying + +`VerifyWorkContext` is the only verification entry point, on both the static +verifier and the JWKS one. It returns `VerifiedWorkContext`, which is the only +thing that can produce verified claims — so code holding claims cannot be +holding a hand-built protobuf, a token that was parsed and never verified, or a +credential nobody held to an installation. + +```go +verified, err := verifier.VerifyWorkContext(token, workcontext.WorkContextExpectations{ + Issuer: issuer, + Audience: myAudience, + + // The delegating principal. + OwnerPrincipalID: ownerID, + OwnerPrincipalKind: ownerKind, + + // The calling principal, checked separately. nil accepts direct owner + // calls ONLY: a credential carrying an actor chain is refused. + Delegation: &workcontext.DelegationExpectations{PrincipalKind: "agent"}, + + // Required. A verifier that does not state where it is cannot refuse a + // credential from somewhere else. + Seal: workcontext.SealExpectations{ + PrincipalEpoch: epoch, + InstallationID: installationID, + InstallationRevision: revision, + BuildIncarnation: build, + }, + + // Required for an operation context; nil refuses a credential that seals one. + OperationBinding: &workcontext.OperationBindingExpectations{ + BindingID: bindingID, BindingRevision: bindingRevision, BindingIncarnation: incarnation, + }, +}) +``` + +Scope comes last, from the verified credential: + +```go +err = workcontext.RequireWorkContextScope(verified, workcontext.WorkContextScopeRequirement{ + ResourceKind: "evidence", Action: "append", ResourceID: recordID, +}) +``` + +### Exact binding, not scope search + +An operation credential names one binding, at one revision, in one incarnation, +and verification resolves that identity. It never searches for a binding whose +scopes would admit the call. A credential sealed to binding X presented as +binding Y is refused **even when Y's scopes are a superset of X's** — scope +containment is not binding identity, and a revoked narrow credential must not +validate through a wider binding. + +### The four refusals, and why they are four + +| Sentinel | Means | What the caller does | +| --- | --- | --- | +| `ErrWorkContextInvalid` | the credential is wrong and cannot become right — bad signature, another installation, another build, another binding id | refuse the call; 401 | +| `ErrWorkContextSuperseded` | it was sound when minted and has been overtaken — principal epoch, installation revision or binding revision moved | mint again, then retry once | +| `ErrWorkContextDenied` | the credential is good and its scope does not cover this | refuse the call; 403 | +| `ErrWorkContextUnavailable` | the key set is unreachable (transport failure, issuer 5xx/429) | retry; 503, never 401 | + +`ErrWorkContextSuperseded` is separate from the other three on purpose. Mapped +onto the retryable-outage path, every installation revision bump becomes a retry +storm against a credential that is guaranteed to keep failing. Mapped onto a +plain denial, a condition one mint would fix becomes a user-visible error. + +## Streams + +A stream authorized when it opened is not authorized forever. `StreamGuard` +re-presents the credential on the host's re-check cadence and terminates on +refusal, rather than draining a snapshot computed under authority that has since +been replaced — the failure mode it exists to prevent is the silent one, where +nothing errors and the data keeps arriving. + +```go +guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Interval: hostRecheckCadence, + Recheck: func(ctx context.Context) error { + _, err := verifier.VerifyWorkContext(token, expectationsRightNow()) + return err + }, +}) +for message := range messages { + if err := guard.BeforeSend(ctx); err != nil { + return err // terminate; do not resume + } + if err := stream.Send(message); err != nil { + return err + } +} +``` + +It guards emission rather than running a timer: an idle stream discloses +nothing, so there is nothing to refuse. Termination is sticky — a caller that +loops past the first refusal is not handed a second chance to emit, and the +authority coming back does not resurrect the stream. + +## Cache partitions + +`DeriveCachePartition` keys on the tenant **and the sealed installation at its +sealed revision**, always. An answer computed while the host held revision N was +computed under the authority of revision N, so the move to N+1 lands every +caller in a fresh partition. That is a cache miss by design and never a stale +hit. + +- `ByAuthorizationView()` adds a digest of the effective scopes and the + authorization revision. Safe only when the result depends on scopes alone. +- `ByViewer()` adds the effective actor — the last actor, or the owner for a + direct call. Use it for anything whose content varies by who is asking: every + reader of a wiki holds `documents:[read]`, and a revision is a number they + share. + +The key prefix is `wc2:`. It changed with the installation revision going into +the key, so a shared cache that outlives the deploy — Redis, a warehouse table — +cannot be reached under the `wc1:` keys it still holds. + +## What a consumer must stop doing + +- **Stop heartbeating, and delete the client that did it.** No periodic mint, no + registration call, no deregistration. The host establishes presence from + delivery, not from processes announcing themselves. +- **Stop reporting your own identity.** Do not send a build digest, an image + reference, an installation, an upstream address or a manifest with a mint + request. The host reads your build from the pod; anything you send instead is + something you could get wrong, and the version it could get wrong is the one + an attacker would choose. +- **Stop caching the projected token.** Read it again before every renewal. The + projection is rotated under the running process, so the copy read at boot + expires long before the process does. +- **Stop re-reading authority-bearing configuration.** A principal, binding or + audience is read once, at boot, through `codefly.ReadAuthority`. The SDK + refuses a runtime change to one: the host is the authority, and a value that + drifts under a running process is an error, not a reload. `WorkspaceValue` + answers from the pin for a pinned name, so there is no way around it. +- **Stop verifying without a seal.** `Verify` is gone; `VerifyWorkContext` takes + required `SealExpectations`. A verifier that does not state which installation + revision and build it is on cannot refuse a credential from an earlier one. +- **Stop searching bindings.** Resolve the sealed binding identity. A superset + of scopes is not a licence to act as a different binding. +- **Stop authorizing from bare claims.** `RequireWorkContextScope` takes a + `VerifiedWorkContext`. Claims that were never verified, or were mutated after + verification, can no longer reach an authorization decision. + +## Running the gates + +This is a separate module. Commands run at the repository root do not compile +it, and report `ok` having never tried. + +```bash +go test ./... && (cd workcontext && go test ./...) +go test -race ./... && (cd workcontext && go test -race ./...) +golangci-lint run && (cd workcontext && golangci-lint run) +go mod tidy && (cd workcontext && go mod tidy) +``` + +Coverage for this module is gated on the **module total** across both its +packages (≥ 75%, an `awk` line in the `coverage` job of `.github/workflows/go.yml`), +not per package — `grpctransport` is carried by the verifier, so read the total: + +```bash +cd workcontext +go test ./... -coverprofile=cover.out -covermode=atomic -coverpkg=./... +go tool cover -func=cover.out | awk '/^total:/ {print $3}' +``` + +## The wire, and the other implementation + +The signed payload is `base64url(JSON payload) + "." + base64url(Ed25519 +signature)`, with one fixed snake_case field order. Revisions and epochs travel +as decimal strings so the complete `uint64` domain survives a JavaScript +verifier. Decoding rejects unknown fields and trailing JSON. + +`codefly-dev/core` also has a `workcontext` package. It signs **deterministic +binary protobuf**, not this JSON, so a token from one fails *signature* +verification in the other — the error reads like a key-rotation problem and is +not one. The host (`accounts`, `auth-gateway`) imports this module, so this is +the production wire; core's package is not on any live path. Anything that adds +a claim must add it to the payload struct pair in `work_context.go`, because a +field present only in the proto is dropped at mint and absent at verify. + +The golden tokens in `work_context_test.go` are shared byte-for-byte with +sdk-js. A change to the payload changes them, and sdk-js must be regenerated in +the same round. diff --git a/workcontext/cache_partition.go b/workcontext/cache_partition.go index f125f87..914acd2 100644 --- a/workcontext/cache_partition.go +++ b/workcontext/cache_partition.go @@ -21,60 +21,85 @@ const cachePartitionViewDomain = "codefly.work-context.cache-partition.view.v1\n // can never be made to spell a view. const cachePartitionViewerDomain = "codefly.work-context.cache-partition.viewer.v1\n" -// VerifiedWorkContext is a Work Context whose signature, lifetime and -// expectations a verifier has checked. Only VerifyWorkContext on a verifier -// constructs one, so code holding it cannot be holding a hand-built protobuf or -// a token that was parsed but never verified. Its zero value is not verified -// and every consumer rejects it. +// VerifiedWorkContext is a credential whose signature, lifetime, expectations +// and seal a verifier has checked. VerifyWorkContext is the only thing that +// constructs one, so code holding it cannot be holding a hand-built protobuf, a +// token that was parsed but never verified, or a credential nobody held to an +// installation. Its zero value is not verified and every consumer rejects it. +// +// It is the single verification result in this module. There is no accessor +// that returns bare claims from a token, because a caller that can obtain +// claims without the seal will eventually authorize from them. type VerifiedWorkContext struct { - claims *basev0.WorkContextV1 + sealed sealedContext } -// Claims returns a copy of the verified claims. Mutating the copy changes -// nothing this value derives. +// Claims returns a copy of the verified delegation claims. Mutating the copy +// changes nothing this value derives. func (v VerifiedWorkContext) Claims() *basev0.WorkContextV1 { - return cloneContext(v.claims) + return cloneContext(v.sealed.context) +} + +// Seal returns the execution this credential is bound to. +func (v VerifiedWorkContext) Seal() Seal { + return v.sealed.seal } -// VerifyWorkContext is Verify, returning a value that can only have come from -// verification. Use it wherever the result feeds a trust-sensitive derivation -// such as DeriveCachePartition. +// OperationBinding returns the one binding this credential may act through, or +// nil when it is not an operation context. +func (v VerifiedWorkContext) OperationBinding() *OperationBinding { + if v.sealed.binding == nil { + return nil + } + binding := *v.sealed.binding + return &binding +} + +// VerifyWorkContext establishes trust in a credential: signature, structure, +// lifetime, the stated expectations, the seal, and the operation binding by +// exact identity. It is the only verification entry point. func (v *WorkContextVerifier) VerifyWorkContext( token WorkContextToken, expected WorkContextExpectations, ) (VerifiedWorkContext, error) { - claims, err := v.Verify(token, expected) + sealed, err := v.decode(token) if err != nil { return VerifiedWorkContext{}, err } - return VerifiedWorkContext{claims: claims}, nil + if err := matchWorkContext(sealed, expected); err != nil { + return VerifiedWorkContext{}, err + } + return VerifiedWorkContext{sealed: sealed}, nil } -// VerifyWorkContext is Verify, returning a value that can only have come from -// verification. Use it wherever the result feeds a trust-sensitive derivation -// such as DeriveCachePartition. +// VerifyWorkContext establishes trust using the current cached key set, +// refreshing once for an unknown key id so a rotation is picked up without a +// request stampede. func (v *WorkContextJWKSVerifier) VerifyWorkContext( ctx context.Context, token WorkContextToken, expected WorkContextExpectations, ) (VerifiedWorkContext, error) { - claims, err := v.Verify(ctx, token, expected) + verifier, err := v.verifierFor(ctx, token) if err != nil { return VerifiedWorkContext{}, err } - return VerifiedWorkContext{claims: claims}, nil + return verifier.VerifyWorkContext(token, expected) } // CachePartition is the identity partition a cache stack scopes its entries // to. It is plain data on purpose: the cache takes it as an opaque value built // from these two fields and never learns what a Work Context is. type CachePartition struct { - // Key is stable for one tenant (and, with ByAuthorizationView, one - // effective authorization view). It contains only base64url, hex and ':'. + // Key is stable for one tenant on one installation revision (and, with + // ByAuthorizationView, one effective authorization view). It contains only + // base64url, hex and ':'. Key string // WriteAround asks the stack to bypass cached reads and writes for this - // call. It is always false today: the approval-grant hop that should set it - // does not exist in WorkContextV1 yet (codefly-dev/core#658). + // call. It is always false today. WorkContextV1 has carried the + // approval-grant hop since core v0.5.10, but this module's signed payload + // does not yet encode it, so no credential reaching here can be carrying + // one; wiring it is codefly-dev/sdk-go#41 item 4. WriteAround bool } @@ -120,16 +145,25 @@ func ByViewer() CachePartitionOption { } } -// DeriveCachePartition computes the cache partition for a verified Work -// Context. The key always carries the tenant. It never carries the session, -// task, nonce or audience: those identify an execution, not an authorization, -// so a child session or an audience exchange with the same effective view -// shares its parent's partition instead of fragmenting the cache per hop. +// DeriveCachePartition computes the cache partition for a verified credential. +// The key always carries the tenant and the sealed installation at its sealed +// revision. It never carries the session, task, nonce or audience: those +// identify an execution, not an authorization, so a child session or an +// audience exchange with the same effective view shares its parent's partition +// instead of fragmenting the cache per hop. +// +// The installation revision is in the key, not optional, and that is the whole +// point of it being there: an answer computed while the host held revision N +// was computed under the authority of revision N, and nothing about it survives +// the host moving to N+1. A revision bump therefore lands every caller in a +// fresh partition, which is a cache miss by design and never a stale hit. The +// installation id travels with the revision because revision numbers are +// per-installation and two installations share their small integers. func DeriveCachePartition( verified VerifiedWorkContext, options ...CachePartitionOption, ) (CachePartition, error) { - claims := verified.claims + claims := verified.sealed.context if claims == nil { return CachePartition{}, fmt.Errorf("%w: cache partition requires a verified Work Context", ErrWorkContextInvalid) } @@ -139,9 +173,19 @@ func DeriveCachePartition( option(&settings) } } - // Tenant IDs are free-form, so the key encodes rather than embeds them: no - // tenant can contain the delimiter and spell another tenant's view key. - key := "wc1:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.TenantId)) + seal := verified.sealed.seal + // Tenant and installation IDs are free-form, so the key encodes rather than + // embeds them: no tenant or installation can contain the delimiter and + // spell another one's view key. + // + // The "wc2" prefix is not decoration. A shared cache that outlives the + // deploy — Redis, a warehouse table — still holds "wc1" keys computed + // without an installation revision in them, and those keys would otherwise + // be reachable by a process that now partitions correctly. Changing the + // prefix orphans them instead. + key := "wc2:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.TenantId)) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.InstallationID)) + + ":r:" + strconv.FormatUint(seal.InstallationRevision, 10) if settings.byAuthorizationView || settings.byViewer { digest, err := authorizationViewDigest(claims) if err != nil { @@ -166,17 +210,16 @@ func DeriveCachePartition( // namespaces are independent: a user and an agent may share an id string // without being the same caller. func viewerDigest(claims *basev0.WorkContextV1) (string, error) { - // The owner carries no kind at this module's core pin, so an owner-derived - // viewer is marked instead of left blank: without it, an owner whose id is - // "x" and a delegated agent whose id is "x" would digest identically, and - // the two id namespaces are independent. codefly-dev/sdk-go#41 replaces the - // marker with the real owner_principal_kind when it adopts core v0.5.10. + // The kind travels with the id because the two namespaces are independent: + // an owner whose id is "x" and a delegated agent whose id is "x" are + // different callers. The owner's kind is the real one now that it is + // required on the wire, so no placeholder stands in for it. viewer := struct { PrincipalID string `json:"principal_id"` PrincipalKind string `json:"principal_kind"` }{ PrincipalID: claims.GetOwnerPrincipalId(), - PrincipalKind: "owner", + PrincipalKind: claims.GetOwnerPrincipalKind(), } if actors := claims.GetActorChain(); len(actors) > 0 { last := actors[len(actors)-1] diff --git a/workcontext/cache_partition_test.go b/workcontext/cache_partition_test.go index 6b0b576..18baae6 100644 --- a/workcontext/cache_partition_test.go +++ b/workcontext/cache_partition_test.go @@ -2,11 +2,14 @@ package workcontext import ( "crypto/ed25519" + "encoding/base64" "fmt" "math/rand/v2" "net/http" "net/http/httptest" + "regexp" "slices" + "strconv" "strings" "testing" "time" @@ -50,9 +53,19 @@ func newPartitionWorld(t *testing.T) *partitionWorld { return world } +// verify accepts both a direct owner call and a delegated one, because the +// partition tests derive from both and the partition is the thing under test +// here rather than the delegation check. A real verifier states one or the +// other; stating both would defeat the point of the check. func (w *partitionWorld) verify(token WorkContextToken) VerifiedWorkContext { w.t.Helper() - verified, err := w.verifier.VerifyWorkContext(token, WorkContextExpectations{}) + expected := workContextTestExpected() + expected.Delegation = nil + verified, err := w.verifier.VerifyWorkContext(token, expected) + if err != nil { + expected.Delegation = &DelegationExpectations{} + verified, err = w.verifier.VerifyWorkContext(token, expected) + } require.NoError(w.t, err) return verified } @@ -134,6 +147,8 @@ func partitionStartInput(random *rand.Rand, tenant string, revision uint64, scop Audience: fmt.Sprintf("service-%d", random.IntN(1000)), TenantID: tenant, OwnerPrincipalID: fmt.Sprintf("principal-%d", random.IntN(1000)), + OwnerPrincipalKind: "user", + Seal: workContextTestSeal(), TaskID: fmt.Sprintf("task-%d", random.Uint64()), SessionID: fmt.Sprintf("session-%d", random.Uint64()), AuthorizationRevision: revision, @@ -141,6 +156,16 @@ func partitionStartInput(random *rand.Rand, tenant string, revision uint64, scop } } +// partitionKeyPrefix is the tenant-and-installation half of every key these +// tests assert on. The installation and its revision are always present, so a +// test that spells the prefix by hand spells all three. +func partitionKeyPrefix(tenant string) string { + seal := workContextTestSeal() + return "wc2:t:" + base64.RawURLEncoding.EncodeToString([]byte(tenant)) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.InstallationID)) + + ":r:" + strconv.FormatUint(seal.InstallationRevision, 10) +} + func TestDeriveCachePartitionIsStableAcrossHopsWithTheSameView(t *testing.T) { world := newPartitionWorld(t) random := rand.New(rand.NewPCG(39, 658)) @@ -260,8 +285,8 @@ func TestDeriveCachePartitionNeverCarriesExecutionIdentity(t *testing.T) { } require.True(t, strings.HasPrefix(partition.Key, tenant.Key)) } - require.Equal(t, "wc1:t:dGVuYW50LWNvZGVmbHk", tenant.Key) - require.Regexp(t, `^wc1:t:dGVuYW50LWNvZGVmbHk:v:[0-9a-f]{64}$`, view.Key) + require.Equal(t, partitionKeyPrefix("tenant-codefly"), tenant.Key) + require.Regexp(t, `^`+regexp.QuoteMeta(partitionKeyPrefix("tenant-codefly"))+`:v:[0-9a-f]{64}$`, view.Key) } func TestDeriveCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) { @@ -274,7 +299,7 @@ func TestDeriveCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) // A tenant ID made of the victim's view key suffix must not produce the // victim's key under tenant-only partitioning. - suffix := strings.TrimPrefix(victimView.Key, "wc1:t:") + suffix := strings.TrimPrefix(victimView.Key, "wc2:t:") forger, _, err := world.signer.StartTask(partitionStartInput(random, "tenant-a:v:"+suffix, 1, scopes)) require.NoError(t, err) forgerTenant, _ := world.partitions(forger) @@ -304,11 +329,11 @@ func TestDeriveCachePartitionAcceptsOnlyVerifiedContexts(t *testing.T) { require.NoError(t, err) parsed, err := ParseWorkContextToken(forged.Encoded()) require.NoError(t, err) - _, err = world.verifier.VerifyWorkContext(parsed, WorkContextExpectations{}) + _, err = world.verifier.VerifyWorkContext(parsed, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) // Expectations still apply: a context for another audience is rejected. - _, err = world.verifier.VerifyWorkContext(token, WorkContextExpectations{Audience: "someone-else"}) + _, err = world.verifier.VerifyWorkContext(token, workContextTestExpectedWith(WorkContextExpectations{Audience: "someone-else"})) require.ErrorIs(t, err, ErrWorkContextInvalid) // Claims hands out a copy; mutating it cannot move the partition. @@ -337,13 +362,13 @@ func TestJWKSVerifierVerifyWorkContextFeedsThePartition(t *testing.T) { }) require.NoError(t, err) - verified, err := verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-1", privateKey), WorkContextExpectations{}) + verified, err := verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-1", privateKey), workContextTestExpected()) require.NoError(t, err) partition, err := DeriveCachePartition(verified) require.NoError(t, err) - require.Equal(t, "wc1:t:dGVuYW50LWNvZGVmbHk", partition.Key) + require.Equal(t, partitionKeyPrefix("tenant-codefly"), partition.Key) - _, err = verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-2", privateKey), WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-2", privateKey), workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) } @@ -378,7 +403,11 @@ func TestAuthorizationViewDigestCanonicalizesScopeOrder(t *testing.T) { require.NotEqual(t, first, actorView) // A nil option is ignored rather than dereferenced. - _, err = DeriveCachePartition(VerifiedWorkContext{claims: ordered}, nil, ByAuthorizationView()) + _, err = DeriveCachePartition( + VerifiedWorkContext{sealed: sealedContext{context: ordered, seal: workContextTestSeal()}}, + nil, + ByAuthorizationView(), + ) require.NoError(t, err) } @@ -474,3 +503,62 @@ func TestDeriveCachePartitionByViewerFollowsTheEffectiveActor(t *testing.T) { require.NotEqual(t, ownerViewer, firstViewer, "an agent acting for an owner is not the owner") require.NotEqual(t, ownerViewer, secondViewer) } + +// Deliverable 3's other half: the SDK never caches an authorization answer +// across the installation revision it was given. An answer computed while the +// host held revision N was computed under the authority of revision N, and +// nothing about it survives the move to N+1 — so the move lands every caller +// in a fresh partition, which is a cache miss by design and never a stale hit. +func TestCachePartitionNeverSpansAnInstallationRevision(t *testing.T) { + world := newPartitionWorld(t) + random := rand.New(rand.NewPCG(47, 11)) + scopes := randomScopes(random) + + atRevision := func(revision uint64, installation string) (CachePartition, CachePartition, CachePartition) { + input := partitionStartInput(random, "tenant-codefly", 5, scopes) + input.OwnerPrincipalID = "principal-stable" + input.Audience = "service-stable" + input.Seal.InstallationRevision = revision + input.Seal.InstallationID = installation + token, _, err := world.signer.StartTask(input) + require.NoError(t, err) + + expected := workContextTestExpected() + expected.Delegation = nil + expected.Seal.InstallationRevision = revision + expected.Seal.InstallationID = installation + verified, err := world.verifier.VerifyWorkContext(token, expected) + require.NoError(t, err) + + tenant, err := DeriveCachePartition(verified) + require.NoError(t, err) + view, err := DeriveCachePartition(verified, ByAuthorizationView()) + require.NoError(t, err) + viewer, err := DeriveCachePartition(verified, ByViewer()) + require.NoError(t, err) + return tenant, view, viewer + } + + beforeTenant, beforeView, beforeViewer := atRevision(41, "installation-0a9f") + afterTenant, afterView, afterViewer := atRevision(42, "installation-0a9f") + require.NotEqual(t, beforeTenant.Key, afterTenant.Key) + require.NotEqual(t, beforeView.Key, afterView.Key) + require.NotEqual(t, beforeViewer.Key, afterViewer.Key) + + // Two installations share their small revision numbers, so the id has to + // travel with the revision or installation A at revision 41 and + // installation B at revision 41 would be one partition. + elsewhereTenant, _, _ := atRevision(41, "installation-b3c1") + require.NotEqual(t, beforeTenant.Key, elsewhereTenant.Key) + + // Nothing else moved, so the same credential still derives the same keys. + againTenant, againView, againViewer := atRevision(41, "installation-0a9f") + require.Equal(t, beforeTenant.Key, againTenant.Key) + require.Equal(t, beforeView.Key, againView.Key) + require.Equal(t, beforeViewer.Key, againViewer.Key) + + // The key shape changed with this credential, so a shared cache that + // outlived the deploy must not be reachable under the keys it still holds. + require.True(t, strings.HasPrefix(beforeTenant.Key, "wc2:")) + require.NotContains(t, beforeTenant.Key, "wc1:") +} diff --git a/workcontext/doc.go b/workcontext/doc.go index 0d3ed28..0dd3b17 100644 --- a/workcontext/doc.go +++ b/workcontext/doc.go @@ -1,9 +1,28 @@ -// Package workcontext signs and verifies Codefly Work Contexts: bounded, -// two-segment Ed25519 capabilities carried between execution boundaries. +// Package workcontext signs and verifies Codefly Work Contexts — bounded, +// two-segment Ed25519 capabilities carried between execution boundaries — and +// holds the client a module process uses to obtain the one credential it runs +// on. +// +// A credential is minted once per execution and sealed to the execution that +// holds it: the principal and its epoch, the installation and its revision, +// and the build incarnation. Every sealed field is required, so a token +// carrying only delegation claims does not verify and cannot be attached to a +// request. Nothing here runs on a timer that is not the credential's own +// expiry: there is no heartbeat, no registration call, and nothing the process +// reports about itself, because the host establishes principal, installation +// and build from the projected service-account token and its own records. +// +// Verification is one path. VerifyWorkContext returns a VerifiedWorkContext, +// which is the only thing that produces verified claims, and an operation +// context resolves its sealed binding identity exactly rather than searching +// for a binding whose scopes would admit the call. // // It is a standalone module so a service whose only need is to verify Work // Contexts against a JWKS can depend on the verifier without inheriting the // full transitive dependency tail of github.com/codefly-dev/sdk-go. The gRPC // transport for Work Contexts lives in the workcontext/grpctransport // subpackage so that consumers that only verify never compile grpc. +// +// See README.md in this directory for the model, the four refusals a caller +// must tell apart, and what a consumer has to stop doing. package workcontext diff --git a/workcontext/go.mod b/workcontext/go.mod index 56a8090..085033a 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.3.32 + github.com/codefly-dev/core v0.7.1 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 ) diff --git a/workcontext/go.sum b/workcontext/go.sum index 6f5c1c4..c388113 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -2,8 +2,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-202608252041 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.3.32 h1:zVgGAljUERRjHe5ONhI2uBuQg0eayZzkFSm0murp5NI= -github.com/codefly-dev/core v0.3.32/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= +github.com/codefly-dev/core v0.7.1 h1:fU4ZU9BvUTVLLOR4LU/zbU3htOGDu0kjNkBH4KoT6/I= +github.com/codefly-dev/core v0.7.1/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/execution_context_grpc.go b/workcontext/grpctransport/execution_context_grpc.go index af2737e..c18bbc5 100644 --- a/workcontext/grpctransport/execution_context_grpc.go +++ b/workcontext/grpctransport/execution_context_grpc.go @@ -12,8 +12,13 @@ import ( const ( workContextGRPCMetadataName = workcontext.WorkContextHeaderName - operationIDGRPCMetadataName = "x-codefly-operation-id" - maxOperationIDBytes = 128 + // The installation carriers are the same names as on HTTP, because they + // are the same fact: gRPC metadata keys and HTTP header names are one + // namespace, and spelling them twice is how they drift. + installationIDGRPCMetadataName = workcontext.InstallationIDHeaderName + installationRevisionGRPCMetadataName = workcontext.InstallationRevisionHeaderName + operationIDGRPCMetadataName = "x-codefly-operation-id" + maxOperationIDBytes = 128 ) // ExecutionContext is the opaque authority and stable logical-operation @@ -58,6 +63,12 @@ func (execution ExecutionContext) OperationID() string { // metadata while preserving unrelated metadata. Existing carrier values are // rejected rather than overwritten or joined. // +// The sealed installation travels beside the capability on every call, so the +// callee can re-check the installation the call is being made under without +// decoding the token first. It is a pre-check and not authority: the +// installation that governs the call is the sealed one, and a callee that finds +// the two disagreeing refuses rather than choosing between them. +// // A Work Context is bound to one audience. A service calling another service // attaches a context the authority exchanged for the callee's audience // (ExchangeWorkContextAudience), never the one it received: the callee's @@ -74,12 +85,23 @@ func WithGRPCExecutionContext( if err != nil { return nil, err } - existing, _ := metadata.FromOutgoingContext(ctx) - if len(existing.Get(workContextGRPCMetadataName)) != 0 { - return nil, fmt.Errorf("%w: outgoing gRPC Work Context already set", workcontext.ErrWorkContextInvalid) + installationID, installationRevision, err := workcontext.SealedInstallation(validated.workContext) + if err != nil { + return nil, err } - if len(existing.Get(operationIDGRPCMetadataName)) != 0 { - return nil, fmt.Errorf("%w: outgoing gRPC operation ID already set", workcontext.ErrWorkContextInvalid) + existing, _ := metadata.FromOutgoingContext(ctx) + for _, carrier := range []string{ + workContextGRPCMetadataName, + operationIDGRPCMetadataName, + installationIDGRPCMetadataName, + installationRevisionGRPCMetadataName, + } { + if len(existing.Get(carrier)) != 0 { + return nil, fmt.Errorf( + "%w: outgoing gRPC metadata %q is already set", + workcontext.ErrWorkContextInvalid, carrier, + ) + } } return metadata.AppendToOutgoingContext( ctx, @@ -87,6 +109,10 @@ func WithGRPCExecutionContext( validated.workContext.Encoded(), operationIDGRPCMetadataName, validated.operationID, + installationIDGRPCMetadataName, + installationID, + installationRevisionGRPCMetadataName, + installationRevision, ), nil } @@ -119,9 +145,50 @@ func GRPCExecutionContextFromIncoming(ctx context.Context) (ExecutionContext, er if err != nil { return ExecutionContext{}, err } + if err := checkIncomingInstallation(values, workContext); err != nil { + return ExecutionContext{}, err + } return NewExecutionContext(workContext, operationIDs[0]) } +// checkIncomingInstallation holds the installation carriers to the sealed +// claim. The carriers exist so a callee can refuse cheaply; they are caller- +// controlled, so the only two acceptable outcomes are "they agree with the +// seal" and "the call is refused". Preferring the carrier would let a caller +// name any installation it liked, and ignoring a disagreement would make the +// carriers decoration that a reader would nonetheless log and believe. +func checkIncomingInstallation( + values metadata.MD, + token workcontext.WorkContextToken, +) error { + sealedID, sealedRevision, err := workcontext.SealedInstallation(token) + if err != nil { + return err + } + for _, carrier := range []struct { + name string + sealed string + }{ + {installationIDGRPCMetadataName, sealedID}, + {installationRevisionGRPCMetadataName, sealedRevision}, + } { + presented := values.Get(carrier.name) + if len(presented) != 1 { + return fmt.Errorf( + "%w: incoming gRPC %s requires exactly one value", + workcontext.ErrWorkContextInvalid, carrier.name, + ) + } + if presented[0] != carrier.sealed { + return fmt.Errorf( + "%w: incoming gRPC %s is %q and the credential seals %q", + workcontext.ErrWorkContextInvalid, carrier.name, presented[0], carrier.sealed, + ) + } + } + return nil +} + // GRPCExecutionContextFromIncomingIfPresent supports compatibility boundaries // where execution attribution is optional. No carrier returns present=false; // a partial or duplicate carrier is still an error. @@ -135,9 +202,16 @@ func GRPCExecutionContextFromIncomingIfPresent( if !ok { return ExecutionContext{}, false, nil } - workContexts := values.Get(workContextGRPCMetadataName) - operationIDs := values.Get(operationIDGRPCMetadataName) - if len(workContexts) == 0 && len(operationIDs) == 0 { + carried := 0 + for _, carrier := range []string{ + workContextGRPCMetadataName, + operationIDGRPCMetadataName, + installationIDGRPCMetadataName, + installationRevisionGRPCMetadataName, + } { + carried += len(values.Get(carrier)) + } + if carried == 0 { return ExecutionContext{}, false, nil } execution, err = GRPCExecutionContextFromIncoming(ctx) diff --git a/workcontext/grpctransport/execution_context_grpc_test.go b/workcontext/grpctransport/execution_context_grpc_test.go index 32423c0..b1ab1a0 100644 --- a/workcontext/grpctransport/execution_context_grpc_test.go +++ b/workcontext/grpctransport/execution_context_grpc_test.go @@ -2,20 +2,48 @@ package grpctransport import ( "context" - "encoding/base64" + "crypto/ed25519" "strings" "testing" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" "github.com/codefly-dev/sdk-go/workcontext" ) +// opaqueTestWorkContext is a real signed credential rather than a shaped +// placeholder. It has to be: the transport reads the installation out of the +// token so every call carries it, so a token with no seal cannot be attached +// at all — which is the behaviour, and a placeholder here would hide it. func opaqueTestWorkContext(t *testing.T) workcontext.WorkContextToken { t.Helper() - signature := base64.RawURLEncoding.EncodeToString(make([]byte, 64)) - token, err := workcontext.ParseWorkContextToken("e30." + signature) + seed := make([]byte, ed25519.SeedSize) + for index := range seed { + seed[index] = byte(index) + } + signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ + Issuer: "https://accounts.codefly.dev/work-context", + KeyID: "transport-test", + PrivateKey: ed25519.NewKeyFromSeed(seed), + }) + require.NoError(t, err) + token, _, err := signer.StartTask(workcontext.StartTaskInput{ + Audience: "warden.transport", TenantID: "tenant-codefly", + OwnerPrincipalID: "principal-owner", OwnerPrincipalKind: "user", + TaskID: "task-transport", SessionID: "session-transport", + AuthorizationRevision: 1, + Seal: workcontext.Seal{ + PrincipalEpoch: 2, + InstallationID: "installation-transport", + InstallationRevision: 5, + BuildIncarnation: "build-incarnation-transport", + }, + AuthorityScopes: []*basev0.WorkScopeV1{ + {ResourceKind: "evidence", Actions: []string{"append"}}, + }, + }) require.NoError(t, err) return token } @@ -105,3 +133,76 @@ func TestGRPCExecutionContextOptionalExtractionDistinguishesAbsentFromPartial(t require.ErrorContains(t, err, "Work Context requires exactly one value") require.False(t, present) } + +// Deliverable 3 on the gRPC side: the sealed installation travels beside the +// capability on every call, under the same names HTTP uses, so a callee can +// refuse before it decodes anything. +func TestGRPCCallsCarryTheSealedInstallation(t *testing.T) { + execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-019f8fc1") + require.NoError(t, err) + outgoing, err := WithGRPCExecutionContext(context.Background(), execution) + require.NoError(t, err) + carried, ok := metadata.FromOutgoingContext(outgoing) + require.True(t, ok) + + require.Equal(t, []string{"installation-transport"}, carried.Get(workcontext.InstallationIDHeaderName)) + require.Equal(t, []string{"5"}, carried.Get(workcontext.InstallationRevisionHeaderName)) +} + +// The carriers are caller-controlled, so the only two acceptable outcomes are +// "they agree with the seal" and "the call is refused". Preferring the carrier +// would let a caller name any installation it liked; ignoring a disagreement +// would make them decoration that a reader would nonetheless log and believe. +func TestIncomingInstallationCarriersAreHeldToTheSeal(t *testing.T) { + execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-019f8fc1") + require.NoError(t, err) + outgoing, err := WithGRPCExecutionContext(context.Background(), execution) + require.NoError(t, err) + honest, ok := metadata.FromOutgoingContext(outgoing) + require.True(t, ok) + + _, err = GRPCExecutionContextFromIncoming(metadata.NewIncomingContext(context.Background(), honest)) + require.NoError(t, err) + + for name, mutate := range map[string]func(metadata.MD){ + "another installation": func(values metadata.MD) { + values.Set(workcontext.InstallationIDHeaderName, "installation-somebody-elses") + }, + "another revision": func(values metadata.MD) { + values.Set(workcontext.InstallationRevisionHeaderName, "6") + }, + "installation removed": func(values metadata.MD) { + values.Delete(workcontext.InstallationIDHeaderName) + }, + "revision removed": func(values metadata.MD) { + values.Delete(workcontext.InstallationRevisionHeaderName) + }, + "duplicate revision": func(values metadata.MD) { + values.Append(workcontext.InstallationRevisionHeaderName, "5") + }, + } { + t.Run(name, func(t *testing.T) { + tampered := honest.Copy() + mutate(tampered) + _, err := GRPCExecutionContextFromIncoming(metadata.NewIncomingContext(context.Background(), tampered)) + require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + }) + } +} + +// A partial carrier set is an error rather than an absence: the optional- +// attribution boundary exists for a call that carries nothing at all, not for +// one that carries half of an authority. +func TestIfPresentTreatsAPartialInstallationCarrierAsAnError(t *testing.T) { + _, present, err := GRPCExecutionContextFromIncomingIfPresent( + metadata.NewIncomingContext(context.Background(), metadata.MD{}), + ) + require.NoError(t, err) + require.False(t, present) + + _, _, err = GRPCExecutionContextFromIncomingIfPresent(metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(workcontext.InstallationIDHeaderName, "installation-transport"), + )) + require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) +} diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index 27054fa..49a521f 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -106,9 +106,7 @@ func verifyIncoming( if err != nil { return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) } - verified, err := verifier.VerifyWorkContext(execution.WorkContext(), workcontext.WorkContextExpectations{ - Issuer: hopIssuer, Audience: audience, - }) + verified, err := verifier.VerifyWorkContext(execution.WorkContext(), hopExpectations(audience)) if err != nil { return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) } @@ -229,11 +227,38 @@ func newHopTopology(t *testing.T) *hopTopology { return topology } +// hopSeal is the execution both services in the hop run as: one installation, +// one revision, one build. A hop crosses an audience boundary inside one +// execution, so the seal is the same on both sides of it. +func hopSeal() workcontext.Seal { + return workcontext.Seal{ + PrincipalEpoch: 3, + InstallationID: "installation-hop", + InstallationRevision: 12, + BuildIncarnation: "build-incarnation-hop", + } +} + +func hopExpectations(audience string) workcontext.WorkContextExpectations { + seal := hopSeal() + return workcontext.WorkContextExpectations{ + Issuer: hopIssuer, + Audience: audience, + Seal: workcontext.SealExpectations{ + PrincipalEpoch: seal.PrincipalEpoch, + InstallationID: seal.InstallationID, + InstallationRevision: seal.InstallationRevision, + BuildIncarnation: seal.BuildIncarnation, + }, + } +} + // startTask issues the context the external caller holds: for service A only. func (h *hopTopology) startTask(t *testing.T) workcontext.WorkContextToken { t.Helper() token, _, err := h.authority.signer.StartTask(workcontext.StartTaskInput{ Audience: audienceA, TenantID: "tenant-codefly", OwnerPrincipalID: "principal-owner", + OwnerPrincipalKind: "user", Seal: hopSeal(), TaskID: "task-hop", SessionID: "session-hop", AuthorizationRevision: 7, AuthorityScopes: cloneHopScopes(), }) @@ -307,7 +332,7 @@ func TestServiceHopExchangeThenCallSucceedsWithoutWideningScopes(t *testing.T) { require.Equal(t, codes.Unauthenticated, status.Code(exchangedToA), exchangedToA) // The hop keeps the cache partition: same tenant, same view. - verifiedA, err := topology.authority.verifier(t).VerifyWorkContext(token, workcontext.WorkContextExpectations{Audience: audienceA}) + verifiedA, err := topology.authority.verifier(t).VerifyWorkContext(token, hopExpectations(audienceA)) require.NoError(t, err) for _, options := range [][]workcontext.CachePartitionOption{nil, {workcontext.ByAuthorizationView()}} { partitionA, err := workcontext.DeriveCachePartition(verifiedA, options...) @@ -323,7 +348,7 @@ func TestServiceHopExchangeMayAttenuateButNeverWiden(t *testing.T) { require.NoError(t, topology.call(t, topology.a, topology.startTask(t), hopAttenuate)) <-topology.atA - atB := (<-topology.atB).Claims() + atB := <-topology.atB require.NoError(t, workcontext.RequireWorkContextScope(atB, workcontext.WorkContextScopeRequirement{ ResourceKind: "evidence", Action: "append", })) diff --git a/workcontext/mint.go b/workcontext/mint.go new file mode 100644 index 0000000..057d9ec --- /dev/null +++ b/workcontext/mint.go @@ -0,0 +1,464 @@ +package workcontext + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Bounds on everything the mint endpoint can hand back. A credential is the +// one thing a process will trust for the rest of its life, so nothing about +// the response is allowed to be unbounded. +const ( + defaultMintRequestTimeout = 5 * time.Second + maxMintRequestTimeout = 30 * time.Second + maxMintResponseBytes = 64 * 1024 + maxProjectedTokenBytes = 64 * 1024 + + // defaultRenewalLead is how far before expiry a renewal is attempted, as a + // fraction of the credential's whole lifetime. It is not a heartbeat: at + // 0.2 a fifteen-minute credential is renewed once, three minutes before it + // expires, and a process that runs for an hour renews four times rather + // than announcing itself two hundred and forty times. + defaultRenewalLead = 0.2 + + // minRenewalLeadTime floors the lead so a very short credential still + // leaves room for one request and one retry. + minRenewalLeadTime = 5 * time.Second +) + +// Errors a mint caller distinguishes. +var ( + // ErrMintRefused marks a refusal the host will give again: the projected + // token is not acceptable, the build does not match what the host approved, + // the installation is not one this process may serve. Minting again changes + // nothing, so a process that sees it must not serve. + ErrMintRefused = errors.New("Codefly credential mint refused") + + // ErrMintUnavailable marks a refusal caused by the endpoint being + // unreachable or answering 5xx/429. It is retryable, and it is the only + // mint outcome that is. + ErrMintUnavailable = errors.New("Codefly credential mint unavailable") +) + +// ProjectedTokenSource reads the service-account token the platform projects +// into the process. It is an interface with one implementation so a test can +// rotate the token without a filesystem, and so the only thing that ever holds +// a path to it is this package. +type ProjectedTokenSource interface { + // ProjectedToken returns the token as it is on disk right now. It is called + // again before every renewal rather than cached, because the projection is + // rotated under the running process and the copy read at boot is expired + // long before the process is. + ProjectedToken() (string, error) +} + +// ProjectedTokenFile reads the projection from its mounted path. +type ProjectedTokenFile string + +// ProjectedToken reads and trims the file. An empty or whitespace-only file is +// an error and never an empty token: a projection that has been unmounted or +// not yet written must not be presented as a credential request, because the +// host would refuse it and the refusal would read as "this build is not +// approved" rather than "the token is missing". +func (path ProjectedTokenFile) ProjectedToken() (string, error) { + if strings.TrimSpace(string(path)) == "" { + return "", fmt.Errorf("%w: no projected token path", ErrMintRefused) + } + file, err := os.Open(string(path)) + if err != nil { + return "", fmt.Errorf("%w: open projected token: %v", ErrMintRefused, err) + } + defer func() { + _ = file.Close() + }() + raw, err := io.ReadAll(io.LimitReader(file, maxProjectedTokenBytes+1)) + if err != nil { + return "", fmt.Errorf("%w: read projected token: %v", ErrMintRefused, err) + } + if len(raw) > maxProjectedTokenBytes { + return "", fmt.Errorf("%w: projected token exceeds %d bytes", ErrMintRefused, maxProjectedTokenBytes) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", fmt.Errorf("%w: projected token is empty", ErrMintRefused) + } + return token, nil +} + +// AuthorityPin is the boot-read authority a renewal is checked against. The +// root SDK's *codefly.Authority satisfies it. +// +// It exists so a renewal cannot re-seal a credential to a value that drifted +// after boot. The host is the authority for the audience and the binding a +// process mints against; a value that changes under a running process is an +// error, and the renewal is exactly the moment that error would otherwise be +// laundered into a new, differently-sealed credential. +type AuthorityPin interface { + Recheck(ctx context.Context) error +} + +// MintOptions configures the one credential a process obtains per execution. +type MintOptions struct { + // URL is the host's mint endpoint, absolute, no query and no fragment. + URL string + // Audience is the Work Context audience to mint for, as the host names it. + // It is read from the process's boot-read authority, never typed. + Audience string + // ProjectedToken reads the platform's projection of this process's + // service-account token. Required. + ProjectedToken ProjectedTokenSource + // ProjectionAudience is the audience the projected token itself was minted + // for, which the host states. Sent so the host can refuse a projection + // aimed at something else rather than reviewing whatever it is handed. + ProjectionAudience string + // Authority, when set, is rechecked before every renewal. + Authority AuthorityPin + // HTTPClient defaults to a client that refuses to follow redirects: a + // redirected mint request is a credential request sent somewhere the + // configuration did not name. + HTTPClient *http.Client + // RequestTimeout bounds one mint request. + RequestTimeout time.Duration + // RenewalLead overrides the fraction of a credential's lifetime at which + // renewal is attempted. Zero takes the default. + RenewalLead float64 + // Now is the clock, for tests. + Now func() time.Time +} + +// Credential is the sealed capability a process holds for its execution. It is +// obtained once by Mint and replaced only by renewal at expiry. +type Credential struct { + token WorkContextToken + seal Seal + binding *OperationBinding + issuedAt time.Time + expiresAt time.Time +} + +// Token returns the opaque signed capability. +func (c Credential) Token() WorkContextToken { return c.token } + +// Seal returns the execution this credential is bound to. +func (c Credential) Seal() Seal { return c.seal } + +// OperationBinding returns the one binding this credential may act through, or +// nil. +func (c Credential) OperationBinding() *OperationBinding { + if c.binding == nil { + return nil + } + binding := *c.binding + return &binding +} + +// IssuedAt reports when the host minted it. +func (c Credential) IssuedAt() time.Time { return c.issuedAt } + +// ExpiresAt reports when the credential stops being accepted. +func (c Credential) ExpiresAt() time.Time { return c.expiresAt } + +// Attach installs the credential on an outbound HTTP request: the signed +// capability and, beside it, the installation it is sealed to. +func (c Credential) Attach(request *http.Request) error { + return AttachWorkContext(request, c.token) +} + +// MintClient obtains and renews one credential for one execution. +// +// What it does not do is the point of it. There is no heartbeat: nothing here +// runs on a timer that is not the credential's own expiry. There is no +// registration call: the process announces nothing about itself, because +// everything the host needs to know — which build, which installation, which +// principal — the host establishes from the projected token and its own +// records, and anything the process could report instead is something it could +// misreport. There is no self-reported upstream or manifest for the same +// reason. +type MintClient struct { + mu sync.Mutex + options MintOptions + httpClient *http.Client + now func() time.Time + credential *Credential + mints uint64 + renewals uint64 +} + +// NewMintClient validates configuration without performing any I/O. +func NewMintClient(options MintOptions) (*MintClient, error) { + endpoint, err := validateMintURL(options.URL) + if err != nil { + return nil, err + } + if strings.TrimSpace(options.Audience) == "" { + return nil, fmt.Errorf("%w: no audience to mint for", ErrMintRefused) + } + if options.ProjectedToken == nil { + return nil, fmt.Errorf("%w: no projected token source", ErrMintRefused) + } + if strings.TrimSpace(options.ProjectionAudience) == "" { + return nil, fmt.Errorf("%w: no projection audience", ErrMintRefused) + } + timeout := options.RequestTimeout + if timeout == 0 { + timeout = defaultMintRequestTimeout + } + if timeout < time.Millisecond || timeout > maxMintRequestTimeout { + return nil, fmt.Errorf( + "%w: mint request timeout must be between 1ms and %s", + ErrMintRefused, maxMintRequestTimeout, + ) + } + lead := options.RenewalLead + if lead == 0 { + lead = defaultRenewalLead + } + if lead <= 0 || lead >= 1 { + return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrMintRefused) + } + now := options.Now + if now == nil { + now = time.Now + } + client := options.HTTPClient + if client == nil { + client = &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + options.URL = endpoint + options.RequestTimeout = timeout + options.RenewalLead = lead + return &MintClient{options: options, httpClient: client, now: now}, nil +} + +// Credential returns the credential for this moment, minting the first one and +// renewing a credential that has reached its renewal point. Every caller goes +// through it, so there is no path on which a stale credential is used. +// +// It holds one lock across the mint request, so concurrent callers wait rather +// than each minting their own. That is the point: a process obtains one +// credential per execution, and a process whose first request burst produced +// one mint per goroutine would be the heartbeat again under another name. The +// request timeout bounds the wait. +// +// Renewal re-reads the projected token and rechecks the boot-read authority +// first. A projection rotated under the running process is therefore picked up +// without a restart, and an authority value that drifted refuses the renewal +// instead of being sealed into a new credential. +func (c *MintClient) Credential(ctx context.Context) (Credential, error) { + if c == nil { + return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) + } + if ctx == nil { + return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.credential != nil && !c.dueForRenewalLocked(*c.credential) { + return *c.credential, nil + } + renewal := c.credential != nil + if renewal && c.options.Authority != nil { + if err := c.options.Authority.Recheck(ctx); err != nil { + return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + } + } + credential, err := c.mintLocked(ctx) + if err != nil { + return Credential{}, err + } + c.credential = &credential + if renewal { + c.renewals++ + } else { + c.mints++ + } + return credential, nil +} + +// Counts reports how many first mints and how many renewals this process has +// performed. A correct execution reports exactly one mint; renewals are +// expected and bounded by the credential's lifetime, not by a beat. +func (c *MintClient) Counts() (mints uint64, renewals uint64) { + if c == nil { + return 0, 0 + } + c.mu.Lock() + defer c.mu.Unlock() + return c.mints, c.renewals +} + +// dueForRenewalLocked is true once the credential has entered its renewal lead. +// An already-expired credential is also due, and is never returned to a caller: +// handing one out would turn a renewal failure into an authorization failure at +// the far end, where nothing can act on it. +func (c *MintClient) dueForRenewalLocked(credential Credential) bool { + now := c.now().UTC() + remaining := credential.expiresAt.Sub(now) + if remaining <= 0 { + return true + } + lifetime := credential.expiresAt.Sub(credential.issuedAt) + lead := time.Duration(float64(lifetime) * c.options.RenewalLead) + if lead < minRenewalLeadTime { + lead = minRenewalLeadTime + } + if lead > lifetime { + lead = lifetime + } + return remaining <= lead +} + +func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { + projected, err := c.options.ProjectedToken.ProjectedToken() + if err != nil { + return Credential{}, err + } + body, err := json.Marshal(mintRequest{ + Audience: c.options.Audience, + ProjectionAudience: c.options.ProjectionAudience, + }) + if err != nil { + return Credential{}, fmt.Errorf("%w: encode mint request: %v", ErrMintRefused, err) + } + requestContext, cancel := context.WithTimeout(ctx, c.options.RequestTimeout) + defer cancel() + request, err := http.NewRequestWithContext( + requestContext, http.MethodPost, c.options.URL, bytes.NewReader(body), + ) + if err != nil { + return Credential{}, fmt.Errorf("%w: create mint request: %v", ErrMintRefused, err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+projected) + response, err := c.httpClient.Do(request) + if err != nil { + return Credential{}, fmt.Errorf("%w: %v", ErrMintUnavailable, err) + } + defer func() { + _ = response.Body.Close() + }() + if response.Request != nil && response.Request.URL != nil && + response.Request.URL.String() != c.options.URL { + // The projected token was already sent to whatever answered. Treating a + // redirect as retryable would send it again; treating it as a refusal + // stops the process, which is the only safe outcome once a credential + // request has left for an address nothing configured. + return Credential{}, fmt.Errorf("%w: mint request was redirected", ErrMintRefused) + } + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) + sentinel := ErrMintRefused + if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { + sentinel = ErrMintUnavailable + } + return Credential{}, fmt.Errorf("%w: mint returned HTTP %d", sentinel, response.StatusCode) + } + if contentType := response.Header.Get("Content-Type"); contentType != "" { + mediaType, _, parseErr := mime.ParseMediaType(contentType) + if parseErr != nil || mediaType != "application/json" { + return Credential{}, fmt.Errorf("%w: mint response is not application/json", ErrMintRefused) + } + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxMintResponseBytes+1)) + if err != nil { + return Credential{}, fmt.Errorf("%w: read mint response: %v", ErrMintRefused, err) + } + if len(payload) > maxMintResponseBytes { + return Credential{}, fmt.Errorf( + "%w: mint response exceeds %d bytes", ErrMintRefused, maxMintResponseBytes, + ) + } + return c.credentialFrom(payload) +} + +// credentialFrom reads the credential out of the response and takes its sealed +// values from the token, not from the response body. The host may echo them for +// a log, and an echo that disagrees with the signature is a refusal: the only +// installation, build and epoch that mean anything are the signed ones. +func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { + var body mintResponse + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintRefused, err) + } + token, err := ParseWorkContextToken(body.WorkContext) + if err != nil { + return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + } + claims, seal, binding, err := readSealedToken(token) + if err != nil { + return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + } + if body.InstallationID != "" && body.InstallationID != seal.InstallationID { + return Credential{}, fmt.Errorf( + "%w: mint reported installation %q and sealed %q", + ErrMintRefused, body.InstallationID, seal.InstallationID, + ) + } + if body.BuildIncarnation != "" && body.BuildIncarnation != seal.BuildIncarnation { + return Credential{}, fmt.Errorf( + "%w: mint reported build incarnation %q and sealed %q", + ErrMintRefused, body.BuildIncarnation, seal.BuildIncarnation, + ) + } + if claims.GetAudience() != c.options.Audience { + return Credential{}, fmt.Errorf( + "%w: credential was minted for audience %q, not %q", + ErrMintRefused, claims.GetAudience(), c.options.Audience, + ) + } + return Credential{ + token: token, + seal: seal, + binding: binding, + issuedAt: time.Unix(claims.GetIssuedAtUnix(), 0).UTC(), + expiresAt: time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), + }, nil +} + +// mintRequest is everything the client tells the host, which is only what it +// wants — never who it is. Principal, installation and build are established +// by the host from the projected token and its own records; a field here for +// any of them would be a field a process could lie in. +type mintRequest struct { + Audience string `json:"audience"` + ProjectionAudience string `json:"projection_audience"` +} + +// mintResponse is the host's answer. The echoed fields are for logs and for +// catching a host and a signature that disagree; nothing authorizes from them. +type mintResponse struct { + WorkContext string `json:"work_context"` + InstallationID string `json:"installation_id,omitempty"` + BuildIncarnation string `json:"build_incarnation,omitempty"` +} + +func validateMintURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || + parsed.Fragment != "" || parsed.RawQuery != "" || + parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf( + "%w: mint URL must be an absolute HTTP(S) URL without credentials, query, or fragment", + ErrMintRefused, + ) + } + return parsed.String(), nil +} diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go new file mode 100644 index 0000000..348cfc1 --- /dev/null +++ b/workcontext/mint_test.go @@ -0,0 +1,361 @@ +package workcontext + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// mintHost is the host's mint endpoint, counting what it is asked for. The +// count is the acceptance criterion: a process that runs for an hour must not +// have announced itself every fifteen seconds. +type mintHost struct { + t *testing.T + server *httptest.Server + requests atomic.Uint64 + presented atomic.Value // the last projected token the host was shown + now func() time.Time + lifetime time.Duration + audience string + seal Seal + echoWrong bool + refuseWith int + // mintAudience, when set, makes the host answer with a credential for an + // audience other than the one asked for. + mintAudience string +} + +func newMintHost(t *testing.T, now func() time.Time) *mintHost { + t.Helper() + host := &mintHost{ + t: t, now: now, lifetime: 15 * time.Minute, + audience: "warden.evidence", seal: workContextTestSeal(), + } + host.server = httptest.NewServer(http.HandlerFunc(host.handle)) + t.Cleanup(host.server.Close) + return host +} + +func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { + h.requests.Add(1) + h.presented.Store(request.Header.Get("Authorization")) + if h.refuseWith != 0 { + writer.WriteHeader(h.refuseWith) + return + } + var body mintRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + input := workContextTestInput() + input.Audience = body.Audience + if h.mintAudience != "" { + input.Audience = h.mintAudience + } + input.Seal = h.seal + input.TTL = h.lifetime + token, _, err := workContextTestSigner(h.t, h.now()).StartTask(input) + require.NoError(h.t, err) + response := mintResponse{WorkContext: token.Encoded(), InstallationID: h.seal.InstallationID} + if h.echoWrong { + response.InstallationID = "installation-the-host-did-not-seal" + } + writer.Header().Set("Content-Type", "application/json") + require.NoError(h.t, json.NewEncoder(writer).Encode(response)) +} + +// projectedFile writes a rotating projected token and hands back its path. +func projectedFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func newTestMintClient(t *testing.T, host *mintHost, path string, now func() time.Time, options ...func(*MintOptions)) *MintClient { + t.Helper() + settings := MintOptions{ + URL: host.server.URL, + Audience: "warden.evidence", + ProjectedToken: ProjectedTokenFile(path), + ProjectionAudience: "accounts", + Now: now, + } + for _, option := range options { + option(&settings) + } + client, err := NewMintClient(settings) + require.NoError(t, err) + return client +} + +// The acceptance criterion, as arithmetic rather than as a hope. A process +// that runs for an hour holding a credential it renews only at expiry mints +// once and renews on the credential's own clock. The heartbeat it replaces +// minted once per surface every fifteen seconds — 240 times an hour, each one +// an audit event recording that nothing had changed. +func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { + clock := workContextTestTime + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected-token-1"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, workContextTestSeal(), first.Seal()) + require.EqualValues(t, 1, host.requests.Load()) + + // Every call for the next twelve minutes is answered from the credential + // already held. Nothing reaches the host. + for minute := range 12 { + clock = workContextTestTime.Add(time.Duration(minute) * time.Minute) + again, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, first.Token().Encoded(), again.Token().Encoded()) + } + require.EqualValues(t, 1, host.requests.Load(), "holding a credential must not talk to the host") + + mints, renewals := client.Counts() + require.EqualValues(t, 1, mints) + require.EqualValues(t, 0, renewals) + + // An hour of a fifteen-minute credential, renewed three minutes before each + // expiry: one mint and five renewals, six requests in total. The heartbeat + // this replaces made 240 in the same hour, one per surface every fifteen + // seconds, each recording that nothing had changed. + for minute := 12; minute <= 60; minute++ { + clock = workContextTestTime.Add(time.Duration(minute) * time.Minute) + _, err := client.Credential(t.Context()) + require.NoError(t, err) + } + mints, renewals = client.Counts() + require.EqualValues(t, 1, mints, "exactly one first mint per execution") + require.EqualValues(t, 5, renewals) + require.EqualValues(t, 6, host.requests.Load()) +} + +// The projection is rotated under the running process, so the copy read at +// boot is expired long before the process is. Re-reading it before every +// renewal is what makes the rotation a non-event; caching it would make the +// process fail at its first renewal and need a restart to recover. +func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { + clock := workContextTestTime + now := func() time.Time { return clock } + host := newMintHost(t, now) + path := projectedFile(t, "projected-token-before-rotation") + client := newTestMintClient(t, host, path, now) + + _, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, "Bearer projected-token-before-rotation", host.presented.Load()) + + require.NoError(t, os.WriteFile(path, []byte("projected-token-after-rotation"), 0o600)) + clock = workContextTestTime.Add(14 * time.Minute) + _, err = client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, "Bearer projected-token-after-rotation", host.presented.Load(), + "the renewal must present the rotated projection, not the one read at boot") + + mints, renewals := client.Counts() + require.EqualValues(t, 1, mints) + require.EqualValues(t, 1, renewals) +} + +// An authority value that drifted under the running process refuses the +// renewal. This is the moment the drift would otherwise be laundered: the +// process would mint a new credential sealed to a value the host never +// approved for it, and nothing downstream could tell. +func TestRenewalRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { + clock := workContextTestTime + now := func() time.Time { return clock } + host := newMintHost(t, now) + drifted := errors.New("audience changed under a running process") + pin := &stubAuthority{} + client := newTestMintClient(t, host, projectedFile(t, "projected"), now, func(options *MintOptions) { + options.Authority = pin + }) + + _, err := client.Credential(t.Context()) + require.NoError(t, err, "the first mint is the boot read, so there is nothing to have drifted from") + require.EqualValues(t, 0, pin.checks.Load(), "the first mint is not a renewal") + + pin.err = drifted + clock = workContextTestTime.Add(14 * time.Minute) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") + require.EqualValues(t, 1, pin.checks.Load()) + require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") +} + +type stubAuthority struct { + checks atomic.Uint64 + err error +} + +func (s *stubAuthority) Recheck(context.Context) error { + s.checks.Add(1) + return s.err +} + +// The host may echo the sealed values for a log. An echo that disagrees with +// the signature is a refusal: the only installation that means anything is the +// signed one, and a client that preferred the echo would hold a credential +// whose installation it had been told wrongly. +func TestMintRefusesAnEchoThatDisagreesWithTheSignature(t *testing.T) { + now := func() time.Time { return workContextTestTime } + host := newMintHost(t, now) + host.echoWrong = true + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "reported installation") +} + +// A credential minted for another audience is refused rather than held. The +// far end would refuse it anyway; refusing it here makes the failure a boot +// error in the process that is wrong rather than an authorization error in the +// service it calls. +func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { + now := func() time.Time { return workContextTestTime } + host := newMintHost(t, now) + host.mintAudience = "warden.other" + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "minted for audience") +} + +// A refusal the host will give again is not retryable, and an outage is. A +// client that confused them would either spin against a permanent refusal or +// give up on a transient one. +func TestMintSeparatesARefusalFromAnOutage(t *testing.T) { + now := func() time.Time { return workContextTestTime } + for status, sentinel := range map[int]error{ + http.StatusForbidden: ErrMintRefused, + http.StatusUnauthorized: ErrMintRefused, + http.StatusTooManyRequests: ErrMintUnavailable, + http.StatusInternalServerError: ErrMintUnavailable, + http.StatusBadGateway: ErrMintUnavailable, + } { + t.Run(fmt.Sprintf("http %d", status), func(t *testing.T) { + host := newMintHost(t, now) + host.refuseWith = status + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, sentinel) + }) + } +} + +// The projection is the one thing the client presents, so an unreadable one is +// a refusal rather than an empty bearer token. Sending the request anyway would +// turn "the token is missing" into the host's "this build is not approved", +// which is the wrong thing to go looking for. +func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { + for name, path := range map[string]ProjectedTokenFile{ + "no path": "", + "absent file": ProjectedTokenFile(filepath.Join(t.TempDir(), "missing")), + "empty file": ProjectedTokenFile(projectedFile(t, "")), + "whitespace": ProjectedTokenFile(projectedFile(t, " \n\t ")), + } { + t.Run(name, func(t *testing.T) { + _, err := path.ProjectedToken() + require.ErrorIs(t, err, ErrMintRefused) + }) + } + + token, err := ProjectedTokenFile(projectedFile(t, " projected-with-trailing-newline\n")).ProjectedToken() + require.NoError(t, err) + require.Equal(t, "projected-with-trailing-newline", token) +} + +// A mint request carries what the process wants and nothing about who it is. +// Principal, installation and build are the host's to establish; a field for +// any of them would be a field a process could lie in. +func TestMintRequestSelfReportsNoIdentity(t *testing.T) { + encoded, err := json.Marshal(mintRequest{Audience: "warden.evidence", ProjectionAudience: "accounts"}) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.Equal(t, []string{"audience", "projection_audience"}, sortedUnique(keysOf(fields))) +} + +func keysOf(fields map[string]any) []string { + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + return names +} + +// A credential request that was answered by an address nothing configured has +// already left with the projected token attached. Retrying would send it again, +// so a redirect is a permanent refusal and the process must not serve. +func TestMintRefusesARedirectedRequest(t *testing.T) { + now := func() time.Time { return workContextTestTime } + elsewhere := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(elsewhere.Close) + redirector := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, elsewhere.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(redirector.Close) + + client, err := NewMintClient(MintOptions{ + URL: redirector.URL, + Audience: "warden.evidence", + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "accounts", + Now: now, + HTTPClient: &http.Client{}, + }) + require.NoError(t, err) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.NotErrorIs(t, err, ErrMintUnavailable) +} + +func TestNewMintClientValidatesItsConfiguration(t *testing.T) { + valid := MintOptions{ + URL: "https://accounts.internal/platform/_mint", + Audience: "warden.evidence", + ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), + ProjectionAudience: "accounts", + } + _, err := NewMintClient(valid) + require.NoError(t, err) + + for name, mutate := range map[string]func(*MintOptions){ + "no url": func(o *MintOptions) { o.URL = "" }, + "url with query": func(o *MintOptions) { o.URL = "https://accounts.internal/_mint?as=root" }, + "url with credentials": func(o *MintOptions) { o.URL = "https://user:pass@accounts.internal/_mint" }, + "no audience": func(o *MintOptions) { o.Audience = "" }, + "no projection": func(o *MintOptions) { o.ProjectedToken = nil }, + "no projection audience": func(o *MintOptions) { o.ProjectionAudience = "" }, + "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, + "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, + "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, + } { + t.Run(name, func(t *testing.T) { + options := valid + mutate(&options) + _, err := NewMintClient(options) + require.ErrorIs(t, err, ErrMintRefused) + }) + } +} diff --git a/workcontext/seal.go b/workcontext/seal.go new file mode 100644 index 0000000..3cf903a --- /dev/null +++ b/workcontext/seal.go @@ -0,0 +1,245 @@ +package workcontext + +import ( + "fmt" + "strconv" +) + +// Carrier names for the sealed installation. They exist so a host can refuse a +// request before it decodes a token, and for no other reason: the installation +// that governs the call is the one sealed inside the signature. A verifier that +// disagrees with these headers refuses the call rather than preferring either +// side, because a header is caller-controlled and the seal is not. +const ( + InstallationIDHeaderName = "x-codefly-installation-id" + InstallationRevisionHeaderName = "x-codefly-installation-revision" +) + +// Seal binds one credential to the execution it was issued to: the principal +// the host authenticated and the epoch it authenticated at, the installation +// the process serves and the revision of that installation, and the build +// incarnation the process is running. +// +// Every field is required. A credential missing any of them does not verify — +// there is no shape in which part of a seal is a seal, because each field is +// the only thing standing between a credential and its reuse somewhere it was +// never issued for. +type Seal struct { + // PrincipalEpoch is the host's epoch for the owner principal. It moves when + // the principal's authority changes, so a credential sealed to an older + // epoch is refused rather than carried across the change. + PrincipalEpoch uint64 + // InstallationID is the installation the process serves. It is the + // immutable, non-reused installation identity, never a reusable route alias + // or registry key: an alias can be released and taken by something else, + // and a credential must not inherit the grants of whatever held it before. + InstallationID string + // InstallationRevision is that installation's revision at mint time. A + // verifier holding a later revision refuses the credential. + InstallationRevision uint64 + // BuildIncarnation identifies the exact build the process is running, as + // the host established it — never as the process reported it. + BuildIncarnation string +} + +// OperationBinding seals an operation context to exactly one binding, at one +// revision, in one incarnation. Verification resolves the binding identity +// rather than searching for a binding whose scopes would admit the call: a +// superset of scopes is not a licence to act as a different binding. +type OperationBinding struct { + BindingID string + BindingRevision uint64 + BindingIncarnation string +} + +// SealedInstallation reads the installation a token is sealed to, for a +// transport that names it beside the capability. It reads the token's own +// content without checking the signature, which is sound only because nothing +// trusts the result: the carrier it fills is a pre-check, and a verifier that +// prefers it over the sealed claim has made the carrier into authority. +// +// A token with no readable seal is an error here, so no transport can attach a +// capability while leaving the installation blank. +func SealedInstallation(token WorkContextToken) (id string, revision string, err error) { + _, seal, _, err := readSealedToken(token) + if err != nil { + return "", "", err + } + return seal.InstallationID, decimal(seal.InstallationRevision), nil +} + +func (s Seal) validate() error { + if s.PrincipalEpoch == 0 { + return fmt.Errorf("%w: seal principal_epoch is required", ErrWorkContextInvalid) + } + if err := validateBounded("seal installation_id", s.InstallationID, workContextMaxIDBytes, true); err != nil { + return err + } + if s.InstallationRevision == 0 { + return fmt.Errorf("%w: seal installation_revision is required", ErrWorkContextInvalid) + } + if err := validateBounded("seal build_incarnation", s.BuildIncarnation, workContextMaxIDBytes, true); err != nil { + return err + } + return nil +} + +func (b OperationBinding) validate() error { + if err := validateBounded("operation_binding_id", b.BindingID, workContextMaxIDBytes, true); err != nil { + return err + } + if b.BindingRevision == 0 { + return fmt.Errorf("%w: operation_binding_revision is required", ErrWorkContextInvalid) + } + if err := validateBounded("operation_binding_incarnation", b.BindingIncarnation, workContextMaxIDBytes, true); err != nil { + return err + } + return nil +} + +// SealExpectations are the sealed values a verifier holds as current. Every +// field is required: a verifier that does not know which installation revision +// it is on cannot refuse a credential from an older one, so leaving a field +// unset is refused instead of being read as "any". +type SealExpectations struct { + PrincipalEpoch uint64 + InstallationID string + InstallationRevision uint64 + BuildIncarnation string +} + +// OperationBindingExpectations identify the one binding a verifier will accept +// an operation context for. Pass it when the call is an operation context; +// leave it nil and a credential carrying an operation binding is refused, +// because a credential issued to act through one binding must not be presented +// where no binding was named. +type OperationBindingExpectations struct { + BindingID string + BindingRevision uint64 + BindingIncarnation string +} + +func (e SealExpectations) validate() error { + if e.PrincipalEpoch == 0 { + return fmt.Errorf("%w: expected principal_epoch is required", ErrWorkContextInvalid) + } + if err := validateBounded("expected installation_id", e.InstallationID, workContextMaxIDBytes, true); err != nil { + return err + } + if e.InstallationRevision == 0 { + return fmt.Errorf("%w: expected installation_revision is required", ErrWorkContextInvalid) + } + if err := validateBounded("expected build_incarnation", e.BuildIncarnation, workContextMaxIDBytes, true); err != nil { + return err + } + return nil +} + +func (e OperationBindingExpectations) validate() error { + if err := validateBounded("expected operation_binding_id", e.BindingID, workContextMaxIDBytes, true); err != nil { + return err + } + if e.BindingRevision == 0 { + return fmt.Errorf("%w: expected operation_binding_revision is required", ErrWorkContextInvalid) + } + if err := validateBounded("expected operation_binding_incarnation", e.BindingIncarnation, workContextMaxIDBytes, true); err != nil { + return err + } + return nil +} + +// matchSeal compares a credential's seal with what the verifier holds. Each +// field is a separate refusal so the reason a call failed is the reason, not a +// single opaque mismatch: a stale installation revision is re-mintable and a +// wrong installation is not, and a caller that cannot tell them apart either +// retries what will never succeed or fails what one mint would fix. +func matchSeal(seal Seal, expected SealExpectations) error { + if err := expected.validate(); err != nil { + return err + } + if seal.InstallationID != expected.InstallationID { + return fmt.Errorf( + "%w: credential is sealed to installation %q, presented to %q", + ErrWorkContextInvalid, seal.InstallationID, expected.InstallationID, + ) + } + if seal.BuildIncarnation != expected.BuildIncarnation { + return fmt.Errorf( + "%w: credential is sealed to build incarnation %q, presented to %q", + ErrWorkContextInvalid, seal.BuildIncarnation, expected.BuildIncarnation, + ) + } + if seal.PrincipalEpoch != expected.PrincipalEpoch { + return fmt.Errorf( + "%w: credential is sealed to principal epoch %d, issuer is at %d", + ErrWorkContextSuperseded, seal.PrincipalEpoch, expected.PrincipalEpoch, + ) + } + if seal.InstallationRevision != expected.InstallationRevision { + return fmt.Errorf( + "%w: credential is sealed to installation revision %d, verifier holds %d", + ErrWorkContextSuperseded, seal.InstallationRevision, expected.InstallationRevision, + ) + } + return nil +} + +// matchOperationBinding resolves the sealed binding identity against the one +// binding the verifier will accept. All three values must agree: a binding id +// at a different revision is a different authorization, and the same revision +// in a different incarnation is a different deployment of it. +func matchOperationBinding(binding *OperationBinding, expected *OperationBindingExpectations) error { + switch { + case expected == nil && binding == nil: + return nil + case expected == nil: + return fmt.Errorf( + "%w: credential is sealed to operation binding %q, presented where no binding was named", + ErrWorkContextInvalid, binding.BindingID, + ) + case binding == nil: + return fmt.Errorf( + "%w: operation binding %q expected, credential seals none", + ErrWorkContextInvalid, expected.BindingID, + ) + } + if err := expected.validate(); err != nil { + return err + } + if binding.BindingID != expected.BindingID { + return fmt.Errorf( + "%w: credential is sealed to operation binding %q, presented to %q", + ErrWorkContextInvalid, binding.BindingID, expected.BindingID, + ) + } + if binding.BindingIncarnation != expected.BindingIncarnation { + return fmt.Errorf( + "%w: operation binding %q is sealed to incarnation %q, verifier holds %q", + ErrWorkContextInvalid, binding.BindingID, binding.BindingIncarnation, expected.BindingIncarnation, + ) + } + if binding.BindingRevision != expected.BindingRevision { + return fmt.Errorf( + "%w: operation binding %q is sealed to revision %d, verifier holds %d", + ErrWorkContextSuperseded, binding.BindingID, binding.BindingRevision, expected.BindingRevision, + ) + } + return nil +} + +// decimal encodes a uint64 for the wire. Revisions and epochs travel as decimal +// strings for the same reason authorization_revision does: a JSON number loses +// the top of the uint64 domain in any JavaScript verifier, and a credential +// that verifies in Go and not in the browser is worse than one that verifies +// nowhere. +func decimal(value uint64) string { + return strconv.FormatUint(value, 10) +} + +func parseDecimal(name string, encoded string) (uint64, error) { + value, err := strconv.ParseUint(encoded, 10, 64) + if err != nil { + return 0, fmt.Errorf("%w: %s must be a uint64 decimal string", ErrWorkContextInvalid, name) + } + return value, nil +} diff --git a/workcontext/seal_test.go b/workcontext/seal_test.go new file mode 100644 index 0000000..9536c27 --- /dev/null +++ b/workcontext/seal_test.go @@ -0,0 +1,371 @@ +package workcontext + +import ( + "net/http" + "net/http/httptest" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/stretchr/testify/require" +) + +// The headline refusal: a credential minted while the host held revision N is +// refused by a verifier that has moved to N+1. It is ErrWorkContextSuperseded +// and not ErrWorkContextInvalid, because the holder's correct response is to +// mint again — and a holder that cannot tell the two apart either retries what +// will never work or gives up on what one mint would fix. +func TestSealedCredentialIsRefusedOnceTheInstallationRevisionMoves(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + verifier := workContextTestVerifier(t, workContextTestTime) + + current := workContextTestExpected() + _, err = verifier.VerifyWorkContext(token, current) + require.NoError(t, err, "the credential verifies on the revision it was minted for") + + moved := workContextTestExpected() + moved.Seal.InstallationRevision++ + _, err = verifier.VerifyWorkContext(token, moved) + require.ErrorIs(t, err, ErrWorkContextSuperseded) + require.NotErrorIs(t, err, ErrWorkContextUnavailable, "a superseded credential must not look retryable") + require.ErrorContains(t, err, "installation revision") +} + +// A different installation is not a stale one. It can never become valid, so it +// is invalid rather than superseded: minting again on this process would +// produce a credential for the same wrong installation. +func TestSealedCredentialIsRefusedForAnotherInstallation(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + + elsewhere := workContextTestExpected() + elsewhere.Seal.InstallationID = "installation-somewhere-else" + _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, elsewhere) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.NotErrorIs(t, err, ErrWorkContextSuperseded) + require.ErrorContains(t, err, "sealed to installation") +} + +// The build is sealed so a credential cannot be carried onto another binary. +// Same principal, same installation, same revision, different build: refused. +func TestSealedCredentialIsRefusedForAnotherBuild(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + + rebuilt := workContextTestExpected() + rebuilt.Seal.BuildIncarnation = "build-incarnation-2026-07-24-b" + _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, rebuilt) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "build incarnation") +} + +// A principal whose epoch has moved has had its authority changed. The +// credential was sound and is now superseded. +func TestSealedCredentialIsRefusedOnceThePrincipalEpochMoves(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + + moved := workContextTestExpected() + moved.Seal.PrincipalEpoch++ + _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, moved) + require.ErrorIs(t, err, ErrWorkContextSuperseded) + require.ErrorContains(t, err, "principal epoch") +} + +// A seal is whole or it is not a seal. Each field missing in turn must refuse +// at signing time, so a credential nobody can verify is never handed out. +func TestSigningRefusesAnIncompleteSeal(t *testing.T) { + for name, mutate := range map[string]func(*Seal){ + "principal epoch": func(s *Seal) { s.PrincipalEpoch = 0 }, + "installation id": func(s *Seal) { s.InstallationID = "" }, + "installation revision": func(s *Seal) { s.InstallationRevision = 0 }, + "build incarnation": func(s *Seal) { s.BuildIncarnation = "" }, + } { + t.Run(name, func(t *testing.T) { + input := workContextTestInput() + seal := input.Seal + mutate(&seal) + input.Seal = seal + _, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "seal ") + }) + } +} + +// A verifier that does not state where it is cannot refuse a credential from +// somewhere else, so an unstated expectation is refused rather than read as +// "accept anything". +func TestVerificationRefusesAVerifierThatStatesNoSeal(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + + for name, expected := range map[string]WorkContextExpectations{ + "nothing at all": {Delegation: &DelegationExpectations{}}, + "no installation": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationRevision: 41, BuildIncarnation: "b"}}, + "no revision": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationID: "i", BuildIncarnation: "b"}}, + "no build incarnation": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationID: "i", InstallationRevision: 41}}, + "no principal epoch": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{InstallationID: "i", InstallationRevision: 41, BuildIncarnation: "b"}}, + } { + t.Run(name, func(t *testing.T) { + _, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, expected) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "expected ") + }) + } +} + +// The exact-binding rule, stated as the acceptance criterion states it: a +// credential sealed to binding X cannot be used for binding Y even when Y's +// scopes are a superset of X's. Scope containment is not binding identity, and +// a verifier that searched for a binding admitting the call would find Y. +func TestOperationBindingIsResolvedByIdentityNotByScope(t *testing.T) { + input := workContextTestInput() + input.OperationBinding = &OperationBinding{ + BindingID: "binding-x", + BindingRevision: 3, + BindingIncarnation: "incarnation-x", + } + // Y admits strictly more than the credential asks for. Under a scope search + // this credential would pass as Y. + input.AuthorityScopes = []*basev0.WorkScopeV1{ + {ResourceKind: "evidence", Actions: []string{"append"}}, + } + input.ActorChain = nil + + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) + require.NoError(t, err) + verifier := workContextTestVerifier(t, workContextTestTime) + + expected := workContextTestExpected() + expected.Delegation = nil + expected.OperationBinding = &OperationBindingExpectations{ + BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-x", + } + verified, err := verifier.VerifyWorkContext(token, expected) + require.NoError(t, err) + require.Equal(t, "binding-x", verified.OperationBinding().BindingID) + + superset := expected + superset.OperationBinding = &OperationBindingExpectations{ + BindingID: "binding-y", BindingRevision: 3, BindingIncarnation: "incarnation-x", + } + _, err = verifier.VerifyWorkContext(token, superset) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "sealed to operation binding") + + movedRevision := expected + movedRevision.OperationBinding = &OperationBindingExpectations{ + BindingID: "binding-x", BindingRevision: 4, BindingIncarnation: "incarnation-x", + } + _, err = verifier.VerifyWorkContext(token, movedRevision) + require.ErrorIs(t, err, ErrWorkContextSuperseded, "a binding revision that moved is re-mintable") + + otherIncarnation := expected + otherIncarnation.OperationBinding = &OperationBindingExpectations{ + BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-z", + } + _, err = verifier.VerifyWorkContext(token, otherIncarnation) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "incarnation") +} + +// An operation credential presented where no binding was named is refused, and +// so is an ordinary credential presented where one was. Neither direction is a +// tolerable default: the first would let a credential issued to act through one +// binding act outside every binding, and the second would let a call that needs +// a binding proceed without one. +func TestOperationBindingPresenceIsCheckedBothWays(t *testing.T) { + signer := workContextTestSigner(t, workContextTestTime) + verifier := workContextTestVerifier(t, workContextTestTime) + + bound := workContextTestInput() + bound.OperationBinding = &OperationBinding{ + BindingID: "binding-x", BindingRevision: 1, BindingIncarnation: "incarnation-x", + } + boundToken, _, err := signer.StartTask(bound) + require.NoError(t, err) + _, err = verifier.VerifyWorkContext(boundToken, workContextTestExpected()) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "presented where no binding was named") + + plainToken, _, err := signer.StartTask(workContextTestInput()) + require.NoError(t, err) + expectsBinding := workContextTestExpected() + expectsBinding.OperationBinding = &OperationBindingExpectations{ + BindingID: "binding-x", BindingRevision: 1, BindingIncarnation: "incarnation-x", + } + _, err = verifier.VerifyWorkContext(plainToken, expectsBinding) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "seals none") +} + +// An operation binding travels whole or not at all. A payload carrying part of +// one would otherwise resolve to a binding identity nobody approved. +func TestPartialOperationBindingOnTheWireIsRefused(t *testing.T) { + _, seal, binding, err := readSealedToken(mustSignBoundToken(t)) + require.NoError(t, err) + require.NotNil(t, binding) + require.Equal(t, workContextTestSeal(), seal) + + id := "binding-x" + _, _, err = sealFromPayload(workContextPayload{ + PrincipalEpoch: "7", + InstallationID: "installation-0a9f", + InstallationRevision: "41", + BuildIncarnation: "build", + OperationBindingID: &id, + }) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "id, revision and incarnation together") +} + +func mustSignBoundToken(t *testing.T) WorkContextToken { + t.Helper() + input := workContextTestInput() + input.OperationBinding = &OperationBinding{ + BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-x", + } + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) + require.NoError(t, err) + return token +} + +// Deliverable 3: the installation reaches the far end on every outbound +// request, beside the capability rather than inside it only. +func TestAttachCarriesTheSealedInstallationOnEveryRequest(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, "https://service.internal/v1/records", nil) + require.NoError(t, AttachWorkContext(request, token)) + + seal := workContextTestSeal() + require.Equal(t, seal.InstallationID, request.Header.Get(InstallationIDHeaderName)) + require.Equal(t, "41", request.Header.Get(InstallationRevisionHeaderName)) + require.Equal(t, token.Encoded(), request.Header.Get(WorkContextHeaderName)) +} + +// A token with no readable seal cannot be attached at all. Attaching it bare +// would produce a request that looks authorized and names no installation, +// which is the shape the whole change exists to remove. +func TestAttachRefusesAnUnsealedToken(t *testing.T) { + unsealed, err := ParseWorkContextToken("e30.AAAA") + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, "https://service.internal/v1/records", nil) + require.ErrorIs(t, AttachWorkContext(request, unsealed), ErrWorkContextInvalid) + require.Empty(t, request.Header.Get(WorkContextHeaderName)) + require.Empty(t, request.Header.Get(InstallationIDHeaderName)) +} + +// Deliverable 2's separate checks. The owner is the delegating principal and +// the final actor is the calling one; matching one says nothing about the other. +func TestDelegatingAndCallingPrincipalsAreCheckedSeparately(t *testing.T) { + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + verifier := workContextTestVerifier(t, workContextTestTime) + + both := workContextTestExpected() + both.OwnerPrincipalID, both.OwnerPrincipalKind = "principal-antoine", "user" + both.Delegation = &DelegationExpectations{ + PrincipalID: "agent-claude-code", PrincipalKind: "agent", DelegationID: "delegation-1", + } + _, err = verifier.VerifyWorkContext(token, both) + require.NoError(t, err) + + // The owner this verifier expects, a caller it does not. + wrongCaller := both + wrongCaller.Delegation = &DelegationExpectations{PrincipalID: "agent-someone-else"} + _, err = verifier.VerifyWorkContext(token, wrongCaller) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "caller mismatch") + + // The caller this verifier expects, an owner it does not. + wrongOwner := both + wrongOwner.OwnerPrincipalID = "principal-other" + _, err = verifier.VerifyWorkContext(token, wrongOwner) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "owner mismatch") + + // A verifier that never considered delegation refuses a delegated caller + // rather than checking only the principal that delegated. + directOnly := both + directOnly.Delegation = nil + _, err = verifier.VerifyWorkContext(token, directOnly) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "only a direct owner call is accepted") +} + +// The owner's kind is required, and an agent owner must name its agent +// manifest identity. Either half without the other is a credential that claims +// to be an agent nothing can name, or names an agent while presenting as a user. +func TestOwnerKindAndAgentIdentityAreOneFact(t *testing.T) { + signer := workContextTestSigner(t, workContextTestTime) + + missingKind := workContextTestInput() + missingKind.OwnerPrincipalKind = "" + _, _, err := signer.StartTask(missingKind) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "owner_principal_kind is required") + + agentWithoutIdentity := workContextTestInput() + agentWithoutIdentity.OwnerPrincipalKind = PrincipalKindAgent + _, _, err = signer.StartTask(agentWithoutIdentity) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "owner_agent_id is required") + + userWithIdentity := workContextTestInput() + userWithIdentity.OwnerAgentID = "agent-manifest-1" + _, _, err = signer.StartTask(userWithIdentity) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, `owner_agent_id is set on a "user" owner`) + + agent := workContextTestInput() + agent.OwnerPrincipalKind = PrincipalKindAgent + agent.OwnerAgentID = "agent-manifest-1" + token, _, err := signer.StartTask(agent) + require.NoError(t, err) + expected := workContextTestExpected() + expected.OwnerPrincipalKind = PrincipalKindAgent + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, expected) + require.NoError(t, err) + require.Equal(t, "agent-manifest-1", verified.Claims().GetOwnerAgentId()) +} + +// An exchange moves authority between boundaries inside one execution, so the +// seal is the same on both sides. A hop that could restate it would be a hop +// that could move a credential onto another build or another installation. +func TestExchangeAndChildSessionCarryTheSealUnchanged(t *testing.T) { + signer := workContextTestSigner(t, workContextTestTime) + parent, _, err := signer.StartTask(workContextTestInput()) + require.NoError(t, err) + + exchanged, _, err := signer.ExchangeWorkContextAudience(parent, ExchangeWorkContextAudienceInput{ + Audience: "warden.gateway", + }) + require.NoError(t, err) + child, _, err := signer.StartChildSession(parent, StartChildSessionInput{ + SessionID: "session-child", Audience: "warden.tools", + Actor: &basev0.WorkActorV1{ + PrincipalId: "tool-editor", PrincipalKind: "tool", DelegationId: "delegation-2", + GrantedScopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append"}}}, + }, + }) + require.NoError(t, err) + session, _, err := signer.StartSession(parent, StartRootSessionInput{ + SessionID: "session-second", Audience: "warden.evidence", + }) + require.NoError(t, err) + + for name, token := range map[string]WorkContextToken{ + "audience exchange": exchanged, + "child session": child, + "root session": session, + } { + t.Run(name, func(t *testing.T) { + _, seal, binding, err := readSealedToken(token) + require.NoError(t, err) + require.Equal(t, workContextTestSeal(), seal) + require.Nil(t, binding) + }) + } +} diff --git a/workcontext/stream.go b/workcontext/stream.go new file mode 100644 index 0000000..9fd3b8d --- /dev/null +++ b/workcontext/stream.go @@ -0,0 +1,131 @@ +package workcontext + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +// Bounds on the re-check cadence. A cadence longer than a credential's own +// lifetime would never fire inside one stream, and one shorter than a second +// would re-present a credential faster than any authority changes its mind. +const minStreamRecheckInterval = time.Second + +// maxStreamRecheckInterval tracks WorkContextMaxTTL, which is a variable a +// consumer may lower, so it is read rather than copied. +func maxStreamRecheckInterval() time.Duration { return WorkContextMaxTTL } + +// ErrStreamTerminated marks a stream that a re-check refused. It is returned to +// every later call on the same guard, including after the refusal that caused +// it: a stream is terminated once, and a caller that loops past the first +// refusal must not be handed a second chance to emit. +var ErrStreamTerminated = errors.New("Codefly Work Context stream terminated") + +// StreamGuardOptions configures one stream's hold on its credential. +type StreamGuardOptions struct { + // Interval is the host's re-check cadence. It is the host's, not a number + // chosen here: the host is what decides how long an authorization answer + // stays true, and a stream that re-checks more slowly than that is a stream + // serving from a snapshot the host has already revised. + Interval time.Duration + + // Recheck re-presents the credential and returns the host's answer. It is + // the same verification an ordinary call performs, against expectations + // read at the moment of the check — not against the expectations the stream + // opened with, which is the whole point. + Recheck func(context.Context) error + + // Now is the clock, for tests. + Now func() time.Time +} + +// StreamGuard holds a stream to its credential for the stream's whole life. +// +// A stream authorized when it opened is not authorized forever. The failure it +// exists to prevent is the quiet one: a long-running stream that was opened +// under an installation revision the host has since replaced, continuing to +// drain a snapshot computed under authority that no longer exists. Nothing in +// the stream fails, no error is logged, and the data keeps arriving. +// +// It guards emission rather than running a timer. An idle stream discloses +// nothing, so there is nothing to refuse; a stream that keeps emitting is +// re-checked on the cadence and terminates the moment the answer changes. +type StreamGuard struct { + mu sync.Mutex + interval time.Duration + recheck func(context.Context) error + now func() time.Time + checkedAt time.Time + terminated error +} + +// NewStreamGuard validates the cadence and performs no I/O. The first +// BeforeSend re-checks, so a stream never emits its first message on the +// strength of the check that opened it. +func NewStreamGuard(options StreamGuardOptions) (*StreamGuard, error) { + if options.Recheck == nil { + return nil, fmt.Errorf("%w: a stream guard needs a re-check", ErrWorkContextInvalid) + } + if options.Interval < minStreamRecheckInterval || options.Interval > maxStreamRecheckInterval() { + return nil, fmt.Errorf( + "%w: stream re-check interval must be between %s and %s", + ErrWorkContextInvalid, minStreamRecheckInterval, maxStreamRecheckInterval(), + ) + } + now := options.Now + if now == nil { + now = time.Now + } + return &StreamGuard{interval: options.Interval, recheck: options.Recheck, now: now}, nil +} + +// BeforeSend is called before every message a stream emits. It re-presents the +// credential when the cadence has elapsed and returns the refusal when the +// host gives one. A non-nil error terminates the stream: the message must not +// be sent, and nothing after it may be either. +// +// A refusal is returned wrapped so a caller can tell a superseded credential +// (ErrWorkContextSuperseded — the installation or binding revision moved, and +// a fresh credential would be accepted) from an invalid one. Neither resumes +// this stream. The distinction is for the caller's decision about whether to +// open another, which is a decision about one new stream and not a licence to +// keep this one alive. +func (g *StreamGuard) BeforeSend(ctx context.Context) error { + if g == nil { + return fmt.Errorf("%w: nil stream guard", ErrWorkContextInvalid) + } + if ctx == nil { + return fmt.Errorf("%w: nil context", ErrWorkContextInvalid) + } + g.mu.Lock() + defer g.mu.Unlock() + if g.terminated != nil { + return g.terminated + } + now := g.now().UTC() + if !g.checkedAt.IsZero() && now.Sub(g.checkedAt) < g.interval { + return nil + } + if err := g.recheck(ctx); err != nil { + // Both sentinels stay in the chain: the caller needs to know the stream + // is over AND whether a fresh credential would be accepted, and + // flattening the refusal to text would leave it guessing. + g.terminated = fmt.Errorf("%w: %w", ErrStreamTerminated, err) + return g.terminated + } + g.checkedAt = now + return nil +} + +// Terminated reports the refusal that ended the stream, or nil while it is +// still authorized. +func (g *StreamGuard) Terminated() error { + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + return g.terminated +} diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go new file mode 100644 index 0000000..589a7e4 --- /dev/null +++ b/workcontext/stream_test.go @@ -0,0 +1,123 @@ +package workcontext + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// The failure a stream guard exists to prevent is the quiet one: a stream +// opened under an installation revision the host has since replaced, still +// draining a snapshot computed under authority that no longer exists. Nothing +// in the stream fails, nothing is logged, and the data keeps arriving. +func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { + clock := workContextTestTime + token, _, err := workContextTestSigner(t, clock).StartTask(workContextTestInput()) + require.NoError(t, err) + verifier := workContextTestVerifier(t, clock) + + // The verifier's view of the installation revision, which the host moves + // while the stream is open. + held := workContextTestExpected() + rechecks := 0 + guard, err := NewStreamGuard(StreamGuardOptions{ + Interval: 30 * time.Second, + Now: func() time.Time { return clock }, + Recheck: func(context.Context) error { + rechecks++ + _, verifyErr := verifier.VerifyWorkContext(token, held) + return verifyErr + }, + }) + require.NoError(t, err) + + // The first message is re-checked rather than riding on the check that + // opened the stream. + require.NoError(t, guard.BeforeSend(t.Context())) + require.Equal(t, 1, rechecks) + + // Messages inside the cadence cost nothing. + clock = clock.Add(20 * time.Second) + require.NoError(t, guard.BeforeSend(t.Context())) + require.Equal(t, 1, rechecks) + + // The cadence elapses and the credential is still current. + clock = clock.Add(20 * time.Second) + require.NoError(t, guard.BeforeSend(t.Context())) + require.Equal(t, 2, rechecks) + require.NoError(t, guard.Terminated()) + + // The host moves the installation on. The next message past the cadence is + // refused, and the refusal says the credential is superseded rather than + // invalid, so the caller knows a fresh credential would be accepted. + held.Seal.InstallationRevision++ + clock = clock.Add(31 * time.Second) + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, ErrWorkContextSuperseded) + require.ErrorIs(t, guard.Terminated(), ErrStreamTerminated) +} + +// A terminated stream stays terminated. A caller that loops past the first +// refusal must not be handed a second chance to emit, and restoring the old +// revision must not resurrect a stream that has already been refused: the +// messages it would send were computed under authority that was withdrawn. +func TestATerminatedStreamIsNotResumed(t *testing.T) { + clock := workContextTestTime + refuse := true + guard, err := NewStreamGuard(StreamGuardOptions{ + Interval: time.Second, + Now: func() time.Time { return clock }, + Recheck: func(context.Context) error { + if refuse { + return ErrWorkContextSuperseded + } + return nil + }, + }) + require.NoError(t, err) + + first := guard.BeforeSend(t.Context()) + require.ErrorIs(t, first, ErrStreamTerminated) + + refuse = false + clock = clock.Add(time.Hour) + for range 3 { + require.ErrorIs(t, guard.BeforeSend(t.Context()), ErrStreamTerminated, + "a stream is terminated once, and the authority coming back does not undo it") + } +} + +func TestStreamGuardValidatesItsCadence(t *testing.T) { + _, err := NewStreamGuard(StreamGuardOptions{Interval: time.Minute}) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "needs a re-check") + + for name, interval := range map[string]time.Duration{ + "zero": 0, + "sub-second": 500 * time.Millisecond, + "beyond a TTL": WorkContextMaxTTL + time.Second, + } { + t.Run(name, func(t *testing.T) { + _, err := NewStreamGuard(StreamGuardOptions{ + Interval: interval, + Recheck: func(context.Context) error { return nil }, + }) + require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorContains(t, err, "interval") + }) + } + + guard, err := NewStreamGuard(StreamGuardOptions{ + Interval: time.Minute, + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + require.ErrorIs(t, guard.BeforeSend(nil), ErrWorkContextInvalid) + + var absent *StreamGuard + require.ErrorIs(t, absent.BeforeSend(context.Background()), ErrWorkContextInvalid) + require.NoError(t, absent.Terminated()) +} diff --git a/workcontext/work_context.go b/workcontext/work_context.go index 924a4f7..11aa142 100644 --- a/workcontext/work_context.go +++ b/workcontext/work_context.go @@ -52,6 +52,16 @@ var ( // 401 — since an otherwise-valid context must not be dropped during an outage. ErrWorkContextUnavailable = errors.New("Codefly Work Context verification unavailable") ErrWorkContextDenied = errors.New("Codefly Work Context scope denied") + // ErrWorkContextSuperseded marks a credential that was sound when it was + // minted and has been overtaken since: the principal epoch, the + // installation revision or the binding revision has moved on. It is + // distinct from ErrWorkContextInvalid because the two need opposite + // responses — a superseded credential is replaced by minting again, and + // the holder can do that unaided, while an invalid one never becomes + // valid. It is equally distinct from ErrWorkContextUnavailable: nothing is + // unreachable, so retrying the same credential is guaranteed to fail and a + // retry loop is the one thing a caller must not do. + ErrWorkContextSuperseded = errors.New("Codefly Work Context superseded") ) // WorkContextToken is an opaque signed capability. Its encoded representation @@ -79,8 +89,20 @@ func ParseWorkContextToken(encoded string) (WorkContextToken, error) { return WorkContextToken{encoded: encoded}, nil } -// AttachWorkContext installs a token without exposing the carrier name to -// application code. +// AttachWorkContext installs a credential on an outbound request without +// exposing the carrier names to application code. It sets the signed +// capability and, beside it, the installation the capability is sealed to, so +// every outbound request carries the installation the host will re-check the +// call against. +// +// The installation headers are a pre-check the host may refuse on cheaply. +// They are not authority and no verifier may prefer them: they are read out of +// the token here without checking its signature, which is sound only because +// nothing downstream trusts them. The installation that governs a call is the +// sealed one. +// +// A token with no readable seal is refused rather than attached bare. There is +// no request on which an unsealed credential is better than no credential. func AttachWorkContext(request *http.Request, token WorkContextToken) error { if request == nil { return fmt.Errorf("%w: nil HTTP request", ErrWorkContextInvalid) @@ -88,10 +110,37 @@ func AttachWorkContext(request *http.Request, token WorkContextToken) error { if token.empty() { return fmt.Errorf("%w: empty token", ErrWorkContextInvalid) } + _, seal, _, err := readSealedToken(token) + if err != nil { + return err + } request.Header.Set(WorkContextHeaderName, token.encoded) + request.Header.Set(InstallationIDHeaderName, seal.InstallationID) + request.Header.Set(InstallationRevisionHeaderName, decimal(seal.InstallationRevision)) return nil } +// readSealedToken reads a token's own content without checking its signature. +// It exists for the two jobs a holder has to do with a credential it was just +// handed: know when to renew it, and name its installation on the way out. +// Neither is a trust decision, and nothing in this package authorizes from its +// result — VerifyWorkContext is the only thing that does that, and it checks +// the signature first. +func readSealedToken(token WorkContextToken) (*basev0.WorkContextV1, Seal, *OperationBinding, error) { + payload, _, err := decodeWorkContextToken(token.encoded) + if err != nil { + return nil, Seal{}, nil, err + } + sealed, err := unmarshalWorkContext(payload) + if err != nil { + return nil, Seal{}, nil, err + } + if err := validateSealedContext(sealed); err != nil { + return nil, Seal{}, nil, err + } + return sealed.context, sealed.seal, sealed.binding, nil +} + // WorkContextFromHeaders extracts an opaque token. It does not verify it. func WorkContextFromHeaders(headers http.Header) (WorkContextToken, error) { if headers == nil { @@ -148,9 +197,22 @@ func NewWorkContextSigner(options WorkContextSignerOptions) (*WorkContextSigner, // StartTaskInput creates a Task and its first root Session capability. type StartTaskInput struct { - Audience string - TenantID string - OwnerPrincipalID string + Audience string + TenantID string + OwnerPrincipalID string + // OwnerPrincipalKind is the sort of principal the owner is. It is required: + // the principal id namespaces are independent, so a user and an agent may + // share an id string without being the same caller, and a verifier that + // cannot tell them apart cannot check the delegating principal at all. + OwnerPrincipalKind string + // OwnerAgentID is the owner's agent manifest identity. Required when + // OwnerPrincipalKind is "agent" and refused otherwise. + OwnerAgentID string + // Seal binds this credential to the execution it is minted for. Required. + Seal Seal + // OperationBinding seals the credential to exactly one binding. Set it only + // for an operation context. + OperationBinding *OperationBinding TaskID string SessionID string AuthorizationRevision uint64 @@ -206,13 +268,24 @@ func (s *WorkContextSigner) StartTask(input StartTaskInput) (WorkContextToken, * ActorChain: cloneActors(input.ActorChain), AttributionTeamIds: append([]string(nil), input.AttributionTeamIDs...), } + if input.OwnerPrincipalKind != "" { + context.OwnerPrincipalKind = stringPointer(input.OwnerPrincipalKind) + } + if input.OwnerAgentID != "" { + context.OwnerAgentId = stringPointer(input.OwnerAgentID) + } if input.WorkspaceID != "" { context.WorkspaceId = stringPointer(input.WorkspaceID) } if input.ProjectID != "" { context.ProjectId = stringPointer(input.ProjectID) } - return s.sign(context) + binding := input.OperationBinding + if binding != nil { + copied := *binding + binding = &copied + } + return s.sign(sealedContext{context: context, seal: input.Seal, binding: binding}) } // StartRootSessionInput exchanges a valid capability for another root Session @@ -248,12 +321,12 @@ func (s *WorkContextSigner) ExchangeWorkContextAudience( if err != nil { return WorkContextToken{}, nil, err } - next := cloneContext(verified) + next := verified.clone() if input.AttenuatedScopes != nil { attenuated := cloneScopes(input.AttenuatedScopes) canonicalizeScopes(attenuated) - effective := verified.GetAuthorityScopes() - if actors := verified.GetActorChain(); len(actors) > 0 { + effective := verified.context.GetAuthorityScopes() + if actors := verified.context.GetActorChain(); len(actors) > 0 { effective = actors[len(actors)-1].GetGrantedScopes() } if !scopesAttenuate(effective, attenuated) { @@ -262,10 +335,10 @@ func (s *WorkContextSigner) ExchangeWorkContextAudience( ErrWorkContextInvalid, ) } - if actors := next.GetActorChain(); len(actors) > 0 { + if actors := next.context.GetActorChain(); len(actors) > 0 { actors[len(actors)-1].GrantedScopes = attenuated } else { - next.AuthorityScopes = attenuated + next.context.AuthorityScopes = attenuated } } return s.exchange( @@ -281,9 +354,9 @@ func (s *WorkContextSigner) StartSession(parent WorkContextToken, input StartRoo if err != nil { return WorkContextToken{}, nil, err } - next := cloneContext(verified) - next.SessionId = input.SessionID - next.ParentSessionId = nil + next := verified.clone() + next.context.SessionId = input.SessionID + next.context.ParentSessionId = nil return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) } @@ -302,14 +375,20 @@ func (s *WorkContextSigner) StartChildSession(parent WorkContextToken, input Sta if err != nil { return WorkContextToken{}, nil, err } - next := cloneContext(verified) - next.ParentSessionId = stringPointer(verified.SessionId) - next.SessionId = input.SessionID - next.ActorChain = append(next.ActorChain, cloneActor(input.Actor)) + next := verified.clone() + next.context.ParentSessionId = stringPointer(verified.context.SessionId) + next.context.SessionId = input.SessionID + next.context.ActorChain = append(next.context.ActorChain, cloneActor(input.Actor)) return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) } -func (s *WorkContextSigner) exchange(context *basev0.WorkContextV1, audience, replayPolicy string, ttl time.Duration) (WorkContextToken, *basev0.WorkContextV1, error) { +// exchange re-signs a verified credential for another audience, session or +// actor. The seal travels unchanged: an exchange moves authority between +// boundaries inside one execution, and the execution is exactly what the seal +// names. A hop that could restate the seal would be a hop that could move a +// credential onto another build or another installation. +func (s *WorkContextSigner) exchange(sealed sealedContext, audience, replayPolicy string, ttl time.Duration) (WorkContextToken, *basev0.WorkContextV1, error) { + context := sealed.context now := s.now().UTC().Truncate(time.Second) if ttl == 0 { ttl = WorkContextDefaultTTL @@ -334,31 +413,44 @@ func (s *WorkContextSigner) exchange(context *basev0.WorkContextV1, audience, re context.ExpiresAtUnix = now.Add(ttl).Unix() context.Nonce = nonce context.ReplayPolicy = replayPolicy - return s.sign(context) + return s.sign(sealed) } -func (s *WorkContextSigner) verifyOwn(token WorkContextToken) (*basev0.WorkContextV1, error) { +// verifyOwn re-reads a credential this signer issued before deriving from it. +// It checks the signature and shape, deliberately not the seal: the authority +// re-signing its own credential is not the party that holds the installation +// revision, and refusing here would make an exchange fail for a reason the +// exchanging authority cannot act on. The seal is what travels, and the +// verifier at the far end is where it is held to the current revision. +func (s *WorkContextSigner) verifyOwn(token WorkContextToken) (sealedContext, error) { publicKey, ok := s.privateKey.Public().(ed25519.PublicKey) if !ok { - return nil, fmt.Errorf("%w: signer has no Ed25519 public key", ErrWorkContextInvalid) + return sealedContext{}, fmt.Errorf("%w: signer has no Ed25519 public key", ErrWorkContextInvalid) } verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ PublicKeys: map[string]ed25519.PublicKey{s.keyID: publicKey}, Now: s.now, }) if err != nil { - return nil, err + return sealedContext{}, err + } + sealed, err := verifier.decode(token) + if err != nil { + return sealedContext{}, err } - return verifier.Verify(token, WorkContextExpectations{Issuer: s.issuer}) + if sealed.context.GetIssuer() != s.issuer { + return sealedContext{}, fmt.Errorf("%w: issuer mismatch", ErrWorkContextInvalid) + } + return sealed, nil } -func (s *WorkContextSigner) sign(context *basev0.WorkContextV1) (WorkContextToken, *basev0.WorkContextV1, error) { - canonical := cloneContext(context) - canonicalizeWorkContext(canonical) - if err := validateWorkContext(canonical); err != nil { +func (s *WorkContextSigner) sign(sealed sealedContext) (WorkContextToken, *basev0.WorkContextV1, error) { + canonical := sealed.clone() + canonicalizeWorkContext(canonical.context) + if err := validateSealedContext(canonical); err != nil { return WorkContextToken{}, nil, err } - payload, err := marshalWorkContext(canonical) + payload, err := marshalWorkContext(canonical.context, canonical.seal, canonical.binding) if err != nil { return WorkContextToken{}, nil, err } @@ -368,7 +460,7 @@ func (s *WorkContextSigner) sign(context *basev0.WorkContextV1) (WorkContextToke if len(encoded) > WorkContextMaxTokenBytes { return WorkContextToken{}, nil, fmt.Errorf("%w: token exceeds %d bytes", ErrWorkContextInvalid, WorkContextMaxTokenBytes) } - return WorkContextToken{encoded: encoded}, canonical, nil + return WorkContextToken{encoded: encoded}, canonical.context, nil } type WorkContextVerifier struct { @@ -411,15 +503,55 @@ func NewWorkContextVerifier(options WorkContextVerifierOptions) (*WorkContextVer return &WorkContextVerifier{publicKeys: keys, now: now, clockSkew: clockSkew}, nil } +// WorkContextExpectations is what a verifier holds as current. Identity +// expectations that are left empty are not checked, as before. The seal is +// different: it is required, because a verifier that does not state which +// installation revision and build it is on cannot refuse a credential from an +// earlier one, and a credential nobody holds to an installation is a bearer +// token. type WorkContextExpectations struct { Issuer string Audience string TenantID string - OwnerPrincipalID string TaskID string SessionID string ParentSessionID *string AuthorizationRevision *uint64 + + // The delegating principal. OwnerPrincipalKind is checked separately from + // OwnerPrincipalID: the two id namespaces are independent, so matching an + // id without its kind matches a different principal that happens to spell + // the same string. + OwnerPrincipalID string + OwnerPrincipalKind string + + // Delegation describes the calling principal — the final actor of a + // delegated credential — and is checked separately from the owner above. + // + // nil means this verifier accepts direct owner calls only, and a credential + // carrying an actor chain is refused. That is deliberate: a service that + // never considered delegation would otherwise accept a delegated caller + // silently, having checked only the principal that delegated rather than + // the one that is actually calling. + Delegation *DelegationExpectations + + // Seal is required. See SealExpectations. + Seal SealExpectations + + // OperationBinding names the one binding an operation context may be + // presented for. nil means no binding was named, and a credential sealed + // to one is refused rather than accepted as if it were an ordinary + // credential that happens to carry extra fields. + OperationBinding *OperationBindingExpectations +} + +// DelegationExpectations is what a verifier holds about the calling principal. +// Empty fields are not checked; the presence of the struct is itself the +// statement that a delegated credential is acceptable here. +type DelegationExpectations struct { + PrincipalID string + PrincipalKind string + DelegationID string } // WorkContextScopeRequirement identifies one exact capability a verified Work @@ -437,15 +569,22 @@ type WorkContextScopeRequirement struct { // authority scopes apply to a direct owner call; when actors are present, only // the final actor's monotonically attenuated granted scopes are effective. // -// Call this only after signature, time, issuer, and audience verification. -// The claims are structurally validated again so a caller cannot accidentally -// authorize from a hand-constructed or mutated protobuf. +// It takes a verified credential rather than claims. Taking claims meant a +// caller could authorize from a hand-constructed protobuf or from a token that +// was parsed and never verified, and the defensive re-validation that used to +// stand here could not tell those apart from a real one — it checked the shape +// of the claims, not where they came from. +// +// Scope is the last question, not the first: the credential has already been +// held to its seal and its binding by the time this runs. A scope that would +// admit the call never widens the binding it was granted under. func RequireWorkContextScope( - claims *basev0.WorkContextV1, + verified VerifiedWorkContext, requirement WorkContextScopeRequirement, ) error { - if err := validateWorkContext(claims); err != nil { - return err + claims := verified.sealed.context + if claims == nil { + return fmt.Errorf("%w: scope check requires a verified Work Context", ErrWorkContextInvalid) } if err := validateBounded( "required resource_kind", @@ -499,41 +638,53 @@ func RequireWorkContextScope( ) } -func (v *WorkContextVerifier) Verify(token WorkContextToken, expected WorkContextExpectations) (*basev0.WorkContextV1, error) { +// decode checks the signature, the structural rules and the lifetime, and +// returns the sealed content. It is not verification: nothing here holds the +// credential to an installation, a build or a caller, so its result must not +// escape this package except through VerifyWorkContext. +func (v *WorkContextVerifier) decode(token WorkContextToken) (sealedContext, error) { if v == nil { - return nil, fmt.Errorf("%w: nil verifier", ErrWorkContextInvalid) + return sealedContext{}, fmt.Errorf("%w: nil verifier", ErrWorkContextInvalid) } payload, signature, err := decodeWorkContextToken(token.encoded) if err != nil { - return nil, err + return sealedContext{}, err } probe := struct { KeyID string `json:"key_id"` }{} if decodeErr := json.Unmarshal(payload, &probe); decodeErr != nil { - return nil, fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, decodeErr) + return sealedContext{}, fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, decodeErr) } publicKey, ok := v.publicKeys[probe.KeyID] if !ok { - return nil, fmt.Errorf("%w: unknown key id %q", ErrWorkContextInvalid, probe.KeyID) + return sealedContext{}, fmt.Errorf("%w: unknown key id %q", ErrWorkContextInvalid, probe.KeyID) + } + // ed25519.Verify panics on a key that is not PublicKeySize bytes. The key + // is selected by the untrusted token's key id, so one malformed entry would + // turn every token naming it into a crash of this process, on demand for + // anyone who learns that key id. NewWorkContextVerifier rejects such a key, + // which is what makes this a belt rather than the only guard. + if len(publicKey) != ed25519.PublicKeySize { + return sealedContext{}, fmt.Errorf( + "%w: verification key %q is %d bytes, not %d", + ErrWorkContextInvalid, probe.KeyID, len(publicKey), ed25519.PublicKeySize, + ) } if !ed25519.Verify(publicKey, payload, signature) { - return nil, fmt.Errorf("%w: signature verification failed", ErrWorkContextInvalid) + return sealedContext{}, fmt.Errorf("%w: signature verification failed", ErrWorkContextInvalid) } - context, err := unmarshalWorkContext(payload) + sealed, err := unmarshalWorkContext(payload) if err != nil { - return nil, err - } - if err := validateWorkContext(context); err != nil { - return nil, err + return sealedContext{}, err } - if err := v.validateTime(context); err != nil { - return nil, err + if err := validateSealedContext(sealed); err != nil { + return sealedContext{}, err } - if err := matchWorkContext(context, expected); err != nil { - return nil, err + if err := v.validateTime(sealed.context); err != nil { + return sealedContext{}, err } - return context, nil + return sealed, nil } func (v *WorkContextVerifier) validateTime(context *basev0.WorkContextV1) error { @@ -553,7 +704,8 @@ func (v *WorkContextVerifier) validateTime(context *basev0.WorkContextV1) error return nil } -func matchWorkContext(context *basev0.WorkContextV1, expected WorkContextExpectations) error { +func matchWorkContext(sealed sealedContext, expected WorkContextExpectations) error { + context := sealed.context checks := []struct { name string got string @@ -563,6 +715,7 @@ func matchWorkContext(context *basev0.WorkContextV1, expected WorkContextExpecta {"audience", context.Audience, expected.Audience}, {"tenant", context.TenantId, expected.TenantID}, {"owner", context.OwnerPrincipalId, expected.OwnerPrincipalID}, + {"owner kind", context.GetOwnerPrincipalKind(), expected.OwnerPrincipalKind}, {"task", context.TaskId, expected.TaskID}, {"session", context.SessionId, expected.SessionID}, } @@ -577,6 +730,47 @@ func matchWorkContext(context *basev0.WorkContextV1, expected WorkContextExpecta if expected.AuthorizationRevision != nil && context.AuthorizationRevision != *expected.AuthorizationRevision { return fmt.Errorf("%w: authorization revision mismatch", ErrWorkContextInvalid) } + if err := matchDelegation(context, expected.Delegation); err != nil { + return err + } + if err := matchSeal(sealed.seal, expected.Seal); err != nil { + return err + } + return matchOperationBinding(sealed.binding, expected.OperationBinding) +} + +// matchDelegation checks the calling principal. The owner was already checked +// above; this is the separate check, and the two are not interchangeable — a +// credential whose owner is the one this verifier expects may still be +// presented by a delegate it does not. +func matchDelegation(context *basev0.WorkContextV1, expected *DelegationExpectations) error { + actors := context.GetActorChain() + if expected == nil { + if len(actors) > 0 { + return fmt.Errorf( + "%w: credential is delegated through %d actor(s) where only a direct owner call is accepted", + ErrWorkContextInvalid, len(actors), + ) + } + return nil + } + if len(actors) == 0 { + return fmt.Errorf("%w: a delegated credential was expected and this one carries no actor", ErrWorkContextInvalid) + } + caller := actors[len(actors)-1] + for _, check := range []struct { + name string + got string + want string + }{ + {"caller", caller.GetPrincipalId(), expected.PrincipalID}, + {"caller kind", caller.GetPrincipalKind(), expected.PrincipalKind}, + {"delegation", caller.GetDelegationId(), expected.DelegationID}, + } { + if check.want != "" && check.got != check.want { + return fmt.Errorf("%w: %s mismatch", ErrWorkContextInvalid, check.name) + } + } return nil } @@ -604,6 +798,22 @@ type workContextPayload struct { AttributionTeamIDs []string `json:"attribution_team_ids"` WorkspaceID *string `json:"workspace_id,omitempty"` ProjectID *string `json:"project_id,omitempty"` + OwnerPrincipalKind string `json:"owner_principal_kind"` + OwnerAgentID *string `json:"owner_agent_id,omitempty"` + + // The seal. These are not claims the owner delegated; they are the binding + // between this credential and the one execution it was issued to, and + // every one of them is required. + PrincipalEpoch string `json:"owner_principal_epoch"` + InstallationID string `json:"installation_id"` + InstallationRevision string `json:"installation_revision"` + BuildIncarnation string `json:"build_incarnation"` + + // The operation binding. Present together or absent together: a partial + // binding would resolve to a binding identity the verifier never approved. + OperationBindingID *string `json:"operation_binding_id,omitempty"` + OperationBindingRevision *string `json:"operation_binding_revision,omitempty"` + OperationBindingIncarnation *string `json:"operation_binding_incarnation,omitempty"` } type workContextScope struct { @@ -619,8 +829,31 @@ type workContextActor struct { GrantedScopes []workContextScope `json:"granted_scopes"` } -func marshalWorkContext(context *basev0.WorkContextV1) ([]byte, error) { - payload := payloadFromContext(context) +// sealedContext is the complete signed content of one credential: the +// delegation claims, the seal binding them to a single execution, and the +// operation binding when the credential is an operation context. +// +// Nothing constructs one without a seal. The seal is not an addition to a Work +// Context that may be left out — it is the half that says which process, build +// and installation the other half was issued to, and a credential carrying +// only the other half is a bearer token for anything that holds it. +type sealedContext struct { + context *basev0.WorkContextV1 + seal Seal + binding *OperationBinding +} + +func (c sealedContext) clone() sealedContext { + cloned := sealedContext{context: cloneContext(c.context), seal: c.seal} + if c.binding != nil { + binding := *c.binding + cloned.binding = &binding + } + return cloned +} + +func marshalWorkContext(context *basev0.WorkContextV1, seal Seal, binding *OperationBinding) ([]byte, error) { + payload := payloadFromContext(context, seal, binding) encoded, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("%w: encode payload: %v", ErrWorkContextInvalid, err) @@ -628,29 +861,87 @@ func marshalWorkContext(context *basev0.WorkContextV1) ([]byte, error) { return encoded, nil } -func unmarshalWorkContext(encoded []byte) (*basev0.WorkContextV1, error) { +func unmarshalWorkContext(encoded []byte) (sealedContext, error) { var payload workContextPayload decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.DisallowUnknownFields() if err := decoder.Decode(&payload); err != nil { - return nil, fmt.Errorf("%w: decode payload: %v", ErrWorkContextInvalid, err) + return sealedContext{}, fmt.Errorf("%w: decode payload: %v", ErrWorkContextInvalid, err) } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { if err == nil { - return nil, fmt.Errorf("%w: trailing JSON value", ErrWorkContextInvalid) + return sealedContext{}, fmt.Errorf("%w: trailing JSON value", ErrWorkContextInvalid) } - return nil, fmt.Errorf("%w: trailing JSON: %v", ErrWorkContextInvalid, err) + return sealedContext{}, fmt.Errorf("%w: trailing JSON: %v", ErrWorkContextInvalid, err) } revision, err := strconv.ParseUint(payload.AuthorizationRevision, 10, 64) if err != nil { - return nil, fmt.Errorf("%w: authorization_revision must be uint64 decimal", ErrWorkContextInvalid) + return sealedContext{}, fmt.Errorf("%w: authorization_revision must be uint64 decimal", ErrWorkContextInvalid) } - return contextFromPayload(payload, revision), nil + seal, binding, err := sealFromPayload(payload) + if err != nil { + return sealedContext{}, err + } + return sealedContext{ + context: contextFromPayload(payload, revision), + seal: seal, + binding: binding, + }, nil } -func payloadFromContext(context *basev0.WorkContextV1) workContextPayload { - return workContextPayload{ +// sealFromPayload reads the seal off the wire. A credential whose seal cannot +// be read is rejected here rather than treated as unsealed: "no seal" and +// "a seal I could not parse" must reach the same refusal, because the second +// is what an attacker produces when trying to reach the first. +func sealFromPayload(payload workContextPayload) (Seal, *OperationBinding, error) { + epoch, err := parseDecimal("owner_principal_epoch", payload.PrincipalEpoch) + if err != nil { + return Seal{}, nil, err + } + installationRevision, err := parseDecimal("installation_revision", payload.InstallationRevision) + if err != nil { + return Seal{}, nil, err + } + seal := Seal{ + PrincipalEpoch: epoch, + InstallationID: payload.InstallationID, + InstallationRevision: installationRevision, + BuildIncarnation: payload.BuildIncarnation, + } + present := 0 + for _, field := range []*string{ + payload.OperationBindingID, + payload.OperationBindingRevision, + payload.OperationBindingIncarnation, + } { + if field != nil { + present++ + } + } + switch present { + case 0: + return seal, nil, nil + case 3: + default: + return Seal{}, nil, fmt.Errorf( + "%w: an operation binding needs its id, revision and incarnation together", + ErrWorkContextInvalid, + ) + } + bindingRevision, err := parseDecimal("operation_binding_revision", *payload.OperationBindingRevision) + if err != nil { + return Seal{}, nil, err + } + return seal, &OperationBinding{ + BindingID: *payload.OperationBindingID, + BindingRevision: bindingRevision, + BindingIncarnation: *payload.OperationBindingIncarnation, + }, nil +} + +func payloadFromContext(context *basev0.WorkContextV1, seal Seal, binding *OperationBinding) workContextPayload { + payload := workContextPayload{ Typ: context.Typ, Algorithm: context.Algorithm, KeyID: context.KeyId, @@ -672,7 +963,19 @@ func payloadFromContext(context *basev0.WorkContextV1) workContextPayload { AttributionTeamIDs: append([]string{}, context.AttributionTeamIds...), WorkspaceID: cloneStringPointer(context.WorkspaceId), ProjectID: cloneStringPointer(context.ProjectId), - } + OwnerPrincipalKind: context.GetOwnerPrincipalKind(), + OwnerAgentID: cloneStringPointer(context.OwnerAgentId), + PrincipalEpoch: decimal(seal.PrincipalEpoch), + InstallationID: seal.InstallationID, + InstallationRevision: decimal(seal.InstallationRevision), + BuildIncarnation: seal.BuildIncarnation, + } + if binding != nil { + payload.OperationBindingID = stringPointer(binding.BindingID) + payload.OperationBindingRevision = stringPointer(decimal(binding.BindingRevision)) + payload.OperationBindingIncarnation = stringPointer(binding.BindingIncarnation) + } + return payload } func payloadScopes(scopes []*basev0.WorkScopeV1) []workContextScope { @@ -731,6 +1034,8 @@ func contextFromPayload(payload workContextPayload, revision uint64) *basev0.Wor AttributionTeamIds: append([]string(nil), payload.AttributionTeamIDs...), WorkspaceId: cloneStringPointer(payload.WorkspaceID), ProjectId: cloneStringPointer(payload.ProjectID), + OwnerPrincipalKind: stringPointer(payload.OwnerPrincipalKind), + OwnerAgentId: cloneStringPointer(payload.OwnerAgentID), } return context } @@ -760,6 +1065,29 @@ func contextActors(actors []workContextActor) []*basev0.WorkActorV1 { return out } +// PrincipalKindAgent is the owner kind that requires an agent manifest +// identity beside the principal id. +const PrincipalKindAgent = "agent" + +// validateSealedContext is the whole structural rule for a credential: the +// delegation claims, the seal, and the operation binding when there is one. +// Signing and verification both go through it, so a credential this module +// would refuse is never one it hands out. +func validateSealedContext(sealed sealedContext) error { + if err := validateWorkContext(sealed.context); err != nil { + return err + } + if err := sealed.seal.validate(); err != nil { + return err + } + if sealed.binding != nil { + if err := sealed.binding.validate(); err != nil { + return err + } + } + return nil +} + func validateWorkContext(context *basev0.WorkContextV1) error { if context == nil { return fmt.Errorf("%w: nil claims", ErrWorkContextInvalid) @@ -812,6 +1140,28 @@ func validateWorkContext(context *basev0.WorkContextV1) error { if context.ParentSessionId != nil && context.GetParentSessionId() == context.SessionId { return fmt.Errorf("%w: parent session equals session", ErrWorkContextInvalid) } + if err := validateBounded( + "owner_principal_kind", + context.GetOwnerPrincipalKind(), + workContextMaxKindBytes, + true, + ); err != nil { + return err + } + // The agent manifest identity and the agent kind are one fact stated twice. + // Allowing either without the other admits a credential that claims to be + // an agent nothing can name, or names an agent while presenting as a user. + switch { + case context.GetOwnerPrincipalKind() == PrincipalKindAgent: + if err := validateBounded("owner_agent_id", context.GetOwnerAgentId(), workContextMaxIDBytes, true); err != nil { + return err + } + case context.OwnerAgentId != nil: + return fmt.Errorf( + "%w: owner_agent_id is set on a %q owner", + ErrWorkContextInvalid, context.GetOwnerPrincipalKind(), + ) + } if context.ReplayPolicy != WorkContextReplayIdempotent && context.ReplayPolicy != WorkContextReplaySingleUse { return fmt.Errorf("%w: unsupported replay policy %q", ErrWorkContextInvalid, context.ReplayPolicy) } @@ -1074,6 +1424,8 @@ func cloneContext(context *basev0.WorkContextV1) *basev0.WorkContextV1 { AttributionTeamIds: append([]string(nil), context.AttributionTeamIds...), WorkspaceId: cloneStringPointer(context.WorkspaceId), ProjectId: cloneStringPointer(context.ProjectId), + OwnerPrincipalKind: cloneStringPointer(context.OwnerPrincipalKind), + OwnerAgentId: cloneStringPointer(context.OwnerAgentId), } } diff --git a/workcontext/work_context_jwks.go b/workcontext/work_context_jwks.go index 9f5b1b9..222cc75 100644 --- a/workcontext/work_context_jwks.go +++ b/workcontext/work_context_jwks.go @@ -14,8 +14,6 @@ import ( "strings" "sync" "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) const ( @@ -107,12 +105,14 @@ func NewWorkContextJWKSVerifier( }, nil } -// Verify establishes Work Context trust using the current cached JWKS. -func (v *WorkContextJWKSVerifier) Verify( +// verifierFor returns the key-set verifier that can check this token, forcing +// at most one generation-aware refresh when the token names a key the cache +// does not hold. Verification itself is VerifyWorkContext's, so the sealed +// checks cannot be reached by a path that skips them. +func (v *WorkContextJWKSVerifier) verifierFor( ctx context.Context, token WorkContextToken, - expected WorkContextExpectations, -) (*basev0.WorkContextV1, error) { +) (*WorkContextVerifier, error) { if v == nil { return nil, fmt.Errorf("%w: nil JWKS verifier", ErrWorkContextInvalid) } @@ -128,7 +128,7 @@ func (v *WorkContextJWKSVerifier) Verify( return nil, err } if _, known := keyIDs[keyID]; known { - return verifier.Verify(token, expected) + return verifier, nil } // A different caller may already have refreshed this generation. Passing @@ -137,7 +137,7 @@ func (v *WorkContextJWKSVerifier) Verify( if err != nil { return nil, err } - return verifier.Verify(token, expected) + return verifier, nil } // Refresh fetches and installs the key set immediately, warming the cache so a diff --git a/workcontext/work_context_jwks_test.go b/workcontext/work_context_jwks_test.go index 256c5cc..78385ab 100644 --- a/workcontext/work_context_jwks_test.go +++ b/workcontext/work_context_jwks_test.go @@ -37,11 +37,11 @@ func TestWorkContextJWKSVerifierCachesAndRefreshesUnknownRotation(t *testing.T) require.NoError(t, err) first := workContextJWKSToken(t, "key-1", firstPrivate) for range 3 { - claims, err := verifier.Verify(t.Context(), first, WorkContextExpectations{ + verified, err := verifier.VerifyWorkContext(t.Context(), first, workContextTestExpectedWith(WorkContextExpectations{ Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - }) + })) require.NoError(t, err) - require.Equal(t, "tenant-codefly", claims.GetTenantId()) + require.Equal(t, "tenant-codefly", verified.Claims().GetTenantId()) } require.EqualValues(t, 1, requests.Load()) @@ -49,9 +49,9 @@ func TestWorkContextJWKSVerifierCachesAndRefreshesUnknownRotation(t *testing.T) keys = map[string]ed25519.PublicKey{"key-2": secondPublic} mu.Unlock() second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), second, WorkContextExpectations{ + _, err = verifier.VerifyWorkContext(t.Context(), second, workContextTestExpectedWith(WorkContextExpectations{ Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - }) + })) require.NoError(t, err) require.EqualValues(t, 2, requests.Load()) @@ -59,14 +59,14 @@ func TestWorkContextJWKSVerifierCachesAndRefreshesUnknownRotation(t *testing.T) // one unknown-key refresh per cache generation, so arbitrary key IDs cannot // turn verification into an unbounded request loop. for index := range 50 { - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), firstPrivate), - WorkContextExpectations{}, + workContextTestExpected(), ) require.Error(t, err) } - _, err = verifier.Verify(t.Context(), first, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), first, workContextTestExpected()) require.Error(t, err) require.EqualValues(t, 2, requests.Load()) } @@ -89,10 +89,10 @@ func TestWorkContextJWKSVerifierRejectsRedirects(t *testing.T) { URL: source.URL, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, + workContextTestExpected(), ) require.Error(t, err) require.Zero(t, destinationRequests.Load()) @@ -117,10 +117,10 @@ func TestWorkContextJWKSVerifierSuppressesConcurrentRotationRefresh(t *testing.T URL: server.URL, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", workContextJWKSPrivateForSeed(1)), - WorkContextExpectations{}, + workContextTestExpected(), ) require.NoError(t, err) rotated.Store(true) @@ -135,7 +135,7 @@ func TestWorkContextJWKSVerifierSuppressesConcurrentRotationRefresh(t *testing.T go func() { defer wait.Done() <-start - _, verifyErr := verifier.Verify(context.Background(), token, WorkContextExpectations{}) + _, verifyErr := verifier.VerifyWorkContext(context.Background(), token, workContextTestExpected()) failures <- verifyErr }() } @@ -171,20 +171,20 @@ func TestWorkContextJWKSVerifierRefreshesExpiredCacheWithoutRefreshingBadSignatu }) require.NoError(t, err) - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, + workContextTestExpected(), ) require.NoError(t, err) require.EqualValues(t, 1, requests.Load()) // A known key ID with an invalid signature is an invalid token, not a key // rotation signal, and must never cause network I/O. - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", wrongPrivateKey), - WorkContextExpectations{}, + workContextTestExpected(), ) require.Error(t, err) require.EqualValues(t, 1, requests.Load()) @@ -192,10 +192,10 @@ func TestWorkContextJWKSVerifierRefreshesExpiredCacheWithoutRefreshingBadSignatu mu.Lock() now = now.Add(time.Minute) mu.Unlock() - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, + workContextTestExpected(), ) require.NoError(t, err) require.EqualValues(t, 2, requests.Load()) @@ -228,7 +228,7 @@ func TestWorkContextJWKSVerifierBoundsAndValidatesRemoteKeys(t *testing.T) { URL: server.URL, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) require.NotErrorIs(t, err, ErrWorkContextUnavailable) }) @@ -247,7 +247,7 @@ func TestWorkContextJWKSVerifierReportsIssuerOutageAsUnavailable(t *testing.T) { URL: endpoint, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextUnavailable) require.NotErrorIs(t, err, ErrWorkContextInvalid) }) @@ -262,7 +262,7 @@ func TestWorkContextJWKSVerifierReportsIssuerOutageAsUnavailable(t *testing.T) { URL: server.URL, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextUnavailable) require.NotErrorIs(t, err, ErrWorkContextInvalid) }) @@ -289,10 +289,10 @@ func TestWorkContextJWKSVerifierKeepsUnknownKeyOutageUnavailableAfterReservation URL: server.URL, Now: func() time.Time { return workContextTestTime }, }) require.NoError(t, err) - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", firstPrivate), - WorkContextExpectations{}, + workContextTestExpected(), ) require.NoError(t, err) require.EqualValues(t, 1, requests.Load()) @@ -301,7 +301,7 @@ func TestWorkContextJWKSVerifierKeepsUnknownKeyOutageUnavailableAfterReservation // unknown and its key set cannot be fetched. down.Store(true) rotated := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), rotated, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), rotated, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextUnavailable) require.NotErrorIs(t, err, ErrWorkContextInvalid) require.EqualValues(t, 2, requests.Load()) @@ -311,7 +311,7 @@ func TestWorkContextJWKSVerifierKeepsUnknownKeyOutageUnavailableAfterReservation // to an invalid-token rejection against the stale cache, and must not spend // another fetch. for range 5 { - _, err = verifier.Verify(t.Context(), rotated, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), rotated, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextUnavailable) require.NotErrorIs(t, err, ErrWorkContextInvalid) } @@ -355,10 +355,10 @@ func TestWorkContextJWKSVerifierRefreshWarmsCacheAtBoot(t *testing.T) { require.EqualValues(t, 1, requests.Load()) // The warmed cache serves the first Verify without another fetch. - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, + workContextTestExpected(), ) require.NoError(t, err) require.EqualValues(t, 1, requests.Load()) @@ -392,15 +392,15 @@ func TestWorkContextJWKSVerifierRefreshPreservesRotationRefreshBudget(t *testing // still force exactly one rotation refresh, and no more. rotated.Store(true) second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), second, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(t.Context(), second, workContextTestExpected()) require.NoError(t, err) require.EqualValues(t, 2, requests.Load()) for index := range 20 { - _, err = verifier.Verify( + _, err = verifier.VerifyWorkContext( t.Context(), workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), secondPrivate), - WorkContextExpectations{}, + workContextTestExpected(), ) require.Error(t, err) } diff --git a/workcontext/work_context_test.go b/workcontext/work_context_test.go index 6a91468..e9afa9e 100644 --- a/workcontext/work_context_test.go +++ b/workcontext/work_context_test.go @@ -37,11 +37,56 @@ func workContextTestSigner(t *testing.T, now time.Time) *WorkContextSigner { return signer } +// workContextTestSeal is the execution every fixture credential is sealed to. +func workContextTestSeal() Seal { + return Seal{ + PrincipalEpoch: 7, + InstallationID: "installation-0a9f", + InstallationRevision: 41, + BuildIncarnation: "build-incarnation-2026-07-23-a", + } +} + +// workContextTestExpected is what a verifier on the same installation, +// revision and build holds. Every verification in these tests starts from it, +// because a verifier that states none of this refuses every credential — which +// is the behaviour, not an inconvenience to work around. +func workContextTestExpected() WorkContextExpectations { + seal := workContextTestSeal() + return WorkContextExpectations{ + Seal: SealExpectations{ + PrincipalEpoch: seal.PrincipalEpoch, + InstallationID: seal.InstallationID, + InstallationRevision: seal.InstallationRevision, + BuildIncarnation: seal.BuildIncarnation, + }, + // The fixture credential is delegated through one actor, so a verifier + // that accepted only direct owner calls would refuse it. + Delegation: &DelegationExpectations{}, + } +} + +// workContextTestExpectedWith keeps whatever a test states and fills in the +// seal and the delegation the fixture credential carries, so a test about +// audience mismatch stays a test about audience mismatch. +func workContextTestExpectedWith(expected WorkContextExpectations) WorkContextExpectations { + baseline := workContextTestExpected() + if expected.Seal == (SealExpectations{}) { + expected.Seal = baseline.Seal + } + if expected.Delegation == nil { + expected.Delegation = baseline.Delegation + } + return expected +} + func workContextTestInput() StartTaskInput { return StartTaskInput{ Audience: "warden.evidence", TenantID: "tenant-codefly", OwnerPrincipalID: "principal-antoine", + OwnerPrincipalKind: "user", + Seal: workContextTestSeal(), TaskID: "task-roadmap", SessionID: "session-root", AuthorizationRevision: ^uint64(0), @@ -110,17 +155,20 @@ func TestWorkContextStartTaskVerifyAndCanonicalize(t *testing.T) { require.Equal(t, []string{"read", "write"}, claims.AuthorityScopes[1].Actions) require.Equal(t, []string{"repo-codefly", "repo-warden"}, claims.AuthorityScopes[1].ResourceIds) - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", - Audience: "warden.evidence", - TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-antoine", - TaskID: "task-roadmap", - SessionID: "session-root", - }) + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpectedWith(WorkContextExpectations{ + Issuer: "https://accounts.codefly.dev/work-context", + Audience: "warden.evidence", + TenantID: "tenant-codefly", + OwnerPrincipalID: "principal-antoine", + OwnerPrincipalKind: "user", + TaskID: "task-roadmap", + SessionID: "session-root", + })) require.NoError(t, err) - require.Equal(t, ^uint64(0), verified.AuthorizationRevision, "uint64 revision must survive the JavaScript-safe wire representation") - require.Equal(t, "agent-claude-code", verified.ActorChain[0].PrincipalId) + require.Equal(t, ^uint64(0), verified.Claims().AuthorizationRevision, "uint64 revision must survive the JavaScript-safe wire representation") + require.Equal(t, "agent-claude-code", verified.Claims().ActorChain[0].PrincipalId) + require.Equal(t, workContextTestSeal(), verified.Seal(), "the seal survives the wire unchanged") + require.Nil(t, verified.OperationBinding()) } func TestWorkContextAudienceExchangePreservesImmutableLineage(t *testing.T) { @@ -157,18 +205,19 @@ func TestWorkContextAudienceExchangePreservesImmutableLineage(t *testing.T) { require.Equal(t, parent.GetWorkspaceId(), exchanged.GetWorkspaceId()) require.Equal(t, parent.GetProjectId(), exchanged.GetProjectId()) - verified, err := workContextTestVerifier(t, workContextTestTime).Verify( + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext( exchangedToken, - WorkContextExpectations{ + workContextTestExpectedWith(WorkContextExpectations{ Audience: "warden.gateway", TenantID: parent.GetTenantId(), OwnerPrincipalID: parent.GetOwnerPrincipalId(), TaskID: parent.GetTaskId(), SessionID: parent.GetSessionId(), - }, + }), ) require.NoError(t, err) - require.Equal(t, parent.GetActorChain(), verified.GetActorChain()) + require.Equal(t, parent.GetActorChain(), verified.Claims().GetActorChain()) + require.Equal(t, workContextTestSeal(), verified.Seal(), "an audience exchange carries the seal unchanged") } func TestWorkContextAudienceExchangeOnlyAttenuatesEffectiveScopes(t *testing.T) { @@ -227,7 +276,9 @@ func TestWorkContextAudienceExchangeOnlyAttenuatesEffectiveScopes(t *testing.T) } func TestRequireWorkContextScopeUsesFinalActorEffectiveAuthority(t *testing.T) { - _, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + require.NoError(t, err) + claims, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) require.NoError(t, err) require.NoError(t, RequireWorkContextScope(claims, WorkContextScopeRequirement{ @@ -260,20 +311,29 @@ func TestRequireWorkContextScopeUsesFinalActorEffectiveAuthority(t *testing.T) { } } -func TestRequireWorkContextScopeDistinguishesInvalidClaimsFromDenial(t *testing.T) { - err := RequireWorkContextScope(nil, WorkContextScopeRequirement{ +// A scope check can only be reached through verification now, so the two ways +// it used to be reachable without one — nil claims, and claims mutated after +// they were verified — are the two things this proves are gone. +func TestRequireWorkContextScopeDistinguishesUnverifiedFromDenial(t *testing.T) { + err := RequireWorkContextScope(VerifiedWorkContext{}, WorkContextScopeRequirement{ ResourceKind: "evidence", Action: "append", ResourceID: "codefly.execution", }) require.ErrorIs(t, err, ErrWorkContextInvalid) require.NotErrorIs(t, err, ErrWorkContextDenied) - _, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) + token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) require.NoError(t, err) - claims.ActorChain[0].GrantedScopes[0].Actions = []string{"write", "read"} - err = RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "repository", Action: "read", ResourceID: "repo-warden", - }) - require.ErrorIs(t, err, ErrWorkContextInvalid, "mutated non-canonical claims must fail closed") + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) + require.NoError(t, err) + + // Claims hands back a copy, so widening the copy widens nothing. Before the + // scope check took a verified credential, this same mutation reached the + // authorizing code and had to be caught by re-validating the shape. + widened := verified.Claims() + widened.ActorChain[0].GrantedScopes[0].Actions = []string{"admin", "append", "read", "write"} + require.ErrorIs(t, RequireWorkContextScope(verified, WorkContextScopeRequirement{ + ResourceKind: "repository", Action: "admin", ResourceID: "repo-warden", + }), ErrWorkContextDenied, "mutating a copy of the claims must not widen the credential") } func TestWorkContextWireGolden(t *testing.T) { @@ -282,7 +342,7 @@ func TestWorkContextWireGolden(t *testing.T) { // Shared with sdk-js. A byte-identical token proves payload field order, // uint64 handling, sorting, base64url, and Ed25519 signing agree. - const expected = "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoicHJvamVjdC13YXJkZW4ifQ.pVZhqvPljkv6SyFD9UAg_oKC4SPj4hIV1Ha0W33cCV04IeaayLDe0w8iVgbxy9wwE2AWY8dXbIMmvnVk0QQRAQ" + const expected = "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoicHJvamVjdC13YXJkZW4iLCJvd25lcl9wcmluY2lwYWxfa2luZCI6InVzZXIiLCJvd25lcl9wcmluY2lwYWxfZXBvY2giOiI3IiwiaW5zdGFsbGF0aW9uX2lkIjoiaW5zdGFsbGF0aW9uLTBhOWYiLCJpbnN0YWxsYXRpb25fcmV2aXNpb24iOiI0MSIsImJ1aWxkX2luY2FybmF0aW9uIjoiYnVpbGQtaW5jYXJuYXRpb24tMjAyNi0wNy0yMy1hIn0.ew7SXOuSo919plScJDHI4kjafHK6V5JvJtBVrgCrhKVYMmdAN0hcnSEX1-jL9kiUmN2amkkAHGyAtokg77P6Aw" require.Equal(t, expected, token.Encoded()) } @@ -291,16 +351,16 @@ func TestWorkContextWireGolden(t *testing.T) { // A conforming verifier in any language must reject all three. func TestWorkContextGoldenRejectsPresentButEmptyOptionalFields(t *testing.T) { goldens := map[string]string{ - "parent_session_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJwYXJlbnRfc2Vzc2lvbl9pZCI6IiIsImF1dGhvcml0eV9zY29wZXMiOlt7InJlc291cmNlX2tpbmQiOiJldmlkZW5jZSIsImFjdGlvbnMiOlsiYXBwZW5kIl0sInJlc291cmNlX2lkcyI6W119LHsicmVzb3VyY2Vfa2luZCI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInJlYWQiLCJ3cml0ZSJdLCJyZXNvdXJjZV9pZHMiOlsicmVwby1jb2RlZmx5IiwicmVwby13YXJkZW4iXX1dLCJhY3Rvcl9jaGFpbiI6W3sicHJpbmNpcGFsX2lkIjoiYWdlbnQtY2xhdWRlLWNvZGUiLCJwcmluY2lwYWxfa2luZCI6ImFnZW50IiwiZGVsZWdhdGlvbl9pZCI6ImRlbGVnYXRpb24tMSIsImdyYW50ZWRfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8td2FyZGVuIl19XX1dLCJhdHRyaWJ1dGlvbl90ZWFtX2lkcyI6WyJ0ZWFtLWFpIiwidGVhbS1wbGF0Zm9ybSJdLCJ3b3Jrc3BhY2VfaWQiOiJ3b3Jrc3BhY2UtZGV1cyIsInByb2plY3RfaWQiOiJwcm9qZWN0LXdhcmRlbiJ9.y4I8b_0ak8amI5-phVBK05RTeNSUmQWbhgoee6lKFfB7zbZvXSYZpnbJdJYLOVr2XQ9-vNcSfGKCa9CYhy8rAA", - "workspace_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoiIiwicHJvamVjdF9pZCI6InByb2plY3Qtd2FyZGVuIn0.dmtRxR3riehJonFGLLKfhI22mJB9Sw4GhPSz5r9YElBv4QN8kKLL4XQAmZx1tluPvobRS_rchAyEHvKYiD2rBw", - "project_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoiIn0.x93RO6IxZaDujBg8NsmvQwiYW6FxkNdMUILIbHyKNU4rnjJZKLHo2I1o4_nTI811Kb0aHEgrk3dfqAE-Ev5eBw", + "parent_session_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJwYXJlbnRfc2Vzc2lvbl9pZCI6IiIsImF1dGhvcml0eV9zY29wZXMiOlt7InJlc291cmNlX2tpbmQiOiJldmlkZW5jZSIsImFjdGlvbnMiOlsiYXBwZW5kIl0sInJlc291cmNlX2lkcyI6W119LHsicmVzb3VyY2Vfa2luZCI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInJlYWQiLCJ3cml0ZSJdLCJyZXNvdXJjZV9pZHMiOlsicmVwby1jb2RlZmx5IiwicmVwby13YXJkZW4iXX1dLCJhY3Rvcl9jaGFpbiI6W3sicHJpbmNpcGFsX2lkIjoiYWdlbnQtY2xhdWRlLWNvZGUiLCJwcmluY2lwYWxfa2luZCI6ImFnZW50IiwiZGVsZWdhdGlvbl9pZCI6ImRlbGVnYXRpb24tMSIsImdyYW50ZWRfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8td2FyZGVuIl19XX1dLCJhdHRyaWJ1dGlvbl90ZWFtX2lkcyI6WyJ0ZWFtLWFpIiwidGVhbS1wbGF0Zm9ybSJdLCJ3b3Jrc3BhY2VfaWQiOiJ3b3Jrc3BhY2UtZGV1cyIsInByb2plY3RfaWQiOiJwcm9qZWN0LXdhcmRlbiIsIm93bmVyX3ByaW5jaXBhbF9raW5kIjoidXNlciIsIm93bmVyX3ByaW5jaXBhbF9lcG9jaCI6IjciLCJpbnN0YWxsYXRpb25faWQiOiJpbnN0YWxsYXRpb24tMGE5ZiIsImluc3RhbGxhdGlvbl9yZXZpc2lvbiI6IjQxIiwiYnVpbGRfaW5jYXJuYXRpb24iOiJidWlsZC1pbmNhcm5hdGlvbi0yMDI2LTA3LTIzLWEifQ.CxdrAx_gkJlVyjTSDpc4oty1Tg67D3n-mwfDVz7ShWJk3KtXI6yDZ1x_8HcgJItZ1vJAXh98aZvujOScfAbCBw", + "workspace_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoiIiwicHJvamVjdF9pZCI6InByb2plY3Qtd2FyZGVuIiwib3duZXJfcHJpbmNpcGFsX2tpbmQiOiJ1c2VyIiwib3duZXJfcHJpbmNpcGFsX2Vwb2NoIjoiNyIsImluc3RhbGxhdGlvbl9pZCI6Imluc3RhbGxhdGlvbi0wYTlmIiwiaW5zdGFsbGF0aW9uX3JldmlzaW9uIjoiNDEiLCJidWlsZF9pbmNhcm5hdGlvbiI6ImJ1aWxkLWluY2FybmF0aW9uLTIwMjYtMDctMjMtYSJ9.Nrs7RXdAEhxC_sEUZEFX5P5LJy7Az_1eeUie9a-DGHGyJ7nDP-Rm7seY221gmib9vGa21GtiFGl4CKo4U4iuDQ", + "project_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoiIiwib3duZXJfcHJpbmNpcGFsX2tpbmQiOiJ1c2VyIiwib3duZXJfcHJpbmNpcGFsX2Vwb2NoIjoiNyIsImluc3RhbGxhdGlvbl9pZCI6Imluc3RhbGxhdGlvbi0wYTlmIiwiaW5zdGFsbGF0aW9uX3JldmlzaW9uIjoiNDEiLCJidWlsZF9pbmNhcm5hdGlvbiI6ImJ1aWxkLWluY2FybmF0aW9uLTIwMjYtMDctMjMtYSJ9.hZWBljC6rWAUf4T4nNlAcW76rceX1-KRk9tb_A4DNzB-fmHjvbDxVsM7ir0gDTUPmlWNA7XSEhxBB9bHHwwPAw", } verifier := workContextTestVerifier(t, workContextTestTime) for field, encoded := range goldens { t.Run(field, func(t *testing.T) { token, err := ParseWorkContextToken(encoded) require.NoError(t, err) - _, err = verifier.Verify(token, WorkContextExpectations{}) + _, err = verifier.VerifyWorkContext(token, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) require.ErrorContains(t, err, field+" must not be empty when present") require.NotContains(t, err.Error(), "signature", "must be rejected by validation, not signature failure") @@ -319,10 +379,10 @@ func TestWorkContextAcceptsSingleCharacterOptionalFields(t *testing.T) { token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) require.NoError(t, err) - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{}) + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) require.NoError(t, err) - require.Equal(t, " ", verified.GetWorkspaceId()) - require.Equal(t, " ", verified.GetProjectId()) + require.Equal(t, " ", verified.Claims().GetWorkspaceId()) + require.Equal(t, " ", verified.Claims().GetProjectId()) } // StartTask omits the optional workspace_id/project_id when the caller passes an @@ -340,7 +400,7 @@ func TestWorkContextStartTaskOmitsEmptyOptionalFields(t *testing.T) { require.Nil(t, claims.WorkspaceId, "empty WorkspaceID must be omitted, not stored as present-but-empty") require.Nil(t, claims.ProjectId, "empty ProjectID must be omitted, not stored as present-but-empty") - _, err = workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{}) + _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) require.NoError(t, err, "a context with absent optional fields must verify") } @@ -355,7 +415,7 @@ func TestWorkContextRejectsForgeryAndClaimSubstitution(t *testing.T) { payload[len(payload)/2] ^= 1 forged, err := ParseWorkContextToken(base64.RawURLEncoding.EncodeToString(payload) + "." + segments[1]) require.NoError(t, err) - _, err = verifier.Verify(forged, WorkContextExpectations{Audience: "warden.evidence"}) + _, err = verifier.VerifyWorkContext(forged, workContextTestExpectedWith(WorkContextExpectations{Audience: "warden.evidence"})) require.ErrorIs(t, err, ErrWorkContextInvalid) require.Contains(t, err.Error(), "signature") @@ -367,7 +427,7 @@ func TestWorkContextRejectsForgeryAndClaimSubstitution(t *testing.T) { "session": {SessionID: "session-other"}, } { t.Run(name, func(t *testing.T) { - _, verifyErr := verifier.Verify(token, expected) + _, verifyErr := verifier.VerifyWorkContext(token, workContextTestExpectedWith(expected)) require.ErrorIs(t, verifyErr, ErrWorkContextInvalid) require.Contains(t, verifyErr.Error(), "mismatch") }) @@ -378,7 +438,7 @@ func TestWorkContextRejectsExpiredFutureAndExcessiveLifetime(t *testing.T) { token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) require.NoError(t, err) - _, err = workContextTestVerifier(t, workContextTestTime.Add(7*time.Minute)).Verify(token, WorkContextExpectations{}) + _, err = workContextTestVerifier(t, workContextTestTime.Add(7*time.Minute)).VerifyWorkContext(token, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) require.Contains(t, err.Error(), "expired") @@ -386,7 +446,7 @@ func TestWorkContextRejectsExpiredFutureAndExcessiveLifetime(t *testing.T) { futureInput.NotBefore = workContextTestTime.Add(2 * time.Minute) futureToken, _, err := workContextTestSigner(t, workContextTestTime).StartTask(futureInput) require.NoError(t, err) - _, err = workContextTestVerifier(t, workContextTestTime).Verify(futureToken, WorkContextExpectations{}) + _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(futureToken, workContextTestExpected()) require.ErrorIs(t, err, ErrWorkContextInvalid) require.Contains(t, err.Error(), "not active") @@ -426,16 +486,16 @@ func TestWorkContextChildSessionPreservesOwnershipAndAttenuates(t *testing.T) { require.Equal(t, "session-child", child.SessionId) require.Len(t, child.ActorChain, 2) - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(childToken, WorkContextExpectations{ + verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(childToken, workContextTestExpectedWith(WorkContextExpectations{ Audience: "warden.tools", TenantID: parentClaims.TenantId, OwnerPrincipalID: parentClaims.OwnerPrincipalId, TaskID: parentClaims.TaskId, SessionID: "session-child", ParentSessionID: stringPointer("session-root"), - }) + })) require.NoError(t, err) - require.Equal(t, "tool-codefly-editor", verified.ActorChain[1].PrincipalId) + require.Equal(t, "tool-codefly-editor", verified.Claims().ActorChain[1].PrincipalId) } func TestWorkContextChildSessionRejectsScopeWidening(t *testing.T) { @@ -492,7 +552,7 @@ func TestWorkContextMalformedTokensFailClosed(t *testing.T) { for _, encoded := range []string{"", "one-segment", "a.b.c", "!!!.!!!"} { token, parseErr := ParseWorkContextToken(encoded) if parseErr == nil { - _, parseErr = verifier.Verify(token, WorkContextExpectations{}) + _, parseErr = verifier.VerifyWorkContext(token, workContextTestExpected()) } require.ErrorIs(t, parseErr, ErrWorkContextInvalid, encoded) } From 0afe59c44a5fe12ee4d745a416e9afb54a7b3657 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:42:35 -0400 Subject: [PATCH 02/35] refactor(workcontext)!: delete this module's Work Context implementation; core's is the only one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wire contract has exactly one implementation, in the repository that owns the type. The Work Context is a core proto, so core's `workcontext` is the only mint and the only verify, and this module is a client of it. That rule is being written down because it was broken here. This module signed a hand-written JSON payload while core signed the deterministic protobuf encoding of the same message. Both forms are "." with an Ed25519 signature, so a token from either looked well-formed to the other and then failed SIGNATURE verification — and "signature does not verify under key X" reads like a rotated key, which is what was investigated while the real problem was two encodings. The host signed with this module's format, so a field added only to core's proto was dropped at mint and absent at verify on every live path. Deleted, not fenced and not deprecated: the signer, both verifiers, the JWKS verifier, the expectations, the scope requirement, the sealed-value matching, the payload struct pair, the token type, this module's four refusal sentinels, and the cross-language golden tokens. 3,532 lines of code and tests. What remains is client plumbing and typed access to core's one implementation: the mint-once client, the capability and installation carriers, the cache partition, the stream guard, and aliases for core's Verifier, Verified, Seal, OperationBinding, sources, scope algebra and sentinels. The verification entry point this module exports IS core's, by type alias. Two tests make it unrepeatable, because a comment did not. TestNoSecondWorkContextImplementation parses every non-test file in the module and fails on a crypto/ed25519 or crypto/ecdsa import, an encoding/json import outside mint.go's HTTP bodies, a json-tagged struct that is not those bodies, or a WorkContext*-named type or func of our own; it also pins the aliases by assignment, which compiles only while they are core's types. TestWorkContextConformance drives core's conformance kit — every token form and every negative case, including the JSON look-alike that must be refused before the signature with ErrNotACoreToken — through the entry point this module exports. Also from the move to core's model, each a behaviour change rather than a rename: ErrRevoked replaces this module's superseded sentinel, because core makes one sentinel of every sealed mismatch and a caller's response to all of them is to mint again; the cache partition takes core's *Verified and writes around a capability carrying an approval grant hop, which the proto encodes and the deleted payload could not; its digest preimages are length-prefixed binary rather than JSON, so the key prefix moves to wc3; and the seal's revisions, epochs and incarnations are uint64 on the wire rather than decimal strings. Pinned to a pseudo-version of core#691's branch, not a local replace. Docs rewritten in the same change: workcontext/README.md, README.md, AGENTS.md and the dual-module-change skill, whose dependency-budget premise this overrides. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/dual-module-change/SKILL.md | 32 +- .golangci.yaml | 2 +- AGENTS.md | 30 +- README.md | 31 +- workcontext/README.md | 312 ++-- workcontext/cache_partition.go | 289 ++-- workcontext/cache_partition_test.go | 690 +++----- workcontext/carrier.go | 221 +++ workcontext/carrier_test.go | 172 ++ workcontext/core.go | 108 ++ workcontext/doc.go | 53 +- workcontext/go.mod | 11 +- workcontext/go.sum | 20 +- .../grpctransport/execution_context_grpc.go | 60 +- .../execution_context_grpc_test.go | 86 +- workcontext/grpctransport/two_service_test.go | 237 +-- workcontext/mint.go | 24 +- workcontext/mint_test.go | 111 +- workcontext/minting_test.go | 165 ++ workcontext/one_implementation_test.go | 260 +++ workcontext/seal.go | 245 --- workcontext/seal_test.go | 371 ----- workcontext/stream.go | 41 +- workcontext/stream_test.go | 47 +- workcontext/work_context.go | 1478 ----------------- workcontext/work_context_jwks.go | 396 ----- workcontext/work_context_jwks_test.go | 483 ------ workcontext/work_context_test.go | 559 ------- 28 files changed, 1934 insertions(+), 4600 deletions(-) create mode 100644 workcontext/carrier.go create mode 100644 workcontext/carrier_test.go create mode 100644 workcontext/core.go create mode 100644 workcontext/minting_test.go create mode 100644 workcontext/one_implementation_test.go delete mode 100644 workcontext/seal.go delete mode 100644 workcontext/seal_test.go delete mode 100644 workcontext/work_context.go delete mode 100644 workcontext/work_context_jwks.go delete mode 100644 workcontext/work_context_jwks_test.go delete mode 100644 workcontext/work_context_test.go diff --git a/.claude/skills/dual-module-change/SKILL.md b/.claude/skills/dual-module-change/SKILL.md index d533459..d7e201b 100644 --- a/.claude/skills/dual-module-change/SKILL.md +++ b/.claude/skills/dual-module-change/SKILL.md @@ -1,6 +1,6 @@ --- name: dual-module-change -description: Use when a change touches workcontext/ — the leaf module — or both it and the root SDK. Covers the dependency budget that is the module's whole reason to exist, the two go.mod/go.sum pairs that must be tidied independently, the two different coverage gates, and how a consumer resolves a version of a Go submodule. Reach for it when a root-module command reports "ok" but workcontext was never compiled, when `go mod tidy` at the root leaves workcontext dirty, or before adding any import to workcontext. +description: Use when a change touches workcontext/ — the leaf module — or both it and the root SDK. Covers the dependency budget and the one rule that overrides it (the Work Context has one implementation, core's), the two go.mod/go.sum pairs that must be tidied independently, the two different coverage gates, and how a consumer resolves a version of a Go submodule. Reach for it when a root-module command reports "ok" but workcontext was never compiled, when `go mod tidy` at the root leaves workcontext dirty, or before adding any import to workcontext. --- # Changing the two modules @@ -14,21 +14,37 @@ That is the contract. Everything below follows from it. ## The dependency budget -`workcontext/go.mod` currently has three direct requirements — `core` (for the -`codefly/base/v0` proto types), `testify`, `grpc` — and five indirect ones. The -root SDK's indirect list is roughly fifty. +`workcontext/go.mod` has four direct requirements — `core` (the +`codefly/base/v0` proto types **and** `core/workcontext`), `protobuf`, +`testify`, `grpc`. The root SDK's indirect list is roughly fifty. -**Adding an import to `workcontext` is a design change.** Before you do: +The fourth one arrived by decision, not by accident, and the decision is worth +knowing before you argue from the budget: `core/workcontext` pulls +`protovalidate` → `cel-go` → `antlr`, which this module used to avoid, and it +was taken anyway because the Work Context has exactly one implementation and it +is core's. A smaller `go.sum` is not worth a second implementation of a signed +credential. See `workcontext/README.md`, "One implementation, and the gate that +keeps it that way". -- Can the type come from `codefly/base/v0`, which the module already has? +**Adding a further import to `workcontext` is still a design change.** Before +you do: + +- Can the type come from `codefly/base/v0` or `core/workcontext`, which the + module already has? - Does it belong in `workcontext/grpctransport/` instead? That subpackage exists - so a verify-only consumer never compiles grpc. Anything transport-shaped goes - there, not beside the verifier. + so a consumer that makes no gRPC call never compiles grpc. Anything + transport-shaped goes there. - Does it belong in the root SDK, with `workcontext` left alone? If none of those work, say in the PR body what the consumer's `go.sum` now costs. Growth is a decision someone makes, not a side effect of an import line. +**One import is refused outright**: nothing in this module may import +`crypto/ed25519` or `crypto/ecdsa`, and only `mint.go` may import +`encoding/json` (the mint endpoint's HTTP bodies). +`TestNoSecondWorkContextImplementation` fails the build on either, which is the +gate rather than this paragraph. + The root module must never import `workcontext` to reach shared code. That would make the leaf module a dependency of the thing it was split out of, and a consumer taking the leaf would be back to the full tail. diff --git a/.golangci.yaml b/.golangci.yaml index 30c518b..6228930 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -21,7 +21,7 @@ linters: # proper noun: every sentinel in these files begins with "Codefly". - linters: - staticcheck - path: (^|/)(runtime_environment_file|work_context|mint|stream)\.go$ + path: (^|/)(runtime_environment_file|mint|stream)\.go$ text: "ST1005:" paths: - .*\.pb\.go diff --git a/AGENTS.md b/AGENTS.md index 7590f49..666ce60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,11 +96,13 @@ package line. | `tls.go` | workload leaf certificates, reloaded on rotation | | `receipts/` | effect receipts: the store, the digest, the replay/conflict interceptor | | `receipts/grpctransport/`, `receipts/connecttransport/` | the two transport adapters, split so neither drags the other's dependency in | -| `workcontext/` | **separate leaf module**: the mint-once client, the seal, signing and verification | -| `workcontext/grpctransport/` | the gRPC carrier, so verify-only consumers never compile grpc | +| `workcontext/` | **separate leaf module**: the mint-once client, the carriers, the cache partition, the stream guard, and typed access to core's one implementation | +| `workcontext/grpctransport/` | the gRPC carrier, so a consumer that makes no gRPC call never compiles grpc | -`workcontext` exists to keep a verify-only consumer's `go.sum` small. Adding a -dependency to it is a design change, not a detail — see the skill below. +`workcontext` mints nothing and verifies nothing: `core/workcontext` is the only +implementation of the capability, and this module re-exports core's verifier by +type alias. Adding a dependency to it is still a design change, not a detail — +see the skill below. ## Rules that bite @@ -114,13 +116,21 @@ dependency to it is a design change, not a detail — see the skill below. - **An empty injected value is not a value.** A composition templating an unset variable ships the name with an empty string; `RuntimeValue` reports `false` so it cannot shadow the configuration a caller falls back to. Keep that. +- **The Work Context has exactly one implementation and it is not here.** + `core/workcontext` signs and verifies; this module is a client of it and + re-exports its verifier by alias. Nothing here may sign, check a signature or + encode a capability — `TestNoSecondWorkContextImplementation` refuses a + `crypto/ed25519` import, an `encoding/json` import outside `mint.go`'s HTTP + bodies, a json-tagged struct that is not those bodies, and a `WorkContext*` + type of our own; `TestWorkContextConformance` drives core's fixtures through + the entry point we export. A claim added to the wire is added to the proto in + core, never to an encoder here. This repository once held a second + implementation signing hand-written JSON: a token from either format failed + *signature* verification in the other, which reads like a rotated key. - **A credential is sealed or it is not a credential.** Every field of - `workcontext.Seal` is required, at sign time and at verify time, and - `AttachWorkContext` refuses an unsealed token. A claim added to the wire must - be added to the payload struct pair in `work_context.go`: the signed bytes are - hand-written JSON, so a field present only in the proto is dropped at mint and - absent at verify. `codefly-dev/core` has a second `workcontext` package that - signs protobuf; it is wire-incompatible and no live path uses it. + `workcontext.Seal` is required at mint and at verify, an operation binding + carries its id, revision and incarnation or none of them, and `Attach` + refuses to put an unsealed capability on a request. - **An authority-bearing value is read once.** A principal, binding or audience comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for a pinned name, so a drift is an error rather than a reload — the process has diff --git a/README.md b/README.md index 80cf99c..0d00885 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,34 @@ # codefly + go = sdk-go -## Work Context: mint once, sealed, verified exactly +## Work Context: mint once, sealed, one implementation A module process obtains its credential once per execution, sealed to the build it is and the installation it serves, and never announces itself again. The leaf -module `github.com/codefly-dev/sdk-go/workcontext` holds the mint client, the -signer and the verifier; it depends only on the shared `codefly/base/v0` proto -types, so a consumer that only verifies keeps a `go.sum` of a handful of entries -rather than inheriting core's transitive tail. +module `github.com/codefly-dev/sdk-go/workcontext` holds the mint-once client, +the carriers that take a credential to a callee, the cache partition and the +stream guard. + +**It mints nothing and verifies nothing.** The Work Context is a core proto, and +`github.com/codefly-dev/core/workcontext` is its only implementation: the only +code that signs a capability, the only code that checks a signature, the only +encoding of either. What this module exports as a verification entry point *is* +core's, by type alias, and two tests hold it that way — +`TestNoSecondWorkContextImplementation` refuses a signing primitive, a +JSON-encoded capability or a `WorkContext*` type of our own anywhere in the +module, and `TestWorkContextConformance` drives core's own fixtures through the +entry point we export. + +That rule is written down because it was broken here, and the cost of breaking +it is a specific misdiagnosis: two encodings of one message both look like +`.` with an Ed25519 signature, so a token from one fails +*signature* verification in the other, and "signature does not verify under key +X" sends everyone to look at keys. See [`workcontext/README.md`](workcontext/README.md) for the model, the sealed -fields, the four refusals a caller must tell apart, and what a consumer has to -stop doing — there is no heartbeat, no registration call, and no self-reported -build or installation. +fields, the refusals a caller must tell apart, the four sources core's verifier +requires, and what a consumer has to stop doing — there is no heartbeat, no +registration call, and no self-reported build or installation. ## Authority-bearing configuration diff --git a/workcontext/README.md b/workcontext/README.md index 054193e..7bb951a 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -1,13 +1,73 @@ # `workcontext` -Signing and verification of Codefly Work Contexts, and the client a module -process uses to obtain the one credential it runs on. +The client side of the Work Context capability: the mint-once client a module +process obtains its one credential with, the carriers that take it to a callee, +and typed access to the one implementation of the capability itself. -It is a separate Go module so a service whose only need is to verify can import +**This module mints nothing and verifies nothing.** The Work Context is a core +proto, and `github.com/codefly-dev/core/workcontext` is its only +implementation — the only code that signs one, the only code that checks a +signature, the only encoding of either. What this module exports as a +verification entry point *is* core's, by type alias. + +It is a separate Go module so a consumer can import `github.com/codefly-dev/sdk-go/workcontext` without inheriting the root SDK's -transitive tail. Three direct dependencies: `codefly-dev/core` (for the -`codefly/base/v0` proto types), `testify`, `grpc`. The gRPC carrier lives in -`workcontext/grpctransport` so a verify-only consumer never compiles grpc. +transitive tail. Four direct dependencies: `codefly-dev/core` (the +`codefly/base/v0` proto types **and** `core/workcontext`), `protobuf`, +`testify`, `grpc`. The gRPC carrier lives in `workcontext/grpctransport` so a +consumer that never makes a gRPC call does not compile grpc. + +Importing `core/workcontext` pulls `protovalidate` → `cel-go` → `antlr`, which +this module used to avoid. That was a deliberate trade and it was made the +other way on purpose: the dependency budget existed to keep a verify-only +consumer's `go.sum` small, and a second implementation of a signed credential +is not a price worth paying for it. + +## One implementation, and the gate that keeps it that way + +A wire contract has exactly one implementation, in the repository that owns the +type. Nothing outside `core/workcontext` may sign, verify or re-encode a Work +Context. + +That is written down because it was broken. A second implementation lived in +this module and signed a **hand-written JSON payload** while core signed the +**deterministic protobuf encoding** of the same message. Both forms are +`.` with an Ed25519 signature, so a +token from either looked well-formed to the other and then failed **signature** +verification. "Signature does not verify under key X" reads like a rotated key +or a wrong trust root, so keys are what everyone investigated, while the actual +problem was two encodings of one message. The host signed with this module's +format, so a field added only to core's proto was dropped at mint and absent at +verify on every live path. + +A comment would not have stopped that, and did not. Two tests do. + +**`TestNoSecondWorkContextImplementation`** parses every non-test file in this +module and fails if any of them: + +- imports `crypto/ed25519` or `crypto/ecdsa` — this module neither signs nor + checks a signature, so nothing here needs a signing primitive; +- imports `encoding/json` outside the one allowlisted file, `mint.go`, which + speaks the mint endpoint's HTTP bodies and never encodes a capability; +- declares a json-tagged struct other than the mint endpoint's request and + response — a struct whose tags enumerate a capability's fields by hand is + exactly the deleted implementation, and is how a field present in the proto + came to be missing on the wire; +- declares a type or func named `WorkContext*` that is not an alias of core's. + +It also pins the aliases by assignment (`var _ *corework.Verifier = +(*Verifier)(nil)`), which compiles only while `Verifier` is core's type and not +a local copy with the same fields. + +**`TestWorkContextConformance`** runs `core/workcontext/conformance.RunWith` +against the verification entry point this module exports. The kit drives every +token form and every negative case through it and fails the build if any +outcome differs. The decisive fixture is the foreign encoding: a JSON-shaped +token that must be refused **before** its signature is checked, with +`ErrNotACoreToken`. A second implementation refuses that token too — as a +signature failure, which is the misdiagnosis the whole rule exists to prevent. +That is why the gate lives here, in the consumer: core cannot see who +re-implements it. ## The model: mint once, sealed, verified exactly @@ -39,9 +99,9 @@ its own records; a field for any of them would be a field a process could lie in. There is no self-reported upstream and no manifest. **A credential is sealed or it is not a credential.** Every sealed field is -required. A token missing one does not verify, and `AttachWorkContext` refuses -to put it on a request at all — there is no request on which an unsealed -credential is better than no credential. +required. A capability missing one does not verify, and `Attach` refuses to put +it on a request at all — there is no request on which an unsealed credential is +better than no credential. ## Obtaining the credential @@ -93,71 +153,104 @@ carrier disagrees with the sealed claim rather than preferring either side. ## Verifying -`VerifyWorkContext` is the only verification entry point, on both the static -verifier and the JWKS one. It returns `VerifiedWorkContext`, which is the only -thing that can produce verified claims — so code holding claims cannot be -holding a hand-built protobuf, a token that was parsed and never verified, or a -credential nobody held to an installation. +Verification is core's, and what this module exports is core's: ```go -verified, err := verifier.VerifyWorkContext(token, workcontext.WorkContextExpectations{ - Issuer: issuer, - Audience: myAudience, - - // The delegating principal. - OwnerPrincipalID: ownerID, - OwnerPrincipalKind: ownerKind, - - // The calling principal, checked separately. nil accepts direct owner - // calls ONLY: a credential carrying an actor chain is refused. - Delegation: &workcontext.DelegationExpectations{PrincipalKind: "agent"}, - - // Required. A verifier that does not state where it is cannot refuse a - // credential from somewhere else. - Seal: workcontext.SealExpectations{ - PrincipalEpoch: epoch, - InstallationID: installationID, - InstallationRevision: revision, - BuildIncarnation: build, - }, +// workcontext.Verifier IS github.com/codefly-dev/core/workcontext.Verifier. +verified, err := verifier.Verify(ctx, token) // *workcontext.Verified, or a refusal +``` - // Required for an operation context; nil refuses a credential that seals one. - OperationBinding: &workcontext.OperationBindingExpectations{ - BindingID: bindingID, BindingRevision: bindingRevision, BindingIncarnation: incarnation, - }, -}) +```go +verifier := &workcontext.Verifier{ + Issuer: issuer, + Audience: myAudience, + Keys: publicKeysByKeyID, + + // All four are required. A verifier missing one refuses everything rather + // than skipping that check, because a verifier that silently skipped the + // seal check would make the strongest check in the model the easiest one + // to omit. + Revisions: revisions, // the issuer's authorization revision + Replay: replay, // consumes single-use capabilities + Grants: grants, // resolves an approval a grant capability claims + Seals: seals, // the live installation, epoch, build and binding state +} ``` -Scope comes last, from the verified credential: +**That shape is issuer-shaped, and it is a real cost for a consumer.** A +component that holds those four locally — the host — supplies them from its own +state. A verify-only module does not hold them, and must back them with calls +to whoever does: `RevisionSource`, `GrantSource` and `SealSource` are narrow +interfaces for exactly that reason, so an implementation can be an RPC. +`ReplayStore` has to be durable and shared when more than one process verifies, +or single-use is not single use. + +Core ships `FixedRevision`, `MemoryReplayStore` and `MemorySealSource` as real +implementations for tests and single-process cases. **`FixedRevision` is not a +production stand-in**: it answers one number forever, so it accepts a +superseded capability. A consumer that reaches for it in a deployment has +turned revocation off. + +Scope comes last, from the verified capability, through core's scope algebra: ```go -err = workcontext.RequireWorkContextScope(verified, workcontext.WorkContextScopeRequirement{ - ResourceKind: "evidence", Action: "append", ResourceID: recordID, -}) +if !workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"append"}, ResourceIds: []string{recordID}}, + verified.EffectiveScopes(), +) { + return errRefused // the capability is good and does not cover this call +} ``` +`verified.EffectiveScopes()` is the current actor's hop, or the owner's +delegated authority when no hop has narrowed it. Asking core rather than +walking the actor chain is what keeps one answer to "what does this caller +hold". + ### Exact binding, not scope search -An operation credential names one binding, at one revision, in one incarnation, -and verification resolves that identity. It never searches for a binding whose -scopes would admit the call. A credential sealed to binding X presented as -binding Y is refused **even when Y's scopes are a superset of X's** — scope -containment is not binding identity, and a revoked narrow credential must not -validate through a wider binding. +An operation capability names one binding, at one revision, in one incarnation, +and verification resolves that identity against the seal source. It never +searches for a binding whose scopes would admit the call: a capability sealed to +binding X presented as binding Y is refused **even when Y's scopes are a +superset of X's**. Scope containment is not binding identity, and a revoked +narrow capability must not validate through a wider binding. + +Core's `SealSource` deliberately has no method that lists bindings or finds one +matching a set of scopes. Making the search unexpressible through the interface +is what keeps it out: a search is a predicate somebody writes, and a predicate +one case too generous grants authority nobody reviewed. -### The four refusals, and why they are four +### The refusals + +They are core's, re-exported here so `errors.Is` answers the same through +either import path. | Sentinel | Means | What the caller does | | --- | --- | --- | -| `ErrWorkContextInvalid` | the credential is wrong and cannot become right — bad signature, another installation, another build, another binding id | refuse the call; 401 | -| `ErrWorkContextSuperseded` | it was sound when minted and has been overtaken — principal epoch, installation revision or binding revision moved | mint again, then retry once | -| `ErrWorkContextDenied` | the credential is good and its scope does not cover this | refuse the call; 403 | -| `ErrWorkContextUnavailable` | the key set is unreachable (transport failure, issuer 5xx/429) | retry; 503, never 401 | - -`ErrWorkContextSuperseded` is separate from the other three on purpose. Mapped -onto the retryable-outage path, every installation revision bump becomes a retry -storm against a credential that is guaranteed to keep failing. Mapped onto a -plain denial, a condition one mint would fix becomes a user-visible error. +| `ErrInvalid` | wrong and cannot become right — bad signature, another audience, another installation, another build, another binding id, a malformed capability | refuse; 401 | +| `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, or the authorization revision moved | mint again, retry once | +| `ErrUnsealed` | the capability carries no seal, or names no installation | refuse; it is not a credential | +| `ErrNotACoreToken` | the payload is not this encoding at all — most usefully, a JSON one | refuse, and do **not** report a signature problem | +| `ErrReplayed` | a single-use capability was presented twice | refuse; this is the resume contract, not a forgery | + +Two of those deserve a note, because getting either wrong costs an outage. + +**`ErrRevoked` is one sentinel for six reasons, deliberately.** In every one of +them the capability was sound when it was minted and the state moved under it, +so the holder's response is identical: mint again. A caller needs to tell "mint +again" from "this caller is not authorized" — those read differently. It does +not need the six reasons for a re-mint to read differently from each other, and +a caller branching on them would be writing a policy nobody reviewed. **This +module no longer defines a `superseded` sentinel of its own**; `ErrRevoked` is +it. + +**`ErrNotACoreToken` does not wrap `ErrInvalid`.** `errors.Is(err, +ErrInvalid)` is false for it, on purpose, so "this is another format" and "this +capability is bad" are not reachable from one branch. A gateway should log the +reason by name: the symptom this prevents is a token that looks valid and fails +signature, and it cannot recur if the look-alike never reaches the signature +check. ## Streams @@ -171,7 +264,9 @@ nothing errors and the data keeps arriving. guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ Interval: hostRecheckCadence, Recheck: func(ctx context.Context) error { - _, err := verifier.VerifyWorkContext(token, expectationsRightNow()) + // The same verification an ordinary call performs, against the + // issuer's state as it is NOW — not the state the stream opened with. + _, err := verifier.Verify(ctx, token) return err }, }) @@ -200,14 +295,23 @@ hit. - `ByAuthorizationView()` adds a digest of the effective scopes and the authorization revision. Safe only when the result depends on scopes alone. -- `ByViewer()` adds the effective actor — the last actor, or the owner for a - direct call. Use it for anything whose content varies by who is asking: every - reader of a wiki holds `documents:[read]`, and a revision is a number they - share. - -The key prefix is `wc2:`. It changed with the installation revision going into -the key, so a shared cache that outlives the deploy — Redis, a warehouse table — -cannot be reached under the `wc1:` keys it still holds. +- `ByViewer()` adds the effective actor — core's `Verified.Actor()`, or the + owner for a direct call — with its kind, agent identity and organization. Use + it for anything whose content varies by who is asking: every reader of a + collection holds the same read scope, and a revision is a number they share. + +`WriteAround` is true for a capability carrying an approval **grant hop**. That +hop is the one audited exception to the attenuation rule, so the answer was +computed under authority nobody else holds: caching it would serve an approved +call's result to callers who were never approved, and reading from the cache +would answer the approved call from an unapproved computation. + +The key prefix is `wc3:`. It moves whenever the key's composition does — this +time, because the digest preimage became length-prefixed binary rather than +JSON — so a shared cache that outlives the deploy (Redis, a warehouse table) +cannot be reached under the keys it still holds. Those digests are framed field +by field, so no tenant, installation or scope can contain a delimiter and spell +another partition's key. ## What a consumer must stop doing @@ -227,14 +331,21 @@ cannot be reached under the `wc1:` keys it still holds. refuses a runtime change to one: the host is the authority, and a value that drifts under a running process is an error, not a reload. `WorkspaceValue` answers from the pin for a pinned name, so there is no way around it. -- **Stop verifying without a seal.** `Verify` is gone; `VerifyWorkContext` takes - required `SealExpectations`. A verifier that does not state which installation - revision and build it is on cannot refuse a credential from an earlier one. +- **Stop verifying through this module.** `WorkContextSigner`, + `WorkContextVerifier`, `WorkContextJWKSVerifier`, `VerifyWorkContext`, + `WorkContextExpectations`, `RequireWorkContextScope` and the token type are + **deleted**, not deprecated. Use `core/workcontext`'s `Verifier`, which this + module re-exports; a consumer that was verifying here owes the four sources + above, and that is the honest price of there being one implementation. - **Stop searching bindings.** Resolve the sealed binding identity. A superset of scopes is not a licence to act as a different binding. -- **Stop authorizing from bare claims.** `RequireWorkContextScope` takes a - `VerifiedWorkContext`. Claims that were never verified, or were mutated after - verification, can no longer reach an authorization decision. +- **Stop authorizing from bare claims.** Read claims off a `*Verified` and + nowhere else. Claims that were never verified, or were mutated after + verification, cannot reach an authorization decision. +- **Stop writing a second encoder.** If a claim is missing from the wire, it is + added to the proto in core and mints from there. Nothing in this repository + assembles, re-encodes or re-signs a capability — the gate above will say so + before review does. ## Running the gates @@ -250,7 +361,7 @@ go mod tidy && (cd workcontext && go mod tidy) Coverage for this module is gated on the **module total** across both its packages (≥ 75%, an `awk` line in the `coverage` job of `.github/workflows/go.yml`), -not per package — `grpctransport` is carried by the verifier, so read the total: +not per package — `grpctransport` is carried by the rest, so read the total: ```bash cd workcontext @@ -258,21 +369,36 @@ go test ./... -coverprofile=cover.out -covermode=atomic -coverpkg=./... go tool cover -func=cover.out | awk '/^total:/ {print $3}' ``` -## The wire, and the other implementation - -The signed payload is `base64url(JSON payload) + "." + base64url(Ed25519 -signature)`, with one fixed snake_case field order. Revisions and epochs travel -as decimal strings so the complete `uint64` domain survives a JavaScript -verifier. Decoding rejects unknown fields and trailing JSON. - -`codefly-dev/core` also has a `workcontext` package. It signs **deterministic -binary protobuf**, not this JSON, so a token from one fails *signature* -verification in the other — the error reads like a key-rotation problem and is -not one. The host (`accounts`, `auth-gateway`) imports this module, so this is -the production wire; core's package is not on any live path. Anything that adds -a claim must add it to the payload struct pair in `work_context.go`, because a -field present only in the proto is dropped at mint and absent at verify. - -The golden tokens in `work_context_test.go` are shared byte-for-byte with -sdk-js. A change to the payload changes them, and sdk-js must be regenerated in -the same round. +Until core#691 is released, this module is pinned to a **pseudo-version of +core's branch** (`go get github.com/codefly-dev/core@`), not a local +`replace`: a pseudo-version is reproducible for anyone who checks the branch +out, and a `replace` to a worktree is not mergeable. Move it to the release when +there is one. + +## The wire + +`base64url(deterministic protobuf of codefly.base.v0.WorkContextV1) + "." + +base64url(Ed25519 signature)`, signed by `core/workcontext` and by nothing +else. The sealed fields are proto fields — `seal.principal_epoch`, +`seal.installation_id`, `seal.installation_revision`, +`seal.build_incarnation`, and for an operation capability +`operation_binding.binding_id` / `.revision` / `.incarnation`, all three +together or none. + +This module reads a capability's seal and lifetime with `proto.Unmarshal` into +core's generated type and **no signature check**, in one place +(`carrier.go`, `claimsOf`). That is sound only because nothing trusts the +result: it fills the pre-check carriers, and it tells the mint client when its +own freshly issued credential expires. A receiver that preferred a carrier over +the sealed claim would have made a caller-controlled header into authority, +which is why the incoming side refuses a disagreement instead. + +There are no cross-language golden tokens in this module any more. They went +with the implementation that produced them: core mints its conformance fixtures +fresh on every call, because a committed token carries a validity window and +would eventually fail for the wrong reason. + +**Adding a claim**: add the field to the proto in `codefly-dev/core`, mint it +from `core/workcontext`, and add a fixture for it there. There is nothing to +change in this module, which is the point — the failure that made this rule was +a field that existed in the proto and in nobody's encoder. diff --git a/workcontext/cache_partition.go b/workcontext/cache_partition.go index 914acd2..ca1fd65 100644 --- a/workcontext/cache_partition.go +++ b/workcontext/cache_partition.go @@ -1,105 +1,43 @@ package workcontext import ( - "context" + "bytes" "crypto/sha256" "encoding/base64" + "encoding/binary" "encoding/hex" - "encoding/json" "fmt" + "slices" "strconv" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" ) -// cachePartitionViewDomain separates the view digest from every other SHA-256 -// a Codefly component computes, so no other structure can be made to collide -// with it. -const cachePartitionViewDomain = "codefly.work-context.cache-partition.view.v1\n" +// cachePartitionViewDomain separates the view digest from every other SHA-256 a +// component computes, so no other structure can be made to collide with it. +const cachePartitionViewDomain = "codefly.work-context.cache-partition.view.v2\n" // cachePartitionViewerDomain does the same for the viewer digest, so a subject // can never be made to spell a view. -const cachePartitionViewerDomain = "codefly.work-context.cache-partition.viewer.v1\n" +const cachePartitionViewerDomain = "codefly.work-context.cache-partition.viewer.v2\n" -// VerifiedWorkContext is a credential whose signature, lifetime, expectations -// and seal a verifier has checked. VerifyWorkContext is the only thing that -// constructs one, so code holding it cannot be holding a hand-built protobuf, a -// token that was parsed but never verified, or a credential nobody held to an -// installation. Its zero value is not verified and every consumer rejects it. -// -// It is the single verification result in this module. There is no accessor -// that returns bare claims from a token, because a caller that can obtain -// claims without the seal will eventually authorize from them. -type VerifiedWorkContext struct { - sealed sealedContext -} - -// Claims returns a copy of the verified delegation claims. Mutating the copy -// changes nothing this value derives. -func (v VerifiedWorkContext) Claims() *basev0.WorkContextV1 { - return cloneContext(v.sealed.context) -} - -// Seal returns the execution this credential is bound to. -func (v VerifiedWorkContext) Seal() Seal { - return v.sealed.seal -} - -// OperationBinding returns the one binding this credential may act through, or -// nil when it is not an operation context. -func (v VerifiedWorkContext) OperationBinding() *OperationBinding { - if v.sealed.binding == nil { - return nil - } - binding := *v.sealed.binding - return &binding -} - -// VerifyWorkContext establishes trust in a credential: signature, structure, -// lifetime, the stated expectations, the seal, and the operation binding by -// exact identity. It is the only verification entry point. -func (v *WorkContextVerifier) VerifyWorkContext( - token WorkContextToken, - expected WorkContextExpectations, -) (VerifiedWorkContext, error) { - sealed, err := v.decode(token) - if err != nil { - return VerifiedWorkContext{}, err - } - if err := matchWorkContext(sealed, expected); err != nil { - return VerifiedWorkContext{}, err - } - return VerifiedWorkContext{sealed: sealed}, nil -} - -// VerifyWorkContext establishes trust using the current cached key set, -// refreshing once for an unknown key id so a rotation is picked up without a -// request stampede. -func (v *WorkContextJWKSVerifier) VerifyWorkContext( - ctx context.Context, - token WorkContextToken, - expected WorkContextExpectations, -) (VerifiedWorkContext, error) { - verifier, err := v.verifierFor(ctx, token) - if err != nil { - return VerifiedWorkContext{}, err - } - return verifier.VerifyWorkContext(token, expected) -} - -// CachePartition is the identity partition a cache stack scopes its entries -// to. It is plain data on purpose: the cache takes it as an opaque value built -// from these two fields and never learns what a Work Context is. +// CachePartition is the identity partition a cache stack scopes its entries to. +// It is plain data on purpose: the cache takes it as an opaque value built from +// these two fields and never learns what a Work Context is. type CachePartition struct { // Key is stable for one tenant on one installation revision (and, with // ByAuthorizationView, one effective authorization view). It contains only // base64url, hex and ':'. Key string + // WriteAround asks the stack to bypass cached reads and writes for this - // call. It is always false today. WorkContextV1 has carried the - // approval-grant hop since core v0.5.10, but this module's signed payload - // does not yet encode it, so no credential reaching here can be carrying - // one; wiring it is codefly-dev/sdk-go#41 item 4. + // call. It is true for a capability carrying an approval grant hop: that + // hop is the one audited exception to the attenuation rule, so the answer + // was computed under authority nobody else holds — caching it would serve + // an approved call's result to callers who were never approved, and reading + // from the cache would answer the approved call from an unapproved + // computation. WriteAround bool } @@ -119,9 +57,9 @@ type cachePartitionOptions struct { // viewer: a service that authorizes per subject — per-subject resource grants, // or a row predicate over the caller's identity — gives every caller with the // same scopes the same partition, so one caller's entries are served to -// another. Every reader of a wiki holds documents:[read], and a revision is a -// number they share. Use ByViewer for anything whose content varies by who is -// asking. +// another. Every reader of a collection holds the same read scope, and a +// revision is a number they share. Use ByViewer for anything whose content +// varies by who is asking. func ByAuthorizationView() CachePartitionOption { return func(options *cachePartitionOptions) { options.byAuthorizationView = true @@ -130,8 +68,8 @@ func ByAuthorizationView() CachePartitionOption { // ByViewer partitions by the identity authorization is evaluated as, on top of // the authorization view: the key adds a digest of the effective actor — the -// last actor in the chain, or the owner principal for a direct owner call, the -// same identity RequireWorkContextScope reads scopes from. +// current actor's hop, or the owner principal for a direct owner call, which is +// the same identity core's Verified.Actor reports. // // This is the option for a result that varies by who is asking, which is any // result a service computes by authorizing the caller rather than by reading @@ -145,27 +83,33 @@ func ByViewer() CachePartitionOption { } } -// DeriveCachePartition computes the cache partition for a verified credential. +// DeriveCachePartition computes the cache partition for a verified capability. // The key always carries the tenant and the sealed installation at its sealed // revision. It never carries the session, task, nonce or audience: those // identify an execution, not an authorization, so a child session or an // audience exchange with the same effective view shares its parent's partition // instead of fragmenting the cache per hop. // -// The installation revision is in the key, not optional, and that is the whole -// point of it being there: an answer computed while the host held revision N -// was computed under the authority of revision N, and nothing about it survives -// the host moving to N+1. A revision bump therefore lands every caller in a -// fresh partition, which is a cache miss by design and never a stale hit. The -// installation id travels with the revision because revision numbers are -// per-installation and two installations share their small integers. +// It takes core's *Verified and nothing else, so there is no way to derive a +// partition from a capability nobody verified. The installation revision is in +// the key, not optional, and that is the whole point of it being there: an +// answer computed while the host held revision N was computed under the +// authority of revision N, and nothing about it survives the host moving to +// N+1. A revision bump therefore lands every caller in a fresh partition, +// which is a cache miss by design and never a stale hit. The installation id +// travels with the revision because revision numbers are per-installation and +// two installations share their small integers. func DeriveCachePartition( - verified VerifiedWorkContext, + verified *Verified, options ...CachePartitionOption, ) (CachePartition, error) { - claims := verified.sealed.context - if claims == nil { - return CachePartition{}, fmt.Errorf("%w: cache partition requires a verified Work Context", ErrWorkContextInvalid) + if verified == nil || verified.Context() == nil { + return CachePartition{}, fmt.Errorf("%w: a cache partition requires a verified Work Context", ErrInvalid) + } + claims := verified.Context() + seal := claims.GetSeal() + if seal.GetInstallationId() == "" || seal.GetInstallationRevision() == 0 { + return CachePartition{}, fmt.Errorf("%w: a cache partition requires the sealed installation", ErrUnsealed) } var settings cachePartitionOptions for _, option := range options { @@ -173,90 +117,113 @@ func DeriveCachePartition( option(&settings) } } - seal := verified.sealed.seal // Tenant and installation IDs are free-form, so the key encodes rather than // embeds them: no tenant or installation can contain the delimiter and // spell another one's view key. // - // The "wc2" prefix is not decoration. A shared cache that outlives the - // deploy — Redis, a warehouse table — still holds "wc1" keys computed - // without an installation revision in them, and those keys would otherwise - // be reachable by a process that now partitions correctly. Changing the - // prefix orphans them instead. - key := "wc2:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.TenantId)) + - ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.InstallationID)) + - ":r:" + strconv.FormatUint(seal.InstallationRevision, 10) + // The "wc3" prefix is not decoration. A shared cache that outlives the + // deploy — Redis, a warehouse table — still holds keys computed under an + // earlier scheme, and those keys would otherwise be reachable by a process + // that now partitions correctly. Changing the prefix orphans them instead. + key := "wc3:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.GetTenantId())) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.GetInstallationId())) + + ":r:" + strconv.FormatUint(seal.GetInstallationRevision(), 10) if settings.byAuthorizationView || settings.byViewer { - digest, err := authorizationViewDigest(claims) + digest, err := authorizationViewDigest(verified) if err != nil { return CachePartition{}, err } key += ":v:" + digest } if settings.byViewer { - digest, err := viewerDigest(claims) - if err != nil { - return CachePartition{}, err - } - key += ":s:" + digest + key += ":s:" + viewerDigest(verified) } - return CachePartition{Key: key}, nil + return CachePartition{Key: key, WriteAround: claims.GetGrantHop() != nil}, nil } -// viewerDigest hashes the effective actor — the last actor in the chain, or the -// owner principal for a direct owner call, chosen exactly as -// authorizationViewDigest chooses effective scopes so the two never disagree -// about who the caller is. The kind travels with the id because the two -// namespaces are independent: a user and an agent may share an id string -// without being the same caller. -func viewerDigest(claims *basev0.WorkContextV1) (string, error) { - // The kind travels with the id because the two namespaces are independent: - // an owner whose id is "x" and a delegated agent whose id is "x" are - // different callers. The owner's kind is the real one now that it is - // required on the wire, so no placeholder stands in for it. - viewer := struct { - PrincipalID string `json:"principal_id"` - PrincipalKind string `json:"principal_kind"` - }{ - PrincipalID: claims.GetOwnerPrincipalId(), - PrincipalKind: claims.GetOwnerPrincipalKind(), +// viewerDigest hashes the effective actor. The kind travels with the id because +// the two namespaces are independent: an owner whose id is "x" and a delegated +// agent whose id is "x" are different callers. The agent identity and the +// organization travel with them for the same reason — authorization is +// evaluated per organization, so two organizations' callers must never share a +// partition even when one principal acts in both. +func viewerDigest(verified *Verified) string { + claims := verified.Context() + principalID := claims.GetOwnerPrincipalId() + kind := claims.GetOwnerPrincipalKind() + agentID := claims.GetOwnerAgentId() + organization := claims.GetOrganizationId() + if actor := verified.Actor(); actor != nil { + principalID = actor.GetPrincipalId() + kind = actor.GetPrincipalKind() + agentID = actor.GetAgentId() + if actor.GetOrganizationId() != "" { + organization = actor.GetOrganizationId() + } } - if actors := claims.GetActorChain(); len(actors) > 0 { - last := actors[len(actors)-1] - viewer.PrincipalID = last.GetPrincipalId() - viewer.PrincipalKind = last.GetPrincipalKind() + preimage := []byte(cachePartitionViewerDomain) + for _, field := range []string{principalID, kind, agentID, organization} { + preimage = appendField(preimage, []byte(field)) } - encoded, err := json.Marshal(viewer) + sum := sha256.Sum256(preimage) + return hex.EncodeToString(sum[:]) +} + +// authorizationViewDigest hashes the effective scopes — core's +// Verified.EffectiveScopes, so this module never decides for itself which hop a +// caller's authority comes from — together with the authorization revision. +// +// The preimage is length-prefixed binary rather than a text encoding: every +// field is framed by its own length, so no scope can contain a delimiter and +// spell another view. The scopes are canonicalized first, so the order a minter +// was handed them in never changes the digest. +func authorizationViewDigest(verified *Verified) (string, error) { + scopes, err := canonicalScopes(verified.EffectiveScopes()) if err != nil { - return "", fmt.Errorf("%w: encode viewer: %v", ErrWorkContextInvalid, err) + return "", err } - sum := sha256.Sum256(append([]byte(cachePartitionViewerDomain), encoded...)) + preimage := binary.BigEndian.AppendUint64( + []byte(cachePartitionViewDomain), verified.Context().GetAuthorizationRevision(), + ) + preimage = binary.BigEndian.AppendUint32(preimage, uint32(len(scopes))) + for _, scope := range scopes { + preimage = appendField(preimage, scope) + } + sum := sha256.Sum256(preimage) return hex.EncodeToString(sum[:]), nil } -// authorizationViewDigest hashes the effective scopes — the final actor's -// granted scopes, or the authority scopes for a direct owner call, exactly as -// RequireWorkContextScope reads them — together with the authorization -// revision. The scopes are canonicalized first so the order a signer was -// handed them in never changes the digest. -func authorizationViewDigest(claims *basev0.WorkContextV1) (string, error) { - effective := claims.GetAuthorityScopes() - if actors := claims.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - scopes := cloneScopes(effective) - canonicalizeScopes(scopes) - view := struct { - AuthorizationRevision string `json:"authorization_revision"` - Scopes []workContextScope `json:"scopes"` - }{ - AuthorizationRevision: strconv.FormatUint(claims.AuthorizationRevision, 10), - Scopes: payloadScopes(scopes), - } - encoded, err := json.Marshal(view) - if err != nil { - return "", fmt.Errorf("%w: encode authorization view: %v", ErrWorkContextInvalid, err) +// canonicalScopes encodes each scope deterministically and sorts the +// encodings, which puts a set of scopes in one order without this module +// defining what "first" means for a scope. Each scope's own repeated fields are +// sorted before encoding, for the same reason. +func canonicalScopes(scopes []*basev0.WorkScopeV1) ([][]byte, error) { + encoded := make([][]byte, 0, len(scopes)) + for _, scope := range scopes { + if scope == nil { + continue + } + clone, ok := proto.Clone(scope).(*basev0.WorkScopeV1) + if !ok { + return nil, fmt.Errorf("%w: scope clone is not a scope", ErrInvalid) + } + slices.Sort(clone.Actions) + clone.Actions = slices.Compact(clone.Actions) + slices.Sort(clone.ResourceIds) + clone.ResourceIds = slices.Compact(clone.ResourceIds) + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(clone) + if err != nil { + return nil, fmt.Errorf("%w: encode scope: %v", ErrInvalid, err) + } + encoded = append(encoded, raw) } - sum := sha256.Sum256(append([]byte(cachePartitionViewDomain), encoded...)) - return hex.EncodeToString(sum[:]), nil + slices.SortFunc(encoded, bytes.Compare) + return encoded, nil +} + +// appendField frames one value by its length, so a digest preimage cannot be +// read two ways. +func appendField(preimage []byte, field []byte) []byte { + preimage = binary.BigEndian.AppendUint32(preimage, uint32(len(field))) + return append(preimage, field...) } diff --git a/workcontext/cache_partition_test.go b/workcontext/cache_partition_test.go index 18baae6..202c8b3 100644 --- a/workcontext/cache_partition_test.go +++ b/workcontext/cache_partition_test.go @@ -1,91 +1,153 @@ package workcontext import ( - "crypto/ed25519" + "context" "encoding/base64" "fmt" "math/rand/v2" - "net/http" - "net/http/httptest" - "regexp" - "slices" "strconv" "strings" "testing" "time" - "github.com/stretchr/testify/require" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -const partitionPropertyIterations = 200 +const partitionPropertyIterations = 50 -// partitionWorld is one real signer and the verifier trusting it. Every token -// a property test derives from goes through both, so the partition is only -// ever computed from what verification produced. -type partitionWorld struct { - t *testing.T - signer *WorkContextSigner - verifier *WorkContextVerifier - nonces int +// verified mints a capability and verifies it, because a partition is only +// ever derived from what verification produced. +func (a *authority) verified(t *testing.T, input mintInput) *Verified { + t.Helper() + verified, err := a.verifier(t).Verify(context.Background(), a.start(t, input)) + require.NoError(t, err) + return verified } -func newPartitionWorld(t *testing.T) *partitionWorld { +func partition(t *testing.T, verified *Verified, options ...CachePartitionOption) CachePartition { t.Helper() - publicKey, privateKey := workContextTestKeys() - world := &partitionWorld{t: t} - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", KeyID: "partition-key", - PrivateKey: privateKey, Now: func() time.Time { return workContextTestTime }, - Nonce: func() (string, error) { - world.nonces++ - return fmt.Sprintf("nonce-%d", world.nonces), nil - }, - }) + derived, err := DeriveCachePartition(verified, options...) require.NoError(t, err) - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{"partition-key": publicKey}, - Now: func() time.Time { return workContextTestTime }, - }) + return derived +} + +// partitionKeyPrefix is the tenant-and-installation half of every key. The +// installation and its revision are always present, so a test that spells the +// prefix by hand spells all three. +func partitionKeyPrefix(tenant string, revision uint64) string { + return "wc3:t:" + base64.RawURLEncoding.EncodeToString([]byte(tenant)) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(testInstallation)) + + ":r:" + strconv.FormatUint(revision, 10) +} + +// A child session with the same effective view shares its parent's partition. +// A session, task or nonce identifies an execution rather than an +// authorization, so putting one in the key would fragment the cache per hop and +// buy nothing: the two calls may read the same entries because they may read +// the same data. +func TestCachePartitionIsStableAcrossHopsWithTheSameView(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read", "write")} + owner := a.verified(t, mintInput{scopes: scopes}) + delegated, err := a.verifier(t).Verify( + context.Background(), a.child(t, a.start(t, mintInput{scopes: scopes}), "actor-1", scopes), + ) require.NoError(t, err) - world.signer, world.verifier = signer, verifier - return world + + require.NotEqual(t, owner.Context().GetSessionId(), delegated.Context().GetSessionId()) + require.Equal(t, + partition(t, owner, ByAuthorizationView()).Key, + partition(t, delegated, ByAuthorizationView()).Key, + "the same authority through a delegation hop is the same authorization view", + ) } -// verify accepts both a direct owner call and a delegated one, because the -// partition tests derive from both and the partition is the thing under test -// here rather than the delegation check. A real verifier states one or the -// other; stating both would defeat the point of the check. -func (w *partitionWorld) verify(token WorkContextToken) VerifiedWorkContext { - w.t.Helper() - expected := workContextTestExpected() - expected.Delegation = nil - verified, err := w.verifier.VerifyWorkContext(token, expected) - if err != nil { - expected.Delegation = &DelegationExpectations{} - verified, err = w.verifier.VerifyWorkContext(token, expected) - } - require.NoError(w.t, err) - return verified +// Tenant, installation revision and effective view each separate partitions on +// their own, and the plain partition carries the first two whether or not a +// caller asks for a view. +func TestCachePartitionSeparatesTenantRevisionAndView(t *testing.T) { + a := newAuthority(t) + read := []*basev0.WorkScopeV1{scope("document", "read")} + write := []*basev0.WorkScopeV1{scope("document", "read", "write")} + + base := a.verified(t, mintInput{scopes: read}) + require.Equal(t, partitionKeyPrefix(testTenant, 3), partition(t, base).Key) + + other := a.verified(t, mintInput{tenant: "tenant-2", scopes: read}) + require.NotEqual(t, partition(t, base).Key, partition(t, other).Key) + + wider := a.verified(t, mintInput{scopes: write}) + require.Equal(t, partition(t, base).Key, partition(t, wider).Key, + "without ByAuthorizationView the key carries the tenant and installation only") + require.NotEqual(t, + partition(t, base, ByAuthorizationView()).Key, + partition(t, wider, ByAuthorizationView()).Key, + "a wider scope set is a different authorization view", + ) } -func (w *partitionWorld) partitions(token WorkContextToken) (tenant, view CachePartition) { - w.t.Helper() - verified := w.verify(token) - tenant, err := DeriveCachePartition(verified) - require.NoError(w.t, err) - view, err = DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(w.t, err) - require.False(w.t, tenant.WriteAround) - require.False(w.t, view.WriteAround) - return tenant, view +// The installation revision is in every key, and that is the whole point of it +// being there: an answer computed while the host held revision N was computed +// under the authority of revision N, and nothing about it survives the host +// moving to N+1. +func TestCachePartitionNeverSpansAnInstallationRevision(t *testing.T) { + a := newAuthority(t) + before := partition(t, a.verified(t, mintInput{}), ByViewer()) + + moved := testSeal + moved.InstallationRevision++ + require.NoError(t, a.seals.Put(testPrincipal, moved)) + + after := partition(t, a.verified(t, mintInput{}), ByViewer()) + require.NotEqual(t, before.Key, after.Key) + require.True(t, strings.HasPrefix(after.Key, partitionKeyPrefix(testTenant, moved.InstallationRevision))) +} + +// A tenant is free-form, so the key encodes it rather than embedding it. A +// tenant that spells the delimiters of another tenant's key must not produce +// that key. +func TestCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) { + a := newAuthority(t) + honest := a.verified(t, mintInput{tenant: "tenant-1"}) + forged := a.verified(t, mintInput{tenant: "tenant-1:i:" + testInstallation + ":r:3"}) + require.NotEqual(t, partition(t, honest).Key, partition(t, forged).Key) + require.NotContains(t, partition(t, forged).Key, ":i:"+testInstallation+":r:3:i:") +} + +// A partition is derived from a verified capability or not at all. There is no +// accessor that produces one from claims nobody verified, because a caller +// holding one would eventually authorize from it. +func TestCachePartitionRequiresAVerifiedCapability(t *testing.T) { + _, err := DeriveCachePartition(nil) + require.ErrorIs(t, err, ErrInvalid) + + var absent *Verified + _, err = DeriveCachePartition(absent) + require.ErrorIs(t, err, ErrInvalid) +} + +// The order a minter was handed its scopes in must not change the view. The +// digest canonicalizes, so the same authority spelled in any order — with +// duplicate actions and resource ids — is one partition. +func TestAuthorizationViewDigestCanonicalizesScopeOrder(t *testing.T) { + a := newAuthority(t) + random := rand.New(rand.NewPCG(1, 2)) + for iteration := range partitionPropertyIterations { + scopes := randomScopes(random) + canonical := partition(t, a.verified(t, mintInput{scopes: scopes}), ByAuthorizationView()).Key + shuffled := partition(t, a.verified(t, mintInput{scopes: shuffledScopes(random, scopes)}), ByAuthorizationView()).Key + require.Equal(t, canonical, shuffled, "iteration %d: the same authority in another order", iteration) + } } // randomScopes returns a valid, non-empty scope set in a random order with -// duplicated entries, so the signer's canonicalization is always exercised. +// duplicated entries, so canonicalization is always exercised. func randomScopes(random *rand.Rand) []*basev0.WorkScopeV1 { - kinds := []string{"repository", "evidence", "document", "profile", "billing"} + kinds := []string{"repository", "record", "document", "profile", "account"} random.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] }) scopes := make([]*basev0.WorkScopeV1, 0, len(kinds)) for _, kind := range kinds[:1+random.IntN(len(kinds))] { @@ -106,459 +168,99 @@ func randomScopes(random *rand.Rand) []*basev0.WorkScopeV1 { // shuffledScopes is the same authority in a different order. func shuffledScopes(random *rand.Rand, scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - out := cloneScopes(scopes) - random.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) - for _, scope := range out { - random.Shuffle(len(scope.Actions), func(i, j int) { - scope.Actions[i], scope.Actions[j] = scope.Actions[j], scope.Actions[i] - }) - random.Shuffle(len(scope.ResourceIds), func(i, j int) { - scope.ResourceIds[i], scope.ResourceIds[j] = scope.ResourceIds[j], scope.ResourceIds[i] - }) - } - return out -} - -// narrowedScopes strictly attenuates a canonical scope set: it drops a kind -// when there are several, else an action, else pins a wildcard to one ID or -// drops one explicit ID. ok is false when nothing can be narrowed. -func narrowedScopes(scopes []*basev0.WorkScopeV1) (out []*basev0.WorkScopeV1, ok bool) { - out = cloneScopes(scopes) - canonicalizeScopes(out) - if len(out) > 1 { - return out[1:], true - } - scope := out[0] - switch { - case len(scope.Actions) > 1: - scope.Actions = scope.Actions[1:] - case len(scope.ResourceIds) == 0: - scope.ResourceIds = []string{"pinned"} - case len(scope.ResourceIds) > 1: - scope.ResourceIds = scope.ResourceIds[1:] - default: - return nil, false - } - return out, true -} - -func partitionStartInput(random *rand.Rand, tenant string, revision uint64, scopes []*basev0.WorkScopeV1) StartTaskInput { - return StartTaskInput{ - Audience: fmt.Sprintf("service-%d", random.IntN(1000)), - TenantID: tenant, - OwnerPrincipalID: fmt.Sprintf("principal-%d", random.IntN(1000)), - OwnerPrincipalKind: "user", - Seal: workContextTestSeal(), - TaskID: fmt.Sprintf("task-%d", random.Uint64()), - SessionID: fmt.Sprintf("session-%d", random.Uint64()), - AuthorizationRevision: revision, - AuthorityScopes: scopes, - } -} - -// partitionKeyPrefix is the tenant-and-installation half of every key these -// tests assert on. The installation and its revision are always present, so a -// test that spells the prefix by hand spells all three. -func partitionKeyPrefix(tenant string) string { - seal := workContextTestSeal() - return "wc2:t:" + base64.RawURLEncoding.EncodeToString([]byte(tenant)) + - ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.InstallationID)) + - ":r:" + strconv.FormatUint(seal.InstallationRevision, 10) -} - -func TestDeriveCachePartitionIsStableAcrossHopsWithTheSameView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(39, 658)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", random.IntN(50)) - revision := random.Uint64() - scopes := randomScopes(random) - root, _, err := world.signer.StartTask(partitionStartInput(random, tenant, revision, scopes)) - require.NoError(t, err) - rootTenant, rootView := world.partitions(root) - - // A different task, session, owner, audience and nonce under the same - // tenant, revision and authority — handed to the signer in another - // order — is the same view. - other, _, err := world.signer.StartTask( - partitionStartInput(random, tenant, revision, shuffledScopes(random, scopes)), - ) - require.NoError(t, err) - otherTenant, otherView := world.partitions(other) - require.Equal(t, rootTenant, otherTenant, "iteration %d", iteration) - require.Equal(t, rootView, otherView, "iteration %d", iteration) - - // A child session whose actor keeps the whole effective authority. - child, _, err := world.signer.StartChildSession(root, StartChildSessionInput{ - SessionID: fmt.Sprintf("child-%d", iteration), - Audience: "service-child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "agent", PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: shuffledScopes(random, scopes), - }, - }) - require.NoError(t, err) - childTenant, childView := world.partitions(child) - require.Equal(t, rootTenant, childTenant, "iteration %d", iteration) - require.Equal(t, rootView, childView, "iteration %d", iteration) - - // Audience exchanges, with no attenuation and with an attenuation equal - // to the effective scopes, from the child's actor-scoped view. - for _, attenuated := range [][]*basev0.WorkScopeV1{nil, shuffledScopes(random, scopes)} { - exchanged, _, err := world.signer.ExchangeWorkContextAudience(child, ExchangeWorkContextAudienceInput{ - Audience: "service-next", AttenuatedScopes: attenuated, - }) - require.NoError(t, err) - exchangedTenant, exchangedView := world.partitions(exchanged) - require.Equal(t, rootTenant, exchangedTenant, "iteration %d", iteration) - require.Equal(t, rootView, exchangedView, "iteration %d", iteration) - } - } -} - -func TestDeriveCachePartitionSeparatesTenantViewAndRevision(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(7, 5)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", iteration) - revision := random.Uint64() - scopes := randomScopes(random) - base, _, err := world.signer.StartTask(partitionStartInput(random, tenant, revision, scopes)) - require.NoError(t, err) - baseTenant, baseView := world.partitions(base) - - otherTenant, _, err := world.signer.StartTask( - partitionStartInput(random, tenant+"-other", revision, scopes), - ) - require.NoError(t, err) - tenantPartition, viewPartition := world.partitions(otherTenant) - require.NotEqual(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - otherRevision, _, err := world.signer.StartTask( - partitionStartInput(random, tenant, revision+1, scopes), - ) - require.NoError(t, err) - tenantPartition, viewPartition = world.partitions(otherRevision) - require.Equal(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - narrowed, ok := narrowedScopes(scopes) + out := make([]*basev0.WorkScopeV1, 0, len(scopes)) + for _, source := range scopes { + clone, ok := proto.Clone(source).(*basev0.WorkScopeV1) if !ok { continue } - // A narrower view reached every way the protocol allows: an audience - // exchange that attenuates, and a child actor granted less. - exchanged, _, err := world.signer.ExchangeWorkContextAudience(base, ExchangeWorkContextAudienceInput{ - Audience: "service-next", AttenuatedScopes: narrowed, + random.Shuffle(len(clone.Actions), func(i, j int) { + clone.Actions[i], clone.Actions[j] = clone.Actions[j], clone.Actions[i] }) - require.NoError(t, err) - tenantPartition, viewPartition = world.partitions(exchanged) - require.Equal(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - child, _, err := world.signer.StartChildSession(base, StartChildSessionInput{ - SessionID: "child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "agent", PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: narrowed, - }, + random.Shuffle(len(clone.ResourceIds), func(i, j int) { + clone.ResourceIds[i], clone.ResourceIds[j] = clone.ResourceIds[j], clone.ResourceIds[i] }) - require.NoError(t, err) - _, childView := world.partitions(child) - require.Equal(t, viewPartition.Key, childView.Key, "iteration %d", iteration) + out = append(out, clone) } + random.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) + return out } -func TestDeriveCachePartitionNeverCarriesExecutionIdentity(t *testing.T) { - world := newPartitionWorld(t) - input := workContextTestInput() - input.TaskID = "task-must-not-leak" - input.SessionID = "session-must-not-leak" - input.Audience = "audience-must-not-leak" - token, _, err := world.signer.StartTask(input) - require.NoError(t, err) - tenant, view := world.partitions(token) - for _, partition := range []CachePartition{tenant, view} { - for _, secret := range []string{"must-not-leak", "nonce-", "principal-antoine"} { - require.NotContains(t, partition.Key, secret) - } - require.True(t, strings.HasPrefix(partition.Key, tenant.Key)) - } - require.Equal(t, partitionKeyPrefix("tenant-codefly"), tenant.Key) - require.Regexp(t, `^`+regexp.QuoteMeta(partitionKeyPrefix("tenant-codefly"))+`:v:[0-9a-f]{64}$`, view.Key) -} - -func TestDeriveCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(1, 2)) - scopes := randomScopes(random) - victim, _, err := world.signer.StartTask(partitionStartInput(random, "tenant-a", 1, scopes)) - require.NoError(t, err) - _, victimView := world.partitions(victim) - - // A tenant ID made of the victim's view key suffix must not produce the - // victim's key under tenant-only partitioning. - suffix := strings.TrimPrefix(victimView.Key, "wc2:t:") - forger, _, err := world.signer.StartTask(partitionStartInput(random, "tenant-a:v:"+suffix, 1, scopes)) - require.NoError(t, err) - forgerTenant, _ := world.partitions(forger) - require.NotEqual(t, victimView.Key, forgerTenant.Key) -} - -func TestDeriveCachePartitionAcceptsOnlyVerifiedContexts(t *testing.T) { - _, err := DeriveCachePartition(VerifiedWorkContext{}, ByAuthorizationView()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - - world := newPartitionWorld(t) - token, claims, err := world.signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - // A token that parses is still not usable: re-signing the same claims for - // another tenant with a key the verifier does not trust fails before any - // partition can be derived. - _, untrustedKey := workContextJWKSKey(9) - forger, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: claims.GetIssuer(), KeyID: "partition-key", PrivateKey: untrustedKey, - Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - forgedInput := workContextTestInput() - forgedInput.TenantID = "tenant-victim" - forged, _, err := forger.StartTask(forgedInput) - require.NoError(t, err) - parsed, err := ParseWorkContextToken(forged.Encoded()) - require.NoError(t, err) - _, err = world.verifier.VerifyWorkContext(parsed, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - - // Expectations still apply: a context for another audience is rejected. - _, err = world.verifier.VerifyWorkContext(token, workContextTestExpectedWith(WorkContextExpectations{Audience: "someone-else"})) - require.ErrorIs(t, err, ErrWorkContextInvalid) - - // Claims hands out a copy; mutating it cannot move the partition. - verified := world.verify(token) - before, err := DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(t, err) - copied := verified.Claims() - copied.TenantId = "tenant-victim" - copied.AuthorizationRevision = 0 - copied.ActorChain = nil - after, err := DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(t, err) - require.Equal(t, before, after) - require.Equal(t, claims.GetTenantId(), verified.Claims().GetTenantId()) -} - -func TestJWKSVerifierVerifyWorkContextFeedsThePartition(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) +// Two callers holding the same scopes are not the same viewer. ByViewer is for +// a result that varies by who is asking, which is any result a service computes +// by authorizing the caller rather than by reading their scopes. +func TestCachePartitionByViewerSeparatesSubjectsSharingAView(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) - verified, err := verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-1", privateKey), workContextTestExpected()) + first, err := a.verifier(t).Verify(context.Background(), a.child(t, parent, "actor-1", scopes)) require.NoError(t, err) - partition, err := DeriveCachePartition(verified) + second, err := a.verifier(t).Verify(context.Background(), a.child(t, parent, "actor-2", scopes)) require.NoError(t, err) - require.Equal(t, partitionKeyPrefix("tenant-codefly"), partition.Key) - _, err = verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-2", privateKey), workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + "the two callers hold the same scopes, so they share an authorization view", + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "and they are still different callers, which is what ByViewer is for", + ) + require.Contains(t, partition(t, first, ByViewer()).Key, partition(t, first, ByAuthorizationView()).Key, + "ByViewer implies the authorization view rather than replacing it", + ) } -func TestAuthorizationViewDigestCanonicalizesScopeOrder(t *testing.T) { - ordered := &basev0.WorkContextV1{ - AuthorizationRevision: 3, - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "document", Actions: []string{"read", "write"}, ResourceIds: []string{"a", "b"}}, - {ResourceKind: "evidence", Actions: []string{"append"}}, - }, - } - shuffled := &basev0.WorkContextV1{ - AuthorizationRevision: 3, - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "evidence", Actions: []string{"append", "append"}, ResourceIds: []string{}}, - {ResourceKind: "document", Actions: []string{"write", "read"}, ResourceIds: []string{"b", "a", "b"}}, - }, - } - first, err := authorizationViewDigest(ordered) - require.NoError(t, err) - second, err := authorizationViewDigest(shuffled) - require.NoError(t, err) - require.Equal(t, first, second) - // Digesting never reorders the caller's claims. - require.Equal(t, "evidence", shuffled.AuthorityScopes[0].ResourceKind) - require.True(t, slices.Equal([]string{"write", "read"}, shuffled.AuthorityScopes[1].Actions)) - - // With actors, only the final actor's scopes are the view. - ordered.ActorChain = []*basev0.WorkActorV1{{GrantedScopes: ordered.AuthorityScopes[1:]}} - actorView, err := authorizationViewDigest(ordered) - require.NoError(t, err) - require.NotEqual(t, first, actorView) - - // A nil option is ignored rather than dereferenced. - _, err = DeriveCachePartition( - VerifiedWorkContext{sealed: sealedContext{context: ordered, seal: workContextTestSeal()}}, - nil, - ByAuthorizationView(), +// The viewer is the effective actor — the current hop, or the owner when the +// owner acts directly — which is the identity core's Verified.Actor reports. +func TestCachePartitionByViewerFollowsTheEffectiveActor(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + owner := a.verified(t, mintInput{scopes: scopes}) + delegated, err := a.verifier(t).Verify( + context.Background(), a.child(t, a.start(t, mintInput{scopes: scopes}), "actor-1", scopes), ) require.NoError(t, err) + require.NotEqual(t, + partition(t, owner, ByViewer()).Key, + partition(t, delegated, ByViewer()).Key, + "an owner acting directly and an actor acting on its authority are different viewers", + ) } -// viewerPartition is the partition for a token under ByViewer, verified like -// every other partition a property test derives. -func (w *partitionWorld) viewerPartition(token WorkContextToken) CachePartition { - w.t.Helper() - viewer, err := DeriveCachePartition(w.verify(token), ByViewer()) - require.NoError(w.t, err) - require.False(w.t, viewer.WriteAround) - return viewer -} - -// Two callers with the same tenant, the same scopes and the same authorization -// revision are still different callers. A service that authorizes per subject — -// per-subject resource grants, or a row predicate over the caller's identity — -// computes a different result for each, so they must not share a partition. -// Under ByAuthorizationView they do, which is why anything viewer-dependent -// needs ByViewer: every reader of a wiki holds documents:[read], and a revision -// is a number they share. -func TestDeriveCachePartitionByViewerSeparatesSubjectsSharingAView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(11, 3)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", iteration) - revision := random.Uint64() - scopes := randomScopes(random) - - first := partitionStartInput(random, tenant, revision, scopes) - first.OwnerPrincipalID = "viewer-one" - second := first - second.OwnerPrincipalID = "viewer-two" - - firstToken, _, err := world.signer.StartTask(first) - require.NoError(t, err) - secondToken, _, err := world.signer.StartTask(second) - require.NoError(t, err) - - // Identical under the coarser options: same tenant, same scopes, same - // revision. That is the collision ByViewer exists to remove. - firstTenant, firstView := world.partitions(firstToken) - secondTenant, secondView := world.partitions(secondToken) - require.Equal(t, firstTenant.Key, secondTenant.Key, "iteration %d", iteration) - require.Equal(t, firstView.Key, secondView.Key, "iteration %d", iteration) - - require.NotEqual(t, world.viewerPartition(firstToken).Key, world.viewerPartition(secondToken).Key, - "two subjects sharing a view must not share a viewer partition, iteration %d", iteration) - - // Same subject, same view: still one partition, or ByViewer would make - // the cache useless rather than safe. - repeat := first - repeat.TaskID = fmt.Sprintf("task-%d", random.Uint64()) - repeat.SessionID = fmt.Sprintf("session-%d", random.Uint64()) - repeatToken, _, err := world.signer.StartTask(repeat) - require.NoError(t, err) - require.Equal(t, world.viewerPartition(firstToken).Key, world.viewerPartition(repeatToken).Key, - "iteration %d", iteration) - } -} - -// A delegated actor is the caller. Two agents acting for one owner with the -// same granted scopes are different viewers, and the owner is a third — so a -// per-subject result computed for one must not be served to the others. -func TestDeriveCachePartitionByViewerFollowsTheEffectiveActor(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(13, 5)) - scopes := randomScopes(random) - owner, _, err := world.signer.StartTask(partitionStartInput(random, "tenant", 9, scopes)) +// A capability carrying an approval grant hop is written around. The hop is the +// one audited exception to the attenuation rule, so the answer was computed +// under authority nobody else holds: caching it would serve an approved call's +// result to callers who were never approved. +// +// The capability comes from core's own conformance kit, so the shape under test +// is the shape core mints rather than one this test invented. +func TestCachePartitionWritesAroundAnApprovalGrant(t *testing.T) { + now := time.Now() + settings := conformance.New(now) + verifier := settings.Verifier() + fixtures, err := corework.Fixtures(now) require.NoError(t, err) - child := func(principal string) WorkContextToken { - token, _, err := world.signer.StartChildSession(owner, StartChildSessionInput{ - SessionID: "child-" + principal, - Actor: &basev0.WorkActorV1{ - PrincipalId: principal, PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: scopes, - }, - }) - require.NoError(t, err) - return token - } - first, second := child("agent-one"), child("agent-two") - - // The view is identical: same scopes, same revision, same tenant. - _, ownerView := world.partitions(owner) - _, firstView := world.partitions(first) - require.Equal(t, ownerView.Key, firstView.Key) - - ownerViewer := world.viewerPartition(owner).Key - firstViewer := world.viewerPartition(first).Key - secondViewer := world.viewerPartition(second).Key - require.NotEqual(t, firstViewer, secondViewer, "two agents are two viewers") - require.NotEqual(t, ownerViewer, firstViewer, "an agent acting for an owner is not the owner") - require.NotEqual(t, ownerViewer, secondViewer) -} - -// Deliverable 3's other half: the SDK never caches an authorization answer -// across the installation revision it was given. An answer computed while the -// host held revision N was computed under the authority of revision N, and -// nothing about it survives the move to N+1 — so the move lands every caller -// in a fresh partition, which is a cache miss by design and never a stale hit. -func TestCachePartitionNeverSpansAnInstallationRevision(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(47, 11)) - scopes := randomScopes(random) - - atRevision := func(revision uint64, installation string) (CachePartition, CachePartition, CachePartition) { - input := partitionStartInput(random, "tenant-codefly", 5, scopes) - input.OwnerPrincipalID = "principal-stable" - input.Audience = "service-stable" - input.Seal.InstallationRevision = revision - input.Seal.InstallationID = installation - token, _, err := world.signer.StartTask(input) - require.NoError(t, err) - - expected := workContextTestExpected() - expected.Delegation = nil - expected.Seal.InstallationRevision = revision - expected.Seal.InstallationID = installation - verified, err := world.verifier.VerifyWorkContext(token, expected) - require.NoError(t, err) - - tenant, err := DeriveCachePartition(verified) - require.NoError(t, err) - view, err := DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(t, err) - viewer, err := DeriveCachePartition(verified, ByViewer()) - require.NoError(t, err) - return tenant, view, viewer + var seen int + for _, fixture := range fixtures { + if fixture.Outcome != corework.OutcomeAccepted { + continue + } + if fixture.Form != corework.FormGrant && fixture.Form != corework.FormSession { + continue + } + verified, verifyErr := verifier.Verify(context.Background(), fixture.Token) + require.NoError(t, verifyErr, fixture.Name) + derived, deriveErr := DeriveCachePartition(verified) + require.NoError(t, deriveErr, fixture.Name) + require.Equal(t, fixture.Form == corework.FormGrant, derived.WriteAround, + "fixture %q (%s): a grant hop is written around and an ordinary session is not", + fixture.Name, fixture.Form) + seen++ } - - beforeTenant, beforeView, beforeViewer := atRevision(41, "installation-0a9f") - afterTenant, afterView, afterViewer := atRevision(42, "installation-0a9f") - require.NotEqual(t, beforeTenant.Key, afterTenant.Key) - require.NotEqual(t, beforeView.Key, afterView.Key) - require.NotEqual(t, beforeViewer.Key, afterViewer.Key) - - // Two installations share their small revision numbers, so the id has to - // travel with the revision or installation A at revision 41 and - // installation B at revision 41 would be one partition. - elsewhereTenant, _, _ := atRevision(41, "installation-b3c1") - require.NotEqual(t, beforeTenant.Key, elsewhereTenant.Key) - - // Nothing else moved, so the same credential still derives the same keys. - againTenant, againView, againViewer := atRevision(41, "installation-0a9f") - require.Equal(t, beforeTenant.Key, againTenant.Key) - require.Equal(t, beforeView.Key, againView.Key) - require.Equal(t, beforeViewer.Key, againViewer.Key) - - // The key shape changed with this credential, so a shared cache that - // outlived the deploy must not be reachable under the keys it still holds. - require.True(t, strings.HasPrefix(beforeTenant.Key, "wc2:")) - require.NotContains(t, beforeTenant.Key, "wc1:") + require.Equal(t, 2, seen, "the kit must have offered one session and one grant capability") } diff --git a/workcontext/carrier.go b/workcontext/carrier.go new file mode 100644 index 0000000..341c9f7 --- /dev/null +++ b/workcontext/carrier.go @@ -0,0 +1,221 @@ +package workcontext + +import ( + "encoding/base64" + "fmt" + "net/http" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" +) + +const ( + // HeaderName is the only HTTP carrier for a signed capability. Product code + // calls Attach instead of naming it. It is spelled in lower case because + // gRPC metadata keys must be, and an HTTP header name is case-insensitive: + // one spelling for one fact, rather than two that drift. + HeaderName = "x-codefly-work-context" + + // InstallationIDHeaderName and InstallationRevisionHeaderName carry the + // installation a capability is sealed to, beside the capability. + // + // They exist so the far end can refuse a call before it decodes a token, + // and for no other reason: the installation that governs a call is the one + // inside the signature. A receiver that finds a carrier disagreeing with + // the seal refuses the call rather than preferring either, because a header + // is caller-controlled and a seal is not. + InstallationIDHeaderName = "x-codefly-installation-id" + InstallationRevisionHeaderName = "x-codefly-installation-revision" + + // MaxTokenBytes bounds a capability this module will carry or read. + MaxTokenBytes = 32 * 1024 +) + +// Attach installs a capability on an outbound request: the signed capability +// and, beside it, the installation it is sealed to. +// +// It refuses an unsealed capability outright. A credential is sealed or it is +// not a credential, and the moment to find that out is before a call is made on +// it rather than at the far end, where the refusal names an installation +// mismatch for a capability that never named an installation at all. +func Attach(request *http.Request, encoded string) error { + if request == nil { + return fmt.Errorf("%w: nil request", ErrInvalid) + } + if request.Header == nil { + request.Header = make(http.Header) + } + id, revision, err := SealedInstallation(encoded) + if err != nil { + return err + } + request.Header.Set(HeaderName, encoded) + request.Header.Set(InstallationIDHeaderName, id) + request.Header.Set(InstallationRevisionHeaderName, revision) + return nil +} + +// FromHeaders reads the capability a request carries. It returns the encoded +// token and nothing else: a token is a string until a Verifier has had it, and +// an accessor that handed back claims here would be an accessor somebody +// authorizes from. +func FromHeaders(headers http.Header) (string, error) { + if headers == nil { + return "", fmt.Errorf("%w: no headers", ErrInvalid) + } + values := headers.Values(HeaderName) + if len(values) == 0 || strings.TrimSpace(values[0]) == "" { + return "", fmt.Errorf("%w: no %s header", ErrInvalid, HeaderName) + } + if len(values) > 1 { + return "", fmt.Errorf("%w: %s appears %d times", ErrInvalid, HeaderName, len(values)) + } + encoded := values[0] + if err := checkCarriedInstallation(headers.Get(InstallationIDHeaderName), + headers.Get(InstallationRevisionHeaderName), encoded); err != nil { + return "", err + } + return encoded, nil +} + +// checkCarriedInstallation refuses a request whose installation carriers +// disagree with the seal. Agreement is required when a carrier is present and +// the carriers are required together: half of a pre-check is a pre-check that +// passes for the wrong reason. +func checkCarriedInstallation(id, revision, encoded string) error { + if id == "" && revision == "" { + return nil + } + if id == "" || revision == "" { + return fmt.Errorf( + "%w: %s and %s travel together; one was presented without the other", + ErrInvalid, InstallationIDHeaderName, InstallationRevisionHeaderName, + ) + } + sealedID, sealedRevision, err := SealedInstallation(encoded) + if err != nil { + return err + } + if id != sealedID { + return fmt.Errorf( + "%w: %s carries %q and the capability is sealed to %q", + ErrInvalid, InstallationIDHeaderName, id, sealedID, + ) + } + if revision != sealedRevision { + return fmt.Errorf( + "%w: %s carries %q and the capability is sealed to %q", + ErrInvalid, InstallationRevisionHeaderName, revision, sealedRevision, + ) + } + return nil +} + +// SealedInstallation reads the installation a capability is sealed to, for a +// transport that names it beside the capability. +// +// It reads the capability's own content without checking the signature, which +// is sound only because nothing trusts the result: the carrier it fills is a +// pre-check, and a receiver that preferred it over the sealed claim would have +// made the carrier into authority. A capability with no readable seal is an +// error here, so no transport can attach one while leaving the installation +// blank. +func SealedInstallation(encoded string) (id string, revision string, err error) { + claims, err := claimsOf(encoded) + if err != nil { + return "", "", err + } + seal := claims.GetSeal() + if seal.GetInstallationId() == "" || seal.GetInstallationRevision() == 0 { + return "", "", fmt.Errorf("%w: the capability names no installation", ErrUnsealed) + } + return seal.GetInstallationId(), strconv.FormatUint(seal.GetInstallationRevision(), 10), nil +} + +// claimsOf decodes a capability's claims WITHOUT establishing any trust in +// them. Nothing in this module authorizes from what it returns: the two callers +// are the pre-check carriers above, and the mint client reading the lifetime +// and the seal of a capability the host just handed it over an authenticated +// channel. +// +// It decodes core's encoding with core's generated type, so there is no second +// reading of the wire here. The one thing it must not become is a verification: +// there is no signature check in it and there must never be one, because a +// second place that checks a signature is a second implementation whatever it +// is called. +func claimsOf(encoded string) (*Claims, error) { + if strings.TrimSpace(encoded) == "" { + return nil, fmt.Errorf("%w: empty capability", ErrInvalid) + } + if len(encoded) > MaxTokenBytes { + return nil, fmt.Errorf("%w: capability exceeds %d bytes", ErrInvalid, MaxTokenBytes) + } + payload, signature, found := strings.Cut(encoded, ".") + if !found || payload == "" || signature == "" { + return nil, fmt.Errorf("%w: capability is not .", ErrInvalid) + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("%w: payload is not base64url: %v", ErrInvalid, err) + } + if _, err := base64.RawURLEncoding.DecodeString(signature); err != nil { + return nil, fmt.Errorf("%w: signature is not base64url: %v", ErrInvalid, err) + } + claims := &Claims{} + if err := proto.Unmarshal(raw, claims); err != nil { + return nil, fmt.Errorf("%w: payload is not a WorkContextV1: %v", ErrInvalid, err) + } + return claims, nil +} + +// sealOf reads the claims, the seal and the operation binding of a capability +// the host has just issued over an authenticated channel. +// +// It requires a whole seal: every field, and for an operation binding all three +// of its fields or none of them. A partial seal is refused here and not carried, +// because a capability sealed to an installation with no revision, or to a +// binding id at no revision, names an authority nobody approved — and the field +// that is missing is the one an attacker would choose to leave out. +func sealOf(encoded string) (*Claims, Seal, *OperationBinding, error) { + claims, err := claimsOf(encoded) + if err != nil { + return nil, Seal{}, nil, err + } + sealed := claims.GetSeal() + if sealed == nil { + return nil, Seal{}, nil, fmt.Errorf("%w: capability carries no seal", ErrUnsealed) + } + seal := Seal{ + PrincipalEpoch: sealed.GetPrincipalEpoch(), + InstallationID: sealed.GetInstallationId(), + InstallationRevision: sealed.GetInstallationRevision(), + BuildIncarnation: sealed.GetBuildIncarnation(), + } + switch { + case seal.PrincipalEpoch == 0: + return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no principal epoch", ErrUnsealed) + case seal.InstallationID == "": + return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no installation", ErrUnsealed) + case seal.InstallationRevision == 0: + return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no installation revision", ErrUnsealed) + case seal.BuildIncarnation == 0: + return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no build incarnation", ErrUnsealed) + } + bound := claims.GetOperationBinding() + if bound == nil { + return claims, seal, nil, nil + } + binding := &OperationBinding{ + ID: bound.GetBindingId(), + Revision: bound.GetRevision(), + Incarnation: bound.GetIncarnation(), + } + if binding.ID == "" || binding.Revision == 0 || binding.Incarnation == 0 { + return nil, Seal{}, nil, fmt.Errorf( + "%w: an operation binding carries its id, revision and incarnation or none of them", + ErrUnsealed, + ) + } + return claims, seal, binding, nil +} diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go new file mode 100644 index 0000000..b303b60 --- /dev/null +++ b/workcontext/carrier_test.go @@ -0,0 +1,172 @@ +package workcontext + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// fixture returns one of core's conformance tokens by name, so a negative case +// here is the same token core's own verifier is held to rather than one this +// test invented. +func fixture(t *testing.T, name string) corework.Fixture { + t.Helper() + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + for _, candidate := range fixtures { + if candidate.Name == name { + return candidate + } + } + t.Fatalf("core's conformance kit has no fixture %q", name) + return corework.Fixture{} +} + +// A capability travels with the installation it is sealed to, so the far end +// can refuse the call before it decodes anything. +func TestAttachCarriesTheCapabilityAndTheSealedInstallation(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.NoError(t, Attach(request, token)) + + require.Equal(t, token, request.Header.Get(HeaderName)) + require.Equal(t, testInstallation, request.Header.Get(InstallationIDHeaderName)) + require.Equal(t, "3", request.Header.Get(InstallationRevisionHeaderName)) + + // And it comes back off the request, with the carriers held to the seal. + carried, err := FromHeaders(request.Header) + require.NoError(t, err) + require.Equal(t, token, carried) +} + +// A credential is sealed or it is not a credential. Finding that out before the +// call is made is the point: at the far end the refusal names an installation +// mismatch for a capability that named no installation at all. +func TestAttachRefusesAnUnsealedCapability(t *testing.T) { + for _, name := range []string{"missing-seal", "seal-without-installation"} { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + err := Attach(request, fixture(t, name).Token) + require.ErrorIs(t, err, ErrUnsealed) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + }) + } +} + +func TestAttachRefusesWhatIsNotACapability(t *testing.T) { + for name, encoded := range map[string]string{ + "empty": "", + "whitespace": " ", + "one segment": "not-a-token", + "empty payload": ".c2lnbmF0dXJl", + "empty signature": "cGF5bG9hZA.", + "payload not b64": "not!base64.c2lnbmF0dXJl", + "signature not b64": "cGF5bG9hZA.not!base64", + "payload not proto": "bm90LWEtcHJvdG8tbWVzc2FnZS1hdC1hbGw.c2lnbmF0dXJl", + "oversized": strings.Repeat("a", MaxTokenBytes+1) + ".sig", + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, encoded), ErrInvalid) + }) + } + require.ErrorIs(t, Attach(nil, "irrelevant"), ErrInvalid) +} + +// The carriers are a pre-check and never authority. A receiver that found them +// disagreeing with the seal and preferred either side would have made a +// caller-controlled header into authority. +func TestIncomingCarriersAreHeldToTheSeal(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + + for name, carriers := range map[string]map[string]string{ + "another installation": { + InstallationIDHeaderName: "installation-the-caller-preferred", + InstallationRevisionHeaderName: "3", + }, + "another revision": { + InstallationIDHeaderName: testInstallation, + InstallationRevisionHeaderName: "4", + }, + "id without revision": { + InstallationIDHeaderName: testInstallation, + }, + "revision without id": { + InstallationRevisionHeaderName: "3", + }, + } { + t.Run(name, func(t *testing.T) { + headers := http.Header{} + headers.Set(HeaderName, token) + for carrier, value := range carriers { + headers.Set(carrier, value) + } + _, err := FromHeaders(headers) + require.ErrorIs(t, err, ErrInvalid) + }) + } + + // Carrying neither is allowed: a sender that attaches nothing beside the + // capability has stated nothing to disagree with, and the seal governs + // regardless. + headers := http.Header{} + headers.Set(HeaderName, token) + carried, err := FromHeaders(headers) + require.NoError(t, err) + require.Equal(t, token, carried) +} + +func TestFromHeadersRequiresExactlyOneCapability(t *testing.T) { + _, err := FromHeaders(nil) + require.ErrorIs(t, err, ErrInvalid) + + _, err = FromHeaders(http.Header{}) + require.ErrorIs(t, err, ErrInvalid) + + blank := http.Header{} + blank.Set(HeaderName, " ") + _, err = FromHeaders(blank) + require.ErrorIs(t, err, ErrInvalid) + + a := newAuthority(t) + twice := http.Header{} + twice.Add(HeaderName, a.start(t, mintInput{})) + twice.Add(HeaderName, a.start(t, mintInput{})) + _, err = FromHeaders(twice) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "appears 2 times") +} + +// An operation binding carries its id, revision and incarnation or none of +// them: a capability sealed to a binding id at no revision names an authority +// nobody approved. +func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { + a := newAuthority(t) + _, _, binding, err := sealOf(a.start(t, mintInput{binding: testBinding})) + require.NoError(t, err) + require.NotNil(t, binding) + require.Equal(t, OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1}, *binding) + + _, _, none, err := sealOf(a.start(t, mintInput{})) + require.NoError(t, err) + require.Nil(t, none, "a session that exercises no binding seals none") +} + +// A token in another encoding is not a capability with a bad signature, and +// nothing here may report it as one. The SDK's reading stops at "this is not a +// WorkContextV1"; the named refusal is core's, which is where the signature +// would have been checked. +func TestAForeignEncodingIsNotReadAsACapability(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + err := Attach(request, fixture(t, "foreign-encoding").Token) + require.ErrorIs(t, err, ErrInvalid) + require.NotContains(t, err.Error(), "signature does not verify") +} diff --git a/workcontext/core.go b/workcontext/core.go new file mode 100644 index 0000000..a5ad65b --- /dev/null +++ b/workcontext/core.go @@ -0,0 +1,108 @@ +package workcontext + +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" +) + +// Everything below is core's, named here so product code can reach it through +// the package it already imports. They are aliases and not wrappers: a wrapper +// is a place a second behaviour grows, and the whole point of this file is that +// there is nowhere for one to grow. + +// Verifier is the verification entry point — core's, unmodified. A consumer +// that verifies a capability constructs one of these. +// +// It is issuer-shaped by design, and that is load-bearing rather than +// inconvenient: it refuses everything unless it is given the authorization +// revision, replay, grant and seal sources, because a verifier that silently +// skipped the seal check would make the strongest check in the model the +// easiest one to omit. A service that cannot answer those four is not in a +// position to verify a capability itself; it presents its own and lets the +// component that holds them decide. +type Verifier = corework.Verifier + +// Verified is a capability that passed every check. Only Verifier.Verify +// constructs one, so a value of this type cannot be a hand-built protobuf or a +// token that was parsed and never verified. +type Verified = corework.Verified + +// Seal is the live binding of a principal's authority to one installation and +// one execution, as the issuer holds it. A Verifier compares a capability's +// sealed values against it for exact equality. +type Seal = corework.Seal + +// OperationBinding is the live state of one unit of authority, resolved by its +// opaque id — never searched for by the scopes it contains. +type OperationBinding = corework.OperationBinding + +// SealSource answers the live values a seal is compared against. +type SealSource = corework.SealSource + +// RevisionSource, ReplayStore and GrantSource are the other three sources a +// Verifier requires. +type ( + RevisionSource = corework.RevisionSource + ReplayStore = corework.ReplayStore + GrantSource = corework.GrantSource +) + +// MemorySealSource and MemoryReplayStore are core's in-memory implementations, +// for a test or a single-process deployment. +type ( + MemorySealSource = corework.MemorySealSource + MemoryReplayStore = corework.MemoryReplayStore + FixedRevision = corework.FixedRevision +) + +// Claims are the capability's fields. Read them off a Verified, never off the +// wire. +type Claims = basev0.WorkContextV1 + +// The refusals, core's. This module defines no second taxonomy of them: a +// caller writes errors.Is against these and gets the same answer whichever +// package it names them through. +var ( + // ErrInvalid covers every rejection that is a property of the capability + // itself — signature, window, audience, attenuation, grant shape — and + // every refusal this package makes about a capability it is asked to + // carry. + ErrInvalid = corework.ErrInvalid + + // ErrNotACoreToken is a token whose payload is not this encoding at all, + // most usefully one carrying a JSON payload. It is refused before the + // signature is checked and with its own error, so a token from another + // format is never diagnosed as a bad key. + ErrNotACoreToken = corework.ErrNotACoreToken + + // ErrUnsealed is a capability carrying no seal, or one naming no + // installation. It does not verify, and this package refuses to put one on + // a request. + ErrUnsealed = corework.ErrUnsealed + + // ErrRevoked is every sealed mismatch: principal epoch, installation + // revision, build incarnation, binding revision or incarnation, a revoked + // binding, or an installation the principal no longer holds — and an + // authorization revision the issuer has moved past. + // + // One sentinel for all of them is core's decision and the right one: in + // every case the capability was sound when it was minted and the state + // moved under it, so the holder's response is the same — mint again. A + // caller that needs to tell "mint again" from "this caller is not + // authorized" needs those two to read differently, which they do; it does + // not need the six reasons a re-mint is due to read differently from each + // other. + ErrRevoked = corework.ErrRevoked + + // ErrReplayed is a single-use capability presented twice. + ErrReplayed = corework.ErrReplayed +) + +// ScopeContained and ScopesAttenuate are core's scope algebra. A service +// deciding whether a verified capability covers a call asks these rather than +// comparing scope structs, because "contains" over a wildcard resource set is +// not an equality. +var ( + ScopeContained = corework.ScopeContained + ScopesAttenuate = corework.ScopesAttenuate +) diff --git a/workcontext/doc.go b/workcontext/doc.go index 0dd3b17..b0697a4 100644 --- a/workcontext/doc.go +++ b/workcontext/doc.go @@ -1,28 +1,35 @@ -// Package workcontext signs and verifies Codefly Work Contexts — bounded, -// two-segment Ed25519 capabilities carried between execution boundaries — and -// holds the client a module process uses to obtain the one credential it runs -// on. +// Package workcontext is the client side of the Work Context capability: it +// obtains one credential per execution from the host's mint endpoint, carries +// it on outbound calls, and gives typed access to the one implementation of +// the capability itself. // -// A credential is minted once per execution and sealed to the execution that -// holds it: the principal and its epoch, the installation and its revision, -// and the build incarnation. Every sealed field is required, so a token -// carrying only delegation claims does not verify and cannot be attached to a -// request. Nothing here runs on a timer that is not the credential's own -// expiry: there is no heartbeat, no registration call, and nothing the process -// reports about itself, because the host establishes principal, installation -// and build from the projected service-account token and its own records. +// It mints nothing and it verifies nothing. The Work Context is a core type +// (codefly.base.v0.WorkContextV1) and github.com/codefly-dev/core/workcontext +// is its only implementation: the only code that signs a capability, the only +// code that checks a signature, and the only encoding of either. This package +// re-exports core's verification entry point rather than offering one of its +// own, so a consumer that verifies through this module is verifying through +// core. // -// Verification is one path. VerifyWorkContext returns a VerifiedWorkContext, -// which is the only thing that produces verified claims, and an operation -// context resolves its sealed binding identity exactly rather than searching -// for a binding whose scopes would admit the call. +// That is a rule and not a preference, because the alternative was tried. A +// second implementation here once signed a hand-written JSON payload while +// core signed the deterministic protobuf encoding of the same message. Both +// forms are "." with an Ed25519 +// signature, so a token from either looked well-formed to the other and then +// failed SIGNATURE verification — a message that reads like a rotated key or a +// wrong trust root, and sends everyone to look at keys. The gate test in this +// package is what stops it coming back; see README.md, "One implementation, and +// the gate that keeps it that way". // -// It is a standalone module so a service whose only need is to verify Work -// Contexts against a JWKS can depend on the verifier without inheriting the -// full transitive dependency tail of github.com/codefly-dev/sdk-go. The gRPC -// transport for Work Contexts lives in the workcontext/grpctransport -// subpackage so that consumers that only verify never compile grpc. +// What this package does own: // -// See README.md in this directory for the model, the four refusals a caller -// must tell apart, and what a consumer has to stop doing. +// - MintClient: one credential per execution, obtained from the projected +// service-account token, renewed only at expiry. No heartbeat, no +// registration, nothing a process reports about itself. +// - The carriers: the capability's header, and the sealed installation +// beside it as a pre-check the far end may refuse on cheaply. +// - CachePartition: the identity partition a cache stack scopes entries to, +// which never spans an installation revision. +// - StreamGuard: a stream re-presents its credential on the host's cadence +// and terminates on refusal. package workcontext diff --git a/workcontext/go.mod b/workcontext/go.mod index 085033a..319b11b 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,17 +3,24 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.1 + github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/google/uuid v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect golang.org/x/net v0.59.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/workcontext/go.sum b/workcontext/go.sum index c388113..81be440 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -1,9 +1,19 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.1 h1:fU4ZU9BvUTVLLOR4LU/zbU3htOGDu0kjNkBH4KoT6/I= -github.com/codefly-dev/core v0.7.1/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec h1:RrvEzVREuTdEuoJJjZBCPKVEWYwfqdugSkkHAahbBCw= +github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -14,6 +24,8 @@ 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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -30,6 +42,8 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= @@ -38,6 +52,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= diff --git a/workcontext/grpctransport/execution_context_grpc.go b/workcontext/grpctransport/execution_context_grpc.go index c18bbc5..d138b6e 100644 --- a/workcontext/grpctransport/execution_context_grpc.go +++ b/workcontext/grpctransport/execution_context_grpc.go @@ -11,7 +11,7 @@ import ( ) const ( - workContextGRPCMetadataName = workcontext.WorkContextHeaderName + workContextGRPCMetadataName = workcontext.HeaderName // The installation carriers are the same names as on HTTP, because they // are the same fact: gRPC metadata keys and HTTP header names are one // namespace, and spelling them twice is how they drift. @@ -27,17 +27,17 @@ const ( // Callers construct it through NewExecutionContext and attach it through // WithGRPCExecutionContext. Carrier names remain SDK-owned. type ExecutionContext struct { - workContext workcontext.WorkContextToken + workContext string operationID string } // NewExecutionContext validates and freezes one Work Context/operation pair. func NewExecutionContext( - workContext workcontext.WorkContextToken, + workContext string, operationID string, ) (ExecutionContext, error) { - if workContext.Encoded() == "" { - return ExecutionContext{}, fmt.Errorf("%w: empty Work Context", workcontext.ErrWorkContextInvalid) + if workContext == "" { + return ExecutionContext{}, fmt.Errorf("%w: empty Work Context", workcontext.ErrInvalid) } if err := validateOperationID(operationID); err != nil { return ExecutionContext{}, err @@ -48,9 +48,10 @@ func NewExecutionContext( }, nil } -// WorkContext returns the opaque signed capability. Trust decisions still -// require WorkContextVerifier. -func (execution ExecutionContext) WorkContext() workcontext.WorkContextToken { +// Capability returns the opaque signed capability. It is a string, and a trust +// decision still requires core's verifier: nothing a transport hands back has +// been verified by being transported. +func (execution ExecutionContext) Capability() string { return execution.workContext } @@ -70,16 +71,16 @@ func (execution ExecutionContext) OperationID() string { // the two disagreeing refuses rather than choosing between them. // // A Work Context is bound to one audience. A service calling another service -// attaches a context the authority exchanged for the callee's audience -// (ExchangeWorkContextAudience), never the one it received: the callee's -// audience check rejects a forwarded context. Nothing here reads incoming -// metadata, so this helper never forwards on its own. +// attaches a capability the authority minted for the callee's audience, never +// the one it received: the callee's audience check rejects a forwarded +// capability. Nothing here reads incoming metadata, so this helper never +// forwards on its own. func WithGRPCExecutionContext( ctx context.Context, execution ExecutionContext, ) (context.Context, error) { if ctx == nil { - return nil, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) + return nil, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) } validated, err := NewExecutionContext(execution.workContext, execution.operationID) if err != nil { @@ -99,14 +100,14 @@ func WithGRPCExecutionContext( if len(existing.Get(carrier)) != 0 { return nil, fmt.Errorf( "%w: outgoing gRPC metadata %q is already set", - workcontext.ErrWorkContextInvalid, carrier, + workcontext.ErrInvalid, carrier, ) } } return metadata.AppendToOutgoingContext( ctx, workContextGRPCMetadataName, - validated.workContext.Encoded(), + validated.workContext, operationIDGRPCMetadataName, validated.operationID, installationIDGRPCMetadataName, @@ -121,30 +122,27 @@ func WithGRPCExecutionContext( // does not verify Work Context trust. func GRPCExecutionContextFromIncoming(ctx context.Context) (ExecutionContext, error) { if ctx == nil { - return ExecutionContext{}, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) + return ExecutionContext{}, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) } values, ok := metadata.FromIncomingContext(ctx) if !ok { - return ExecutionContext{}, fmt.Errorf("%w: missing incoming gRPC metadata", workcontext.ErrWorkContextInvalid) + return ExecutionContext{}, fmt.Errorf("%w: missing incoming gRPC metadata", workcontext.ErrInvalid) } workContexts := values.Get(workContextGRPCMetadataName) if len(workContexts) != 1 { return ExecutionContext{}, fmt.Errorf( "%w: incoming gRPC Work Context requires exactly one value", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, ) } operationIDs := values.Get(operationIDGRPCMetadataName) if len(operationIDs) != 1 { return ExecutionContext{}, fmt.Errorf( "%w: incoming gRPC operation ID requires exactly one value", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, ) } - workContext, err := workcontext.ParseWorkContextToken(workContexts[0]) - if err != nil { - return ExecutionContext{}, err - } + workContext := workContexts[0] if err := checkIncomingInstallation(values, workContext); err != nil { return ExecutionContext{}, err } @@ -159,7 +157,7 @@ func GRPCExecutionContextFromIncoming(ctx context.Context) (ExecutionContext, er // carriers decoration that a reader would nonetheless log and believe. func checkIncomingInstallation( values metadata.MD, - token workcontext.WorkContextToken, + token string, ) error { sealedID, sealedRevision, err := workcontext.SealedInstallation(token) if err != nil { @@ -176,13 +174,13 @@ func checkIncomingInstallation( if len(presented) != 1 { return fmt.Errorf( "%w: incoming gRPC %s requires exactly one value", - workcontext.ErrWorkContextInvalid, carrier.name, + workcontext.ErrInvalid, carrier.name, ) } if presented[0] != carrier.sealed { return fmt.Errorf( "%w: incoming gRPC %s is %q and the credential seals %q", - workcontext.ErrWorkContextInvalid, carrier.name, presented[0], carrier.sealed, + workcontext.ErrInvalid, carrier.name, presented[0], carrier.sealed, ) } } @@ -196,7 +194,7 @@ func GRPCExecutionContextFromIncomingIfPresent( ctx context.Context, ) (execution ExecutionContext, present bool, err error) { if ctx == nil { - return ExecutionContext{}, false, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) + return ExecutionContext{}, false, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) } values, ok := metadata.FromIncomingContext(ctx) if !ok { @@ -223,15 +221,15 @@ func GRPCExecutionContextFromIncomingIfPresent( func validateOperationID(operationID string) error { if operationID == "" { - return fmt.Errorf("%w: operation ID is required", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID is required", workcontext.ErrInvalid) } if strings.TrimSpace(operationID) != operationID { - return fmt.Errorf("%w: operation ID is not canonical", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID is not canonical", workcontext.ErrInvalid) } if len(operationID) > maxOperationIDBytes { return fmt.Errorf( "%w: operation ID exceeds %d bytes", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, maxOperationIDBytes, ) } @@ -245,7 +243,7 @@ func validateOperationID(operationID string) error { case '-', '_', '.', ':': continue default: - return fmt.Errorf("%w: operation ID contains unsupported characters", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID contains unsupported characters", workcontext.ErrInvalid) } } return nil diff --git a/workcontext/grpctransport/execution_context_grpc_test.go b/workcontext/grpctransport/execution_context_grpc_test.go index b1ab1a0..7a22f70 100644 --- a/workcontext/grpctransport/execution_context_grpc_test.go +++ b/workcontext/grpctransport/execution_context_grpc_test.go @@ -2,59 +2,51 @@ package grpctransport import ( "context" - "crypto/ed25519" + "strconv" "strings" "testing" + "time" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" "github.com/codefly-dev/sdk-go/workcontext" ) -// opaqueTestWorkContext is a real signed credential rather than a shaped +// opaqueTestCapability is a real sealed capability rather than a shaped // placeholder. It has to be: the transport reads the installation out of the -// token so every call carries it, so a token with no seal cannot be attached +// capability so every call carries it, so one with no seal cannot be attached // at all — which is the behaviour, and a placeholder here would hide it. -func opaqueTestWorkContext(t *testing.T) workcontext.WorkContextToken { +// +// It comes from core's conformance kit, which is the only minter. A test that +// assembled a token here would be asserting against a format nothing issues. +func opaqueTestCapability(t *testing.T) string { t.Helper() - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = byte(index) - } - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", - KeyID: "transport-test", - PrivateKey: ed25519.NewKeyFromSeed(seed), - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workcontext.StartTaskInput{ - Audience: "warden.transport", TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-owner", OwnerPrincipalKind: "user", - TaskID: "task-transport", SessionID: "session-transport", - AuthorizationRevision: 1, - Seal: workcontext.Seal{ - PrincipalEpoch: 2, - InstallationID: "installation-transport", - InstallationRevision: 5, - BuildIncarnation: "build-incarnation-transport", - }, - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "evidence", Actions: []string{"append"}}, - }, - }) + fixtures, err := corework.Fixtures(time.Now()) require.NoError(t, err) - return token + for _, fixture := range fixtures { + if fixture.Form == corework.FormSession && fixture.Outcome == corework.OutcomeAccepted { + return fixture.Token + } + } + t.Fatalf("core's conformance kit offered no accepted session capability") + return "" } +// The installation every capability above is sealed to, as the kit holds it. +var ( + sealedInstallationID = corework.FixtureInstallation + sealedInstallationRevision = strconv.Itoa(corework.FixtureInstallationRevision) +) + func TestGRPCExecutionContextRoundTripPreservesOtherMetadata(t *testing.T) { original := metadata.NewOutgoingContext( context.Background(), metadata.Pairs("authorization", "Bearer gateway-token"), ) execution, err := NewExecutionContext( - opaqueTestWorkContext(t), + opaqueTestCapability(t), "operation-019f8fc1", ) require.NoError(t, err) @@ -68,17 +60,17 @@ func TestGRPCExecutionContextRoundTripPreservesOtherMetadata(t *testing.T) { incoming := metadata.NewIncomingContext(context.Background(), outgoingMetadata) received, err := GRPCExecutionContextFromIncoming(incoming) require.NoError(t, err) - require.Equal(t, execution.WorkContext().Encoded(), received.WorkContext().Encoded()) + require.Equal(t, execution.Capability(), received.Capability()) require.Equal(t, execution.OperationID(), received.OperationID()) } func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { - execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-1") + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-1") require.NoError(t, err) outgoing := metadata.NewOutgoingContext( context.Background(), - metadata.Pairs(workContextGRPCMetadataName, execution.WorkContext().Encoded()), + metadata.Pairs(workContextGRPCMetadataName, execution.Capability()), ) _, err = WithGRPCExecutionContext(outgoing, execution) require.ErrorContains(t, err, "already set") @@ -87,9 +79,9 @@ func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { context.Background(), metadata.Pairs( workContextGRPCMetadataName, - execution.WorkContext().Encoded(), + execution.Capability(), workContextGRPCMetadataName, - execution.WorkContext().Encoded(), + execution.Capability(), operationIDGRPCMetadataName, execution.OperationID(), ), @@ -99,7 +91,7 @@ func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { } func TestGRPCExecutionContextRejectsMissingOrNonCanonicalOperationID(t *testing.T) { - workContext := opaqueTestWorkContext(t) + workContext := opaqueTestCapability(t) for _, operationID := range []string{ "", " operation-1", @@ -113,7 +105,7 @@ func TestGRPCExecutionContextRejectsMissingOrNonCanonicalOperationID(t *testing. incoming := metadata.NewIncomingContext( context.Background(), - metadata.Pairs(workContextGRPCMetadataName, workContext.Encoded()), + metadata.Pairs(workContextGRPCMetadataName, workContext), ) _, err := GRPCExecutionContextFromIncoming(incoming) require.ErrorContains(t, err, "operation ID requires exactly one value") @@ -138,15 +130,15 @@ func TestGRPCExecutionContextOptionalExtractionDistinguishesAbsentFromPartial(t // capability on every call, under the same names HTTP uses, so a callee can // refuse before it decodes anything. func TestGRPCCallsCarryTheSealedInstallation(t *testing.T) { - execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-019f8fc1") + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-019f8fc1") require.NoError(t, err) outgoing, err := WithGRPCExecutionContext(context.Background(), execution) require.NoError(t, err) carried, ok := metadata.FromOutgoingContext(outgoing) require.True(t, ok) - require.Equal(t, []string{"installation-transport"}, carried.Get(workcontext.InstallationIDHeaderName)) - require.Equal(t, []string{"5"}, carried.Get(workcontext.InstallationRevisionHeaderName)) + require.Equal(t, []string{sealedInstallationID}, carried.Get(workcontext.InstallationIDHeaderName)) + require.Equal(t, []string{sealedInstallationRevision}, carried.Get(workcontext.InstallationRevisionHeaderName)) } // The carriers are caller-controlled, so the only two acceptable outcomes are @@ -154,7 +146,7 @@ func TestGRPCCallsCarryTheSealedInstallation(t *testing.T) { // would let a caller name any installation it liked; ignoring a disagreement // would make them decoration that a reader would nonetheless log and believe. func TestIncomingInstallationCarriersAreHeldToTheSeal(t *testing.T) { - execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-019f8fc1") + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-019f8fc1") require.NoError(t, err) outgoing, err := WithGRPCExecutionContext(context.Background(), execution) require.NoError(t, err) @@ -178,14 +170,14 @@ func TestIncomingInstallationCarriersAreHeldToTheSeal(t *testing.T) { values.Delete(workcontext.InstallationRevisionHeaderName) }, "duplicate revision": func(values metadata.MD) { - values.Append(workcontext.InstallationRevisionHeaderName, "5") + values.Append(workcontext.InstallationRevisionHeaderName, sealedInstallationRevision) }, } { t.Run(name, func(t *testing.T) { tampered := honest.Copy() mutate(tampered) _, err := GRPCExecutionContextFromIncoming(metadata.NewIncomingContext(context.Background(), tampered)) - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + require.ErrorIs(t, err, workcontext.ErrInvalid) }) } } @@ -202,7 +194,7 @@ func TestIfPresentTreatsAPartialInstallationCarrierAsAnError(t *testing.T) { _, _, err = GRPCExecutionContextFromIncomingIfPresent(metadata.NewIncomingContext( context.Background(), - metadata.Pairs(workcontext.InstallationIDHeaderName, "installation-transport"), + metadata.Pairs(workcontext.InstallationIDHeaderName, sealedInstallationID), )) - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + require.ErrorIs(t, err, workcontext.ErrInvalid) } diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index 49a521f..d761db0 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -16,18 +16,27 @@ import ( "google.golang.org/grpc/status" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" "github.com/codefly-dev/sdk-go/workcontext" "github.com/codefly-dev/sdk-go/workcontext/grpctransport" ) -// Two real gRPC services on loopback listeners, a real Ed25519 authority, and -// a real verifier in each service. Service A is called by a client holding a -// Work Context for A's audience, and calls service B on the same logical -// operation. The request's Service field names how A reaches B. +// Two real gRPC services on loopback listeners, core's authority holding the +// one signing key, and core's verifier in each service. Service A is called by +// a client holding a capability for A's audience, and calls service B on the +// same logical operation. The request's Service field names how A reaches B. +// +// Everything that mints or verifies here is core's. The transport under test is +// this module's, and that is the whole shape of the module: it carries a +// capability it did not make to a verifier it does not own. const ( - hopIssuer = "https://accounts.codefly.dev/work-context" + hopIssuer = "https://authority.codefly.test/work-context" hopKeyID = "hop-key" + hopTenant = "tenant-hop" + hopPrincipal = "principal-owner" + hopActor = "principal-service-a" + hopRevision = 7 audienceA = "codefly.test.service-a" audienceB = "codefly.test.service-b" hopMethod = "/codefly.test.Hop/Call" @@ -41,7 +50,7 @@ var hopNow = time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) var hopScopes = []*basev0.WorkScopeV1{ {ResourceKind: "document", Actions: []string{"read", "write"}}, - {ResourceKind: "evidence", Actions: []string{"append"}}, + {ResourceKind: "record", Actions: []string{"append"}}, } // hopService is the handler both services register under one ServiceDesc. It @@ -66,10 +75,12 @@ var hopServiceDesc = grpc.ServiceDesc{ }}, } -// hopAuthority stands in for the exchange endpoint a service calls: it holds -// the signer, which services never do. +// hopAuthority is the exchange endpoint a service calls: it holds the signing +// key, which a service never does. type hopAuthority struct { - signer *workcontext.WorkContextSigner + minter *corework.Authority + seals *corework.MemorySealSource + replay corework.ReplayStore publicKey ed25519.PublicKey } @@ -77,49 +88,67 @@ func newHopAuthority(t *testing.T) *hopAuthority { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: hopIssuer, KeyID: hopKeyID, PrivateKey: privateKey, - Now: func() time.Time { return hopNow }, - }) - require.NoError(t, err) - return &hopAuthority{signer: signer, publicKey: publicKey} + seals := corework.NewMemorySealSource() + require.NoError(t, seals.Put(hopPrincipal, hopSeal())) + return &hopAuthority{ + minter: &corework.Authority{ + Issuer: hopIssuer, KeyID: hopKeyID, Key: privateKey, + Revisions: corework.FixedRevision(hopRevision), + Seals: seals, + Now: func() time.Time { return hopNow }, + }, + seals: seals, + replay: corework.NewMemoryReplayStore(), + publicKey: publicKey, + } } -func (a *hopAuthority) verifier(t *testing.T) *workcontext.WorkContextVerifier { - t.Helper() - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{hopKeyID: a.publicKey}, - Now: func() time.Time { return hopNow }, - }) - require.NoError(t, err) - return verifier +// verifier is core's, configured for one service's audience. A verifier is +// given all four of core's sources because a verifier missing one refuses +// everything rather than skipping that check. +func (a *hopAuthority) verifier(audience string) *workcontext.Verifier { + return &workcontext.Verifier{ + Issuer: hopIssuer, + Audience: audience, + Keys: map[string]ed25519.PublicKey{hopKeyID: a.publicKey}, + Revisions: corework.FixedRevision(hopRevision), + Replay: a.replay, + Grants: hopNoGrants{}, + Seals: a.seals, + Now: func() time.Time { return hopNow }, + } +} + +type hopNoGrants struct{} + +func (hopNoGrants) Grant(context.Context, string) (*corework.Grant, error) { + return nil, corework.ErrInvalid } // verifyIncoming is the authentication a service runs on every call: the // carrier from gRPC metadata, verified for this service's own audience. func verifyIncoming( ctx context.Context, - verifier *workcontext.WorkContextVerifier, - audience string, -) (grpctransport.ExecutionContext, workcontext.VerifiedWorkContext, error) { + verifier *workcontext.Verifier, +) (grpctransport.ExecutionContext, *workcontext.Verified, error) { execution, err := grpctransport.GRPCExecutionContextFromIncoming(ctx) if err != nil { - return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) + return grpctransport.ExecutionContext{}, nil, status.Error(codes.Unauthenticated, err.Error()) } - verified, err := verifier.VerifyWorkContext(execution.WorkContext(), hopExpectations(audience)) + verified, err := verifier.Verify(ctx, execution.Capability()) if err != nil { - return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) + return grpctransport.ExecutionContext{}, nil, status.Error(codes.Unauthenticated, err.Error()) } return execution, verified, nil } type serviceB struct { - verifier *workcontext.WorkContextVerifier - accepted chan workcontext.VerifiedWorkContext + verifier *workcontext.Verifier + accepted chan *workcontext.Verified } func (s *serviceB) call(ctx context.Context, _ *healthv1.HealthCheckRequest) (*healthv1.HealthCheckResponse, error) { - _, verified, err := verifyIncoming(ctx, s.verifier, audienceB) + _, verified, err := verifyIncoming(ctx, s.verifier) if err != nil { return nil, err } @@ -128,14 +157,14 @@ func (s *serviceB) call(ctx context.Context, _ *healthv1.HealthCheckRequest) (*h } type serviceA struct { - verifier *workcontext.WorkContextVerifier + verifier *workcontext.Verifier authority *hopAuthority b grpc.ClientConnInterface - accepted chan workcontext.VerifiedWorkContext + accepted chan *workcontext.Verified } func (s *serviceA) call(ctx context.Context, request *healthv1.HealthCheckRequest) (*healthv1.HealthCheckResponse, error) { - incoming, verified, err := verifyIncoming(ctx, s.verifier, audienceA) + incoming, verified, err := verifyIncoming(ctx, s.verifier) if err != nil { return nil, err } @@ -143,16 +172,26 @@ func (s *serviceA) call(ctx context.Context, request *healthv1.HealthCheckReques outgoing := incoming if request.GetService() != hopForward { - input := workcontext.ExchangeWorkContextAudienceInput{Audience: audienceB} + // A hop across an audience boundary is a delegation minted by the + // authority for the callee's audience, never the capability A was + // handed: B's audience check refuses a forwarded one. + scopes := cloneHopScopes() switch request.GetService() { case hopAttenuate: - input.AttenuatedScopes = hopScopes[1:] + scopes = cloneHopScopes()[1:] case hopWiden: - input.AttenuatedScopes = append(cloneHopScopes(), &basev0.WorkScopeV1{ + scopes = append(cloneHopScopes(), &basev0.WorkScopeV1{ ResourceKind: "profile", Actions: []string{"read"}, }) } - exchanged, _, err := s.authority.signer.ExchangeWorkContextAudience(incoming.WorkContext(), input) + exchanged, _, err := s.authority.minter.Child(ctx, verified, corework.ChildInput{ + PrincipalID: hopActor, + PrincipalKind: "service", + DelegationID: "delegation-hop", + GrantedScopes: scopes, + Audience: audienceB, + TTL: 5 * time.Minute, + }) if err != nil { return nil, status.Error(codes.PermissionDenied, err.Error()) } @@ -208,8 +247,8 @@ type hopTopology struct { authority *hopAuthority a *grpc.ClientConn b *grpc.ClientConn - atA chan workcontext.VerifiedWorkContext - atB chan workcontext.VerifiedWorkContext + atA chan *workcontext.Verified + atB chan *workcontext.Verified } func newHopTopology(t *testing.T) *hopTopology { @@ -217,12 +256,12 @@ func newHopTopology(t *testing.T) *hopTopology { authority := newHopAuthority(t) topology := &hopTopology{ authority: authority, - atA: make(chan workcontext.VerifiedWorkContext, 1), - atB: make(chan workcontext.VerifiedWorkContext, 1), + atA: make(chan *workcontext.Verified, 1), + atB: make(chan *workcontext.Verified, 1), } - topology.b = serveHop(t, &serviceB{verifier: authority.verifier(t), accepted: topology.atB}) + topology.b = serveHop(t, &serviceB{verifier: authority.verifier(audienceB), accepted: topology.atB}) topology.a = serveHop(t, &serviceA{ - verifier: authority.verifier(t), authority: authority, b: topology.b, accepted: topology.atA, + verifier: authority.verifier(audienceA), authority: authority, b: topology.b, accepted: topology.atA, }) return topology } @@ -235,38 +274,26 @@ func hopSeal() workcontext.Seal { PrincipalEpoch: 3, InstallationID: "installation-hop", InstallationRevision: 12, - BuildIncarnation: "build-incarnation-hop", - } -} - -func hopExpectations(audience string) workcontext.WorkContextExpectations { - seal := hopSeal() - return workcontext.WorkContextExpectations{ - Issuer: hopIssuer, - Audience: audience, - Seal: workcontext.SealExpectations{ - PrincipalEpoch: seal.PrincipalEpoch, - InstallationID: seal.InstallationID, - InstallationRevision: seal.InstallationRevision, - BuildIncarnation: seal.BuildIncarnation, - }, + BuildIncarnation: 9, } } -// startTask issues the context the external caller holds: for service A only. -func (h *hopTopology) startTask(t *testing.T) workcontext.WorkContextToken { +// startTask issues the capability the external caller holds: for service A +// only. +func (h *hopTopology) startTask(t *testing.T) string { t.Helper() - token, _, err := h.authority.signer.StartTask(workcontext.StartTaskInput{ - Audience: audienceA, TenantID: "tenant-codefly", OwnerPrincipalID: "principal-owner", - OwnerPrincipalKind: "user", Seal: hopSeal(), - TaskID: "task-hop", SessionID: "session-hop", AuthorizationRevision: 7, + token, _, err := h.authority.minter.Start(context.Background(), corework.StartInput{ + Audience: audienceA, TenantID: hopTenant, OwnerPrincipalID: hopPrincipal, + OwnerPrincipalKind: "service", TaskID: "task-hop", + InstallationID: hopSeal().InstallationID, AuthorityScopes: cloneHopScopes(), + TTL: 10 * time.Minute, }) require.NoError(t, err) return token } -func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token workcontext.WorkContextToken, mode string) error { +func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token string, mode string) error { t.Helper() execution, err := grpctransport.NewExecutionContext(token, "operation-hop") require.NoError(t, err) @@ -275,20 +302,13 @@ func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token work return connection.Invoke(ctx, hopMethod, &healthv1.HealthCheckRequest{Service: mode}, new(healthv1.HealthCheckResponse)) } -func effectiveScopes(claims *basev0.WorkContextV1) []*basev0.WorkScopeV1 { - if actors := claims.GetActorChain(); len(actors) > 0 { - return actors[len(actors)-1].GetGrantedScopes() - } - return claims.GetAuthorityScopes() -} - -func TestServiceHopRejectsAWorkContextForwardedToAnotherAudience(t *testing.T) { +func TestServiceHopRejectsACapabilityForwardedToAnotherAudience(t *testing.T) { topology := newHopTopology(t) token := topology.startTask(t) err := topology.call(t, topology.a, token, hopForward) require.Equal(t, codes.Unauthenticated, status.Code(err), err) - require.ErrorContains(t, err, "audience mismatch") + require.ErrorContains(t, err, "presented to") require.Len(t, topology.atA, 1, "A accepted its own context before forwarding it") require.Empty(t, topology.atB, "B must not accept a context minted for A") @@ -304,9 +324,10 @@ func TestServiceHopExchangeThenCallSucceedsWithoutWideningScopes(t *testing.T) { token := topology.startTask(t) require.NoError(t, topology.call(t, topology.a, token, hopExchange)) - atA := (<-topology.atA).Claims() + verifiedA := <-topology.atA + atA := verifiedA.Context() verifiedB := <-topology.atB - atB := verifiedB.Claims() + atB := verifiedB.Context() require.Equal(t, audienceA, atA.GetAudience()) require.Equal(t, audienceB, atB.GetAudience()) @@ -314,26 +335,36 @@ func TestServiceHopExchangeThenCallSucceedsWithoutWideningScopes(t *testing.T) { {atA.GetTenantId(), atB.GetTenantId()}, {atA.GetOwnerPrincipalId(), atB.GetOwnerPrincipalId()}, {atA.GetTaskId(), atB.GetTaskId()}, - {atA.GetSessionId(), atB.GetSessionId()}, } { require.Equal(t, pair[0], pair[1]) } + // A hop is a child session of the same task, not the same session: the + // task is what survives a delegation, and the parent is named rather than + // impersonated. + require.NotEqual(t, atA.GetSessionId(), atB.GetSessionId()) + require.Equal(t, atA.GetSessionId(), atB.GetParentSessionId()) require.Equal(t, atA.GetAuthorizationRevision(), atB.GetAuthorizationRevision()) - require.Equal(t, len(effectiveScopes(atA)), len(effectiveScopes(atB))) - for index, scope := range effectiveScopes(atB) { - want := effectiveScopes(atA)[index] + require.Equal(t, len(verifiedA.EffectiveScopes()), len(verifiedB.EffectiveScopes())) + for index, scope := range verifiedB.EffectiveScopes() { + want := verifiedA.EffectiveScopes()[index] require.Equal(t, want.GetResourceKind(), scope.GetResourceKind()) require.Equal(t, want.GetActions(), scope.GetActions()) require.Equal(t, want.GetResourceIds(), scope.GetResourceIds()) } - // B's exchanged context is itself bound to B: sent back to A it fails. - exchangedToA := topology.call(t, topology.a, mustReissueForB(t, topology.authority, token), hopExchange) + // The seal survives the hop unchanged: a hop crosses an audience boundary + // inside one execution, so both sides are sealed to one installation at one + // revision on one build. + require.Equal(t, atA.GetSeal().GetInstallationId(), atB.GetSeal().GetInstallationId()) + require.Equal(t, atA.GetSeal().GetInstallationRevision(), atB.GetSeal().GetInstallationRevision()) + require.Equal(t, atA.GetSeal().GetBuildIncarnation(), atB.GetSeal().GetBuildIncarnation()) + + // B's delegated capability is itself bound to B: sent back to A it fails. + exchangedToA := topology.call(t, topology.a, mustReissueForB(t, topology.authority, verifiedA), hopExchange) require.Equal(t, codes.Unauthenticated, status.Code(exchangedToA), exchangedToA) - // The hop keeps the cache partition: same tenant, same view. - verifiedA, err := topology.authority.verifier(t).VerifyWorkContext(token, hopExpectations(audienceA)) - require.NoError(t, err) + // The hop keeps the cache partition: same tenant, same installation, same + // view. for _, options := range [][]workcontext.CachePartitionOption{nil, {workcontext.ByAuthorizationView()}} { partitionA, err := workcontext.DeriveCachePartition(verifiedA, options...) require.NoError(t, err) @@ -349,25 +380,35 @@ func TestServiceHopExchangeMayAttenuateButNeverWiden(t *testing.T) { require.NoError(t, topology.call(t, topology.a, topology.startTask(t), hopAttenuate)) <-topology.atA atB := <-topology.atB - require.NoError(t, workcontext.RequireWorkContextScope(atB, workcontext.WorkContextScopeRequirement{ - ResourceKind: "evidence", Action: "append", - })) - err := workcontext.RequireWorkContextScope(atB, workcontext.WorkContextScopeRequirement{ - ResourceKind: "document", Action: "read", - }) - require.ErrorIs(t, err, workcontext.ErrWorkContextDenied) - err = topology.call(t, topology.a, topology.startTask(t), hopWiden) + // Whether the delegated capability covers a call is core's scope algebra, + // asked of the scopes core says are effective. This module answers no part + // of that question. + require.True(t, workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"append"}}, + atB.EffectiveScopes(), + )) + require.False(t, workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "document", Actions: []string{"read"}}, + atB.EffectiveScopes(), + ), "the hop attenuated the document scope away, so it is no longer covered") + + err := topology.call(t, topology.a, topology.startTask(t), hopWiden) require.Equal(t, codes.PermissionDenied, status.Code(err), err) require.ErrorContains(t, err, "widens authority") <-topology.atA require.Empty(t, topology.atB, "a widening exchange must never reach B") } -func mustReissueForB(t *testing.T, authority *hopAuthority, token workcontext.WorkContextToken) workcontext.WorkContextToken { +func mustReissueForB(t *testing.T, authority *hopAuthority, parent *workcontext.Verified) string { t.Helper() - exchanged, _, err := authority.signer.ExchangeWorkContextAudience(token, workcontext.ExchangeWorkContextAudienceInput{ - Audience: audienceB, + exchanged, _, err := authority.minter.Child(context.Background(), parent, corework.ChildInput{ + PrincipalID: hopActor, + PrincipalKind: "service", + DelegationID: "delegation-hop", + GrantedScopes: cloneHopScopes(), + Audience: audienceB, + TTL: 5 * time.Minute, }) require.NoError(t, err) return exchanged diff --git a/workcontext/mint.go b/workcontext/mint.go index 057d9ec..359b867 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "sync" "time" @@ -140,15 +141,18 @@ type MintOptions struct { // Credential is the sealed capability a process holds for its execution. It is // obtained once by Mint and replaced only by renewal at expiry. type Credential struct { - token WorkContextToken + token string seal Seal binding *OperationBinding issuedAt time.Time expiresAt time.Time } -// Token returns the opaque signed capability. -func (c Credential) Token() WorkContextToken { return c.token } +// Token returns the opaque signed capability, as it travels. It is a string and +// not a type of this module's: a capability is an opaque string until a Verifier +// has had it, and a typed wrapper here would offer the reassurance of a check +// nobody performed. +func (c Credential) Token() string { return c.token } // Seal returns the execution this credential is bound to. func (c Credential) Seal() Seal { return c.seal } @@ -172,7 +176,7 @@ func (c Credential) ExpiresAt() time.Time { return c.expiresAt } // Attach installs the credential on an outbound HTTP request: the signed // capability and, beside it, the installation it is sealed to. func (c Credential) Attach(request *http.Request) error { - return AttachWorkContext(request, c.token) + return Attach(request, c.token) } // MintClient obtains and renews one credential for one execution. @@ -398,11 +402,8 @@ func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { if err := decoder.Decode(&body); err != nil { return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintRefused, err) } - token, err := ParseWorkContextToken(body.WorkContext) - if err != nil { - return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) - } - claims, seal, binding, err := readSealedToken(token) + token := body.WorkContext + claims, seal, binding, err := sealOf(token) if err != nil { return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) } @@ -412,9 +413,10 @@ func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { ErrMintRefused, body.InstallationID, seal.InstallationID, ) } - if body.BuildIncarnation != "" && body.BuildIncarnation != seal.BuildIncarnation { + if body.BuildIncarnation != "" && + body.BuildIncarnation != strconv.FormatUint(seal.BuildIncarnation, 10) { return Credential{}, fmt.Errorf( - "%w: mint reported build incarnation %q and sealed %q", + "%w: mint reported build incarnation %q and sealed %d", ErrMintRefused, body.BuildIncarnation, seal.BuildIncarnation, ) } diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 348cfc1..7303040 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "sync/atomic" "testing" "time" @@ -20,15 +21,18 @@ import ( // count is the acceptance criterion: a process that runs for an hour must not // have announced itself every fifteen seconds. type mintHost struct { - t *testing.T - server *httptest.Server - requests atomic.Uint64 - presented atomic.Value // the last projected token the host was shown - now func() time.Time - lifetime time.Duration - audience string - seal Seal - echoWrong bool + t *testing.T + server *httptest.Server + requests atomic.Uint64 + presented atomic.Value // the last projected token the host was shown + now func() time.Time + lifetime time.Duration + audience string + authority *authority + echoWrong bool + // binding, when set, makes the host mint an operation capability sealed to + // that binding. + binding string refuseWith int // mintAudience, when set, makes the host answer with a credential for an // audience other than the one asked for. @@ -39,7 +43,7 @@ func newMintHost(t *testing.T, now func() time.Time) *mintHost { t.Helper() host := &mintHost{ t: t, now: now, lifetime: 15 * time.Minute, - audience: "warden.evidence", seal: workContextTestSeal(), + audience: testAudience, authority: newAuthority(t), } host.server = httptest.NewServer(http.HandlerFunc(host.handle)) t.Cleanup(host.server.Close) @@ -58,16 +62,15 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadRequest) return } - input := workContextTestInput() - input.Audience = body.Audience + audience := body.Audience if h.mintAudience != "" { - input.Audience = h.mintAudience + audience = h.mintAudience } - input.Seal = h.seal - input.TTL = h.lifetime - token, _, err := workContextTestSigner(h.t, h.now()).StartTask(input) - require.NoError(h.t, err) - response := mintResponse{WorkContext: token.Encoded(), InstallationID: h.seal.InstallationID} + // The host mints with core's Authority, because that is what the host does. + // A test endpoint that assembled a token itself would be a second + // implementation wearing a test's clothes. + token := h.authority.startAt(h.t, h.now(), mintInput{audience: audience, binding: h.binding}, h.lifetime) + response := mintResponse{WorkContext: token, InstallationID: testInstallation} if h.echoWrong { response.InstallationID = "installation-the-host-did-not-seal" } @@ -87,7 +90,7 @@ func newTestMintClient(t *testing.T, host *mintHost, path string, now func() tim t.Helper() settings := MintOptions{ URL: host.server.URL, - Audience: "warden.evidence", + Audience: testAudience, ProjectedToken: ProjectedTokenFile(path), ProjectionAudience: "accounts", Now: now, @@ -106,23 +109,23 @@ func newTestMintClient(t *testing.T, host *mintHost, path string, now func() tim // minted once per surface every fifteen seconds — 240 times an hour, each one // an audit event recording that nothing had changed. func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { - clock := workContextTestTime + clock := testClock now := func() time.Time { return clock } host := newMintHost(t, now) client := newTestMintClient(t, host, projectedFile(t, "projected-token-1"), now) first, err := client.Credential(t.Context()) require.NoError(t, err) - require.Equal(t, workContextTestSeal(), first.Seal()) + require.Equal(t, testSeal, first.Seal()) require.EqualValues(t, 1, host.requests.Load()) // Every call for the next twelve minutes is answered from the credential // already held. Nothing reaches the host. for minute := range 12 { - clock = workContextTestTime.Add(time.Duration(minute) * time.Minute) + clock = testClock.Add(time.Duration(minute) * time.Minute) again, err := client.Credential(t.Context()) require.NoError(t, err) - require.Equal(t, first.Token().Encoded(), again.Token().Encoded()) + require.Equal(t, first.Token(), again.Token()) } require.EqualValues(t, 1, host.requests.Load(), "holding a credential must not talk to the host") @@ -135,7 +138,7 @@ func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { // this replaces made 240 in the same hour, one per surface every fifteen // seconds, each recording that nothing had changed. for minute := 12; minute <= 60; minute++ { - clock = workContextTestTime.Add(time.Duration(minute) * time.Minute) + clock = testClock.Add(time.Duration(minute) * time.Minute) _, err := client.Credential(t.Context()) require.NoError(t, err) } @@ -150,7 +153,7 @@ func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { // renewal is what makes the rotation a non-event; caching it would make the // process fail at its first renewal and need a restart to recover. func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { - clock := workContextTestTime + clock := testClock now := func() time.Time { return clock } host := newMintHost(t, now) path := projectedFile(t, "projected-token-before-rotation") @@ -161,7 +164,7 @@ func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { require.Equal(t, "Bearer projected-token-before-rotation", host.presented.Load()) require.NoError(t, os.WriteFile(path, []byte("projected-token-after-rotation"), 0o600)) - clock = workContextTestTime.Add(14 * time.Minute) + clock = testClock.Add(14 * time.Minute) _, err = client.Credential(t.Context()) require.NoError(t, err) require.Equal(t, "Bearer projected-token-after-rotation", host.presented.Load(), @@ -177,7 +180,7 @@ func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { // process would mint a new credential sealed to a value the host never // approved for it, and nothing downstream could tell. func TestRenewalRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { - clock := workContextTestTime + clock := testClock now := func() time.Time { return clock } host := newMintHost(t, now) drifted := errors.New("audience changed under a running process") @@ -191,7 +194,7 @@ func TestRenewalRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { require.EqualValues(t, 0, pin.checks.Load(), "the first mint is not a renewal") pin.err = drifted - clock = workContextTestTime.Add(14 * time.Minute) + clock = testClock.Add(14 * time.Minute) _, err = client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "audience changed") @@ -214,7 +217,7 @@ func (s *stubAuthority) Recheck(context.Context) error { // signed one, and a client that preferred the echo would hold a credential // whose installation it had been told wrongly. func TestMintRefusesAnEchoThatDisagreesWithTheSignature(t *testing.T) { - now := func() time.Time { return workContextTestTime } + now := func() time.Time { return testClock } host := newMintHost(t, now) host.echoWrong = true client := newTestMintClient(t, host, projectedFile(t, "projected"), now) @@ -229,9 +232,9 @@ func TestMintRefusesAnEchoThatDisagreesWithTheSignature(t *testing.T) { // error in the process that is wrong rather than an authorization error in the // service it calls. func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { - now := func() time.Time { return workContextTestTime } + now := func() time.Time { return testClock } host := newMintHost(t, now) - host.mintAudience = "warden.other" + host.mintAudience = "codefly.other-audience" client := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err := client.Credential(t.Context()) @@ -243,7 +246,7 @@ func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { // client that confused them would either spin against a permanent refusal or // give up on a transient one. func TestMintSeparatesARefusalFromAnOutage(t *testing.T) { - now := func() time.Time { return workContextTestTime } + now := func() time.Time { return testClock } for status, sentinel := range map[int]error{ http.StatusForbidden: ErrMintRefused, http.StatusUnauthorized: ErrMintRefused, @@ -287,11 +290,11 @@ func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { // Principal, installation and build are the host's to establish; a field for // any of them would be a field a process could lie in. func TestMintRequestSelfReportsNoIdentity(t *testing.T) { - encoded, err := json.Marshal(mintRequest{Audience: "warden.evidence", ProjectionAudience: "accounts"}) + encoded, err := json.Marshal(mintRequest{Audience: testAudience, ProjectionAudience: "accounts"}) require.NoError(t, err) var fields map[string]any require.NoError(t, json.Unmarshal(encoded, &fields)) - require.Equal(t, []string{"audience", "projection_audience"}, sortedUnique(keysOf(fields))) + require.Equal(t, []string{"audience", "projection_audience"}, keysOf(fields)) } func keysOf(fields map[string]any) []string { @@ -299,14 +302,15 @@ func keysOf(fields map[string]any) []string { for name := range fields { names = append(names, name) } - return names + slices.Sort(names) + return slices.Compact(names) } // A credential request that was answered by an address nothing configured has // already left with the projected token attached. Retrying would send it again, // so a redirect is a permanent refusal and the process must not serve. func TestMintRefusesARedirectedRequest(t *testing.T) { - now := func() time.Time { return workContextTestTime } + now := func() time.Time { return testClock } elsewhere := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusOK) })) @@ -318,7 +322,7 @@ func TestMintRefusesARedirectedRequest(t *testing.T) { client, err := NewMintClient(MintOptions{ URL: redirector.URL, - Audience: "warden.evidence", + Audience: testAudience, ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), ProjectionAudience: "accounts", Now: now, @@ -333,7 +337,7 @@ func TestMintRefusesARedirectedRequest(t *testing.T) { func TestNewMintClientValidatesItsConfiguration(t *testing.T) { valid := MintOptions{ URL: "https://accounts.internal/platform/_mint", - Audience: "warden.evidence", + Audience: testAudience, ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), ProjectionAudience: "accounts", } @@ -359,3 +363,34 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { }) } } + +// The credential a process holds, and what it puts on a call. The sealed +// values come off the signature rather than the response body, so what a +// caller reads here is what a verifier will compare against. +func TestCredentialSurfaceComesFromTheSignedCapability(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + host.binding = testBinding + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, testSeal, credential.Seal()) + require.Equal(t, + &OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1}, + credential.OperationBinding(), + ) + require.Equal(t, testClock, credential.IssuedAt()) + require.Equal(t, testClock.Add(15*time.Minute), credential.ExpiresAt()) + + // OperationBinding hands back a copy: a caller that mutated it must not be + // able to change what the credential reports it is bound to. + mutated := credential.OperationBinding() + mutated.ID = "binding-the-caller-preferred" + require.Equal(t, testBinding, credential.OperationBinding().ID) + + request := httptest.NewRequest(http.MethodPost, "/records", nil) + require.NoError(t, credential.Attach(request)) + require.Equal(t, credential.Token(), request.Header.Get(HeaderName)) + require.Equal(t, testInstallation, request.Header.Get(InstallationIDHeaderName)) +} diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go new file mode 100644 index 0000000..4273435 --- /dev/null +++ b/workcontext/minting_test.go @@ -0,0 +1,165 @@ +package workcontext + +import ( + "context" + "crypto/ed25519" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// Every capability in this package's tests is minted by core's Authority, +// because that is the only minter there is. A test that assembled a token +// another way would be testing a format nothing issues, which is how this +// module came to hold a second implementation in the first place. + +// testClock is the instant every capability in these tests is minted at, and +// testSeal is the live sealed state the authority holds. +var ( + testClock = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + testSeal = Seal{ + PrincipalEpoch: 7, + InstallationID: testInstallation, + InstallationRevision: 3, + BuildIncarnation: 11, + } +) + +const ( + testIssuer = "codefly.test-authority" + testAudience = "codefly.test-audience" + testKeyID = "test-key-1" + testTenant = "tenant-1" + testPrincipal = "principal-1" + testInstallation = "installation-1" + testBinding = "binding-1" +) + +type authority struct { + core *corework.Authority + seals *corework.MemorySealSource + now func() time.Time +} + +// newAuthority stands up core's minter over an in-memory seal source, holding +// the live values a fresh capability is sealed to. +func newAuthority(t *testing.T) *authority { + t.Helper() + _, private := corework.FixtureKeyPair() + seals := corework.NewMemorySealSource() + require.NoError(t, seals.Put(testPrincipal, testSeal)) + require.NoError(t, seals.PutBinding(OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1})) + now := func() time.Time { return testClock } + return &authority{ + core: &corework.Authority{ + Issuer: testIssuer, + KeyID: testKeyID, + Key: private, + Revisions: corework.FixedRevision(5), + Seals: seals, + Now: now, + }, + seals: seals, + now: now, + } +} + +// verifier is core's verifier configured for this authority — the module's only +// verification entry point, reached through the alias in core.go. +func (a *authority) verifier(t *testing.T) *Verifier { + t.Helper() + public, _ := corework.FixtureKeyPair() + return &Verifier{ + Issuer: testIssuer, + Audience: testAudience, + Keys: map[string]ed25519.PublicKey{testKeyID: public}, + Revisions: corework.FixedRevision(5), + Replay: corework.NewMemoryReplayStore(), + Grants: noGrants{}, + Seals: a.seals, + Now: a.now, + } +} + +// noGrants is a grant source for tests that mint no grant capability. A +// Verifier requires one even so: a verifier that treated a missing source as +// "no grants to check" would skip the check for a capability that carried one. +type noGrants struct{} + +func (noGrants) Grant(context.Context, string) (*corework.Grant, error) { + return nil, corework.ErrInvalid +} + +type mintInput struct { + tenant string + audience string + scopes []*basev0.WorkScopeV1 + binding string + taskID string + organization string +} + +// start mints a session capability, sealed to the live values above. +func (a *authority) start(t *testing.T, input mintInput) string { + t.Helper() + return a.startAt(t, a.now(), input, 15*time.Minute) +} + +// startAt mints at a given instant for a given lifetime, which is what a mint +// endpoint under a moving test clock needs. +func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifetime time.Duration) string { + t.Helper() + minter := *a.core + minter.Now = func() time.Time { return now } + if input.tenant == "" { + input.tenant = testTenant + } + if input.audience == "" { + input.audience = testAudience + } + if input.taskID == "" { + input.taskID = "task-1" + } + if input.scopes == nil { + input.scopes = []*basev0.WorkScopeV1{scope("document", "read")} + } + token, _, err := minter.Start(context.Background(), corework.StartInput{ + TenantID: input.tenant, + OwnerPrincipalID: testPrincipal, + OwnerPrincipalKind: "service", + TaskID: input.taskID, + Audience: input.audience, + AuthorityScopes: input.scopes, + OrganizationID: input.organization, + InstallationID: testInstallation, + OperationBindingID: input.binding, + TTL: lifetime, + }) + require.NoError(t, err) + return token +} + +// child delegates a verified capability one hop, which is how a test obtains a +// capability whose effective actor is not its owner. +func (a *authority) child(t *testing.T, parent string, principal string, scopes []*basev0.WorkScopeV1) string { + t.Helper() + verified, err := a.verifier(t).Verify(context.Background(), parent) + require.NoError(t, err) + token, _, err := a.core.Child(context.Background(), verified, corework.ChildInput{ + PrincipalID: principal, + PrincipalKind: "service", + DelegationID: "delegation-" + principal, + GrantedScopes: scopes, + Audience: testAudience, + TTL: 10 * time.Minute, + }) + require.NoError(t, err) + return token +} + +func scope(kind string, actions ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} +} diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go new file mode 100644 index 0000000..ba4afce --- /dev/null +++ b/workcontext/one_implementation_test.go @@ -0,0 +1,260 @@ +package workcontext + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" + "github.com/stretchr/testify/require" +) + +// The aliases in core.go are core's types and not this module's copies of +// them. These declarations compile only while that is true: a named type +// declared here, however identical its fields, would not be assignable to +// core's pointer type, and the gate below would be guarding a surface that had +// already forked. +var ( + _ *corework.Verifier = (*Verifier)(nil) + _ *corework.Verified = (*Verified)(nil) + _ corework.Seal = Seal{} + _ corework.OperationBinding = OperationBinding{} +) + +// cryptoImplementationPackages are the packages a second implementation would +// have to reach for. Neither may be imported by anything in this module: this +// module presents capabilities and reads the sealed values off ones the host +// issued it, and it neither signs nor checks a signature. +var cryptoImplementationPackages = []string{"crypto/ed25519", "crypto/ecdsa"} + +// jsonAllowlist is every non-test file that may import encoding/json, and why. +// +// The ban exists because the second implementation this module used to hold +// signed a hand-written JSON payload. The one remaining use is not a token at +// all: it is the body of the host's mint endpoint, an ordinary HTTP API whose +// request is two strings and whose response hands back an opaque capability. +// The capability itself never passes through a JSON codec here — it arrives as +// a string and is read with core's generated type — and the second assertion +// below is what holds that apart from the first, by refusing a JSON-tagged +// struct anywhere in the module except that request and that response. +var jsonAllowlist = map[string]string{ + "mint.go": "the mint endpoint's HTTP request and response bodies, which carry the capability as an opaque string", +} + +// mintEndpointJSONTypes are the only structs in this module that may carry +// json tags. They are the mint endpoint's two bodies. A payload struct pair for +// a capability — the shape of the implementation this module deleted — is a +// third entry here, which is a failing test rather than a review comment +// somebody might not leave. +var mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} + +// TestNoSecondWorkContextImplementation is the gate. +// +// A wire contract has exactly one implementation, in the repository that owns +// the type. The Work Context is a core proto, so core's workcontext package is +// the only mint and the only verify, and this module is a client of it. That +// rule was broken once: a second implementation here signed a hand-written +// JSON payload while core signed the deterministic protobuf encoding of the +// same message, and because both forms are "." with +// Ed25519, a token from either looked well-formed to the other and then failed +// SIGNATURE verification. "Signature does not verify under key X" reads like a +// rotated key, so keys are what everyone investigated. +// +// A comment would not have stopped that and did not. This does, structurally, +// in four parts: nothing here may reach for a signing primitive, nothing may +// JSON-encode a capability, nothing may declare a type or function that reads +// as a second WorkContext surface, and the verification entrypoint this module +// exports is driven by core's own conformance fixtures. +func TestNoSecondWorkContextImplementation(t *testing.T) { + files := moduleFiles(t) + require.NotEmpty(t, files, "the gate scanned no files, so it would pass for an empty module") + + packages := map[string]bool{} + jsonImporters := []string{} + for _, file := range files { + packages[filepath.Dir(file.path)] = true + for _, path := range importsOf(file.syntax) { + if slices.Contains(cryptoImplementationPackages, path) { + t.Errorf( + "%s imports %q.\n"+ + "This module signs nothing and verifies nothing: core's workcontext is the only\n"+ + "implementation of the capability. If a signature has to be checked, it is checked\n"+ + "by core's Verifier, reached through the alias in core.go.", + file.path, path, + ) + } + if path == "encoding/json" { + jsonImporters = append(jsonImporters, filepath.Base(file.path)) + } + } + checkDeclarations(t, file) + } + + for _, importer := range jsonImporters { + reason, allowed := jsonAllowlist[importer] + require.True(t, allowed, + "%s imports encoding/json.\n"+ + "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ + "The only JSON in this module is %v — the mint endpoint's HTTP bodies. If a capability\n"+ + "is being JSON-encoded here, that is the second implementation coming back.", + importer, allowedJSONFiles()) + require.NotEmpty(t, reason) + } + + require.GreaterOrEqual(t, len(packages), 2, + "the gate found %d package(s) in this module; it is meant to scan every one, "+ + "so a package it cannot see is a package the rule does not reach", len(packages)) +} + +// checkDeclarations refuses a declaration that reads as a second WorkContext +// surface, and a json tag outside the mint endpoint's two bodies. +func checkDeclarations(t *testing.T, file sourceFile) { + t.Helper() + for _, declaration := range file.syntax.Decls { + switch declared := declaration.(type) { + case *ast.FuncDecl: + if declared.Recv == nil && strings.HasPrefix(declared.Name.Name, "WorkContext") { + t.Errorf( + "%s declares func %s.\n"+ + "A WorkContext-named function here is how the second implementation looked. The\n"+ + "capability's own operations are core's; this module names what it does to one\n"+ + "(Attach, FromHeaders, SealedInstallation) and re-exports core's for the rest.", + file.path, declared.Name.Name, + ) + } + case *ast.GenDecl: + for _, spec := range declared.Specs { + typed, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if typed.Assign == 0 && strings.HasPrefix(typed.Name.Name, "WorkContext") { + t.Errorf( + "%s declares type %s, which is not an alias of core's.\n"+ + "The capability's types are core's. Alias them (type X = corework.X) so there is\n"+ + "one definition, or name what this module adds without naming the capability.", + file.path, typed.Name.Name, + ) + } + checkJSONTags(t, file, typed) + } + } + } +} + +// checkJSONTags refuses a json-tagged struct outside the mint endpoint. The +// deleted implementation's signed payload was exactly this: a struct whose +// tags enumerated a capability's fields by hand, which is why a field present +// in the proto and absent from the struct was dropped at mint and missing at +// verify. +func checkJSONTags(t *testing.T, file sourceFile, typed *ast.TypeSpec) { + t.Helper() + structure, ok := typed.Type.(*ast.StructType) + if !ok || structure.Fields == nil { + return + } + for _, field := range structure.Fields.List { + if field.Tag == nil { + continue + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil || !strings.Contains(tag, `json:"`) { + continue + } + if reflect.StructTag(tag).Get("json") == "" { + continue + } + require.Contains(t, mintEndpointJSONTypes, typed.Name.Name, + "%s declares type %s with json tags.\n"+ + "Only the mint endpoint's bodies (%v) are JSON here. A JSON-tagged struct describing a\n"+ + "capability's fields is the second implementation: core signs the deterministic protobuf\n"+ + "encoding, and a field enumerated by hand is a field dropped at mint and absent at verify.", + file.path, typed.Name.Name, mintEndpointJSONTypes) + return + } +} + +// TestWorkContextConformance drives core's own fixtures against the +// verification entrypoint this module exports, which is core's Verifier. +// +// It is the half of the gate the static checks cannot make: a module could +// import no signing primitive and still hand a consumer a verifier that +// answered differently from core's. The decisive fixture is the foreign +// encoding — a JSON-shaped token that must be refused BEFORE its signature is +// checked, with ErrNotACoreToken. A second implementation refuses that token +// too, but as a signature failure, which is the misdiagnosis this whole rule +// exists to prevent. +func TestWorkContextConformance(t *testing.T) { + settings := conformance.New(time.Now()) + verifier := settings.Verifier() + conformance.RunWith(t, settings, func(ctx context.Context, token string) error { + _, err := verifier.Verify(ctx, token) + return err + }) +} + +type sourceFile struct { + path string + syntax *ast.File +} + +// moduleFiles parses every non-test Go file in this module. The walk starts at +// the module root, which is this package's directory, so a new package added +// beside grpctransport is scanned without anyone remembering to list it. +func moduleFiles(t *testing.T) []sourceFile { + t.Helper() + var files []sourceFile + fileSet := token.NewFileSet() + require.NoError(t, filepath.WalkDir(".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + syntax, parseErr := parser.ParseFile(fileSet, path, nil, parser.SkipObjectResolution) + if parseErr != nil { + return parseErr + } + files = append(files, sourceFile{path: path, syntax: syntax}) + return nil + })) + return files +} + +func importsOf(file *ast.File) []string { + paths := make([]string, 0, len(file.Imports)) + for _, imported := range file.Imports { + path, err := strconv.Unquote(imported.Path.Value) + if err != nil { + continue + } + paths = append(paths, path) + } + return paths +} + +func allowedJSONFiles() []string { + names := make([]string, 0, len(jsonAllowlist)) + for name := range jsonAllowlist { + names = append(names, name) + } + slices.Sort(names) + return names +} diff --git a/workcontext/seal.go b/workcontext/seal.go deleted file mode 100644 index 3cf903a..0000000 --- a/workcontext/seal.go +++ /dev/null @@ -1,245 +0,0 @@ -package workcontext - -import ( - "fmt" - "strconv" -) - -// Carrier names for the sealed installation. They exist so a host can refuse a -// request before it decodes a token, and for no other reason: the installation -// that governs the call is the one sealed inside the signature. A verifier that -// disagrees with these headers refuses the call rather than preferring either -// side, because a header is caller-controlled and the seal is not. -const ( - InstallationIDHeaderName = "x-codefly-installation-id" - InstallationRevisionHeaderName = "x-codefly-installation-revision" -) - -// Seal binds one credential to the execution it was issued to: the principal -// the host authenticated and the epoch it authenticated at, the installation -// the process serves and the revision of that installation, and the build -// incarnation the process is running. -// -// Every field is required. A credential missing any of them does not verify — -// there is no shape in which part of a seal is a seal, because each field is -// the only thing standing between a credential and its reuse somewhere it was -// never issued for. -type Seal struct { - // PrincipalEpoch is the host's epoch for the owner principal. It moves when - // the principal's authority changes, so a credential sealed to an older - // epoch is refused rather than carried across the change. - PrincipalEpoch uint64 - // InstallationID is the installation the process serves. It is the - // immutable, non-reused installation identity, never a reusable route alias - // or registry key: an alias can be released and taken by something else, - // and a credential must not inherit the grants of whatever held it before. - InstallationID string - // InstallationRevision is that installation's revision at mint time. A - // verifier holding a later revision refuses the credential. - InstallationRevision uint64 - // BuildIncarnation identifies the exact build the process is running, as - // the host established it — never as the process reported it. - BuildIncarnation string -} - -// OperationBinding seals an operation context to exactly one binding, at one -// revision, in one incarnation. Verification resolves the binding identity -// rather than searching for a binding whose scopes would admit the call: a -// superset of scopes is not a licence to act as a different binding. -type OperationBinding struct { - BindingID string - BindingRevision uint64 - BindingIncarnation string -} - -// SealedInstallation reads the installation a token is sealed to, for a -// transport that names it beside the capability. It reads the token's own -// content without checking the signature, which is sound only because nothing -// trusts the result: the carrier it fills is a pre-check, and a verifier that -// prefers it over the sealed claim has made the carrier into authority. -// -// A token with no readable seal is an error here, so no transport can attach a -// capability while leaving the installation blank. -func SealedInstallation(token WorkContextToken) (id string, revision string, err error) { - _, seal, _, err := readSealedToken(token) - if err != nil { - return "", "", err - } - return seal.InstallationID, decimal(seal.InstallationRevision), nil -} - -func (s Seal) validate() error { - if s.PrincipalEpoch == 0 { - return fmt.Errorf("%w: seal principal_epoch is required", ErrWorkContextInvalid) - } - if err := validateBounded("seal installation_id", s.InstallationID, workContextMaxIDBytes, true); err != nil { - return err - } - if s.InstallationRevision == 0 { - return fmt.Errorf("%w: seal installation_revision is required", ErrWorkContextInvalid) - } - if err := validateBounded("seal build_incarnation", s.BuildIncarnation, workContextMaxIDBytes, true); err != nil { - return err - } - return nil -} - -func (b OperationBinding) validate() error { - if err := validateBounded("operation_binding_id", b.BindingID, workContextMaxIDBytes, true); err != nil { - return err - } - if b.BindingRevision == 0 { - return fmt.Errorf("%w: operation_binding_revision is required", ErrWorkContextInvalid) - } - if err := validateBounded("operation_binding_incarnation", b.BindingIncarnation, workContextMaxIDBytes, true); err != nil { - return err - } - return nil -} - -// SealExpectations are the sealed values a verifier holds as current. Every -// field is required: a verifier that does not know which installation revision -// it is on cannot refuse a credential from an older one, so leaving a field -// unset is refused instead of being read as "any". -type SealExpectations struct { - PrincipalEpoch uint64 - InstallationID string - InstallationRevision uint64 - BuildIncarnation string -} - -// OperationBindingExpectations identify the one binding a verifier will accept -// an operation context for. Pass it when the call is an operation context; -// leave it nil and a credential carrying an operation binding is refused, -// because a credential issued to act through one binding must not be presented -// where no binding was named. -type OperationBindingExpectations struct { - BindingID string - BindingRevision uint64 - BindingIncarnation string -} - -func (e SealExpectations) validate() error { - if e.PrincipalEpoch == 0 { - return fmt.Errorf("%w: expected principal_epoch is required", ErrWorkContextInvalid) - } - if err := validateBounded("expected installation_id", e.InstallationID, workContextMaxIDBytes, true); err != nil { - return err - } - if e.InstallationRevision == 0 { - return fmt.Errorf("%w: expected installation_revision is required", ErrWorkContextInvalid) - } - if err := validateBounded("expected build_incarnation", e.BuildIncarnation, workContextMaxIDBytes, true); err != nil { - return err - } - return nil -} - -func (e OperationBindingExpectations) validate() error { - if err := validateBounded("expected operation_binding_id", e.BindingID, workContextMaxIDBytes, true); err != nil { - return err - } - if e.BindingRevision == 0 { - return fmt.Errorf("%w: expected operation_binding_revision is required", ErrWorkContextInvalid) - } - if err := validateBounded("expected operation_binding_incarnation", e.BindingIncarnation, workContextMaxIDBytes, true); err != nil { - return err - } - return nil -} - -// matchSeal compares a credential's seal with what the verifier holds. Each -// field is a separate refusal so the reason a call failed is the reason, not a -// single opaque mismatch: a stale installation revision is re-mintable and a -// wrong installation is not, and a caller that cannot tell them apart either -// retries what will never succeed or fails what one mint would fix. -func matchSeal(seal Seal, expected SealExpectations) error { - if err := expected.validate(); err != nil { - return err - } - if seal.InstallationID != expected.InstallationID { - return fmt.Errorf( - "%w: credential is sealed to installation %q, presented to %q", - ErrWorkContextInvalid, seal.InstallationID, expected.InstallationID, - ) - } - if seal.BuildIncarnation != expected.BuildIncarnation { - return fmt.Errorf( - "%w: credential is sealed to build incarnation %q, presented to %q", - ErrWorkContextInvalid, seal.BuildIncarnation, expected.BuildIncarnation, - ) - } - if seal.PrincipalEpoch != expected.PrincipalEpoch { - return fmt.Errorf( - "%w: credential is sealed to principal epoch %d, issuer is at %d", - ErrWorkContextSuperseded, seal.PrincipalEpoch, expected.PrincipalEpoch, - ) - } - if seal.InstallationRevision != expected.InstallationRevision { - return fmt.Errorf( - "%w: credential is sealed to installation revision %d, verifier holds %d", - ErrWorkContextSuperseded, seal.InstallationRevision, expected.InstallationRevision, - ) - } - return nil -} - -// matchOperationBinding resolves the sealed binding identity against the one -// binding the verifier will accept. All three values must agree: a binding id -// at a different revision is a different authorization, and the same revision -// in a different incarnation is a different deployment of it. -func matchOperationBinding(binding *OperationBinding, expected *OperationBindingExpectations) error { - switch { - case expected == nil && binding == nil: - return nil - case expected == nil: - return fmt.Errorf( - "%w: credential is sealed to operation binding %q, presented where no binding was named", - ErrWorkContextInvalid, binding.BindingID, - ) - case binding == nil: - return fmt.Errorf( - "%w: operation binding %q expected, credential seals none", - ErrWorkContextInvalid, expected.BindingID, - ) - } - if err := expected.validate(); err != nil { - return err - } - if binding.BindingID != expected.BindingID { - return fmt.Errorf( - "%w: credential is sealed to operation binding %q, presented to %q", - ErrWorkContextInvalid, binding.BindingID, expected.BindingID, - ) - } - if binding.BindingIncarnation != expected.BindingIncarnation { - return fmt.Errorf( - "%w: operation binding %q is sealed to incarnation %q, verifier holds %q", - ErrWorkContextInvalid, binding.BindingID, binding.BindingIncarnation, expected.BindingIncarnation, - ) - } - if binding.BindingRevision != expected.BindingRevision { - return fmt.Errorf( - "%w: operation binding %q is sealed to revision %d, verifier holds %d", - ErrWorkContextSuperseded, binding.BindingID, binding.BindingRevision, expected.BindingRevision, - ) - } - return nil -} - -// decimal encodes a uint64 for the wire. Revisions and epochs travel as decimal -// strings for the same reason authorization_revision does: a JSON number loses -// the top of the uint64 domain in any JavaScript verifier, and a credential -// that verifies in Go and not in the browser is worse than one that verifies -// nowhere. -func decimal(value uint64) string { - return strconv.FormatUint(value, 10) -} - -func parseDecimal(name string, encoded string) (uint64, error) { - value, err := strconv.ParseUint(encoded, 10, 64) - if err != nil { - return 0, fmt.Errorf("%w: %s must be a uint64 decimal string", ErrWorkContextInvalid, name) - } - return value, nil -} diff --git a/workcontext/seal_test.go b/workcontext/seal_test.go deleted file mode 100644 index 9536c27..0000000 --- a/workcontext/seal_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package workcontext - -import ( - "net/http" - "net/http/httptest" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/stretchr/testify/require" -) - -// The headline refusal: a credential minted while the host held revision N is -// refused by a verifier that has moved to N+1. It is ErrWorkContextSuperseded -// and not ErrWorkContextInvalid, because the holder's correct response is to -// mint again — and a holder that cannot tell the two apart either retries what -// will never work or gives up on what one mint would fix. -func TestSealedCredentialIsRefusedOnceTheInstallationRevisionMoves(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - verifier := workContextTestVerifier(t, workContextTestTime) - - current := workContextTestExpected() - _, err = verifier.VerifyWorkContext(token, current) - require.NoError(t, err, "the credential verifies on the revision it was minted for") - - moved := workContextTestExpected() - moved.Seal.InstallationRevision++ - _, err = verifier.VerifyWorkContext(token, moved) - require.ErrorIs(t, err, ErrWorkContextSuperseded) - require.NotErrorIs(t, err, ErrWorkContextUnavailable, "a superseded credential must not look retryable") - require.ErrorContains(t, err, "installation revision") -} - -// A different installation is not a stale one. It can never become valid, so it -// is invalid rather than superseded: minting again on this process would -// produce a credential for the same wrong installation. -func TestSealedCredentialIsRefusedForAnotherInstallation(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - elsewhere := workContextTestExpected() - elsewhere.Seal.InstallationID = "installation-somewhere-else" - _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, elsewhere) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.NotErrorIs(t, err, ErrWorkContextSuperseded) - require.ErrorContains(t, err, "sealed to installation") -} - -// The build is sealed so a credential cannot be carried onto another binary. -// Same principal, same installation, same revision, different build: refused. -func TestSealedCredentialIsRefusedForAnotherBuild(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - rebuilt := workContextTestExpected() - rebuilt.Seal.BuildIncarnation = "build-incarnation-2026-07-24-b" - _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, rebuilt) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "build incarnation") -} - -// A principal whose epoch has moved has had its authority changed. The -// credential was sound and is now superseded. -func TestSealedCredentialIsRefusedOnceThePrincipalEpochMoves(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - moved := workContextTestExpected() - moved.Seal.PrincipalEpoch++ - _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, moved) - require.ErrorIs(t, err, ErrWorkContextSuperseded) - require.ErrorContains(t, err, "principal epoch") -} - -// A seal is whole or it is not a seal. Each field missing in turn must refuse -// at signing time, so a credential nobody can verify is never handed out. -func TestSigningRefusesAnIncompleteSeal(t *testing.T) { - for name, mutate := range map[string]func(*Seal){ - "principal epoch": func(s *Seal) { s.PrincipalEpoch = 0 }, - "installation id": func(s *Seal) { s.InstallationID = "" }, - "installation revision": func(s *Seal) { s.InstallationRevision = 0 }, - "build incarnation": func(s *Seal) { s.BuildIncarnation = "" }, - } { - t.Run(name, func(t *testing.T) { - input := workContextTestInput() - seal := input.Seal - mutate(&seal) - input.Seal = seal - _, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "seal ") - }) - } -} - -// A verifier that does not state where it is cannot refuse a credential from -// somewhere else, so an unstated expectation is refused rather than read as -// "accept anything". -func TestVerificationRefusesAVerifierThatStatesNoSeal(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - for name, expected := range map[string]WorkContextExpectations{ - "nothing at all": {Delegation: &DelegationExpectations{}}, - "no installation": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationRevision: 41, BuildIncarnation: "b"}}, - "no revision": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationID: "i", BuildIncarnation: "b"}}, - "no build incarnation": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{PrincipalEpoch: 7, InstallationID: "i", InstallationRevision: 41}}, - "no principal epoch": {Delegation: &DelegationExpectations{}, Seal: SealExpectations{InstallationID: "i", InstallationRevision: 41, BuildIncarnation: "b"}}, - } { - t.Run(name, func(t *testing.T) { - _, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, expected) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "expected ") - }) - } -} - -// The exact-binding rule, stated as the acceptance criterion states it: a -// credential sealed to binding X cannot be used for binding Y even when Y's -// scopes are a superset of X's. Scope containment is not binding identity, and -// a verifier that searched for a binding admitting the call would find Y. -func TestOperationBindingIsResolvedByIdentityNotByScope(t *testing.T) { - input := workContextTestInput() - input.OperationBinding = &OperationBinding{ - BindingID: "binding-x", - BindingRevision: 3, - BindingIncarnation: "incarnation-x", - } - // Y admits strictly more than the credential asks for. Under a scope search - // this credential would pass as Y. - input.AuthorityScopes = []*basev0.WorkScopeV1{ - {ResourceKind: "evidence", Actions: []string{"append"}}, - } - input.ActorChain = nil - - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - verifier := workContextTestVerifier(t, workContextTestTime) - - expected := workContextTestExpected() - expected.Delegation = nil - expected.OperationBinding = &OperationBindingExpectations{ - BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-x", - } - verified, err := verifier.VerifyWorkContext(token, expected) - require.NoError(t, err) - require.Equal(t, "binding-x", verified.OperationBinding().BindingID) - - superset := expected - superset.OperationBinding = &OperationBindingExpectations{ - BindingID: "binding-y", BindingRevision: 3, BindingIncarnation: "incarnation-x", - } - _, err = verifier.VerifyWorkContext(token, superset) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "sealed to operation binding") - - movedRevision := expected - movedRevision.OperationBinding = &OperationBindingExpectations{ - BindingID: "binding-x", BindingRevision: 4, BindingIncarnation: "incarnation-x", - } - _, err = verifier.VerifyWorkContext(token, movedRevision) - require.ErrorIs(t, err, ErrWorkContextSuperseded, "a binding revision that moved is re-mintable") - - otherIncarnation := expected - otherIncarnation.OperationBinding = &OperationBindingExpectations{ - BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-z", - } - _, err = verifier.VerifyWorkContext(token, otherIncarnation) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "incarnation") -} - -// An operation credential presented where no binding was named is refused, and -// so is an ordinary credential presented where one was. Neither direction is a -// tolerable default: the first would let a credential issued to act through one -// binding act outside every binding, and the second would let a call that needs -// a binding proceed without one. -func TestOperationBindingPresenceIsCheckedBothWays(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - verifier := workContextTestVerifier(t, workContextTestTime) - - bound := workContextTestInput() - bound.OperationBinding = &OperationBinding{ - BindingID: "binding-x", BindingRevision: 1, BindingIncarnation: "incarnation-x", - } - boundToken, _, err := signer.StartTask(bound) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(boundToken, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "presented where no binding was named") - - plainToken, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - expectsBinding := workContextTestExpected() - expectsBinding.OperationBinding = &OperationBindingExpectations{ - BindingID: "binding-x", BindingRevision: 1, BindingIncarnation: "incarnation-x", - } - _, err = verifier.VerifyWorkContext(plainToken, expectsBinding) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "seals none") -} - -// An operation binding travels whole or not at all. A payload carrying part of -// one would otherwise resolve to a binding identity nobody approved. -func TestPartialOperationBindingOnTheWireIsRefused(t *testing.T) { - _, seal, binding, err := readSealedToken(mustSignBoundToken(t)) - require.NoError(t, err) - require.NotNil(t, binding) - require.Equal(t, workContextTestSeal(), seal) - - id := "binding-x" - _, _, err = sealFromPayload(workContextPayload{ - PrincipalEpoch: "7", - InstallationID: "installation-0a9f", - InstallationRevision: "41", - BuildIncarnation: "build", - OperationBindingID: &id, - }) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "id, revision and incarnation together") -} - -func mustSignBoundToken(t *testing.T) WorkContextToken { - t.Helper() - input := workContextTestInput() - input.OperationBinding = &OperationBinding{ - BindingID: "binding-x", BindingRevision: 3, BindingIncarnation: "incarnation-x", - } - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - return token -} - -// Deliverable 3: the installation reaches the far end on every outbound -// request, beside the capability rather than inside it only. -func TestAttachCarriesTheSealedInstallationOnEveryRequest(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - request := httptest.NewRequest(http.MethodGet, "https://service.internal/v1/records", nil) - require.NoError(t, AttachWorkContext(request, token)) - - seal := workContextTestSeal() - require.Equal(t, seal.InstallationID, request.Header.Get(InstallationIDHeaderName)) - require.Equal(t, "41", request.Header.Get(InstallationRevisionHeaderName)) - require.Equal(t, token.Encoded(), request.Header.Get(WorkContextHeaderName)) -} - -// A token with no readable seal cannot be attached at all. Attaching it bare -// would produce a request that looks authorized and names no installation, -// which is the shape the whole change exists to remove. -func TestAttachRefusesAnUnsealedToken(t *testing.T) { - unsealed, err := ParseWorkContextToken("e30.AAAA") - require.NoError(t, err) - request := httptest.NewRequest(http.MethodGet, "https://service.internal/v1/records", nil) - require.ErrorIs(t, AttachWorkContext(request, unsealed), ErrWorkContextInvalid) - require.Empty(t, request.Header.Get(WorkContextHeaderName)) - require.Empty(t, request.Header.Get(InstallationIDHeaderName)) -} - -// Deliverable 2's separate checks. The owner is the delegating principal and -// the final actor is the calling one; matching one says nothing about the other. -func TestDelegatingAndCallingPrincipalsAreCheckedSeparately(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - verifier := workContextTestVerifier(t, workContextTestTime) - - both := workContextTestExpected() - both.OwnerPrincipalID, both.OwnerPrincipalKind = "principal-antoine", "user" - both.Delegation = &DelegationExpectations{ - PrincipalID: "agent-claude-code", PrincipalKind: "agent", DelegationID: "delegation-1", - } - _, err = verifier.VerifyWorkContext(token, both) - require.NoError(t, err) - - // The owner this verifier expects, a caller it does not. - wrongCaller := both - wrongCaller.Delegation = &DelegationExpectations{PrincipalID: "agent-someone-else"} - _, err = verifier.VerifyWorkContext(token, wrongCaller) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "caller mismatch") - - // The caller this verifier expects, an owner it does not. - wrongOwner := both - wrongOwner.OwnerPrincipalID = "principal-other" - _, err = verifier.VerifyWorkContext(token, wrongOwner) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "owner mismatch") - - // A verifier that never considered delegation refuses a delegated caller - // rather than checking only the principal that delegated. - directOnly := both - directOnly.Delegation = nil - _, err = verifier.VerifyWorkContext(token, directOnly) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "only a direct owner call is accepted") -} - -// The owner's kind is required, and an agent owner must name its agent -// manifest identity. Either half without the other is a credential that claims -// to be an agent nothing can name, or names an agent while presenting as a user. -func TestOwnerKindAndAgentIdentityAreOneFact(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - - missingKind := workContextTestInput() - missingKind.OwnerPrincipalKind = "" - _, _, err := signer.StartTask(missingKind) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "owner_principal_kind is required") - - agentWithoutIdentity := workContextTestInput() - agentWithoutIdentity.OwnerPrincipalKind = PrincipalKindAgent - _, _, err = signer.StartTask(agentWithoutIdentity) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, "owner_agent_id is required") - - userWithIdentity := workContextTestInput() - userWithIdentity.OwnerAgentID = "agent-manifest-1" - _, _, err = signer.StartTask(userWithIdentity) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, `owner_agent_id is set on a "user" owner`) - - agent := workContextTestInput() - agent.OwnerPrincipalKind = PrincipalKindAgent - agent.OwnerAgentID = "agent-manifest-1" - token, _, err := signer.StartTask(agent) - require.NoError(t, err) - expected := workContextTestExpected() - expected.OwnerPrincipalKind = PrincipalKindAgent - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, expected) - require.NoError(t, err) - require.Equal(t, "agent-manifest-1", verified.Claims().GetOwnerAgentId()) -} - -// An exchange moves authority between boundaries inside one execution, so the -// seal is the same on both sides. A hop that could restate it would be a hop -// that could move a credential onto another build or another installation. -func TestExchangeAndChildSessionCarryTheSealUnchanged(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parent, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - exchanged, _, err := signer.ExchangeWorkContextAudience(parent, ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - }) - require.NoError(t, err) - child, _, err := signer.StartChildSession(parent, StartChildSessionInput{ - SessionID: "session-child", Audience: "warden.tools", - Actor: &basev0.WorkActorV1{ - PrincipalId: "tool-editor", PrincipalKind: "tool", DelegationId: "delegation-2", - GrantedScopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append"}}}, - }, - }) - require.NoError(t, err) - session, _, err := signer.StartSession(parent, StartRootSessionInput{ - SessionID: "session-second", Audience: "warden.evidence", - }) - require.NoError(t, err) - - for name, token := range map[string]WorkContextToken{ - "audience exchange": exchanged, - "child session": child, - "root session": session, - } { - t.Run(name, func(t *testing.T) { - _, seal, binding, err := readSealedToken(token) - require.NoError(t, err) - require.Equal(t, workContextTestSeal(), seal) - require.Nil(t, binding) - }) - } -} diff --git a/workcontext/stream.go b/workcontext/stream.go index 9fd3b8d..f90fb48 100644 --- a/workcontext/stream.go +++ b/workcontext/stream.go @@ -8,14 +8,18 @@ import ( "time" ) -// Bounds on the re-check cadence. A cadence longer than a credential's own -// lifetime would never fire inside one stream, and one shorter than a second -// would re-present a credential faster than any authority changes its mind. -const minStreamRecheckInterval = time.Second - -// maxStreamRecheckInterval tracks WorkContextMaxTTL, which is a variable a -// consumer may lower, so it is read rather than copied. -func maxStreamRecheckInterval() time.Duration { return WorkContextMaxTTL } +// Bounds on the re-check cadence. One shorter than a second would re-present a +// credential faster than any authority changes its mind; one longer than the +// longest credential a host issues would never fire inside a single stream, so +// the stream would serve its whole life on the check that opened it. +// +// They are client-side sanity bounds and not a statement about the credential: +// the cadence itself is the host's to state, because the host is what decides +// how long an authorization answer stays true. +const ( + minStreamRecheckInterval = time.Second + maxStreamRecheckInterval = 15 * time.Minute +) // ErrStreamTerminated marks a stream that a re-check refused. It is returned to // every later call on the same guard, including after the refusal that caused @@ -66,12 +70,12 @@ type StreamGuard struct { // strength of the check that opened it. func NewStreamGuard(options StreamGuardOptions) (*StreamGuard, error) { if options.Recheck == nil { - return nil, fmt.Errorf("%w: a stream guard needs a re-check", ErrWorkContextInvalid) + return nil, fmt.Errorf("%w: a stream guard needs a re-check", ErrInvalid) } - if options.Interval < minStreamRecheckInterval || options.Interval > maxStreamRecheckInterval() { + if options.Interval < minStreamRecheckInterval || options.Interval > maxStreamRecheckInterval { return nil, fmt.Errorf( "%w: stream re-check interval must be between %s and %s", - ErrWorkContextInvalid, minStreamRecheckInterval, maxStreamRecheckInterval(), + ErrInvalid, minStreamRecheckInterval, maxStreamRecheckInterval, ) } now := options.Now @@ -86,18 +90,17 @@ func NewStreamGuard(options StreamGuardOptions) (*StreamGuard, error) { // host gives one. A non-nil error terminates the stream: the message must not // be sent, and nothing after it may be either. // -// A refusal is returned wrapped so a caller can tell a superseded credential -// (ErrWorkContextSuperseded — the installation or binding revision moved, and -// a fresh credential would be accepted) from an invalid one. Neither resumes -// this stream. The distinction is for the caller's decision about whether to -// open another, which is a decision about one new stream and not a licence to -// keep this one alive. +// A refusal is returned wrapped, so a caller can tell a credential the state +// moved under (ErrRevoked — a fresh credential would be accepted) from one that +// never becomes valid (ErrInvalid). Neither resumes this stream. The +// distinction is for the caller's decision about whether to open another, which +// is a decision about one new stream and not a licence to keep this one alive. func (g *StreamGuard) BeforeSend(ctx context.Context) error { if g == nil { - return fmt.Errorf("%w: nil stream guard", ErrWorkContextInvalid) + return fmt.Errorf("%w: nil stream guard", ErrInvalid) } if ctx == nil { - return fmt.Errorf("%w: nil context", ErrWorkContextInvalid) + return fmt.Errorf("%w: nil context", ErrInvalid) } g.mu.Lock() defer g.mu.Unlock() diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go index 589a7e4..d3e5c16 100644 --- a/workcontext/stream_test.go +++ b/workcontext/stream_test.go @@ -13,21 +13,21 @@ import ( // draining a snapshot computed under authority that no longer exists. Nothing // in the stream fails, nothing is logged, and the data keeps arriving. func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { - clock := workContextTestTime - token, _, err := workContextTestSigner(t, clock).StartTask(workContextTestInput()) - require.NoError(t, err) - verifier := workContextTestVerifier(t, clock) + clock := testClock + a := newAuthority(t) + token := a.start(t, mintInput{}) + verifier := a.verifier(t) - // The verifier's view of the installation revision, which the host moves - // while the stream is open. - held := workContextTestExpected() + // The host's live view of the installation revision, which it moves while + // the stream is open. The re-check is core's verify against it, which is + // what an ordinary call performs. rechecks := 0 guard, err := NewStreamGuard(StreamGuardOptions{ Interval: 30 * time.Second, Now: func() time.Time { return clock }, - Recheck: func(context.Context) error { + Recheck: func(ctx context.Context) error { rechecks++ - _, verifyErr := verifier.VerifyWorkContext(token, held) + _, verifyErr := verifier.Verify(ctx, token) return verifyErr }, }) @@ -50,13 +50,16 @@ func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { require.NoError(t, guard.Terminated()) // The host moves the installation on. The next message past the cadence is - // refused, and the refusal says the credential is superseded rather than - // invalid, so the caller knows a fresh credential would be accepted. - held.Seal.InstallationRevision++ + // refused, and the refusal says the state moved under the credential + // (ErrRevoked) rather than that the credential is malformed, so the caller + // knows a fresh credential would be accepted. + moved := testSeal + moved.InstallationRevision++ + require.NoError(t, a.seals.Put(testPrincipal, moved)) clock = clock.Add(31 * time.Second) err = guard.BeforeSend(t.Context()) require.ErrorIs(t, err, ErrStreamTerminated) - require.ErrorIs(t, err, ErrWorkContextSuperseded) + require.ErrorIs(t, err, ErrRevoked) require.ErrorIs(t, guard.Terminated(), ErrStreamTerminated) } @@ -65,14 +68,14 @@ func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { // revision must not resurrect a stream that has already been refused: the // messages it would send were computed under authority that was withdrawn. func TestATerminatedStreamIsNotResumed(t *testing.T) { - clock := workContextTestTime + clock := testClock refuse := true guard, err := NewStreamGuard(StreamGuardOptions{ Interval: time.Second, Now: func() time.Time { return clock }, Recheck: func(context.Context) error { if refuse { - return ErrWorkContextSuperseded + return ErrRevoked } return nil }, @@ -92,20 +95,20 @@ func TestATerminatedStreamIsNotResumed(t *testing.T) { func TestStreamGuardValidatesItsCadence(t *testing.T) { _, err := NewStreamGuard(StreamGuardOptions{Interval: time.Minute}) - require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorIs(t, err, ErrInvalid) require.ErrorContains(t, err, "needs a re-check") for name, interval := range map[string]time.Duration{ - "zero": 0, - "sub-second": 500 * time.Millisecond, - "beyond a TTL": WorkContextMaxTTL + time.Second, + "zero": 0, + "sub-second": 500 * time.Millisecond, + "beyond the bound": maxStreamRecheckInterval + time.Second, } { t.Run(name, func(t *testing.T) { _, err := NewStreamGuard(StreamGuardOptions{ Interval: interval, Recheck: func(context.Context) error { return nil }, }) - require.ErrorIs(t, err, ErrWorkContextInvalid) + require.ErrorIs(t, err, ErrInvalid) require.ErrorContains(t, err, "interval") }) } @@ -115,9 +118,9 @@ func TestStreamGuardValidatesItsCadence(t *testing.T) { Recheck: func(context.Context) error { return nil }, }) require.NoError(t, err) - require.ErrorIs(t, guard.BeforeSend(nil), ErrWorkContextInvalid) + require.ErrorIs(t, guard.BeforeSend(nil), ErrInvalid) var absent *StreamGuard - require.ErrorIs(t, absent.BeforeSend(context.Background()), ErrWorkContextInvalid) + require.ErrorIs(t, absent.BeforeSend(context.Background()), ErrInvalid) require.NoError(t, absent.Terminated()) } diff --git a/workcontext/work_context.go b/workcontext/work_context.go deleted file mode 100644 index 11aa142..0000000 --- a/workcontext/work_context.go +++ /dev/null @@ -1,1478 +0,0 @@ -package workcontext - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" -) - -const ( - // WorkContextHeaderName is the only HTTP carrier for a signed Work Context. - // Product code should call AttachWorkContext instead of naming this header. - WorkContextHeaderName = "x-codefly-work-context" - - WorkContextType = "codefly.work-context/v1" - WorkContextAlgorithm = "Ed25519" - - WorkContextReplayIdempotent = "idempotent" - WorkContextReplaySingleUse = "single-use" - - WorkContextMaxActorDepth = 16 - WorkContextMaxTokenBytes = 32 * 1024 -) - -const ( - workContextMaxIDBytes = 512 - workContextMaxKindBytes = 128 - workContextMaxScopes = 64 - workContextMaxScopeEntries = 256 -) - -var ( - WorkContextDefaultTTL = 5 * time.Minute - WorkContextMaxTTL = 15 * time.Minute - WorkContextClockSkew = time.Minute - - ErrWorkContextInvalid = errors.New("invalid Codefly Work Context") - // ErrWorkContextUnavailable marks a fail-closed rejection caused by the key - // set being unreachable (transport failure or an issuer 5xx/429) rather than - // by the token itself. Callers should treat it as retryable — HTTP 503, not - // 401 — since an otherwise-valid context must not be dropped during an outage. - ErrWorkContextUnavailable = errors.New("Codefly Work Context verification unavailable") - ErrWorkContextDenied = errors.New("Codefly Work Context scope denied") - // ErrWorkContextSuperseded marks a credential that was sound when it was - // minted and has been overtaken since: the principal epoch, the - // installation revision or the binding revision has moved on. It is - // distinct from ErrWorkContextInvalid because the two need opposite - // responses — a superseded credential is replaced by minting again, and - // the holder can do that unaided, while an invalid one never becomes - // valid. It is equally distinct from ErrWorkContextUnavailable: nothing is - // unreachable, so retrying the same credential is guaranteed to fail and a - // retry loop is the one thing a caller must not do. - ErrWorkContextSuperseded = errors.New("Codefly Work Context superseded") -) - -// WorkContextToken is an opaque signed capability. Its encoded representation -// is exposed only for storage and transport adapters; use AttachWorkContext for -// HTTP requests and WorkContextVerifier for trust decisions. -type WorkContextToken struct { - encoded string -} - -// Encoded returns the signed wire value for persistence or a non-HTTP transport. -func (t WorkContextToken) Encoded() string { - return t.encoded -} - -func (t WorkContextToken) empty() bool { - return t.encoded == "" -} - -// ParseWorkContextToken validates only the bounded two-segment wire shape. It -// does not establish trust; call WorkContextVerifier.Verify before using claims. -func ParseWorkContextToken(encoded string) (WorkContextToken, error) { - if err := validateTokenShape(encoded); err != nil { - return WorkContextToken{}, err - } - return WorkContextToken{encoded: encoded}, nil -} - -// AttachWorkContext installs a credential on an outbound request without -// exposing the carrier names to application code. It sets the signed -// capability and, beside it, the installation the capability is sealed to, so -// every outbound request carries the installation the host will re-check the -// call against. -// -// The installation headers are a pre-check the host may refuse on cheaply. -// They are not authority and no verifier may prefer them: they are read out of -// the token here without checking its signature, which is sound only because -// nothing downstream trusts them. The installation that governs a call is the -// sealed one. -// -// A token with no readable seal is refused rather than attached bare. There is -// no request on which an unsealed credential is better than no credential. -func AttachWorkContext(request *http.Request, token WorkContextToken) error { - if request == nil { - return fmt.Errorf("%w: nil HTTP request", ErrWorkContextInvalid) - } - if token.empty() { - return fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - _, seal, _, err := readSealedToken(token) - if err != nil { - return err - } - request.Header.Set(WorkContextHeaderName, token.encoded) - request.Header.Set(InstallationIDHeaderName, seal.InstallationID) - request.Header.Set(InstallationRevisionHeaderName, decimal(seal.InstallationRevision)) - return nil -} - -// readSealedToken reads a token's own content without checking its signature. -// It exists for the two jobs a holder has to do with a credential it was just -// handed: know when to renew it, and name its installation on the way out. -// Neither is a trust decision, and nothing in this package authorizes from its -// result — VerifyWorkContext is the only thing that does that, and it checks -// the signature first. -func readSealedToken(token WorkContextToken) (*basev0.WorkContextV1, Seal, *OperationBinding, error) { - payload, _, err := decodeWorkContextToken(token.encoded) - if err != nil { - return nil, Seal{}, nil, err - } - sealed, err := unmarshalWorkContext(payload) - if err != nil { - return nil, Seal{}, nil, err - } - if err := validateSealedContext(sealed); err != nil { - return nil, Seal{}, nil, err - } - return sealed.context, sealed.seal, sealed.binding, nil -} - -// WorkContextFromHeaders extracts an opaque token. It does not verify it. -func WorkContextFromHeaders(headers http.Header) (WorkContextToken, error) { - if headers == nil { - return WorkContextToken{}, fmt.Errorf("%w: missing HTTP headers", ErrWorkContextInvalid) - } - return ParseWorkContextToken(headers.Get(WorkContextHeaderName)) -} - -// WorkContextSigner is an authority-side capability. Product applications -// should receive tokens from an authority/exchange endpoint, not receive this -// signer or its private key. -type WorkContextSigner struct { - issuer string - keyID string - privateKey ed25519.PrivateKey - now func() time.Time - nonce func() (string, error) -} - -type WorkContextSignerOptions struct { - Issuer string - KeyID string - PrivateKey ed25519.PrivateKey - Now func() time.Time - Nonce func() (string, error) -} - -func NewWorkContextSigner(options WorkContextSignerOptions) (*WorkContextSigner, error) { - if err := validateBounded("issuer", options.Issuer, workContextMaxIDBytes, true); err != nil { - return nil, err - } - if err := validateBounded("key_id", options.KeyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - if len(options.PrivateKey) != ed25519.PrivateKeySize { - return nil, fmt.Errorf("%w: Ed25519 private key must be %d bytes", ErrWorkContextInvalid, ed25519.PrivateKeySize) - } - now := options.Now - if now == nil { - now = time.Now - } - nonce := options.Nonce - if nonce == nil { - nonce = randomWorkContextNonce - } - return &WorkContextSigner{ - issuer: options.Issuer, - keyID: options.KeyID, - privateKey: append(ed25519.PrivateKey(nil), options.PrivateKey...), - now: now, - nonce: nonce, - }, nil -} - -// StartTaskInput creates a Task and its first root Session capability. -type StartTaskInput struct { - Audience string - TenantID string - OwnerPrincipalID string - // OwnerPrincipalKind is the sort of principal the owner is. It is required: - // the principal id namespaces are independent, so a user and an agent may - // share an id string without being the same caller, and a verifier that - // cannot tell them apart cannot check the delegating principal at all. - OwnerPrincipalKind string - // OwnerAgentID is the owner's agent manifest identity. Required when - // OwnerPrincipalKind is "agent" and refused otherwise. - OwnerAgentID string - // Seal binds this credential to the execution it is minted for. Required. - Seal Seal - // OperationBinding seals the credential to exactly one binding. Set it only - // for an operation context. - OperationBinding *OperationBinding - TaskID string - SessionID string - AuthorizationRevision uint64 - ReplayPolicy string - AuthorityScopes []*basev0.WorkScopeV1 - ActorChain []*basev0.WorkActorV1 - AttributionTeamIDs []string - WorkspaceID string - ProjectID string - TTL time.Duration - NotBefore time.Time -} - -// StartTask constructs and signs an immutable Task/root-Session context. -func (s *WorkContextSigner) StartTask(input StartTaskInput) (WorkContextToken, *basev0.WorkContextV1, error) { - if s == nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: nil signer", ErrWorkContextInvalid) - } - now := s.now().UTC().Truncate(time.Second) - ttl := input.TTL - if ttl == 0 { - ttl = WorkContextDefaultTTL - } - notBefore := input.NotBefore - if notBefore.IsZero() { - notBefore = now - } - nonce, err := s.nonce() - if err != nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: generate nonce: %v", ErrWorkContextInvalid, err) - } - replayPolicy := input.ReplayPolicy - if replayPolicy == "" { - replayPolicy = WorkContextReplayIdempotent - } - context := &basev0.WorkContextV1{ - Typ: WorkContextType, - Algorithm: WorkContextAlgorithm, - KeyId: s.keyID, - Issuer: s.issuer, - Audience: input.Audience, - NotBeforeUnix: notBefore.UTC().Truncate(time.Second).Unix(), - IssuedAtUnix: now.Unix(), - ExpiresAtUnix: now.Add(ttl).Unix(), - Nonce: nonce, - AuthorizationRevision: input.AuthorizationRevision, - ReplayPolicy: replayPolicy, - TenantId: input.TenantID, - OwnerPrincipalId: input.OwnerPrincipalID, - TaskId: input.TaskID, - SessionId: input.SessionID, - AuthorityScopes: cloneScopes(input.AuthorityScopes), - ActorChain: cloneActors(input.ActorChain), - AttributionTeamIds: append([]string(nil), input.AttributionTeamIDs...), - } - if input.OwnerPrincipalKind != "" { - context.OwnerPrincipalKind = stringPointer(input.OwnerPrincipalKind) - } - if input.OwnerAgentID != "" { - context.OwnerAgentId = stringPointer(input.OwnerAgentID) - } - if input.WorkspaceID != "" { - context.WorkspaceId = stringPointer(input.WorkspaceID) - } - if input.ProjectID != "" { - context.ProjectId = stringPointer(input.ProjectID) - } - binding := input.OperationBinding - if binding != nil { - copied := *binding - binding = &copied - } - return s.sign(sealedContext{context: context, seal: input.Seal, binding: binding}) -} - -// StartRootSessionInput exchanges a valid capability for another root Session -// under the same Task. Identity, owner, scopes, actors, and attribution cannot -// be changed by the caller. -type StartRootSessionInput struct { - SessionID string - Audience string - ReplayPolicy string - TTL time.Duration -} - -// ExchangeWorkContextAudienceInput reissues one verified Work Context for a -// different consumer. Immutable work and delegation identity is preserved. -// AttenuatedScopes, when non-nil, replaces the effective scopes and must be a -// subset of the parent's effective scopes. -type ExchangeWorkContextAudienceInput struct { - Audience string - ReplayPolicy string - TTL time.Duration - AttenuatedScopes []*basev0.WorkScopeV1 -} - -// ExchangeWorkContextAudience reissues a capability for another audience -// without creating a new Task, Session, actor, or delegation. Authorities use -// this when one logical execution crosses service trust boundaries. An -// exchange may reduce effective authority but can never widen it. -func (s *WorkContextSigner) ExchangeWorkContextAudience( - parent WorkContextToken, - input ExchangeWorkContextAudienceInput, -) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := verified.clone() - if input.AttenuatedScopes != nil { - attenuated := cloneScopes(input.AttenuatedScopes) - canonicalizeScopes(attenuated) - effective := verified.context.GetAuthorityScopes() - if actors := verified.context.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - if !scopesAttenuate(effective, attenuated) { - return WorkContextToken{}, nil, fmt.Errorf( - "%w: audience exchange widens authority", - ErrWorkContextInvalid, - ) - } - if actors := next.context.GetActorChain(); len(actors) > 0 { - actors[len(actors)-1].GrantedScopes = attenuated - } else { - next.context.AuthorityScopes = attenuated - } - } - return s.exchange( - next, - input.Audience, - input.ReplayPolicy, - input.TTL, - ) -} - -func (s *WorkContextSigner) StartSession(parent WorkContextToken, input StartRootSessionInput) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := verified.clone() - next.context.SessionId = input.SessionID - next.context.ParentSessionId = nil - return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) -} - -// StartChildSessionInput appends exactly one verified Actor and creates a child -// Session. The new actor's scopes must attenuate the parent's effective scope. -type StartChildSessionInput struct { - SessionID string - Audience string - Actor *basev0.WorkActorV1 - ReplayPolicy string - TTL time.Duration -} - -func (s *WorkContextSigner) StartChildSession(parent WorkContextToken, input StartChildSessionInput) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := verified.clone() - next.context.ParentSessionId = stringPointer(verified.context.SessionId) - next.context.SessionId = input.SessionID - next.context.ActorChain = append(next.context.ActorChain, cloneActor(input.Actor)) - return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) -} - -// exchange re-signs a verified credential for another audience, session or -// actor. The seal travels unchanged: an exchange moves authority between -// boundaries inside one execution, and the execution is exactly what the seal -// names. A hop that could restate the seal would be a hop that could move a -// credential onto another build or another installation. -func (s *WorkContextSigner) exchange(sealed sealedContext, audience, replayPolicy string, ttl time.Duration) (WorkContextToken, *basev0.WorkContextV1, error) { - context := sealed.context - now := s.now().UTC().Truncate(time.Second) - if ttl == 0 { - ttl = WorkContextDefaultTTL - } - if audience == "" { - audience = context.Audience - } - if replayPolicy == "" { - replayPolicy = context.ReplayPolicy - } - nonce, err := s.nonce() - if err != nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: generate nonce: %v", ErrWorkContextInvalid, err) - } - context.Typ = WorkContextType - context.Algorithm = WorkContextAlgorithm - context.KeyId = s.keyID - context.Issuer = s.issuer - context.Audience = audience - context.NotBeforeUnix = now.Unix() - context.IssuedAtUnix = now.Unix() - context.ExpiresAtUnix = now.Add(ttl).Unix() - context.Nonce = nonce - context.ReplayPolicy = replayPolicy - return s.sign(sealed) -} - -// verifyOwn re-reads a credential this signer issued before deriving from it. -// It checks the signature and shape, deliberately not the seal: the authority -// re-signing its own credential is not the party that holds the installation -// revision, and refusing here would make an exchange fail for a reason the -// exchanging authority cannot act on. The seal is what travels, and the -// verifier at the far end is where it is held to the current revision. -func (s *WorkContextSigner) verifyOwn(token WorkContextToken) (sealedContext, error) { - publicKey, ok := s.privateKey.Public().(ed25519.PublicKey) - if !ok { - return sealedContext{}, fmt.Errorf("%w: signer has no Ed25519 public key", ErrWorkContextInvalid) - } - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{s.keyID: publicKey}, - Now: s.now, - }) - if err != nil { - return sealedContext{}, err - } - sealed, err := verifier.decode(token) - if err != nil { - return sealedContext{}, err - } - if sealed.context.GetIssuer() != s.issuer { - return sealedContext{}, fmt.Errorf("%w: issuer mismatch", ErrWorkContextInvalid) - } - return sealed, nil -} - -func (s *WorkContextSigner) sign(sealed sealedContext) (WorkContextToken, *basev0.WorkContextV1, error) { - canonical := sealed.clone() - canonicalizeWorkContext(canonical.context) - if err := validateSealedContext(canonical); err != nil { - return WorkContextToken{}, nil, err - } - payload, err := marshalWorkContext(canonical.context, canonical.seal, canonical.binding) - if err != nil { - return WorkContextToken{}, nil, err - } - signature := ed25519.Sign(s.privateKey, payload) - encoded := base64.RawURLEncoding.EncodeToString(payload) + "." + - base64.RawURLEncoding.EncodeToString(signature) - if len(encoded) > WorkContextMaxTokenBytes { - return WorkContextToken{}, nil, fmt.Errorf("%w: token exceeds %d bytes", ErrWorkContextInvalid, WorkContextMaxTokenBytes) - } - return WorkContextToken{encoded: encoded}, canonical.context, nil -} - -type WorkContextVerifier struct { - publicKeys map[string]ed25519.PublicKey - now func() time.Time - clockSkew time.Duration -} - -type WorkContextVerifierOptions struct { - PublicKeys map[string]ed25519.PublicKey - Now func() time.Time - ClockSkew time.Duration -} - -func NewWorkContextVerifier(options WorkContextVerifierOptions) (*WorkContextVerifier, error) { - if len(options.PublicKeys) == 0 { - return nil, fmt.Errorf("%w: no public verification keys", ErrWorkContextInvalid) - } - keys := make(map[string]ed25519.PublicKey, len(options.PublicKeys)) - for keyID, publicKey := range options.PublicKeys { - if err := validateBounded("key_id", keyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - if len(publicKey) != ed25519.PublicKeySize { - return nil, fmt.Errorf("%w: public key %q must be %d bytes", ErrWorkContextInvalid, keyID, ed25519.PublicKeySize) - } - keys[keyID] = append(ed25519.PublicKey(nil), publicKey...) - } - now := options.Now - if now == nil { - now = time.Now - } - clockSkew := options.ClockSkew - if clockSkew == 0 { - clockSkew = WorkContextClockSkew - } - if clockSkew < 0 || clockSkew > WorkContextClockSkew { - return nil, fmt.Errorf("%w: clock skew must be between zero and %s", ErrWorkContextInvalid, WorkContextClockSkew) - } - return &WorkContextVerifier{publicKeys: keys, now: now, clockSkew: clockSkew}, nil -} - -// WorkContextExpectations is what a verifier holds as current. Identity -// expectations that are left empty are not checked, as before. The seal is -// different: it is required, because a verifier that does not state which -// installation revision and build it is on cannot refuse a credential from an -// earlier one, and a credential nobody holds to an installation is a bearer -// token. -type WorkContextExpectations struct { - Issuer string - Audience string - TenantID string - TaskID string - SessionID string - ParentSessionID *string - AuthorizationRevision *uint64 - - // The delegating principal. OwnerPrincipalKind is checked separately from - // OwnerPrincipalID: the two id namespaces are independent, so matching an - // id without its kind matches a different principal that happens to spell - // the same string. - OwnerPrincipalID string - OwnerPrincipalKind string - - // Delegation describes the calling principal — the final actor of a - // delegated credential — and is checked separately from the owner above. - // - // nil means this verifier accepts direct owner calls only, and a credential - // carrying an actor chain is refused. That is deliberate: a service that - // never considered delegation would otherwise accept a delegated caller - // silently, having checked only the principal that delegated rather than - // the one that is actually calling. - Delegation *DelegationExpectations - - // Seal is required. See SealExpectations. - Seal SealExpectations - - // OperationBinding names the one binding an operation context may be - // presented for. nil means no binding was named, and a credential sealed - // to one is refused rather than accepted as if it were an ordinary - // credential that happens to carry extra fields. - OperationBinding *OperationBindingExpectations -} - -// DelegationExpectations is what a verifier holds about the calling principal. -// Empty fields are not checked; the presence of the struct is itself the -// statement that a delegated credential is acceptable here. -type DelegationExpectations struct { - PrincipalID string - PrincipalKind string - DelegationID string -} - -// WorkContextScopeRequirement identifies one exact capability a verified Work -// Context must grant. An empty ResourceID asks whether the effective scope -// grants every resource of ResourceKind; it never ignores explicit resource -// restrictions. -type WorkContextScopeRequirement struct { - ResourceKind string - Action string - ResourceID string - RequireExplicitResource bool -} - -// RequireWorkContextScope evaluates the current actor's effective scope. The -// authority scopes apply to a direct owner call; when actors are present, only -// the final actor's monotonically attenuated granted scopes are effective. -// -// It takes a verified credential rather than claims. Taking claims meant a -// caller could authorize from a hand-constructed protobuf or from a token that -// was parsed and never verified, and the defensive re-validation that used to -// stand here could not tell those apart from a real one — it checked the shape -// of the claims, not where they came from. -// -// Scope is the last question, not the first: the credential has already been -// held to its seal and its binding by the time this runs. A scope that would -// admit the call never widens the binding it was granted under. -func RequireWorkContextScope( - verified VerifiedWorkContext, - requirement WorkContextScopeRequirement, -) error { - claims := verified.sealed.context - if claims == nil { - return fmt.Errorf("%w: scope check requires a verified Work Context", ErrWorkContextInvalid) - } - if err := validateBounded( - "required resource_kind", - requirement.ResourceKind, - workContextMaxKindBytes, - true, - ); err != nil { - return err - } - if err := validateBounded( - "required action", - requirement.Action, - workContextMaxKindBytes, - true, - ); err != nil { - return err - } - if err := validateBounded( - "required resource_id", - requirement.ResourceID, - workContextMaxIDBytes, - requirement.RequireExplicitResource, - ); err != nil { - return err - } - - effective := claims.GetAuthorityScopes() - if actors := claims.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - for _, scope := range effective { - if scope.GetResourceKind() != requirement.ResourceKind || - !sortedStringsContain(scope.GetActions(), requirement.Action) { - continue - } - resourceIDs := scope.GetResourceIds() - switch { - case len(resourceIDs) == 0 && !requirement.RequireExplicitResource: - return nil - case requirement.ResourceID != "" && - sortedStringsContain(resourceIDs, requirement.ResourceID): - return nil - } - } - return fmt.Errorf( - "%w: %s:%s:%s", - ErrWorkContextDenied, - requirement.ResourceKind, - requirement.Action, - requirement.ResourceID, - ) -} - -// decode checks the signature, the structural rules and the lifetime, and -// returns the sealed content. It is not verification: nothing here holds the -// credential to an installation, a build or a caller, so its result must not -// escape this package except through VerifyWorkContext. -func (v *WorkContextVerifier) decode(token WorkContextToken) (sealedContext, error) { - if v == nil { - return sealedContext{}, fmt.Errorf("%w: nil verifier", ErrWorkContextInvalid) - } - payload, signature, err := decodeWorkContextToken(token.encoded) - if err != nil { - return sealedContext{}, err - } - probe := struct { - KeyID string `json:"key_id"` - }{} - if decodeErr := json.Unmarshal(payload, &probe); decodeErr != nil { - return sealedContext{}, fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, decodeErr) - } - publicKey, ok := v.publicKeys[probe.KeyID] - if !ok { - return sealedContext{}, fmt.Errorf("%w: unknown key id %q", ErrWorkContextInvalid, probe.KeyID) - } - // ed25519.Verify panics on a key that is not PublicKeySize bytes. The key - // is selected by the untrusted token's key id, so one malformed entry would - // turn every token naming it into a crash of this process, on demand for - // anyone who learns that key id. NewWorkContextVerifier rejects such a key, - // which is what makes this a belt rather than the only guard. - if len(publicKey) != ed25519.PublicKeySize { - return sealedContext{}, fmt.Errorf( - "%w: verification key %q is %d bytes, not %d", - ErrWorkContextInvalid, probe.KeyID, len(publicKey), ed25519.PublicKeySize, - ) - } - if !ed25519.Verify(publicKey, payload, signature) { - return sealedContext{}, fmt.Errorf("%w: signature verification failed", ErrWorkContextInvalid) - } - sealed, err := unmarshalWorkContext(payload) - if err != nil { - return sealedContext{}, err - } - if err := validateSealedContext(sealed); err != nil { - return sealedContext{}, err - } - if err := v.validateTime(sealed.context); err != nil { - return sealedContext{}, err - } - return sealed, nil -} - -func (v *WorkContextVerifier) validateTime(context *basev0.WorkContextV1) error { - now := v.now().UTC() - notBefore := time.Unix(context.NotBeforeUnix, 0) - issuedAt := time.Unix(context.IssuedAtUnix, 0) - expiresAt := time.Unix(context.ExpiresAtUnix, 0) - if now.Before(notBefore.Add(-v.clockSkew)) { - return fmt.Errorf("%w: token is not active yet", ErrWorkContextInvalid) - } - if issuedAt.After(now.Add(v.clockSkew)) { - return fmt.Errorf("%w: token was issued in the future", ErrWorkContextInvalid) - } - if now.After(expiresAt.Add(v.clockSkew)) { - return fmt.Errorf("%w: token expired", ErrWorkContextInvalid) - } - return nil -} - -func matchWorkContext(sealed sealedContext, expected WorkContextExpectations) error { - context := sealed.context - checks := []struct { - name string - got string - want string - }{ - {"issuer", context.Issuer, expected.Issuer}, - {"audience", context.Audience, expected.Audience}, - {"tenant", context.TenantId, expected.TenantID}, - {"owner", context.OwnerPrincipalId, expected.OwnerPrincipalID}, - {"owner kind", context.GetOwnerPrincipalKind(), expected.OwnerPrincipalKind}, - {"task", context.TaskId, expected.TaskID}, - {"session", context.SessionId, expected.SessionID}, - } - for _, check := range checks { - if check.want != "" && check.got != check.want { - return fmt.Errorf("%w: %s mismatch", ErrWorkContextInvalid, check.name) - } - } - if expected.ParentSessionID != nil && context.GetParentSessionId() != *expected.ParentSessionID { - return fmt.Errorf("%w: parent session mismatch", ErrWorkContextInvalid) - } - if expected.AuthorizationRevision != nil && context.AuthorizationRevision != *expected.AuthorizationRevision { - return fmt.Errorf("%w: authorization revision mismatch", ErrWorkContextInvalid) - } - if err := matchDelegation(context, expected.Delegation); err != nil { - return err - } - if err := matchSeal(sealed.seal, expected.Seal); err != nil { - return err - } - return matchOperationBinding(sealed.binding, expected.OperationBinding) -} - -// matchDelegation checks the calling principal. The owner was already checked -// above; this is the separate check, and the two are not interchangeable — a -// credential whose owner is the one this verifier expects may still be -// presented by a delegate it does not. -func matchDelegation(context *basev0.WorkContextV1, expected *DelegationExpectations) error { - actors := context.GetActorChain() - if expected == nil { - if len(actors) > 0 { - return fmt.Errorf( - "%w: credential is delegated through %d actor(s) where only a direct owner call is accepted", - ErrWorkContextInvalid, len(actors), - ) - } - return nil - } - if len(actors) == 0 { - return fmt.Errorf("%w: a delegated credential was expected and this one carries no actor", ErrWorkContextInvalid) - } - caller := actors[len(actors)-1] - for _, check := range []struct { - name string - got string - want string - }{ - {"caller", caller.GetPrincipalId(), expected.PrincipalID}, - {"caller kind", caller.GetPrincipalKind(), expected.PrincipalKind}, - {"delegation", caller.GetDelegationId(), expected.DelegationID}, - } { - if check.want != "" && check.got != check.want { - return fmt.Errorf("%w: %s mismatch", ErrWorkContextInvalid, check.name) - } - } - return nil -} - -// The token payload uses one fixed snake_case JSON layout. authorization_revision -// is a decimal string so Go and JavaScript preserve the complete uint64 domain. -type workContextPayload struct { - Typ string `json:"typ"` - Algorithm string `json:"algorithm"` - KeyID string `json:"key_id"` - Issuer string `json:"issuer"` - Audience string `json:"audience"` - NotBeforeUnix int64 `json:"not_before_unix"` - IssuedAtUnix int64 `json:"issued_at_unix"` - ExpiresAtUnix int64 `json:"expires_at_unix"` - Nonce string `json:"nonce"` - AuthorizationRevision string `json:"authorization_revision"` - ReplayPolicy string `json:"replay_policy"` - TenantID string `json:"tenant_id"` - OwnerPrincipalID string `json:"owner_principal_id"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - ParentSessionID *string `json:"parent_session_id,omitempty"` - AuthorityScopes []workContextScope `json:"authority_scopes"` - ActorChain []workContextActor `json:"actor_chain"` - AttributionTeamIDs []string `json:"attribution_team_ids"` - WorkspaceID *string `json:"workspace_id,omitempty"` - ProjectID *string `json:"project_id,omitempty"` - OwnerPrincipalKind string `json:"owner_principal_kind"` - OwnerAgentID *string `json:"owner_agent_id,omitempty"` - - // The seal. These are not claims the owner delegated; they are the binding - // between this credential and the one execution it was issued to, and - // every one of them is required. - PrincipalEpoch string `json:"owner_principal_epoch"` - InstallationID string `json:"installation_id"` - InstallationRevision string `json:"installation_revision"` - BuildIncarnation string `json:"build_incarnation"` - - // The operation binding. Present together or absent together: a partial - // binding would resolve to a binding identity the verifier never approved. - OperationBindingID *string `json:"operation_binding_id,omitempty"` - OperationBindingRevision *string `json:"operation_binding_revision,omitempty"` - OperationBindingIncarnation *string `json:"operation_binding_incarnation,omitempty"` -} - -type workContextScope struct { - ResourceKind string `json:"resource_kind"` - Actions []string `json:"actions"` - ResourceIDs []string `json:"resource_ids"` -} - -type workContextActor struct { - PrincipalID string `json:"principal_id"` - PrincipalKind string `json:"principal_kind"` - DelegationID string `json:"delegation_id"` - GrantedScopes []workContextScope `json:"granted_scopes"` -} - -// sealedContext is the complete signed content of one credential: the -// delegation claims, the seal binding them to a single execution, and the -// operation binding when the credential is an operation context. -// -// Nothing constructs one without a seal. The seal is not an addition to a Work -// Context that may be left out — it is the half that says which process, build -// and installation the other half was issued to, and a credential carrying -// only the other half is a bearer token for anything that holds it. -type sealedContext struct { - context *basev0.WorkContextV1 - seal Seal - binding *OperationBinding -} - -func (c sealedContext) clone() sealedContext { - cloned := sealedContext{context: cloneContext(c.context), seal: c.seal} - if c.binding != nil { - binding := *c.binding - cloned.binding = &binding - } - return cloned -} - -func marshalWorkContext(context *basev0.WorkContextV1, seal Seal, binding *OperationBinding) ([]byte, error) { - payload := payloadFromContext(context, seal, binding) - encoded, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("%w: encode payload: %v", ErrWorkContextInvalid, err) - } - return encoded, nil -} - -func unmarshalWorkContext(encoded []byte) (sealedContext, error) { - var payload workContextPayload - decoder := json.NewDecoder(bytes.NewReader(encoded)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&payload); err != nil { - return sealedContext{}, fmt.Errorf("%w: decode payload: %v", ErrWorkContextInvalid, err) - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - if err == nil { - return sealedContext{}, fmt.Errorf("%w: trailing JSON value", ErrWorkContextInvalid) - } - return sealedContext{}, fmt.Errorf("%w: trailing JSON: %v", ErrWorkContextInvalid, err) - } - revision, err := strconv.ParseUint(payload.AuthorizationRevision, 10, 64) - if err != nil { - return sealedContext{}, fmt.Errorf("%w: authorization_revision must be uint64 decimal", ErrWorkContextInvalid) - } - seal, binding, err := sealFromPayload(payload) - if err != nil { - return sealedContext{}, err - } - return sealedContext{ - context: contextFromPayload(payload, revision), - seal: seal, - binding: binding, - }, nil -} - -// sealFromPayload reads the seal off the wire. A credential whose seal cannot -// be read is rejected here rather than treated as unsealed: "no seal" and -// "a seal I could not parse" must reach the same refusal, because the second -// is what an attacker produces when trying to reach the first. -func sealFromPayload(payload workContextPayload) (Seal, *OperationBinding, error) { - epoch, err := parseDecimal("owner_principal_epoch", payload.PrincipalEpoch) - if err != nil { - return Seal{}, nil, err - } - installationRevision, err := parseDecimal("installation_revision", payload.InstallationRevision) - if err != nil { - return Seal{}, nil, err - } - seal := Seal{ - PrincipalEpoch: epoch, - InstallationID: payload.InstallationID, - InstallationRevision: installationRevision, - BuildIncarnation: payload.BuildIncarnation, - } - present := 0 - for _, field := range []*string{ - payload.OperationBindingID, - payload.OperationBindingRevision, - payload.OperationBindingIncarnation, - } { - if field != nil { - present++ - } - } - switch present { - case 0: - return seal, nil, nil - case 3: - default: - return Seal{}, nil, fmt.Errorf( - "%w: an operation binding needs its id, revision and incarnation together", - ErrWorkContextInvalid, - ) - } - bindingRevision, err := parseDecimal("operation_binding_revision", *payload.OperationBindingRevision) - if err != nil { - return Seal{}, nil, err - } - return seal, &OperationBinding{ - BindingID: *payload.OperationBindingID, - BindingRevision: bindingRevision, - BindingIncarnation: *payload.OperationBindingIncarnation, - }, nil -} - -func payloadFromContext(context *basev0.WorkContextV1, seal Seal, binding *OperationBinding) workContextPayload { - payload := workContextPayload{ - Typ: context.Typ, - Algorithm: context.Algorithm, - KeyID: context.KeyId, - Issuer: context.Issuer, - Audience: context.Audience, - NotBeforeUnix: context.NotBeforeUnix, - IssuedAtUnix: context.IssuedAtUnix, - ExpiresAtUnix: context.ExpiresAtUnix, - Nonce: context.Nonce, - AuthorizationRevision: strconv.FormatUint(context.AuthorizationRevision, 10), - ReplayPolicy: context.ReplayPolicy, - TenantID: context.TenantId, - OwnerPrincipalID: context.OwnerPrincipalId, - TaskID: context.TaskId, - SessionID: context.SessionId, - ParentSessionID: cloneStringPointer(context.ParentSessionId), - AuthorityScopes: payloadScopes(context.AuthorityScopes), - ActorChain: payloadActors(context.ActorChain), - AttributionTeamIDs: append([]string{}, context.AttributionTeamIds...), - WorkspaceID: cloneStringPointer(context.WorkspaceId), - ProjectID: cloneStringPointer(context.ProjectId), - OwnerPrincipalKind: context.GetOwnerPrincipalKind(), - OwnerAgentID: cloneStringPointer(context.OwnerAgentId), - PrincipalEpoch: decimal(seal.PrincipalEpoch), - InstallationID: seal.InstallationID, - InstallationRevision: decimal(seal.InstallationRevision), - BuildIncarnation: seal.BuildIncarnation, - } - if binding != nil { - payload.OperationBindingID = stringPointer(binding.BindingID) - payload.OperationBindingRevision = stringPointer(decimal(binding.BindingRevision)) - payload.OperationBindingIncarnation = stringPointer(binding.BindingIncarnation) - } - return payload -} - -func payloadScopes(scopes []*basev0.WorkScopeV1) []workContextScope { - out := make([]workContextScope, 0, len(scopes)) - for _, scope := range scopes { - if scope == nil { - out = append(out, workContextScope{}) - continue - } - out = append(out, workContextScope{ - ResourceKind: scope.ResourceKind, - Actions: append([]string{}, scope.Actions...), - ResourceIDs: append([]string{}, scope.ResourceIds...), - }) - } - return out -} - -func payloadActors(actors []*basev0.WorkActorV1) []workContextActor { - out := make([]workContextActor, 0, len(actors)) - for _, actor := range actors { - if actor == nil { - out = append(out, workContextActor{}) - continue - } - out = append(out, workContextActor{ - PrincipalID: actor.PrincipalId, - PrincipalKind: actor.PrincipalKind, - DelegationID: actor.DelegationId, - GrantedScopes: payloadScopes(actor.GrantedScopes), - }) - } - return out -} - -func contextFromPayload(payload workContextPayload, revision uint64) *basev0.WorkContextV1 { - context := &basev0.WorkContextV1{ - Typ: payload.Typ, - Algorithm: payload.Algorithm, - KeyId: payload.KeyID, - Issuer: payload.Issuer, - Audience: payload.Audience, - NotBeforeUnix: payload.NotBeforeUnix, - IssuedAtUnix: payload.IssuedAtUnix, - ExpiresAtUnix: payload.ExpiresAtUnix, - Nonce: payload.Nonce, - AuthorizationRevision: revision, - ReplayPolicy: payload.ReplayPolicy, - TenantId: payload.TenantID, - OwnerPrincipalId: payload.OwnerPrincipalID, - TaskId: payload.TaskID, - SessionId: payload.SessionID, - ParentSessionId: cloneStringPointer(payload.ParentSessionID), - AuthorityScopes: contextScopes(payload.AuthorityScopes), - ActorChain: contextActors(payload.ActorChain), - AttributionTeamIds: append([]string(nil), payload.AttributionTeamIDs...), - WorkspaceId: cloneStringPointer(payload.WorkspaceID), - ProjectId: cloneStringPointer(payload.ProjectID), - OwnerPrincipalKind: stringPointer(payload.OwnerPrincipalKind), - OwnerAgentId: cloneStringPointer(payload.OwnerAgentID), - } - return context -} - -func contextScopes(scopes []workContextScope) []*basev0.WorkScopeV1 { - out := make([]*basev0.WorkScopeV1, 0, len(scopes)) - for _, scope := range scopes { - out = append(out, &basev0.WorkScopeV1{ - ResourceKind: scope.ResourceKind, - Actions: append([]string(nil), scope.Actions...), - ResourceIds: append([]string(nil), scope.ResourceIDs...), - }) - } - return out -} - -func contextActors(actors []workContextActor) []*basev0.WorkActorV1 { - out := make([]*basev0.WorkActorV1, 0, len(actors)) - for _, actor := range actors { - out = append(out, &basev0.WorkActorV1{ - PrincipalId: actor.PrincipalID, - PrincipalKind: actor.PrincipalKind, - DelegationId: actor.DelegationID, - GrantedScopes: contextScopes(actor.GrantedScopes), - }) - } - return out -} - -// PrincipalKindAgent is the owner kind that requires an agent manifest -// identity beside the principal id. -const PrincipalKindAgent = "agent" - -// validateSealedContext is the whole structural rule for a credential: the -// delegation claims, the seal, and the operation binding when there is one. -// Signing and verification both go through it, so a credential this module -// would refuse is never one it hands out. -func validateSealedContext(sealed sealedContext) error { - if err := validateWorkContext(sealed.context); err != nil { - return err - } - if err := sealed.seal.validate(); err != nil { - return err - } - if sealed.binding != nil { - if err := sealed.binding.validate(); err != nil { - return err - } - } - return nil -} - -func validateWorkContext(context *basev0.WorkContextV1) error { - if context == nil { - return fmt.Errorf("%w: nil claims", ErrWorkContextInvalid) - } - if context.Typ != WorkContextType { - return fmt.Errorf("%w: unsupported typ %q", ErrWorkContextInvalid, context.Typ) - } - if context.Algorithm != WorkContextAlgorithm { - return fmt.Errorf("%w: unsupported algorithm %q", ErrWorkContextInvalid, context.Algorithm) - } - fields := []struct { - name string - value string - max int - }{ - {"key_id", context.KeyId, workContextMaxKindBytes}, - {"issuer", context.Issuer, workContextMaxIDBytes}, - {"audience", context.Audience, workContextMaxIDBytes}, - {"nonce", context.Nonce, workContextMaxIDBytes}, - {"tenant_id", context.TenantId, workContextMaxIDBytes}, - {"owner_principal_id", context.OwnerPrincipalId, workContextMaxIDBytes}, - {"task_id", context.TaskId, workContextMaxIDBytes}, - {"session_id", context.SessionId, workContextMaxIDBytes}, - } - for _, field := range fields { - if err := validateBounded(field.name, field.value, field.max, true); err != nil { - return err - } - } - // These proto fields are optional, but each declares min_len=1: absent is - // allowed, present-but-empty is not. min_len is a length rule, so the guard - // is emptiness, not whitespace-trimming — a proto-generated verifier in - // another language accepts a one-character value, and this one must agree on - // the same wire bytes. - for name, value := range map[string]*string{ - "parent_session_id": context.ParentSessionId, - "workspace_id": context.WorkspaceId, - "project_id": context.ProjectId, - } { - if value == nil { - continue - } - if *value == "" { - return fmt.Errorf("%w: %s must not be empty when present", ErrWorkContextInvalid, name) - } - if err := validateBounded(name, *value, workContextMaxIDBytes, false); err != nil { - return err - } - } - if context.ParentSessionId != nil && context.GetParentSessionId() == context.SessionId { - return fmt.Errorf("%w: parent session equals session", ErrWorkContextInvalid) - } - if err := validateBounded( - "owner_principal_kind", - context.GetOwnerPrincipalKind(), - workContextMaxKindBytes, - true, - ); err != nil { - return err - } - // The agent manifest identity and the agent kind are one fact stated twice. - // Allowing either without the other admits a credential that claims to be - // an agent nothing can name, or names an agent while presenting as a user. - switch { - case context.GetOwnerPrincipalKind() == PrincipalKindAgent: - if err := validateBounded("owner_agent_id", context.GetOwnerAgentId(), workContextMaxIDBytes, true); err != nil { - return err - } - case context.OwnerAgentId != nil: - return fmt.Errorf( - "%w: owner_agent_id is set on a %q owner", - ErrWorkContextInvalid, context.GetOwnerPrincipalKind(), - ) - } - if context.ReplayPolicy != WorkContextReplayIdempotent && context.ReplayPolicy != WorkContextReplaySingleUse { - return fmt.Errorf("%w: unsupported replay policy %q", ErrWorkContextInvalid, context.ReplayPolicy) - } - if context.NotBeforeUnix > context.ExpiresAtUnix { - return fmt.Errorf("%w: not-before is after expiry", ErrWorkContextInvalid) - } - if context.IssuedAtUnix > context.ExpiresAtUnix { - return fmt.Errorf("%w: issued-at is after expiry", ErrWorkContextInvalid) - } - if ttl := time.Duration(context.ExpiresAtUnix-context.IssuedAtUnix) * time.Second; ttl <= 0 || ttl > WorkContextMaxTTL { - return fmt.Errorf("%w: lifetime must be positive and at most %s", ErrWorkContextInvalid, WorkContextMaxTTL) - } - if len(context.ActorChain) > WorkContextMaxActorDepth { - return fmt.Errorf("%w: actor chain exceeds depth %d", ErrWorkContextInvalid, WorkContextMaxActorDepth) - } - if len(context.AttributionTeamIds) > workContextMaxScopeEntries { - return fmt.Errorf("%w: too many attribution teams", ErrWorkContextInvalid) - } - if err := validateSortedUniqueStrings("attribution_team_ids", context.AttributionTeamIds, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateScopes("authority_scopes", context.AuthorityScopes); err != nil { - return err - } - previous := context.AuthorityScopes - for index, actor := range context.ActorChain { - if actor == nil { - return fmt.Errorf("%w: actor_chain[%d] is nil", ErrWorkContextInvalid, index) - } - if err := validateBounded("actor principal_id", actor.PrincipalId, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateBounded("actor principal_kind", actor.PrincipalKind, workContextMaxKindBytes, true); err != nil { - return err - } - if err := validateBounded("actor delegation_id", actor.DelegationId, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateScopes(fmt.Sprintf("actor_chain[%d].granted_scopes", index), actor.GrantedScopes); err != nil { - return err - } - if !scopesAttenuate(previous, actor.GrantedScopes) { - return fmt.Errorf("%w: actor_chain[%d] widens authority", ErrWorkContextInvalid, index) - } - previous = actor.GrantedScopes - } - return nil -} - -func validateScopes(name string, scopes []*basev0.WorkScopeV1) error { - if len(scopes) > workContextMaxScopes { - return fmt.Errorf("%w: %s exceeds %d scopes", ErrWorkContextInvalid, name, workContextMaxScopes) - } - previousKind := "" - for index, scope := range scopes { - if scope == nil { - return fmt.Errorf("%w: %s[%d] is nil", ErrWorkContextInvalid, name, index) - } - if err := validateBounded(name+" resource_kind", scope.ResourceKind, workContextMaxKindBytes, true); err != nil { - return err - } - if previousKind >= scope.ResourceKind { - return fmt.Errorf("%w: %s resource kinds must be sorted and unique", ErrWorkContextInvalid, name) - } - previousKind = scope.ResourceKind - if len(scope.Actions) == 0 || len(scope.Actions) > workContextMaxScopeEntries { - return fmt.Errorf("%w: %s[%d] actions must contain 1..%d entries", ErrWorkContextInvalid, name, index, workContextMaxScopeEntries) - } - if err := validateSortedUniqueStrings(name+" actions", scope.Actions, workContextMaxKindBytes, true); err != nil { - return err - } - if len(scope.ResourceIds) > workContextMaxScopeEntries { - return fmt.Errorf("%w: %s[%d] has too many resource IDs", ErrWorkContextInvalid, name, index) - } - if err := validateSortedUniqueStrings(name+" resource_ids", scope.ResourceIds, workContextMaxIDBytes, true); err != nil { - return err - } - } - return nil -} - -func scopesAttenuate(parent, child []*basev0.WorkScopeV1) bool { - parentByKind := make(map[string]*basev0.WorkScopeV1, len(parent)) - for _, scope := range parent { - parentByKind[scope.ResourceKind] = scope - } - for _, scope := range child { - ancestor, ok := parentByKind[scope.ResourceKind] - if !ok || !stringSubset(scope.Actions, ancestor.Actions) { - return false - } - if len(ancestor.ResourceIds) > 0 { - // Empty means wildcard, so an explicit parent set may only be - // narrowed to another non-empty subset. - if len(scope.ResourceIds) == 0 || !stringSubset(scope.ResourceIds, ancestor.ResourceIds) { - return false - } - } - } - return true -} - -func stringSubset(child, parent []string) bool { - parentSet := make(map[string]struct{}, len(parent)) - for _, value := range parent { - parentSet[value] = struct{}{} - } - for _, value := range child { - if _, ok := parentSet[value]; !ok { - return false - } - } - return true -} - -func sortedStringsContain(values []string, wanted string) bool { - index := sort.SearchStrings(values, wanted) - return index < len(values) && values[index] == wanted -} - -func canonicalizeWorkContext(context *basev0.WorkContextV1) { - context.AttributionTeamIds = sortedUnique(context.AttributionTeamIds) - canonicalizeScopes(context.AuthorityScopes) - for _, actor := range context.ActorChain { - if actor != nil { - canonicalizeScopes(actor.GrantedScopes) - } - } -} - -func canonicalizeScopes(scopes []*basev0.WorkScopeV1) { - for _, scope := range scopes { - if scope == nil { - continue - } - scope.Actions = sortedUnique(scope.Actions) - scope.ResourceIds = sortedUnique(scope.ResourceIds) - } - sort.SliceStable(scopes, func(i, j int) bool { - if scopes[i] == nil { - return scopes[j] != nil - } - if scopes[j] == nil { - return false - } - return scopes[i].ResourceKind < scopes[j].ResourceKind - }) -} - -func sortedUnique(values []string) []string { - out := append([]string(nil), values...) - sort.Strings(out) - if len(out) < 2 { - return out - } - write := 1 - for read := 1; read < len(out); read++ { - if out[read] == out[write-1] { - continue - } - out[write] = out[read] - write++ - } - return out[:write] -} - -func validateSortedUniqueStrings(name string, values []string, max int, nonEmpty bool) error { - previous := "" - for index, value := range values { - if err := validateBounded(name, value, max, nonEmpty); err != nil { - return err - } - if index > 0 && previous >= value { - return fmt.Errorf("%w: %s must be sorted and unique", ErrWorkContextInvalid, name) - } - previous = value - } - return nil -} - -func validateBounded(name, value string, max int, required bool) error { - if required && strings.TrimSpace(value) == "" { - return fmt.Errorf("%w: %s is required", ErrWorkContextInvalid, name) - } - if len(value) > max { - return fmt.Errorf("%w: %s exceeds %d bytes", ErrWorkContextInvalid, name, max) - } - return nil -} - -func validateTokenShape(encoded string) error { - if encoded == "" { - return fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - if len(encoded) > WorkContextMaxTokenBytes { - return fmt.Errorf("%w: token exceeds %d bytes", ErrWorkContextInvalid, WorkContextMaxTokenBytes) - } - if strings.Count(encoded, ".") != 1 { - return fmt.Errorf("%w: token must have exactly two segments", ErrWorkContextInvalid) - } - return nil -} - -func decodeWorkContextToken(encoded string) ([]byte, []byte, error) { - if err := validateTokenShape(encoded); err != nil { - return nil, nil, err - } - segments := strings.SplitN(encoded, ".", 2) - payload, err := base64.RawURLEncoding.DecodeString(segments[0]) - if err != nil { - return nil, nil, fmt.Errorf("%w: payload base64: %v", ErrWorkContextInvalid, err) - } - if base64.RawURLEncoding.EncodeToString(payload) != segments[0] { - return nil, nil, fmt.Errorf("%w: payload is not canonical base64url", ErrWorkContextInvalid) - } - signature, err := base64.RawURLEncoding.DecodeString(segments[1]) - if err != nil { - return nil, nil, fmt.Errorf("%w: signature base64: %v", ErrWorkContextInvalid, err) - } - if base64.RawURLEncoding.EncodeToString(signature) != segments[1] { - return nil, nil, fmt.Errorf("%w: signature is not canonical base64url", ErrWorkContextInvalid) - } - if len(signature) != ed25519.SignatureSize { - return nil, nil, fmt.Errorf("%w: signature must be %d bytes", ErrWorkContextInvalid, ed25519.SignatureSize) - } - return payload, signature, nil -} - -func randomWorkContextNonce() (string, error) { - var raw [16]byte - if _, err := rand.Read(raw[:]); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(raw[:]), nil -} - -func cloneContext(context *basev0.WorkContextV1) *basev0.WorkContextV1 { - if context == nil { - return nil - } - return &basev0.WorkContextV1{ - Typ: context.Typ, - Algorithm: context.Algorithm, - KeyId: context.KeyId, - Issuer: context.Issuer, - Audience: context.Audience, - NotBeforeUnix: context.NotBeforeUnix, - IssuedAtUnix: context.IssuedAtUnix, - ExpiresAtUnix: context.ExpiresAtUnix, - Nonce: context.Nonce, - AuthorizationRevision: context.AuthorizationRevision, - ReplayPolicy: context.ReplayPolicy, - TenantId: context.TenantId, - OwnerPrincipalId: context.OwnerPrincipalId, - TaskId: context.TaskId, - SessionId: context.SessionId, - ParentSessionId: cloneStringPointer(context.ParentSessionId), - AuthorityScopes: cloneScopes(context.AuthorityScopes), - ActorChain: cloneActors(context.ActorChain), - AttributionTeamIds: append([]string(nil), context.AttributionTeamIds...), - WorkspaceId: cloneStringPointer(context.WorkspaceId), - ProjectId: cloneStringPointer(context.ProjectId), - OwnerPrincipalKind: cloneStringPointer(context.OwnerPrincipalKind), - OwnerAgentId: cloneStringPointer(context.OwnerAgentId), - } -} - -func cloneScopes(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - out := make([]*basev0.WorkScopeV1, 0, len(scopes)) - for _, scope := range scopes { - if scope == nil { - out = append(out, nil) - continue - } - out = append(out, &basev0.WorkScopeV1{ - ResourceKind: scope.ResourceKind, - Actions: append([]string(nil), scope.Actions...), - ResourceIds: append([]string(nil), scope.ResourceIds...), - }) - } - return out -} - -func cloneActor(actor *basev0.WorkActorV1) *basev0.WorkActorV1 { - if actor == nil { - return nil - } - return &basev0.WorkActorV1{ - PrincipalId: actor.PrincipalId, - PrincipalKind: actor.PrincipalKind, - DelegationId: actor.DelegationId, - GrantedScopes: cloneScopes(actor.GrantedScopes), - } -} - -func cloneActors(actors []*basev0.WorkActorV1) []*basev0.WorkActorV1 { - out := make([]*basev0.WorkActorV1, 0, len(actors)) - for _, actor := range actors { - out = append(out, cloneActor(actor)) - } - return out -} - -func stringPointer(value string) *string { - return &value -} - -func cloneStringPointer(value *string) *string { - if value == nil { - return nil - } - copy := *value - return © -} diff --git a/workcontext/work_context_jwks.go b/workcontext/work_context_jwks.go deleted file mode 100644 index 222cc75..0000000 --- a/workcontext/work_context_jwks.go +++ /dev/null @@ -1,396 +0,0 @@ -package workcontext - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -const ( - defaultWorkContextJWKSCacheTTL = 5 * time.Minute - defaultWorkContextJWKSRequestTimeout = 2 * time.Second - maxWorkContextJWKSCacheTTL = 24 * time.Hour - maxWorkContextJWKSRequestTimeout = 30 * time.Second - maxWorkContextJWKSBytes = 256 * 1024 - maxWorkContextJWKSKeys = 64 -) - -// WorkContextJWKSVerifierOptions configures rotation-aware public Work Context -// verification. The verifier caches only public keys and never stores bearer -// tokens or private material. -type WorkContextJWKSVerifierOptions struct { - URL string - HTTPClient *http.Client - CacheTTL time.Duration - RequestTimeout time.Duration - Now func() time.Time - ClockSkew time.Duration -} - -// WorkContextJWKSVerifier verifies signed Work Contexts against a bounded, -// cached Ed25519 JWKS. An unknown key ID forces one generation-aware refresh, -// so rotation is picked up immediately without a request stampede. -type WorkContextJWKSVerifier struct { - mu sync.Mutex - url string - httpClient *http.Client - cacheTTL time.Duration - requestTimeout time.Duration - now func() time.Time - clockSkew time.Duration - verifier *WorkContextVerifier - keyIDs map[string]struct{} - expiresAt time.Time - generation uint64 - unknownRefreshGeneration uint64 - outageRefreshGeneration uint64 -} - -// NewWorkContextJWKSVerifier validates configuration without performing -// network I/O. The first Verify call fetches the JWKS. -func NewWorkContextJWKSVerifier( - options WorkContextJWKSVerifierOptions, -) (*WorkContextJWKSVerifier, error) { - endpoint, err := validateWorkContextJWKSURL(options.URL) - if err != nil { - return nil, err - } - cacheTTL := options.CacheTTL - if cacheTTL == 0 { - cacheTTL = defaultWorkContextJWKSCacheTTL - } - if cacheTTL < time.Second || cacheTTL > maxWorkContextJWKSCacheTTL { - return nil, fmt.Errorf( - "%w: JWKS cache TTL must be between 1s and %s", - ErrWorkContextInvalid, - maxWorkContextJWKSCacheTTL, - ) - } - requestTimeout := options.RequestTimeout - if requestTimeout == 0 { - requestTimeout = defaultWorkContextJWKSRequestTimeout - } - if requestTimeout < time.Millisecond || requestTimeout > maxWorkContextJWKSRequestTimeout { - return nil, fmt.Errorf( - "%w: JWKS request timeout must be between 1ms and %s", - ErrWorkContextInvalid, - maxWorkContextJWKSRequestTimeout, - ) - } - now := options.Now - if now == nil { - now = time.Now - } - client := options.HTTPClient - if client == nil { - client = &http.Client{ - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } - return &WorkContextJWKSVerifier{ - url: endpoint, httpClient: client, cacheTTL: cacheTTL, - requestTimeout: requestTimeout, now: now, clockSkew: options.ClockSkew, - }, nil -} - -// verifierFor returns the key-set verifier that can check this token, forcing -// at most one generation-aware refresh when the token names a key the cache -// does not hold. Verification itself is VerifyWorkContext's, so the sealed -// checks cannot be reached by a path that skips them. -func (v *WorkContextJWKSVerifier) verifierFor( - ctx context.Context, - token WorkContextToken, -) (*WorkContextVerifier, error) { - if v == nil { - return nil, fmt.Errorf("%w: nil JWKS verifier", ErrWorkContextInvalid) - } - if ctx == nil { - return nil, fmt.Errorf("%w: nil context", ErrWorkContextInvalid) - } - keyID, err := workContextTokenKeyID(token) - if err != nil { - return nil, err - } - verifier, keyIDs, generation, err := v.current(ctx) - if err != nil { - return nil, err - } - if _, known := keyIDs[keyID]; known { - return verifier, nil - } - - // A different caller may already have refreshed this generation. Passing - // the observed generation lets that refresh satisfy every waiter. - verifier, _, _, err = v.refreshUnknown(ctx, generation) - if err != nil { - return nil, err - } - return verifier, nil -} - -// Refresh fetches and installs the key set immediately, warming the cache so a -// service can fail closed at boot: call it during startup and treat a non-nil -// error as fatal rather than letting the first requests race the initial fetch. -// -// Each call always performs a fetch and is not deduplicated: it forces a live -// fetch so the caller can prove the endpoint is reachable now. Concurrent calls -// serialize on the same lock and each fetch independently, so call it once at -// boot rather than fanning it out across goroutines. -func (v *WorkContextJWKSVerifier) Refresh(ctx context.Context) error { - if v == nil { - return fmt.Errorf("%w: nil JWKS verifier", ErrWorkContextInvalid) - } - if ctx == nil { - return fmt.Errorf("%w: nil context", ErrWorkContextInvalid) - } - v.mu.Lock() - defer v.mu.Unlock() - _, _, _, err := v.refreshLocked(ctx) - if err == nil { - // A successful full refresh starts a fresh generation in which one - // unknown key may force an early refresh for normal key rotation. - v.unknownRefreshGeneration = 0 - } - return err -} - -func (v *WorkContextJWKSVerifier) current( - ctx context.Context, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - v.mu.Lock() - defer v.mu.Unlock() - if v.verifier != nil && v.now().UTC().Before(v.expiresAt) { - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - verifier, keyIDs, generation, err := v.refreshLocked(ctx) - if err == nil { - // A scheduled cache refresh starts a fresh generation in which one - // unknown key may force an early refresh for normal key rotation. - v.unknownRefreshGeneration = 0 - } - return verifier, keyIDs, generation, err -} - -func (v *WorkContextJWKSVerifier) refreshUnknown( - ctx context.Context, - observedGeneration uint64, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - v.mu.Lock() - defer v.mu.Unlock() - if v.verifier != nil && v.generation != observedGeneration { - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - if v.verifier != nil && v.unknownRefreshGeneration == v.generation { - // The single refresh this generation allows was already spent. If it - // failed because the key set was unreachable, the key still can't be - // obtained, so this stays an outage rather than degrading to an - // invalid-token rejection against the stale cache. - if v.outageRefreshGeneration == v.generation { - return nil, nil, v.generation, fmt.Errorf( - "%w: Work Context JWKS unreachable during key rotation", - ErrWorkContextUnavailable, - ) - } - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - - // Reserve this generation before network I/O. A failed rotation refresh - // must not let attacker-controlled key IDs turn verification into an - // unbounded JWKS request loop. The normal TTL refresh opens a new window. - v.unknownRefreshGeneration = v.generation - verifier, keyIDs, generation, err := v.refreshLocked(ctx) - if err != nil { - if errors.Is(err, ErrWorkContextUnavailable) { - v.outageRefreshGeneration = v.generation - } - return nil, nil, generation, err - } - v.unknownRefreshGeneration = generation - return verifier, keyIDs, generation, nil -} - -func (v *WorkContextJWKSVerifier) refreshLocked( - ctx context.Context, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - keys, err := v.fetch(ctx) - if err != nil { - return nil, nil, v.generation, err - } - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: keys, - Now: v.now, - ClockSkew: v.clockSkew, - }) - if err != nil { - return nil, nil, v.generation, err - } - keyIDs := make(map[string]struct{}, len(keys)) - for keyID := range keys { - keyIDs[keyID] = struct{}{} - } - if v.generation == ^uint64(0) { - return nil, nil, v.generation, fmt.Errorf("%w: JWKS generation exhausted", ErrWorkContextInvalid) - } - v.verifier = verifier - v.keyIDs = keyIDs - v.expiresAt = v.now().UTC().Add(v.cacheTTL) - v.generation++ - return verifier, cloneKeyIDs(keyIDs), v.generation, nil -} - -func (v *WorkContextJWKSVerifier) fetch(ctx context.Context) (map[string]ed25519.PublicKey, error) { - requestContext, cancel := context.WithTimeout(ctx, v.requestTimeout) - defer cancel() - request, err := http.NewRequestWithContext(requestContext, http.MethodGet, v.url, nil) - if err != nil { - return nil, fmt.Errorf("%w: create JWKS request: %v", ErrWorkContextInvalid, err) - } - request.Header.Set("Accept", "application/json") - response, err := v.httpClient.Do(request) - if err != nil { - return nil, fmt.Errorf("%w: fetch Work Context JWKS: %v", ErrWorkContextUnavailable, err) - } - defer func() { - _ = response.Body.Close() - }() - if response.Request != nil && response.Request.URL != nil && - response.Request.URL.String() != v.url { - return nil, fmt.Errorf("%w: Work Context JWKS redirected", ErrWorkContextInvalid) - } - if response.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) - sentinel := ErrWorkContextInvalid - if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { - sentinel = ErrWorkContextUnavailable - } - return nil, fmt.Errorf( - "%w: Work Context JWKS returned HTTP %d", - sentinel, - response.StatusCode, - ) - } - if contentType := response.Header.Get("Content-Type"); contentType != "" { - mediaType, _, parseErr := mime.ParseMediaType(contentType) - if parseErr != nil || mediaType != "application/json" { - return nil, fmt.Errorf("%w: Work Context JWKS is not application/json", ErrWorkContextInvalid) - } - } - payload, err := io.ReadAll(io.LimitReader(response.Body, maxWorkContextJWKSBytes+1)) - if err != nil { - return nil, fmt.Errorf("%w: read Work Context JWKS: %v", ErrWorkContextInvalid, err) - } - if len(payload) > maxWorkContextJWKSBytes { - return nil, fmt.Errorf( - "%w: Work Context JWKS exceeds %d bytes", - ErrWorkContextInvalid, - maxWorkContextJWKSBytes, - ) - } - return parseWorkContextJWKS(payload) -} - -type workContextJWKS struct { - Keys []workContextJWK `json:"keys"` -} - -type workContextJWK struct { - KeyType string `json:"kty"` - Curve string `json:"crv"` - Use string `json:"use"` - Alg string `json:"alg"` - KeyID string `json:"kid"` - X string `json:"x"` -} - -func parseWorkContextJWKS(payload []byte) (map[string]ed25519.PublicKey, error) { - var document workContextJWKS - if err := json.Unmarshal(payload, &document); err != nil { - return nil, fmt.Errorf("%w: decode Work Context JWKS: %v", ErrWorkContextInvalid, err) - } - if len(document.Keys) == 0 || len(document.Keys) > maxWorkContextJWKSKeys { - return nil, fmt.Errorf( - "%w: Work Context JWKS must contain between 1 and %d keys", - ErrWorkContextInvalid, - maxWorkContextJWKSKeys, - ) - } - keys := make(map[string]ed25519.PublicKey, len(document.Keys)) - for _, key := range document.Keys { - if key.KeyType != "OKP" || key.Curve != "Ed25519" || - key.Alg != "" && key.Alg != "EdDSA" || - key.Use != "" && key.Use != "sig" { - return nil, fmt.Errorf("%w: JWKS contains a non-Ed25519 signing key", ErrWorkContextInvalid) - } - if err := validateBounded("key_id", key.KeyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - decoded, err := base64.RawURLEncoding.DecodeString(key.X) - if err != nil || len(decoded) != ed25519.PublicKeySize { - return nil, fmt.Errorf( - "%w: JWKS key %q has an invalid Ed25519 public key", - ErrWorkContextInvalid, - key.KeyID, - ) - } - if _, duplicate := keys[key.KeyID]; duplicate { - return nil, fmt.Errorf("%w: JWKS has duplicate key ID %q", ErrWorkContextInvalid, key.KeyID) - } - keys[key.KeyID] = append(ed25519.PublicKey(nil), decoded...) - } - return keys, nil -} - -func workContextTokenKeyID(token WorkContextToken) (string, error) { - if token.empty() { - return "", fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - payloadSegment, _, found := strings.Cut(token.encoded, ".") - if !found { - return "", fmt.Errorf("%w: malformed token", ErrWorkContextInvalid) - } - payload, err := base64.RawURLEncoding.DecodeString(payloadSegment) - if err != nil { - return "", fmt.Errorf("%w: malformed token payload", ErrWorkContextInvalid) - } - probe := struct { - KeyID string `json:"key_id"` - }{} - if err := json.Unmarshal(payload, &probe); err != nil { - return "", fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, err) - } - if err := validateBounded("key_id", probe.KeyID, workContextMaxKindBytes, true); err != nil { - return "", err - } - return probe.KeyID, nil -} - -func validateWorkContextJWKSURL(raw string) (string, error) { - raw = strings.TrimSpace(raw) - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" || parsed.User != nil || - parsed.Fragment != "" || parsed.RawQuery != "" || - parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("%w: Work Context JWKS URL must be an absolute HTTP(S) URL without credentials, query, or fragment", ErrWorkContextInvalid) - } - return parsed.String(), nil -} - -func cloneKeyIDs(source map[string]struct{}) map[string]struct{} { - cloned := make(map[string]struct{}, len(source)) - for keyID := range source { - cloned[keyID] = struct{}{} - } - return cloned -} diff --git a/workcontext/work_context_jwks_test.go b/workcontext/work_context_jwks_test.go deleted file mode 100644 index 78385ab..0000000 --- a/workcontext/work_context_jwks_test.go +++ /dev/null @@ -1,483 +0,0 @@ -package workcontext - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestWorkContextJWKSVerifierCachesAndRefreshesUnknownRotation(t *testing.T) { - firstPublic, firstPrivate := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var mu sync.RWMutex - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests.Add(1) - mu.RLock() - defer mu.RUnlock() - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - first := workContextJWKSToken(t, "key-1", firstPrivate) - for range 3 { - verified, err := verifier.VerifyWorkContext(t.Context(), first, workContextTestExpectedWith(WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - })) - require.NoError(t, err) - require.Equal(t, "tenant-codefly", verified.Claims().GetTenantId()) - } - require.EqualValues(t, 1, requests.Load()) - - mu.Lock() - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - mu.Unlock() - second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.VerifyWorkContext(t.Context(), second, workContextTestExpectedWith(WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - })) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) - - // Removed keys fail after the rotation refresh. The verifier permits only - // one unknown-key refresh per cache generation, so arbitrary key IDs cannot - // turn verification into an unbounded request loop. - for index := range 50 { - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), firstPrivate), - workContextTestExpected(), - ) - require.Error(t, err) - } - _, err = verifier.VerifyWorkContext(t.Context(), first, workContextTestExpected()) - require.Error(t, err) - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRejectsRedirects(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - var destinationRequests atomic.Int32 - destination := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - destinationRequests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(destination.Close) - source := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - http.Redirect(writer, request, destination.URL, http.StatusTemporaryRedirect) - })) - t.Cleanup(source.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: source.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - workContextTestExpected(), - ) - require.Error(t, err) - require.Zero(t, destinationRequests.Load()) -} - -func TestWorkContextJWKSVerifierSuppressesConcurrentRotationRefresh(t *testing.T) { - firstPublic, _ := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var rotated atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests.Add(1) - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - if rotated.Load() { - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", workContextJWKSPrivateForSeed(1)), - workContextTestExpected(), - ) - require.NoError(t, err) - rotated.Store(true) - - token := workContextJWKSToken(t, "key-2", secondPrivate) - const callers = 20 - start := make(chan struct{}) - failures := make(chan error, callers) - var wait sync.WaitGroup - for range callers { - wait.Add(1) - go func() { - defer wait.Done() - <-start - _, verifyErr := verifier.VerifyWorkContext(context.Background(), token, workContextTestExpected()) - failures <- verifyErr - }() - } - close(start) - wait.Wait() - close(failures) - for verifyErr := range failures { - require.NoError(t, verifyErr) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshesExpiredCacheWithoutRefreshingBadSignatures(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - _, wrongPrivateKey := workContextJWKSKey(2) - var requests atomic.Int32 - var mu sync.RWMutex - now := workContextTestTime - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, - Now: func() time.Time { - mu.RLock() - defer mu.RUnlock() - return now - }, - CacheTTL: time.Minute, - }) - require.NoError(t, err) - - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - workContextTestExpected(), - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) - - // A known key ID with an invalid signature is an invalid token, not a key - // rotation signal, and must never cause network I/O. - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", wrongPrivateKey), - workContextTestExpected(), - ) - require.Error(t, err) - require.EqualValues(t, 1, requests.Load()) - - mu.Lock() - now = now.Add(time.Minute) - mu.Unlock() - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - workContextTestExpected(), - ) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierBoundsAndValidatesRemoteKeys(t *testing.T) { - cases := []struct { - name string - status int - contentType string - body []byte - }{ - {name: "status", status: http.StatusNotFound, contentType: "application/json", body: []byte(`{}`)}, - {name: "content type", status: http.StatusOK, contentType: "text/plain", body: []byte(`{}`)}, - {name: "empty", status: http.StatusOK, contentType: "application/json", body: []byte(`{"keys":[]}`)}, - {name: "wrong curve", status: http.StatusOK, contentType: "application/json", body: []byte(`{"keys":[{"kty":"OKP","crv":"X25519","kid":"key-1","x":"AA"}]}`)}, - {name: "oversized", status: http.StatusOK, contentType: "application/json", body: make([]byte, maxWorkContextJWKSBytes+1)}, - } - _, privateKey := workContextJWKSKey(1) - token := workContextJWKSToken(t, "key-1", privateKey) - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", testCase.contentType) - writer.WriteHeader(testCase.status) - _, _ = writer.Write(testCase.body) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.NotErrorIs(t, err, ErrWorkContextUnavailable) - }) - } -} - -func TestWorkContextJWKSVerifierReportsIssuerOutageAsUnavailable(t *testing.T) { - _, privateKey := workContextJWKSKey(1) - token := workContextJWKSToken(t, "key-1", privateKey) - - t.Run("transport failure", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - endpoint := server.URL - server.Close() - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: endpoint, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - }) - - for _, status := range []int{http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusTooManyRequests} { - t.Run(fmt.Sprintf("status %d", status), func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(status) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(t.Context(), token, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - }) - } -} - -func TestWorkContextJWKSVerifierKeepsUnknownKeyOutageUnavailableAfterReservation(t *testing.T) { - firstPublic, firstPrivate := workContextJWKSKey(1) - _, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var down atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - if down.Load() { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": firstPublic})) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", firstPrivate), - workContextTestExpected(), - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) - - // Keys rotate to key-2 exactly as the issuer goes down, so the new key is - // unknown and its key set cannot be fetched. - down.Store(true) - rotated := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.VerifyWorkContext(t.Context(), rotated, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - require.EqualValues(t, 2, requests.Load()) - - // The one-refresh-per-generation reservation is now spent, but the failure - // was an outage: repeat lookups must stay Unavailable rather than degrading - // to an invalid-token rejection against the stale cache, and must not spend - // another fetch. - for range 5 { - _, err = verifier.VerifyWorkContext(t.Context(), rotated, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestNewWorkContextJWKSVerifierRejectsUnsafeConfiguration(t *testing.T) { - cases := []WorkContextJWKSVerifierOptions{ - {}, - {URL: "accounts.example.test/keys"}, - {URL: "file:///tmp/keys.json"}, - {URL: "https://user:secret@accounts.example.test/keys"}, - {URL: "https://accounts.example.test/keys?tenant=codefly"}, - {URL: "https://accounts.example.test/keys#fragment"}, - {URL: "https://accounts.example.test/keys", CacheTTL: time.Millisecond}, - {URL: "https://accounts.example.test/keys", CacheTTL: maxWorkContextJWKSCacheTTL + time.Second}, - {URL: "https://accounts.example.test/keys", RequestTimeout: maxWorkContextJWKSRequestTimeout + time.Millisecond}, - } - for _, options := range cases { - _, err := NewWorkContextJWKSVerifier(options) - require.Error(t, err) - } -} - -func TestWorkContextJWKSVerifierRefreshWarmsCacheAtBoot(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - - require.NoError(t, verifier.Refresh(t.Context())) - require.EqualValues(t, 1, requests.Load()) - - // The warmed cache serves the first Verify without another fetch. - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - workContextTestExpected(), - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshPreservesRotationRefreshBudget(t *testing.T) { - firstPublic, _ := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var rotated atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - if rotated.Load() { - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - - require.NoError(t, verifier.Refresh(t.Context())) - require.EqualValues(t, 1, requests.Load()) - - // After a manual Refresh opens a fresh generation, one unknown key ID must - // still force exactly one rotation refresh, and no more. - rotated.Store(true) - second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.VerifyWorkContext(t.Context(), second, workContextTestExpected()) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) - - for index := range 20 { - _, err = verifier.VerifyWorkContext( - t.Context(), - workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), secondPrivate), - workContextTestExpected(), - ) - require.Error(t, err) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshFailsClosedOnUnreachableEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(http.StatusServiceUnavailable) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - require.Error(t, verifier.Refresh(t.Context())) -} - -func TestWorkContextJWKSVerifierRefreshRejectsNilReceiverAndContext(t *testing.T) { - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: "https://accounts.example.test/keys", Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - require.Error(t, verifier.Refresh(nil)) - - var nilVerifier *WorkContextJWKSVerifier - require.Error(t, nilVerifier.Refresh(context.Background())) -} - -func workContextJWKSKey(seedByte byte) (ed25519.PublicKey, ed25519.PrivateKey) { - privateKey := workContextJWKSPrivateForSeed(seedByte) - return privateKey.Public().(ed25519.PublicKey), privateKey -} - -func workContextJWKSPrivateForSeed(seedByte byte) ed25519.PrivateKey { - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = seedByte + byte(index) - } - return ed25519.NewKeyFromSeed(seed) -} - -func workContextJWKSToken( - t *testing.T, - keyID string, - privateKey ed25519.PrivateKey, -) WorkContextToken { - t.Helper() - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", KeyID: keyID, - PrivateKey: privateKey, Now: func() time.Time { return workContextTestTime }, - Nonce: func() (string, error) { - return fmt.Sprintf("nonce-%s", keyID), nil - }, - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - return token -} - -func workContextJWKSJSON( - t *testing.T, - keys map[string]ed25519.PublicKey, -) []byte { - t.Helper() - document := struct { - Keys []map[string]string `json:"keys"` - }{} - for keyID, publicKey := range keys { - document.Keys = append(document.Keys, map[string]string{ - "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", - "kid": keyID, "x": base64.RawURLEncoding.EncodeToString(publicKey), - }) - } - payload, err := json.Marshal(document) - require.NoError(t, err) - return payload -} diff --git a/workcontext/work_context_test.go b/workcontext/work_context_test.go deleted file mode 100644 index e9afa9e..0000000 --- a/workcontext/work_context_test.go +++ /dev/null @@ -1,559 +0,0 @@ -package workcontext - -import ( - "crypto/ed25519" - "encoding/base64" - "net/http" - "strings" - "testing" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/stretchr/testify/require" -) - -var workContextTestTime = time.Date(2026, 7, 23, 12, 34, 56, 0, time.UTC) - -func workContextTestKeys() (ed25519.PublicKey, ed25519.PrivateKey) { - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = byte(index) - } - privateKey := ed25519.NewKeyFromSeed(seed) - return privateKey.Public().(ed25519.PublicKey), privateKey -} - -func workContextTestSigner(t *testing.T, now time.Time) *WorkContextSigner { - t.Helper() - _, privateKey := workContextTestKeys() - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", - KeyID: "work-context-test-2026-07", - PrivateKey: privateKey, - Now: func() time.Time { return now }, - Nonce: func() (string, error) { return "nonce-fixed-for-golden", nil }, - }) - require.NoError(t, err) - return signer -} - -// workContextTestSeal is the execution every fixture credential is sealed to. -func workContextTestSeal() Seal { - return Seal{ - PrincipalEpoch: 7, - InstallationID: "installation-0a9f", - InstallationRevision: 41, - BuildIncarnation: "build-incarnation-2026-07-23-a", - } -} - -// workContextTestExpected is what a verifier on the same installation, -// revision and build holds. Every verification in these tests starts from it, -// because a verifier that states none of this refuses every credential — which -// is the behaviour, not an inconvenience to work around. -func workContextTestExpected() WorkContextExpectations { - seal := workContextTestSeal() - return WorkContextExpectations{ - Seal: SealExpectations{ - PrincipalEpoch: seal.PrincipalEpoch, - InstallationID: seal.InstallationID, - InstallationRevision: seal.InstallationRevision, - BuildIncarnation: seal.BuildIncarnation, - }, - // The fixture credential is delegated through one actor, so a verifier - // that accepted only direct owner calls would refuse it. - Delegation: &DelegationExpectations{}, - } -} - -// workContextTestExpectedWith keeps whatever a test states and fills in the -// seal and the delegation the fixture credential carries, so a test about -// audience mismatch stays a test about audience mismatch. -func workContextTestExpectedWith(expected WorkContextExpectations) WorkContextExpectations { - baseline := workContextTestExpected() - if expected.Seal == (SealExpectations{}) { - expected.Seal = baseline.Seal - } - if expected.Delegation == nil { - expected.Delegation = baseline.Delegation - } - return expected -} - -func workContextTestInput() StartTaskInput { - return StartTaskInput{ - Audience: "warden.evidence", - TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-antoine", - OwnerPrincipalKind: "user", - Seal: workContextTestSeal(), - TaskID: "task-roadmap", - SessionID: "session-root", - AuthorizationRevision: ^uint64(0), - ReplayPolicy: WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write", "read", "write"}, - ResourceIds: []string{"repo-warden", "repo-codefly"}, - }, - { - ResourceKind: "evidence", - Actions: []string{"append"}, - }, - }, - ActorChain: []*basev0.WorkActorV1{ - { - PrincipalId: "agent-claude-code", - PrincipalKind: "agent", - DelegationId: "delegation-1", - GrantedScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write", "read"}, - ResourceIds: []string{"repo-warden"}, - }, - { - ResourceKind: "evidence", - Actions: []string{"append"}, - }, - }, - }, - }, - AttributionTeamIDs: []string{"team-platform", "team-ai", "team-platform"}, - WorkspaceID: "workspace-deus", - ProjectID: "project-warden", - TTL: 5 * time.Minute, - } -} - -func workContextTestVerifier(t *testing.T, now time.Time) *WorkContextVerifier { - t.Helper() - publicKey, _ := workContextTestKeys() - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{ - "work-context-test-2026-07": publicKey, - }, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - return verifier -} - -func TestWorkContextStartTaskVerifyAndCanonicalize(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - token, claims, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - require.NotEmpty(t, token.Encoded()) - require.LessOrEqual(t, len(token.Encoded()), WorkContextMaxTokenBytes) - - require.Equal(t, []string{"team-ai", "team-platform"}, claims.AttributionTeamIds) - require.Equal(t, []string{"evidence", "repository"}, []string{ - claims.AuthorityScopes[0].ResourceKind, - claims.AuthorityScopes[1].ResourceKind, - }) - require.Equal(t, []string{"read", "write"}, claims.AuthorityScopes[1].Actions) - require.Equal(t, []string{"repo-codefly", "repo-warden"}, claims.AuthorityScopes[1].ResourceIds) - - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpectedWith(WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", - Audience: "warden.evidence", - TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-antoine", - OwnerPrincipalKind: "user", - TaskID: "task-roadmap", - SessionID: "session-root", - })) - require.NoError(t, err) - require.Equal(t, ^uint64(0), verified.Claims().AuthorizationRevision, "uint64 revision must survive the JavaScript-safe wire representation") - require.Equal(t, "agent-claude-code", verified.Claims().ActorChain[0].PrincipalId) - require.Equal(t, workContextTestSeal(), verified.Seal(), "the seal survives the wire unchanged") - require.Nil(t, verified.OperationBinding()) -} - -func TestWorkContextAudienceExchangePreservesImmutableLineage(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parentToken, parent, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - exchangedToken, exchanged, err := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - ReplayPolicy: WorkContextReplaySingleUse, - TTL: 2 * time.Minute, - }, - ) - require.NoError(t, err) - require.NotEqual(t, parentToken.Encoded(), exchangedToken.Encoded()) - require.Equal(t, "warden.gateway", exchanged.GetAudience()) - require.Equal(t, WorkContextReplaySingleUse, exchanged.GetReplayPolicy()) - require.Equal(t, workContextTestTime.Add(2*time.Minute).Unix(), exchanged.GetExpiresAtUnix()) - - require.Equal(t, parent.GetTenantId(), exchanged.GetTenantId()) - require.Equal(t, parent.GetOwnerPrincipalId(), exchanged.GetOwnerPrincipalId()) - require.Equal(t, parent.GetTaskId(), exchanged.GetTaskId()) - require.Equal(t, parent.GetSessionId(), exchanged.GetSessionId()) - require.Equal(t, parent.GetParentSessionId(), exchanged.GetParentSessionId()) - require.Equal(t, parent.GetAuthorizationRevision(), exchanged.GetAuthorizationRevision()) - require.Equal(t, parent.GetAuthorityScopes(), exchanged.GetAuthorityScopes()) - require.Equal(t, parent.GetActorChain()[0].GetPrincipalId(), exchanged.GetActorChain()[0].GetPrincipalId()) - require.Equal(t, parent.GetActorChain()[0].GetPrincipalKind(), exchanged.GetActorChain()[0].GetPrincipalKind()) - require.Equal(t, parent.GetActorChain()[0].GetDelegationId(), exchanged.GetActorChain()[0].GetDelegationId()) - require.Equal(t, parent.GetActorChain()[0].GetGrantedScopes(), exchanged.GetActorChain()[0].GetGrantedScopes()) - require.Equal(t, parent.GetAttributionTeamIds(), exchanged.GetAttributionTeamIds()) - require.Equal(t, parent.GetWorkspaceId(), exchanged.GetWorkspaceId()) - require.Equal(t, parent.GetProjectId(), exchanged.GetProjectId()) - - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext( - exchangedToken, - workContextTestExpectedWith(WorkContextExpectations{ - Audience: "warden.gateway", - TenantID: parent.GetTenantId(), - OwnerPrincipalID: parent.GetOwnerPrincipalId(), - TaskID: parent.GetTaskId(), - SessionID: parent.GetSessionId(), - }), - ) - require.NoError(t, err) - require.Equal(t, parent.GetActorChain(), verified.Claims().GetActorChain()) - require.Equal(t, workContextTestSeal(), verified.Seal(), "an audience exchange carries the seal unchanged") -} - -func TestWorkContextAudienceExchangeOnlyAttenuatesEffectiveScopes(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parentToken, parent, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - token, exchanged, err := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - AttenuatedScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }}, - }, - ) - require.NoError(t, err) - require.NotEmpty(t, token.Encoded()) - require.Equal(t, parent.GetActorChain()[0].GetDelegationId(), exchanged.GetActorChain()[0].GetDelegationId()) - require.Equal(t, []*basev0.WorkScopeV1{{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }}, exchanged.GetActorChain()[0].GetGrantedScopes()) - - for name, scopes := range map[string][]*basev0.WorkScopeV1{ - "new action": {{ - ResourceKind: "repository", - Actions: []string{"admin"}, - ResourceIds: []string{"repo-warden"}, - }}, - "new resource": {{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-other"}, - }}, - "explicit to wildcard": {{ - ResourceKind: "repository", - Actions: []string{"write"}, - }}, - } { - t.Run(name, func(t *testing.T) { - _, _, exchangeErr := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - AttenuatedScopes: scopes, - }, - ) - require.ErrorIs(t, exchangeErr, ErrWorkContextInvalid) - require.Contains(t, exchangeErr.Error(), "widens authority") - }) - } -} - -func TestRequireWorkContextScopeUsesFinalActorEffectiveAuthority(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - claims, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) - require.NoError(t, err) - - require.NoError(t, RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "repository", - Action: "write", - ResourceID: "repo-warden", - RequireExplicitResource: true, - })) - require.NoError(t, RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "evidence", - Action: "append", - ResourceID: "codefly.execution", - }), "empty resource IDs grant every resource of the kind") - err = RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "evidence", - Action: "append", - ResourceID: "codefly.execution", - RequireExplicitResource: true, - }) - require.ErrorIs(t, err, ErrWorkContextDenied, "explicit producer binding must reject wildcard authority") - - for _, denied := range []WorkContextScopeRequirement{ - {ResourceKind: "repository", Action: "write", ResourceID: "repo-codefly"}, - {ResourceKind: "repository", Action: "admin", ResourceID: "repo-warden"}, - {ResourceKind: "repository", Action: "write"}, - {ResourceKind: "deployment", Action: "write", ResourceID: "prod"}, - } { - err = RequireWorkContextScope(claims, denied) - require.ErrorIs(t, err, ErrWorkContextDenied) - } -} - -// A scope check can only be reached through verification now, so the two ways -// it used to be reachable without one — nil claims, and claims mutated after -// they were verified — are the two things this proves are gone. -func TestRequireWorkContextScopeDistinguishesUnverifiedFromDenial(t *testing.T) { - err := RequireWorkContextScope(VerifiedWorkContext{}, WorkContextScopeRequirement{ - ResourceKind: "evidence", Action: "append", ResourceID: "codefly.execution", - }) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.NotErrorIs(t, err, ErrWorkContextDenied) - - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) - require.NoError(t, err) - - // Claims hands back a copy, so widening the copy widens nothing. Before the - // scope check took a verified credential, this same mutation reached the - // authorizing code and had to be caught by re-validating the shape. - widened := verified.Claims() - widened.ActorChain[0].GrantedScopes[0].Actions = []string{"admin", "append", "read", "write"} - require.ErrorIs(t, RequireWorkContextScope(verified, WorkContextScopeRequirement{ - ResourceKind: "repository", Action: "admin", ResourceID: "repo-warden", - }), ErrWorkContextDenied, "mutating a copy of the claims must not widen the credential") -} - -func TestWorkContextWireGolden(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - // Shared with sdk-js. A byte-identical token proves payload field order, - // uint64 handling, sorting, base64url, and Ed25519 signing agree. - const expected = "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoicHJvamVjdC13YXJkZW4iLCJvd25lcl9wcmluY2lwYWxfa2luZCI6InVzZXIiLCJvd25lcl9wcmluY2lwYWxfZXBvY2giOiI3IiwiaW5zdGFsbGF0aW9uX2lkIjoiaW5zdGFsbGF0aW9uLTBhOWYiLCJpbnN0YWxsYXRpb25fcmV2aXNpb24iOiI0MSIsImJ1aWxkX2luY2FybmF0aW9uIjoiYnVpbGQtaW5jYXJuYXRpb24tMjAyNi0wNy0yMy1hIn0.ew7SXOuSo919plScJDHI4kjafHK6V5JvJtBVrgCrhKVYMmdAN0hcnSEX1-jL9kiUmN2amkkAHGyAtokg77P6Aw" - require.Equal(t, expected, token.Encoded()) -} - -// Shared with sdk-js. Each token is byte-identical to the wire golden except -// that one optional field the proto declares min_len=1 is present but empty. -// A conforming verifier in any language must reject all three. -func TestWorkContextGoldenRejectsPresentButEmptyOptionalFields(t *testing.T) { - goldens := map[string]string{ - "parent_session_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJwYXJlbnRfc2Vzc2lvbl9pZCI6IiIsImF1dGhvcml0eV9zY29wZXMiOlt7InJlc291cmNlX2tpbmQiOiJldmlkZW5jZSIsImFjdGlvbnMiOlsiYXBwZW5kIl0sInJlc291cmNlX2lkcyI6W119LHsicmVzb3VyY2Vfa2luZCI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInJlYWQiLCJ3cml0ZSJdLCJyZXNvdXJjZV9pZHMiOlsicmVwby1jb2RlZmx5IiwicmVwby13YXJkZW4iXX1dLCJhY3Rvcl9jaGFpbiI6W3sicHJpbmNpcGFsX2lkIjoiYWdlbnQtY2xhdWRlLWNvZGUiLCJwcmluY2lwYWxfa2luZCI6ImFnZW50IiwiZGVsZWdhdGlvbl9pZCI6ImRlbGVnYXRpb24tMSIsImdyYW50ZWRfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8td2FyZGVuIl19XX1dLCJhdHRyaWJ1dGlvbl90ZWFtX2lkcyI6WyJ0ZWFtLWFpIiwidGVhbS1wbGF0Zm9ybSJdLCJ3b3Jrc3BhY2VfaWQiOiJ3b3Jrc3BhY2UtZGV1cyIsInByb2plY3RfaWQiOiJwcm9qZWN0LXdhcmRlbiIsIm93bmVyX3ByaW5jaXBhbF9raW5kIjoidXNlciIsIm93bmVyX3ByaW5jaXBhbF9lcG9jaCI6IjciLCJpbnN0YWxsYXRpb25faWQiOiJpbnN0YWxsYXRpb24tMGE5ZiIsImluc3RhbGxhdGlvbl9yZXZpc2lvbiI6IjQxIiwiYnVpbGRfaW5jYXJuYXRpb24iOiJidWlsZC1pbmNhcm5hdGlvbi0yMDI2LTA3LTIzLWEifQ.CxdrAx_gkJlVyjTSDpc4oty1Tg67D3n-mwfDVz7ShWJk3KtXI6yDZ1x_8HcgJItZ1vJAXh98aZvujOScfAbCBw", - "workspace_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoiIiwicHJvamVjdF9pZCI6InByb2plY3Qtd2FyZGVuIiwib3duZXJfcHJpbmNpcGFsX2tpbmQiOiJ1c2VyIiwib3duZXJfcHJpbmNpcGFsX2Vwb2NoIjoiNyIsImluc3RhbGxhdGlvbl9pZCI6Imluc3RhbGxhdGlvbi0wYTlmIiwiaW5zdGFsbGF0aW9uX3JldmlzaW9uIjoiNDEiLCJidWlsZF9pbmNhcm5hdGlvbiI6ImJ1aWxkLWluY2FybmF0aW9uLTIwMjYtMDctMjMtYSJ9.Nrs7RXdAEhxC_sEUZEFX5P5LJy7Az_1eeUie9a-DGHGyJ7nDP-Rm7seY221gmib9vGa21GtiFGl4CKo4U4iuDQ", - "project_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoiIiwib3duZXJfcHJpbmNpcGFsX2tpbmQiOiJ1c2VyIiwib3duZXJfcHJpbmNpcGFsX2Vwb2NoIjoiNyIsImluc3RhbGxhdGlvbl9pZCI6Imluc3RhbGxhdGlvbi0wYTlmIiwiaW5zdGFsbGF0aW9uX3JldmlzaW9uIjoiNDEiLCJidWlsZF9pbmNhcm5hdGlvbiI6ImJ1aWxkLWluY2FybmF0aW9uLTIwMjYtMDctMjMtYSJ9.hZWBljC6rWAUf4T4nNlAcW76rceX1-KRk9tb_A4DNzB-fmHjvbDxVsM7ir0gDTUPmlWNA7XSEhxBB9bHHwwPAw", - } - verifier := workContextTestVerifier(t, workContextTestTime) - for field, encoded := range goldens { - t.Run(field, func(t *testing.T) { - token, err := ParseWorkContextToken(encoded) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(token, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, field+" must not be empty when present") - require.NotContains(t, err.Error(), "signature", "must be rejected by validation, not signature failure") - }) - } -} - -// A present optional field satisfies min_len=1 at length one; a proto-generated -// verifier accepts a single-space value, so this one must not reject it for -// emptiness. -func TestWorkContextAcceptsSingleCharacterOptionalFields(t *testing.T) { - input := workContextTestInput() - input.WorkspaceID = " " - input.ProjectID = " " - - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) - require.NoError(t, err) - require.Equal(t, " ", verified.Claims().GetWorkspaceId()) - require.Equal(t, " ", verified.Claims().GetProjectId()) -} - -// StartTask omits the optional workspace_id/project_id when the caller passes an -// empty string, so the signed context carries them as absent rather than -// present-but-empty, and the verifier accepts an absent optional field. The -// present-but-empty half of the contract is pinned by -// TestWorkContextGoldenRejectsPresentButEmptyOptionalFields. -func TestWorkContextStartTaskOmitsEmptyOptionalFields(t *testing.T) { - input := workContextTestInput() - input.WorkspaceID = "" - input.ProjectID = "" - - token, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - require.Nil(t, claims.WorkspaceId, "empty WorkspaceID must be omitted, not stored as present-but-empty") - require.Nil(t, claims.ProjectId, "empty ProjectID must be omitted, not stored as present-but-empty") - - _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(token, workContextTestExpected()) - require.NoError(t, err, "a context with absent optional fields must verify") -} - -func TestWorkContextRejectsForgeryAndClaimSubstitution(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - verifier := workContextTestVerifier(t, workContextTestTime) - - segments := strings.Split(token.Encoded(), ".") - payload, err := base64.RawURLEncoding.DecodeString(segments[0]) - require.NoError(t, err) - payload[len(payload)/2] ^= 1 - forged, err := ParseWorkContextToken(base64.RawURLEncoding.EncodeToString(payload) + "." + segments[1]) - require.NoError(t, err) - _, err = verifier.VerifyWorkContext(forged, workContextTestExpectedWith(WorkContextExpectations{Audience: "warden.evidence"})) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "signature") - - for name, expected := range map[string]WorkContextExpectations{ - "audience": {Audience: "other-service"}, - "tenant": {TenantID: "tenant-mind"}, - "owner": {OwnerPrincipalID: "principal-other"}, - "task": {TaskID: "task-other"}, - "session": {SessionID: "session-other"}, - } { - t.Run(name, func(t *testing.T) { - _, verifyErr := verifier.VerifyWorkContext(token, workContextTestExpectedWith(expected)) - require.ErrorIs(t, verifyErr, ErrWorkContextInvalid) - require.Contains(t, verifyErr.Error(), "mismatch") - }) - } -} - -func TestWorkContextRejectsExpiredFutureAndExcessiveLifetime(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - _, err = workContextTestVerifier(t, workContextTestTime.Add(7*time.Minute)).VerifyWorkContext(token, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "expired") - - futureInput := workContextTestInput() - futureInput.NotBefore = workContextTestTime.Add(2 * time.Minute) - futureToken, _, err := workContextTestSigner(t, workContextTestTime).StartTask(futureInput) - require.NoError(t, err) - _, err = workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(futureToken, workContextTestExpected()) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "not active") - - longInput := workContextTestInput() - longInput.TTL = WorkContextMaxTTL + time.Second - _, _, err = workContextTestSigner(t, workContextTestTime).StartTask(longInput) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "lifetime") -} - -func TestWorkContextChildSessionPreservesOwnershipAndAttenuates(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parent, parentClaims, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - childToken, child, err := signer.StartChildSession(parent, StartChildSessionInput{ - SessionID: "session-child", - Audience: "warden.tools", - Actor: &basev0.WorkActorV1{ - PrincipalId: "tool-codefly-editor", - PrincipalKind: "tool", - DelegationId: "delegation-2", - GrantedScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, parentClaims.TenantId, child.TenantId) - require.Equal(t, parentClaims.OwnerPrincipalId, child.OwnerPrincipalId) - require.Equal(t, parentClaims.TaskId, child.TaskId) - require.Equal(t, "session-root", child.GetParentSessionId()) - require.Equal(t, "session-child", child.SessionId) - require.Len(t, child.ActorChain, 2) - - verified, err := workContextTestVerifier(t, workContextTestTime).VerifyWorkContext(childToken, workContextTestExpectedWith(WorkContextExpectations{ - Audience: "warden.tools", - TenantID: parentClaims.TenantId, - OwnerPrincipalID: parentClaims.OwnerPrincipalId, - TaskID: parentClaims.TaskId, - SessionID: "session-child", - ParentSessionID: stringPointer("session-root"), - })) - require.NoError(t, err) - require.Equal(t, "tool-codefly-editor", verified.Claims().ActorChain[1].PrincipalId) -} - -func TestWorkContextChildSessionRejectsScopeWidening(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parent, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - cases := map[string][]*basev0.WorkScopeV1{ - "new action": { - {ResourceKind: "repository", Actions: []string{"admin"}, ResourceIds: []string{"repo-warden"}}, - }, - "new resource": { - {ResourceKind: "repository", Actions: []string{"read"}, ResourceIds: []string{"repo-mind"}}, - }, - "explicit to wildcard": { - {ResourceKind: "repository", Actions: []string{"read"}}, - }, - "new resource kind": { - {ResourceKind: "secrets", Actions: []string{"read"}}, - }, - } - for name, scopes := range cases { - t.Run(name, func(t *testing.T) { - _, _, childErr := signer.StartChildSession(parent, StartChildSessionInput{ - SessionID: "session-child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "actor-child", - PrincipalKind: "agent", - DelegationId: "delegation-child", - GrantedScopes: scopes, - }, - }) - require.ErrorIs(t, childErr, ErrWorkContextInvalid) - require.Contains(t, childErr.Error(), "widens authority") - }) - } -} - -func TestWorkContextHeaderIsSDKOwned(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - request, err := http.NewRequest(http.MethodPost, "https://warden.example/receipts", nil) - require.NoError(t, err) - require.NoError(t, AttachWorkContext(request, token)) - require.Equal(t, token.Encoded(), request.Header.Get(WorkContextHeaderName)) - - extracted, err := WorkContextFromHeaders(request.Header) - require.NoError(t, err) - require.Equal(t, token.Encoded(), extracted.Encoded()) -} - -func TestWorkContextMalformedTokensFailClosed(t *testing.T) { - verifier := workContextTestVerifier(t, workContextTestTime) - for _, encoded := range []string{"", "one-segment", "a.b.c", "!!!.!!!"} { - token, parseErr := ParseWorkContextToken(encoded) - if parseErr == nil { - _, parseErr = verifier.VerifyWorkContext(token, workContextTestExpected()) - } - require.ErrorIs(t, parseErr, ErrWorkContextInvalid, encoded) - } -} From e84ae769a65f12cf87b6e015a1abc25c0d9a3247 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:46:21 -0400 Subject: [PATCH 03/35] docs(receipts): name the verifier the interceptor defers to, which is core's Co-Authored-By: Claude Opus 5 (1M context) --- receipts/interceptor.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/receipts/interceptor.go b/receipts/interceptor.go index 6da698b..797f06b 100644 --- a/receipts/interceptor.go +++ b/receipts/interceptor.go @@ -19,12 +19,13 @@ type Options struct { Store Store // Tenant reads the tenant of the caller's verified Work Context. // - // Receipts never verifies a Work Context itself — the verifier is the - // separate github.com/codefly-dev/sdk-go/workcontext module, which the root - // SDK must not depend on — so the module's own authentication interceptor, - // which has already verified the context by the time this one runs, says - // where the tenant is. Required: a receipt without a tenant would let one - // tenant read another's outcome. + // Receipts never verifies a Work Context itself — the verifier is + // github.com/codefly-dev/core/workcontext, the capability's one + // implementation, which the service reaches through the separate + // github.com/codefly-dev/sdk-go/workcontext module — so the service's own + // authentication interceptor, which has already verified the capability by + // the time this one runs, says where the tenant is. Required: a receipt + // without a tenant would let one tenant read another's outcome. Tenant func(ctx context.Context) (string, error) // Files is where the operation option is read from. Nil means // protoregistry.GlobalFiles, which is what the module's own generated code From 40bd5ec84d9030e4ecbfb6688217b93d00bfe92b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:49:24 -0400 Subject: [PATCH 04/35] fix(workcontext): name a foreign encoding with core's own check, not a failed unmarshal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core#691 exported CheckEncoding, the discrimination its verifier runs before unmarshalling and well before the signature check. This module's one decode site now calls it instead of reporting "payload is not a WorkContextV1". The difference is what an operator reads. A token in another format was already refused here, but as a decode failure, so one condition had two messages depending on which end saw it first — the same diagnostic fragmentation as the two encodings, in miniature. It is now ErrNotACoreToken on both ends, and it still does not wrap ErrInvalid, so "this is another format" and "this capability is bad" stay unreachable from one branch. Nothing here checks a signature and nothing trusts the result: the decode fills the pre-check carriers and tells the mint client when its own credential expires. Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/README.md | 20 +++++++++++++------- workcontext/carrier.go | 13 +++++++++++++ workcontext/carrier_test.go | 23 +++++++++++++++++------ workcontext/go.mod | 2 +- workcontext/go.sum | 4 ++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/workcontext/README.md b/workcontext/README.md index 7bb951a..8b951ea 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -385,13 +385,19 @@ else. The sealed fields are proto fields — `seal.principal_epoch`, `operation_binding.binding_id` / `.revision` / `.incarnation`, all three together or none. -This module reads a capability's seal and lifetime with `proto.Unmarshal` into -core's generated type and **no signature check**, in one place -(`carrier.go`, `claimsOf`). That is sound only because nothing trusts the -result: it fills the pre-check carriers, and it tells the mint client when its -own freshly issued credential expires. A receiver that preferred a carrier over -the sealed claim would have made a caller-controlled header into authority, -which is why the incoming side refuses a disagreement instead. +This module reads a capability's seal and lifetime in one place (`carrier.go`, +`claimsOf`): core's `CheckEncoding` on the decoded payload, then +`proto.Unmarshal` into core's generated type, and **no signature check**. That +is sound only because nothing trusts the result — it fills the pre-check +carriers, and it tells the mint client when its own freshly issued credential +expires. A receiver that preferred a carrier over the sealed claim would have +made a caller-controlled header into authority, which is why the incoming side +refuses a disagreement instead. + +`CheckEncoding` is core's and is called rather than reimplemented, so a token in +another format is named `ErrNotACoreToken` **here too**. Two decoders giving an +operator two different messages for one condition is the same diagnostic +fragmentation as the two encodings, in miniature. There are no cross-language golden tokens in this module any more. They went with the implementation that produced them: core mints its conformance fixtures diff --git a/workcontext/carrier.go b/workcontext/carrier.go index 341c9f7..6fd84c8 100644 --- a/workcontext/carrier.go +++ b/workcontext/carrier.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + corework "github.com/codefly-dev/core/workcontext" "google.golang.org/protobuf/proto" ) @@ -162,6 +163,18 @@ func claimsOf(encoded string) (*Claims, error) { if _, err := base64.RawURLEncoding.DecodeString(signature); err != nil { return nil, fmt.Errorf("%w: signature is not base64url: %v", ErrInvalid, err) } + // Before unmarshalling: is this even this encoding? Core's check, not a + // second one — so a token in another format is named here exactly as the + // verifier would name it (ErrNotACoreToken), rather than reported as a + // payload that failed to unmarshal. Two different messages for one + // condition is the diagnostic fragmentation that produced this rule. + // + // A nil return means only that the payload is not visibly another format. + // Nothing here checks a signature, and nothing that follows trusts the + // result. + if err := corework.CheckEncoding(raw); err != nil { + return nil, err + } claims := &Claims{} if err := proto.Unmarshal(raw, claims); err != nil { return nil, fmt.Errorf("%w: payload is not a WorkContextV1: %v", ErrInvalid, err) diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go index b303b60..82154f8 100644 --- a/workcontext/carrier_test.go +++ b/workcontext/carrier_test.go @@ -160,13 +160,24 @@ func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { require.Nil(t, none, "a session that exercises no binding seals none") } -// A token in another encoding is not a capability with a bad signature, and -// nothing here may report it as one. The SDK's reading stops at "this is not a -// WorkContextV1"; the named refusal is core's, which is where the signature -// would have been checked. -func TestAForeignEncodingIsNotReadAsACapability(t *testing.T) { +// A token in another encoding is refused as that, by name, in the one place +// this module decodes — using core's own discrimination, so an operator does +// not get two different messages for one condition. It is never reported as a +// signature problem, which is the misdiagnosis the whole rule exists to end. +func TestAForeignEncodingIsNamedAsOne(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/records", nil) err := Attach(request, fixture(t, "foreign-encoding").Token) + require.ErrorIs(t, err, ErrNotACoreToken) + require.NotErrorIs(t, err, ErrInvalid, + "ErrNotACoreToken does not wrap ErrInvalid: the two diagnoses must not be reachable from one branch") + require.NotContains(t, err.Error(), "signature") + + // An empty payload is NOT that error, here or in core: it is a malformed + // token of no format at all. Widening "not a core token" to cover it would + // make it mean "something was wrong early", which is the vagueness it + // exists to remove. + empty := httptest.NewRequest(http.MethodGet, "/records", nil) + err = Attach(empty, ".c2lnbmF0dXJl") require.ErrorIs(t, err, ErrInvalid) - require.NotContains(t, err.Error(), "signature does not verify") + require.NotErrorIs(t, err, ErrNotACoreToken) } diff --git a/workcontext/go.mod b/workcontext/go.mod index 319b11b..7248b6b 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec + github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 81be440..85b3890 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec h1:RrvEzVREuTdEuoJJjZBCPKVEWYwfqdugSkkHAahbBCw= -github.com/codefly-dev/core v0.7.2-0.20261002172647-2ffbbb5aa8ec/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8 h1:rQW1NUyhFruE4iqiJF8dW3N4HlHuyI8+4niFaIWfxc8= +github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= From 75e99cc7928a9c0e6c23efefe3904964b8bcd9da Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:51:30 -0400 Subject: [PATCH 05/35] docs(workcontext): say what the conformance kit actually drives Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/workcontext/README.md b/workcontext/README.md index 8b951ea..625dde1 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -60,9 +60,14 @@ It also pins the aliases by assignment (`var _ *corework.Verifier = a local copy with the same fields. **`TestWorkContextConformance`** runs `core/workcontext/conformance.RunWith` -against the verification entry point this module exports. The kit drives every -token form and every negative case through it and fails the build if any -outcome differs. The decisive fixture is the foreign encoding: a JSON-shaped +against the verification entry point this module exports. The kit drives **21 +fixtures** through it — every token form (session, operation, delegated, +delegated-operation, grant) and every negative case, including a stale and a +*future* installation revision, because the seal is compared for exact equality +and there is no legitimate way to hold a capability sealed ahead of live — and +fails the build if any outcome or any named sentinel differs. The single-use +grant fixture is presented twice against one replay store, so a verifier +without a working one passes the other twenty and fails exactly there. The decisive fixture is the foreign encoding: a JSON-shaped token that must be refused **before** its signature is checked, with `ErrNotACoreToken`. A second implementation refuses that token too — as a signature failure, which is the misdiagnosis the whole rule exists to prevent. From 45501b7cdd6313c916b3e528c4fcf0244fd4557d Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:11:50 -0400 Subject: [PATCH 06/35] ci(workcontext): sweep every ref CI builds for a second implementation, not only this tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestNoSecondWorkContextImplementation guards the tree it runs in, which is all a test can do: a branch's CI run uses that branch's own tree and workflow, so a test added here never executes on the release lines. Those lines still carry the root JSON signer, verifier and JWKS verifier — measured, three of them: compat/v0.1.65 (8157d3a), compat/v0.1.65-tls (eabe9b8) and feat/file-carriers-compat (0fbc3ce), the last of which no published-artifact rule even names. They would have stayed green containing exactly what the rule forbids. scripts/check-one-implementation.sh reads every such ref out of the object database and names the files carrying an implementation. Its predicate is narrow on purpose — a non-test Go file importing a signing primitive AND mentioning the Work Context — because on the release lines those files sit at the repository root rather than under workcontext/, so a path-shaped rule would miss them. It is not a pull-request check. The refs are retired by the owner at the cold cutover, once the one consumer pinned to them has repinned to a release carrying this change; until then the workflow is RED BY DESIGN, which is a visible countdown rather than a silence. Which consumer that is belongs to the runbook outside this repository: this repository stays generic, and that applies to a commit message as much as to a test fixture. Back-porting the deletion is the wrong move and docs/cutover.md says so: it would break the consumer the line exists for. That page holds the precondition, the re-enumeration command and the exact `git push --delete` lines, and states that the deletion is performed by the repository owner, never automatically and never by an agent. The sweep names main today, because this change has not merged. That is the check verifying itself: main flips to ok on merge and the three refs do not. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/one-implementation.yml | 48 +++++++++++++++ AGENTS.md | 8 ++- docs/cutover.md | 70 +++++++++++++++++++++ scripts/check-one-implementation.sh | 77 ++++++++++++++++++++++++ workcontext/README.md | 11 ++++ workcontext/one_implementation_test.go | 7 +++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/one-implementation.yml create mode 100644 docs/cutover.md create mode 100755 scripts/check-one-implementation.sh diff --git a/.github/workflows/one-implementation.yml b/.github/workflows/one-implementation.yml new file mode 100644 index 0000000..bec6565 --- /dev/null +++ b/.github/workflows/one-implementation.yml @@ -0,0 +1,48 @@ +# The Work Context has exactly one implementation and it lives in +# codefly-dev/core. TestNoSecondWorkContextImplementation guards the tree it +# runs in; this workflow is the half of that guarantee a test cannot reach. +# +# A branch's CI run uses that branch's own tree and workflow, so a test added on +# main never executes on compat/v0.1.65. The release lines that still carry the +# deleted JSON implementation would stay green containing exactly what the rule +# forbids. This sweep reads every ref CI builds out of the object database +# instead, so the rule is true of the repository rather than of one branch. +# +# It is NOT a pull-request check, on purpose. The refs it names are retired by +# the owner at the cold cutover; until then it is red by design — a visible +# countdown rather than a silence. See docs/cutover.md. +name: one implementation + +on: + push: + branches: [ main ] + schedule: + # Weekly, so a reintroduction on a branch nobody pushed to is still found, + # and so the countdown stays visible without anyone pushing anything. + - cron: '41 6 * * 1' + workflow_dispatch: + +concurrency: + group: sdk-go-one-implementation + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sweep: + name: no second Work Context implementation, on any ref CI builds + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # The sweep reads other refs, so it needs them: a shallow checkout of + # one branch would report "no refs to sweep" and pass for the wrong + # reason, which the script refuses to do. + fetch-depth: 0 + - name: Fetch every branch CI builds + run: git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' + - name: Sweep + run: ./scripts/check-one-implementation.sh diff --git a/AGENTS.md b/AGENTS.md index 666ce60..36a1824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,11 @@ see the skill below. the entry point we export. A claim added to the wire is added to the proto in core, never to an encoder here. This repository once held a second implementation signing hand-written JSON: a token from either format failed - *signature* verification in the other, which reads like a rotated key. + *signature* verification in the other, which reads like a rotated key. A test + guards only its own tree, so `scripts/check-one-implementation.sh` sweeps + every ref CI builds; it is **red by design** while a release line still + carries the old implementation — `docs/cutover.md` says who retires those and + when, and it is never an agent. - **A credential is sealed or it is not a credential.** Every field of `workcontext.Seal` is required at mint and at verify, an operation binding carries its id, revision and incarnation or none of them, and `Attach` @@ -141,6 +145,8 @@ see the skill below. effect that already happened. That is why `receipts.Record` takes a `Tx`. - **`compat/**` branches are published artifacts.** Consumers pin them when `main` holds an unreleased breaking change, so CI builds them like `main`. + Never back-port a breaking deletion into one: it breaks the consumer the line + exists for. A line is retired, by the owner, once its last consumer repins. ## Procedures diff --git a/docs/cutover.md b/docs/cutover.md new file mode 100644 index 0000000..5932fa7 --- /dev/null +++ b/docs/cutover.md @@ -0,0 +1,70 @@ +# Retiring the release lines that still carry the deleted implementation + +The Work Context has exactly one implementation, `codefly-dev/core/workcontext`. +On `main` that is enforced by `TestNoSecondWorkContextImplementation` and by +core's conformance kit, both in `workcontext/`. This page is about the branches +those tests cannot reach. + +## Why there is anything left to retire + +`compat/**` branches are published artifacts: a consumer pins one when `main` +holds an unreleased breaking change, and CI builds them like `main` +(`AGENTS.md`). The one-implementation change is exactly such a breaking change, +so the release lines still carry the root JSON signer, verifier and JWKS +verifier — in full, and deliberately. **Back-porting the deletion is the wrong +move**: it would break the consumer the line exists for. + +A branch's CI run uses that branch's own tree and workflow, so no test added on +`main` executes there. `scripts/check-one-implementation.sh`, run by the +`one implementation` workflow, reads every ref out of the object database +instead, and is **red by design** while any of them still carries an +implementation. That is a visible countdown, not a failure to fix. + +## The refs, and the one consumer pinned to them + +| Ref | Head at the time of writing | Carries | +| --- | --- | --- | +| `origin/compat/v0.1.65` | `8157d3a` (2026-09-27) | `work_context.go`, `work_context_jwks.go`, root `execution_context_grpc.go` | +| `origin/compat/v0.1.65-tls` | `eabe9b8` (2026-09-17) | the same | +| `origin/feat/file-carriers-compat` | `0fbc3ce` (2026-09-27) | the same | + +`feat/file-carriers-compat` is not under `compat/**`, so no published-artifact +rule names it; the sweep covers it anyway, because CI builds it. + +**One known consumer**, `obin-ai/module-robin`, pins +`github.com/codefly-dev/sdk-go v0.1.66-0.20260927165945-8157d3a…` — a commit on +`compat/v0.1.65`. That is why it still compiles `codefly.WorkContextVerifier` +and the other root identifiers, which left `main` on 2026-09-03 in `cd3613b`. + +## Precondition + +**Every consumer pinned to these refs has repinned to a release carrying the +one-implementation change.** For `module-robin` that is one move, not two: the +mint-once client has never existed on the `v0.1.65` line, so leaving the compat +line and adopting `workcontext.MintClient` happen together. + +Re-enumerate rather than trusting the table above — new pins and new branches +appear: + +```bash +git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' +./scripts/check-one-implementation.sh # names every ref still carrying one +``` + +## The retirement + +Performed by the repository owner at the cold cutover, once the precondition +holds. **Never by an agent, and never automatically** — deleting a published ref +breaks every build still pinned to it, and whether that moment has arrived is a +judgement about consumers rather than a property of this repository. + +```bash +git push --delete origin compat/v0.1.65 +git push --delete origin compat/v0.1.65-tls +git push --delete origin feat/file-carriers-compat +``` + +The `one implementation` workflow goes green on the next run, and the rule is +then true of the repository rather than of one branch. If it stays red, it is +naming a ref the commands above did not cover, which is the point of running the +script rather than the list. diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh new file mode 100755 index 0000000..3c3076d --- /dev/null +++ b/scripts/check-one-implementation.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Every branch CI builds is checked for a second Work Context implementation, +# not only the one being pushed. +# +# TestNoSecondWorkContextImplementation guards the tree it runs in, and that is +# the right place for it — but a branch's CI run uses that branch's own tree and +# workflow, so a test added on main can never execute on compat/v0.1.65. The +# branches that still carry the deleted JSON implementation would therefore stay +# green while containing exactly what the rule forbids. This sweep closes that: +# it reads every ref CI builds straight out of the object database and reports +# the ones carrying an implementation. +# +# It is deliberately NOT part of the pull-request checks. The refs it names are +# retired by the owner at the cold cutover, so until then it is red by design — +# a visible countdown rather than a silence. See docs/cutover.md. +# +# The predicate is narrow on purpose: a non-test Go file that imports a signing +# primitive AND mentions the Work Context. Importing ed25519 for something else +# is not this; mentioning the capability without signing it is not this either. +# Both together is a mint or a verify, wherever in the tree it lives — which +# matters because on the release lines these files sit at the repository root +# rather than under workcontext/. +set -euo pipefail + +primitives='crypto/ed25519|crypto/ecdsa' +subject='WorkContext' +status=0 + +refs=$(git for-each-ref --format='%(refname:short)' \ + 'refs/remotes/origin/main' 'refs/remotes/origin/compat/*' 'refs/remotes/origin/feat/*compat*') + +if [ -z "$refs" ]; then + echo "no refs to sweep: fetch the remote branches first (git fetch origin '+refs/heads/*:refs/remotes/origin/*')" >&2 + exit 1 +fi + +for ref in $refs; do + carrying="" + while IFS= read -r path; do + case "$path" in + *_test.go) continue ;; + *.go) ;; + *) continue ;; + esac + blob=$(git cat-file blob "$ref:$path" 2>/dev/null) || continue + if printf '%s' "$blob" | grep -Eq "\"($primitives)\"" && + printf '%s' "$blob" | grep -q "$subject"; then + carrying="$carrying $path" + fi + done <<< "$(git ls-tree -r --name-only "$ref")" + + if [ -n "$carrying" ]; then + status=1 + echo "FAIL $ref carries a Work Context implementation:" + for path in $carrying; do echo " $path"; done + else + echo "ok $ref" + fi +done + +if [ "$status" -ne 0 ]; then + cat >&2 <<'MSG' + +A wire contract has exactly one implementation, in the repository that owns the +type: codefly-dev/core/workcontext. The refs above still sign or verify a Work +Context themselves. + +If a ref above is a release line a consumer still pins, this is EXPECTED and the +answer is not to back-port the deletion — that would break the consumer the line +exists for. The answer is to retire the ref once its last consumer has repinned. +docs/cutover.md holds the preconditions and the exact commands, and the deletion +is performed by the repository owner at the cutover, never automatically. + +If a ref above is main, a second implementation has been reintroduced. Delete it. +MSG +fi +exit "$status" diff --git a/workcontext/README.md b/workcontext/README.md index 625dde1..bb4da2d 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -74,6 +74,17 @@ signature failure, which is the misdiagnosis the whole rule exists to prevent. That is why the gate lives here, in the consumer: core cannot see who re-implements it. +**And one sweep, because a test guards only its own tree.** A branch's CI run +uses that branch's own tree and workflow, so neither test above executes on the +release lines under `compat/**`, which still carry the deleted implementation in +full — deliberately, because back-porting the deletion would break the consumer +a release line exists for. `scripts/check-one-implementation.sh` (the +`one implementation` workflow) reads every ref CI builds out of the object +database and names the ones carrying a signer or a verifier. It is **red by +design** until those refs are retired, which is a visible countdown rather than +a silence; `docs/cutover.md` holds the precondition and the commands, and the +retirement is the owner's act at the cutover. + ## The model: mint once, sealed, verified exactly A module process obtains its credential **once per execution**, bound to the diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index ba4afce..d2f5856 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -75,6 +75,13 @@ var mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} // JSON-encode a capability, nothing may declare a type or function that reads // as a second WorkContext surface, and the verification entrypoint this module // exports is driven by core's own conformance fixtures. +// +// It guards the tree it runs in, which is all a test can do: a branch's CI run +// uses that branch's own tree, so this file never executes on the release lines +// that still carry the deleted implementation. +// scripts/check-one-implementation.sh is the other half — it reads every ref CI +// builds out of the object database — and docs/cutover.md says when those refs +// are retired and by whom. func TestNoSecondWorkContextImplementation(t *testing.T) { files := moduleFiles(t) require.NotEmpty(t, files, "the gate scanned no files, so it would pass for an empty module") From defe3cc7ad1cd6e42f40eaa2eb6af7c816095e5e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:07:57 -0400 Subject: [PATCH 07/35] fix(workcontext)!: refuse plaintext and redirects on every mint client, check every emission, and delete the compat extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review on #48 found three blockers and seven majors. This is all of them, plus the test-strength items. Transport. The mint URL must be absolute https: the projected service-account token travels on that request as a bearer credential, so plaintext was a disclosure the configuration could choose. Redirects are refused on EVERY client, on a copy of the one the caller supplied, before any request leaves — Go's own client forwards Authorization across a same-host redirect, so a supplied &http.Client{} disclosed the projection and the final-URL check named it afterwards. A transport that skips certificate verification or permits TLS below 1.2 is refused at construction. The test asserts CONTAINMENT: zero requests at the redirect destination, asserted before the classification, for a supplied client with no redirect policy and for one that explicitly follows. Streams. The fifteen-minute re-check cadence is deleted. Condition 1 is per emission, so the check is per emission: revoking the installation one second after a successful check now refuses the very next message, and expiry and source unavailability are visible at the same granularity. Any error from Recheck terminates, including one that is neither ErrRevoked nor ErrInvalid — a live source that could not be reached has not said the credential is good. One implementation. The gate is a REQUIRED pull-request check and it is green. It swept other refs and was "red by design" while three branches still carried the deleted implementation, which codified a compatibility period — the opposite of the rule. It now sweeps every tracked Go file in both modules, which is the half TestNoSecondWorkContextImplementation cannot reach: that test walks from the workcontext module root and the deleted implementation lived at the repository root. docs/cutover.md and its consumer inventory are gone; which refs remain, and when they are deleted, is the cold-cutover runbook's business and it lives outside this repository. Fixtures are generic. Majors. GRPCExecutionContextFromIncomingIfPresent and its tests are deleted. Refresh(ctx, refused) replaces a credential the host has refused, serialized and tied to the refused generation, so concurrent refusals produce one replacement mint. A credential is checked against its own window before it is installed — expired, not yet valid, or a lifetime entirely inside the renewal lead — with core's own skew. SealedInstallation is now the whole-seal check, so every carrier refuses a missing epoch, a missing incarnation and a partial binding through one function. Duplicate installation headers are refused by cardinality before comparison, as gRPC already did. Authority pins compare and install atomically under the write lock, whole or not at all. Also: every mint rechecks the pin, including the first, because a client built at boot may not be asked for a credential for an hour. Credential.OperationBinding returns the wire type rather than core's live OperationBinding, which gained PrincipalID and InstallationID at the new pin — filling those from a token would hand a reader "granted to nobody" instead of "the wire does not say". core is pinned to 80208d6c (the reviewed head of core#692): WorkActorV1 carries principal_epoch per hop, SealSource gained PrincipalEpoch, and an operation binding must be granted to the exercising principal within the sealed installation. Tests. Each of these fails against the code it replaces, verified by reverting the behaviour one at a time: the gate's five bypasses (a nested mint.go, an anonymous json-tagged struct, a function-local json type, a function-local WorkContext type, an alias of a local type) were all accepted before and are refused now; the conformance kit is driven through a verifier built as this module's exported type rather than core's own constructor; the redirect test fails on containment; the pin test moves the value after the failed read. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/one-implementation.yml | 38 +- AGENTS.md | 25 +- authority.go | 57 +- authority_pin_internal_test.go | 51 ++ authority_test.go | 71 ++- docs/cutover.md | 70 --- scripts/check-one-implementation.sh | 108 ++-- workcontext/README.md | 164 ++++-- workcontext/carrier.go | 123 ++-- workcontext/carrier_test.go | 114 +++- workcontext/core.go | 18 +- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- .../grpctransport/execution_context_grpc.go | 32 -- .../execution_context_grpc_test.go | 51 +- workcontext/grpctransport/two_service_test.go | 6 + workcontext/mint.go | 331 +++++++++-- workcontext/mint_test.go | 523 ++++++++++++++++-- workcontext/minting_test.go | 77 ++- workcontext/one_implementation_test.go | 459 +++++++++++---- workcontext/stream.go | 90 ++- workcontext/stream_test.go | 140 +++-- 22 files changed, 1920 insertions(+), 634 deletions(-) create mode 100644 authority_pin_internal_test.go delete mode 100644 docs/cutover.md diff --git a/.github/workflows/one-implementation.yml b/.github/workflows/one-implementation.yml index bec6565..b232f0a 100644 --- a/.github/workflows/one-implementation.yml +++ b/.github/workflows/one-implementation.yml @@ -1,29 +1,26 @@ # The Work Context has exactly one implementation and it lives in -# codefly-dev/core. TestNoSecondWorkContextImplementation guards the tree it -# runs in; this workflow is the half of that guarantee a test cannot reach. +# codefly-dev/core. TestNoSecondWorkContextImplementation guards the workcontext +# module; this sweeps the whole repository, which is the half that test cannot +# reach — it walks from the workcontext module root, and the implementation this +# repository deleted lived at the ROOT in package codefly. # -# A branch's CI run uses that branch's own tree and workflow, so a test added on -# main never executes on compat/v0.1.65. The release lines that still carry the -# deleted JSON implementation would stay green containing exactly what the rule -# forbids. This sweep reads every ref CI builds out of the object database -# instead, so the rule is true of the repository rather than of one branch. -# -# It is NOT a pull-request check, on purpose. The refs it names are retired by -# the owner at the cold cutover; until then it is red by design — a visible -# countdown rather than a silence. See docs/cutover.md. +# It is a REQUIRED pull-request check and it is green. It was neither: it ran +# only on main and on a schedule, and it swept other refs so that it was red +# while three branches still carried the deleted implementation — a documented +# compatibility period, which is the opposite of the rule it was added to +# enforce. Which refs still carry it, and when they are deleted, belongs to the +# cold-cutover runbook outside this repository; the gate here says only that the +# ref CI is building carries no second implementation, and it says it green. name: one implementation on: + pull_request: push: branches: [ main ] - schedule: - # Weekly, so a reintroduction on a branch nobody pushed to is still found, - # and so the countdown stays visible without anyone pushing anything. - - cron: '41 6 * * 1' workflow_dispatch: concurrency: - group: sdk-go-one-implementation + group: sdk-go-one-implementation-${{ github.ref }} cancel-in-progress: true permissions: @@ -31,18 +28,11 @@ permissions: jobs: sweep: - name: no second Work Context implementation, on any ref CI builds + name: no second Work Context implementation runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # The sweep reads other refs, so it needs them: a shallow checkout of - # one branch would report "no refs to sweep" and pass for the wrong - # reason, which the script refuses to do. - fetch-depth: 0 - - name: Fetch every branch CI builds - run: git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' - name: Sweep run: ./scripts/check-one-implementation.sh diff --git a/AGENTS.md b/AGENTS.md index 36a1824..f150c7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,16 +121,23 @@ see the skill below. re-exports its verifier by alias. Nothing here may sign, check a signature or encode a capability — `TestNoSecondWorkContextImplementation` refuses a `crypto/ed25519` import, an `encoding/json` import outside `mint.go`'s HTTP - bodies, a json-tagged struct that is not those bodies, and a `WorkContext*` - type of our own; `TestWorkContextConformance` drives core's fixtures through - the entry point we export. A claim added to the wire is added to the proto in + bodies (by exact path), a json-tagged struct that is not those bodies anywhere + in the file including inside a function, and a `WorkContext*` type of our own + — an alias counts only if it resolves into core's module; + `TestWorkContextConformance` drives core's fixtures through a verifier built as + the type we export, which is the behavioural half, the compile-time assertions + being the identity half. A claim added to the wire is added to the proto in core, never to an encoder here. This repository once held a second - implementation signing hand-written JSON: a token from either format failed - *signature* verification in the other, which reads like a rotated key. A test - guards only its own tree, so `scripts/check-one-implementation.sh` sweeps - every ref CI builds; it is **red by design** while a release line still - carries the old implementation — `docs/cutover.md` says who retires those and - when, and it is never an agent. + implementation signing hand-written JSON. The signatures were sound — each + side signed and verified the bytes it handled — but the key id is a field + *inside* the payload, so reading a protobuf payload as JSON yielded no key id + and the failure surfaced as "unknown key"/"signature does not verify", which + reads like a rotated key. That + test walks from the `workcontext` module root, and the deleted implementation + lived at the repository root, so `scripts/check-one-implementation.sh` sweeps + every tracked Go file in both modules as a **required check that is green**. + There is no compatibility period in it: refs outside this repository's build + that still carry it belong to the cold-cutover runbook, which lives elsewhere. - **A credential is sealed or it is not a credential.** Every field of `workcontext.Seal` is required at mint and at verify, an operation binding carries its id, revision and incarnation or none of them, and `Attach` diff --git a/authority.go b/authority.go index ef194d9..bd01a4d 100644 --- a/authority.go +++ b/authority.go @@ -98,14 +98,20 @@ func ReadAuthority(ctx context.Context, names ...AuthorityValueName) (*Authority if err != nil || strings.TrimSpace(value) == "" { return nil, fmt.Errorf("%w: %s", ErrAuthorityValueMissing, name) } - if pinned, ok := authorityPin(name); ok && pinned != value { - return nil, fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) - } values[name] = value } - authority := &Authority{readAt: time.Now().UTC(), values: values} - pinAuthorityValues(values) - return authority, nil + // Compare and install together, under one hold of the write lock. Checking + // the pins here and writing them afterwards was a race with a wrong + // outcome rather than a torn read: two goroutines reading the same name + // across a configuration reload both saw no pin, both resolved — to + // different values — and both succeeded, the second pin overwriting the + // first. Two live *Authority values then disagreed about the audience the + // process runs under, and the race detector sees nothing because every + // access was correctly locked. + if err := pinAuthorityValues(values); err != nil { + return nil, err + } + return &Authority{readAt: time.Now().UTC(), values: values}, nil } // Value answers from the frozen set. It performs no lookup, so it cannot @@ -132,12 +138,7 @@ func (a *Authority) Names() []AuthorityValueName { for name := range a.values { names = append(names, name) } - sort.Slice(names, func(i, j int) bool { - if names[i].Name != names[j].Name { - return names[i].Name < names[j].Name - } - return names[i].Key < names[j].Key - }) + sortAuthorityValueNames(names) return names } @@ -180,10 +181,40 @@ func authorityPin(name AuthorityValueName) (string, bool) { return value, ok } -func pinAuthorityValues(values map[AuthorityValueName]string) { +// pinAuthorityValues installs the whole proposed set or none of it. +// +// It compares every member against what is already pinned before it writes any +// member, so a set that conflicts in one name leaves the pins exactly as they +// were. A partial install would be the same defect ReadAuthority refuses for a +// partial read: a process running under an authority it could only half +// establish, with the half that moved silently replaced. +// +// Conflicts are reported in a stable order, so two processes racing on the same +// drift name the same value rather than whichever map iteration reached first. +func pinAuthorityValues(values map[AuthorityValueName]string) error { + names := make([]AuthorityValueName, 0, len(values)) + for name := range values { + names = append(names, name) + } + sortAuthorityValueNames(names) authorityPinsMu.Lock() defer authorityPinsMu.Unlock() + for _, name := range names { + if pinned, ok := authorityPins[name]; ok && pinned != values[name] { + return fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) + } + } for name, value := range values { authorityPins[name] = value } + return nil +} + +func sortAuthorityValueNames(names []AuthorityValueName) { + sort.Slice(names, func(i, j int) bool { + if names[i].Name != names[j].Name { + return names[i].Name < names[j].Name + } + return names[i].Key < names[j].Key + }) } diff --git a/authority_pin_internal_test.go b/authority_pin_internal_test.go new file mode 100644 index 0000000..190eb66 --- /dev/null +++ b/authority_pin_internal_test.go @@ -0,0 +1,51 @@ +package codefly + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The pin is installed whole or not at all, and never overwritten. +// +// This is tested at the primitive rather than through ReadAuthority because the +// defect was an interleaving, and an interleaving is not what a unit test can +// reproduce: two readers both observed no pin, both resolved — to different +// values, across a configuration reload — and both succeeded, the second +// install silently replacing the first. The race detector sees nothing, because +// every access was correctly locked. What IS testable is the property that +// makes the interleaving harmless, so that is what is asserted here: a proposed +// set is compared against the pins and installed under one hold of the write +// lock, and a set conflicting in any name installs none of its names. +func TestPinningAnAuthoritySetIsWholeOrNothing(t *testing.T) { + first := AuthorityValueName{Name: "pin-whole", Key: "audience"} + second := AuthorityValueName{Name: "pin-whole", Key: "binding"} + t.Cleanup(func() { + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + delete(authorityPins, first) + delete(authorityPins, second) + }) + + require.NoError(t, pinAuthorityValues(map[AuthorityValueName]string{first: "one"})) + + // A set whose first name conflicts must install neither name. + err := pinAuthorityValues(map[AuthorityValueName]string{ + first: "two", + second: "a value nobody has seen", + }) + require.ErrorIs(t, err, ErrAuthorityValueChanged) + require.ErrorContains(t, err, first.String()) + + pinned, ok := authorityPin(first) + require.True(t, ok) + require.Equal(t, "one", pinned, + "a conflicting install must not overwrite the pin the process already minted against") + _, ok = authorityPin(second) + require.False(t, ok, + "a set that conflicts in one name must install none of its names") + + // Re-installing the same values is not a conflict: reading the authority + // twice is allowed and must agree, which is the same comparison. + require.NoError(t, pinAuthorityValues(map[AuthorityValueName]string{first: "one"})) +} diff --git a/authority_test.go b/authority_test.go index b8b41cc..cc6ea44 100644 --- a/authority_test.go +++ b/authority_test.go @@ -1,6 +1,7 @@ package codefly_test import ( + "sync" "testing" codefly "github.com/codefly-dev/sdk-go" @@ -12,24 +13,24 @@ import ( // for these values; a process that already minted a credential against the old // one cannot be made correct by noticing the new one. func TestAuthorityValuesAreReadOnceAndADriftIsRefused(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "warden.evidence") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "example.audience") require.NoError(t, codefly.LoadEnvironmentVariables()) authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-one", Key: "audience"}) require.NoError(t, err) value, err := authority.Value("mint-one", "audience") require.NoError(t, err) - require.Equal(t, "warden.evidence", value) + require.Equal(t, "example.audience", value) require.NoError(t, authority.Recheck(t.Context()), "nothing has moved yet") // The host changes the value under the running process. - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "warden.something-else") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "example.other-audience") require.NoError(t, codefly.LoadEnvironmentVariables()) // The frozen read is unchanged — that is what "read once" means. value, err = authority.Value("mint-one", "audience") require.NoError(t, err) - require.Equal(t, "warden.evidence", value) + require.Equal(t, "example.audience", value) // And the drift is an error, not a reload. err = authority.Recheck(t.Context()) @@ -41,7 +42,7 @@ func TestAuthorityValuesAreReadOnceAndADriftIsRefused(t *testing.T) { // tolerate. A process whose audience has been removed must not keep serving on // the one it remembers. func TestAuthorityValueWithdrawnIsAChange(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "warden.evidence") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "example.audience") require.NoError(t, codefly.LoadEnvironmentVariables()) authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-gone", Key: "audience"}) require.NoError(t, err) @@ -88,7 +89,7 @@ func TestWorkspaceValueAnswersFromThePinAndRefusesADrift(t *testing.T) { // that did resolve would let a process serve under an identity it could not // fully establish, which is the quiet half of a missing credential. func TestReadAuthorityFailsWholeWhenOneNameIsMissing(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "warden.evidence") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "example.audience") require.NoError(t, codefly.LoadEnvironmentVariables()) _, err := codefly.ReadAuthority(t.Context(), @@ -98,16 +99,62 @@ func TestReadAuthorityFailsWholeWhenOneNameIsMissing(t *testing.T) { require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) require.ErrorContains(t, err, "mint-partial/binding") - // Nothing was pinned, so the ordinary accessor still answers live. + // Nothing was pinned. Reading the name back unchanged would prove nothing — + // a pinned name answers with the pinned value, which is the same string — + // so the host MOVES it and the accessor must follow, which it can only do + // if the failed read left it live. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "example.moved-after-the-failed-read") + require.NoError(t, codefly.LoadEnvironmentVariables()) value, err := codefly.For(t.Context()).WorkspaceValue("mint-partial", "audience") - require.NoError(t, err) - require.Equal(t, "warden.evidence", value) + require.NoError(t, err, "a read that failed whole must pin nothing") + require.Equal(t, "example.moved-after-the-failed-read", value) +} + +// Two processes-worth of concurrent reads of one name must not end up holding +// two authorities that disagree. Whichever value wins, every Authority that was +// handed back carries it; the losers are refused. +func TestConcurrentAuthorityReadsNeverDisagree(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_RACE__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + name := codefly.AuthorityValueName{Name: "mint-race", Key: "audience"} + + const readers = 16 + values := make(chan string, readers) + var waiting sync.WaitGroup + for range readers { + waiting.Add(1) + go func() { + defer waiting.Done() + authority, err := codefly.ReadAuthority(t.Context(), name) + if err != nil { + return + } + value, err := authority.Value(name.Name, name.Key) + if err != nil { + return + } + values <- value + }() + } + waiting.Wait() + close(values) + + var agreed string + for value := range values { + if agreed == "" { + agreed = value + continue + } + require.Equal(t, agreed, value, + "two concurrent reads produced authorities that disagree about %s", name) + } + require.NotEmpty(t, agreed, "at least one read must succeed") } // A name the process never declared is not answerable from a frozen set. // Declaring the set up front is what makes "read once" checkable at all. func TestAuthorityRefusesANameItNeverRead(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_DECLARED__AUDIENCE", "warden.evidence") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_DECLARED__AUDIENCE", "example.audience") require.NoError(t, codefly.LoadEnvironmentVariables()) authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-declared", Key: "audience"}) require.NoError(t, err) @@ -130,7 +177,7 @@ func TestAuthorityRefusesANameItNeverRead(t *testing.T) { // held to the pin exactly as Recheck would hold it, so a process cannot obtain // a second, differently-valued authority by asking again. func TestReadingTheAuthorityTwiceMustAgree(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "warden.evidence") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "example.audience") require.NoError(t, codefly.LoadEnvironmentVariables()) name := codefly.AuthorityValueName{Name: "mint-twice", Key: "audience"} first, err := codefly.ReadAuthority(t.Context(), name) @@ -145,7 +192,7 @@ func TestReadingTheAuthorityTwiceMustAgree(t *testing.T) { require.Equal(t, []codefly.AuthorityValueName{name}, second.Names()) require.False(t, second.ReadAt().IsZero()) - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "warden.other") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "example.second-audience") require.NoError(t, codefly.LoadEnvironmentVariables()) _, err = codefly.ReadAuthority(t.Context(), name) require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) diff --git a/docs/cutover.md b/docs/cutover.md deleted file mode 100644 index 5932fa7..0000000 --- a/docs/cutover.md +++ /dev/null @@ -1,70 +0,0 @@ -# Retiring the release lines that still carry the deleted implementation - -The Work Context has exactly one implementation, `codefly-dev/core/workcontext`. -On `main` that is enforced by `TestNoSecondWorkContextImplementation` and by -core's conformance kit, both in `workcontext/`. This page is about the branches -those tests cannot reach. - -## Why there is anything left to retire - -`compat/**` branches are published artifacts: a consumer pins one when `main` -holds an unreleased breaking change, and CI builds them like `main` -(`AGENTS.md`). The one-implementation change is exactly such a breaking change, -so the release lines still carry the root JSON signer, verifier and JWKS -verifier — in full, and deliberately. **Back-porting the deletion is the wrong -move**: it would break the consumer the line exists for. - -A branch's CI run uses that branch's own tree and workflow, so no test added on -`main` executes there. `scripts/check-one-implementation.sh`, run by the -`one implementation` workflow, reads every ref out of the object database -instead, and is **red by design** while any of them still carries an -implementation. That is a visible countdown, not a failure to fix. - -## The refs, and the one consumer pinned to them - -| Ref | Head at the time of writing | Carries | -| --- | --- | --- | -| `origin/compat/v0.1.65` | `8157d3a` (2026-09-27) | `work_context.go`, `work_context_jwks.go`, root `execution_context_grpc.go` | -| `origin/compat/v0.1.65-tls` | `eabe9b8` (2026-09-17) | the same | -| `origin/feat/file-carriers-compat` | `0fbc3ce` (2026-09-27) | the same | - -`feat/file-carriers-compat` is not under `compat/**`, so no published-artifact -rule names it; the sweep covers it anyway, because CI builds it. - -**One known consumer**, `obin-ai/module-robin`, pins -`github.com/codefly-dev/sdk-go v0.1.66-0.20260927165945-8157d3a…` — a commit on -`compat/v0.1.65`. That is why it still compiles `codefly.WorkContextVerifier` -and the other root identifiers, which left `main` on 2026-09-03 in `cd3613b`. - -## Precondition - -**Every consumer pinned to these refs has repinned to a release carrying the -one-implementation change.** For `module-robin` that is one move, not two: the -mint-once client has never existed on the `v0.1.65` line, so leaving the compat -line and adopting `workcontext.MintClient` happen together. - -Re-enumerate rather than trusting the table above — new pins and new branches -appear: - -```bash -git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' -./scripts/check-one-implementation.sh # names every ref still carrying one -``` - -## The retirement - -Performed by the repository owner at the cold cutover, once the precondition -holds. **Never by an agent, and never automatically** — deleting a published ref -breaks every build still pinned to it, and whether that moment has arrived is a -judgement about consumers rather than a property of this repository. - -```bash -git push --delete origin compat/v0.1.65 -git push --delete origin compat/v0.1.65-tls -git push --delete origin feat/file-carriers-compat -``` - -The `one implementation` workflow goes green on the next run, and the rule is -then true of the repository rather than of one branch. If it stays red, it is -naming a ref the commands above did not cover, which is the point of running the -script rather than the list. diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 3c3076d..7ac06cf 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -1,77 +1,111 @@ #!/usr/bin/env bash -# Every branch CI builds is checked for a second Work Context implementation, -# not only the one being pushed. +# The Work Context has exactly one implementation and it is not in this +# repository: it is codefly-dev/core/workcontext. This sweeps the WHOLE +# repository — both modules, every tracked Go file — for a second one. # -# TestNoSecondWorkContextImplementation guards the tree it runs in, and that is -# the right place for it — but a branch's CI run uses that branch's own tree and -# workflow, so a test added on main can never execute on compat/v0.1.65. The -# branches that still carry the deleted JSON implementation would therefore stay -# green while containing exactly what the rule forbids. This sweep closes that: -# it reads every ref CI builds straight out of the object database and reports -# the ones carrying an implementation. +# It is a required pull-request check and it is green. There is no compatibility +# period here and no countdown: a second implementation on a ref this repository +# builds is a failure to fix, not a state to document. # -# It is deliberately NOT part of the pull-request checks. The refs it names are -# retired by the owner at the cold cutover, so until then it is red by design — -# a visible countdown rather than a silence. See docs/cutover.md. +# It is not the same check as TestNoSecondWorkContextImplementation, which walks +# from the workcontext module root and therefore cannot see the rest of the +# repository. The implementation this repository deleted lived at the ROOT, in +# package codefly, which is exactly the place that test's walk does not reach. +# Hence a sweep over every tracked file rather than over one module. # # The predicate is narrow on purpose: a non-test Go file that imports a signing # primitive AND mentions the Work Context. Importing ed25519 for something else # is not this; mentioning the capability without signing it is not this either. -# Both together is a mint or a verify, wherever in the tree it lives — which -# matters because on the release lines these files sit at the repository root -# rather than under workcontext/. +# Both together is a mint or a verify, wherever in the tree it lives. +# +# Usage: +# scripts/check-one-implementation.sh # the working tree (what CI runs) +# scripts/check-one-implementation.sh ... # named refs, for an operator +# +# The second form exists for whoever is retiring a ref that still carries the +# deleted implementation. Which refs those are, and when they are deleted, is +# the cold-cutover runbook's business and it lives outside this repository: this +# repository holds the rule, not a consumer inventory. set -euo pipefail primitives='crypto/ed25519|crypto/ecdsa' subject='WorkContext' status=0 -refs=$(git for-each-ref --format='%(refname:short)' \ - 'refs/remotes/origin/main' 'refs/remotes/origin/compat/*' 'refs/remotes/origin/feat/*compat*') - -if [ -z "$refs" ]; then - echo "no refs to sweep: fetch the remote branches first (git fetch origin '+refs/heads/*:refs/remotes/origin/*')" >&2 - exit 1 -fi +carrying_in_tree() { + local found="" + while IFS= read -r path; do + case "$path" in + *_test.go) continue ;; + *.go) ;; + *) continue ;; + esac + if grep -Eq "\"($primitives)\"" "$path" && grep -q "$subject" "$path"; then + found="$found $path" + fi + done <<< "$(git ls-files -- '*.go')" + printf '%s' "$found" +} -for ref in $refs; do - carrying="" +carrying_in_ref() { + local ref="$1" found="" while IFS= read -r path; do case "$path" in *_test.go) continue ;; *.go) ;; *) continue ;; esac + local blob blob=$(git cat-file blob "$ref:$path" 2>/dev/null) || continue if printf '%s' "$blob" | grep -Eq "\"($primitives)\"" && printf '%s' "$blob" | grep -q "$subject"; then - carrying="$carrying $path" + found="$found $path" fi done <<< "$(git ls-tree -r --name-only "$ref")" + printf '%s' "$found" +} +report() { + local what="$1" carrying="$2" if [ -n "$carrying" ]; then status=1 - echo "FAIL $ref carries a Work Context implementation:" + echo "FAIL $what carries a Work Context implementation:" for path in $carrying; do echo " $path"; done else - echo "ok $ref" + echo "ok $what" fi -done +} + +if [ "$#" -eq 0 ]; then + files=$(git ls-files -- '*.go' | wc -l | tr -d ' ') + if [ "$files" -eq 0 ]; then + echo "no Go files tracked here: the sweep would pass for an empty repository" >&2 + exit 1 + fi + report "working tree ($files Go files)" "$(carrying_in_tree)" +else + for ref in "$@"; do + git rev-parse --verify --quiet "$ref" >/dev/null || { + echo "no such ref: $ref" >&2 + exit 1 + } + report "$ref" "$(carrying_in_ref "$ref")" + done +fi if [ "$status" -ne 0 ]; then cat >&2 <<'MSG' A wire contract has exactly one implementation, in the repository that owns the -type: codefly-dev/core/workcontext. The refs above still sign or verify a Work -Context themselves. - -If a ref above is a release line a consumer still pins, this is EXPECTED and the -answer is not to back-port the deletion — that would break the consumer the line -exists for. The answer is to retire the ref once its last consumer has repinned. -docs/cutover.md holds the preconditions and the exact commands, and the deletion -is performed by the repository owner at the cutover, never automatically. +type: codefly-dev/core/workcontext. The files above sign or verify a Work +Context here instead. -If a ref above is main, a second implementation has been reintroduced. Delete it. +Delete them and reach core's Verifier through workcontext/core.go. This +repository once held a second implementation signing hand-written JSON. The +signatures were sound; the key id is a field inside the payload, so reading a +protobuf payload as JSON yielded no key id and the failure surfaced as "unknown +key" / "signature does not verify under key X" — which reads like a rotated key, +so keys are what everyone investigated. MSG fi exit "$status" diff --git a/workcontext/README.md b/workcontext/README.md index bb4da2d..2577d09 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -60,30 +60,47 @@ It also pins the aliases by assignment (`var _ *corework.Verifier = a local copy with the same fields. **`TestWorkContextConformance`** runs `core/workcontext/conformance.RunWith` -against the verification entry point this module exports. The kit drives **21 -fixtures** through it — every token form (session, operation, delegated, +against a verifier built **field by field from the kit's settings as this +module's exported `Verifier`** — not through `conformance.Settings.Verifier()`, +which returns core's own and would have passed unchanged beside the +implementation this module deleted. The kit drives every fixture it carries +through it (the count moves as core adds cases; read it, never pin it) — every token form (session, operation, delegated, delegated-operation, grant) and every negative case, including a stale and a *future* installation revision, because the seal is compared for exact equality and there is no legitimate way to hold a capability sealed ahead of live — and fails the build if any outcome or any named sentinel differs. The single-use grant fixture is presented twice against one replay store, so a verifier -without a working one passes the other twenty and fails exactly there. The decisive fixture is the foreign encoding: a JSON-shaped -token that must be refused **before** its signature is checked, with -`ErrNotACoreToken`. A second implementation refuses that token too — as a -signature failure, which is the misdiagnosis the whole rule exists to prevent. -That is why the gate lives here, in the consumer: core cannot see who -re-implements it. - -**And one sweep, because a test guards only its own tree.** A branch's CI run -uses that branch's own tree and workflow, so neither test above executes on the -release lines under `compat/**`, which still carry the deleted implementation in -full — deliberately, because back-porting the deletion would break the consumer -a release line exists for. `scripts/check-one-implementation.sh` (the -`one implementation` workflow) reads every ref CI builds out of the object -database and names the ones carrying a signer or a verifier. It is **red by -design** until those refs are retired, which is a visible countdown rather than -a silence; `docs/cutover.md` holds the precondition and the commands, and the -retirement is the owner's act at the cutover. +without a working one passes everything else and fails exactly there. The +decisive fixture is the foreign encoding: a JSON-shaped token that must be +refused **before** its signature is checked, with `ErrNotACoreToken`. A second +implementation refuses that token too — but only after reading a key id out of a +payload it cannot read, so it refuses as "unknown key" or "signature does not +verify", which is the misdiagnosis the whole rule exists to prevent. The +signatures were never the problem: each implementation signed and verified the +bytes it handled. The key id is a field *inside* the payload, which is why +naming the format before any key lookup is the only diagnosis that points at the +format. That is why the gate lives here, +in the consumer: core cannot see who re-implements it. + +What the kit proves is **behavioural**: this entry point reaches core's +accept/refuse decision with core's named reason on every fixture. It does not +prove identity — an equivalent second implementation would pass the same +fixtures. Identity is the compile-time assertions (`var _ *corework.Verifier = +(*Verifier)(nil)`) and the static gate. Two halves, claimed separately. + +**And one sweep, because the test above walks from this module's root.** The +implementation this repository deleted lived at the REPOSITORY root, in package +`codefly` — exactly where that walk does not reach. +`scripts/check-one-implementation.sh` (the `one implementation` workflow) sweeps +every tracked Go file in both modules, and it is a **required pull-request check +that is green**. There is no compatibility period in it and no countdown: a +second implementation on a ref this repository builds is a failure to fix. + +Refs outside this repository's build that still carry the deleted +implementation are the cold-cutover runbook's business, and that runbook lives +outside this repository — this repository holds the rule, not a consumer +inventory. The script takes refs as arguments for whoever is doing that work +(`scripts/check-one-implementation.sh origin/some-ref`), which CI does not run. ## The model: mint once, sealed, verified exactly @@ -138,10 +155,20 @@ client, err := workcontext.NewMintClient(workcontext.MintOptions{ Audience: audience, ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), ProjectionAudience: projectionAudience, - Authority: authority, // rechecked before every renewal + Authority: authority, // rechecked before EVERY mint, including the first }) ``` +`URL` must be **absolute https** with no userinfo, query or fragment. The +projected service-account token travels on that request as a bearer credential, +so plaintext is a disclosure the configuration must not be able to choose; a +supplied `HTTPClient` whose transport skips certificate verification, or permits +TLS below 1.2, is refused for the same reason. Redirects are refused on **every** +client, on a copy of the one you supplied, before any request leaves: Go's own +client forwards `Authorization` across a redirect to the same host, so a +redirect that was merely classified afterwards would be classified after the +projection had already gone somewhere nothing configured. + Then, on every outbound request: ```go @@ -154,11 +181,48 @@ if err := credential.Attach(request); err != nil { } ``` -`Credential` is the only way to reach the credential, so there is no path on -which a stale one is used. It re-reads the projected token and rechecks the -boot-read authority before each renewal: a projection rotated under the running -process is picked up without a restart, and an authority value that has drifted -refuses the renewal instead of being sealed into a new credential. +`Credential` mints the first credential and renews one that has reached its +renewal point, so no caller obtains a credential the client knows to be past +that point. It is **not** a guarantee about a credential a caller already holds: +`Credential` is a value, so one read out and kept can be attached after the host +has moved the state under it. That is what `Refresh` is for, and it is why a +receiver verifies rather than trusting that a caller re-asked. + +Every mint re-reads the projected token and rechecks the pinned authority — the +first one included, because a client constructed at boot may not be asked for a +credential until an hour later and the value can have moved in between. A +drifted value refuses the mint instead of being sealed into a new credential. +`Authority` is optional; without it there is no pin for the client to check, +which is why a process that resolved its audience through `ReadAuthority` should +pass it. + +A credential is also checked against **its own window** before it is installed: +an expired or not-yet-valid response, or one whose whole lifetime sits inside the +renewal lead, is refused rather than held and counted as a mint that worked. The +tolerance is `core/workcontext.DefaultSkew`, so the client is not stricter than +the verifier that will accept the credential. + +### When the host refuses the credential you hold + +`ErrRevoked` from the far end means the credential was sound when it was minted +and the state moved under it. Hand the refused credential back: + +```go +credential, err := client.Credential(ctx) +// ... the call is refused with ErrRevoked ... +credential, err = client.Refresh(ctx, credential) +``` + +`Refresh` is tied to the credential that was refused, not to the clock. If the +client has already replaced that one, the replacement is returned and nothing is +minted — so a burst of concurrent refusals on one credential produces exactly +one replacement mint, and a caller holding a credential two generations old +cannot roll the client backwards. It rechecks the pin and re-reads the +projection like any other mint, and a refused replacement leaves the credential +already held in place rather than clearing it. + +`client.Counts()` returns `MintCounts{Mints, Renewals, Refreshes}`. A correct +execution reports exactly one mint. `Attach` sets `x-codefly-work-context` and, beside it, `x-codefly-installation-id` / `x-codefly-installation-revision`. The gRPC @@ -271,14 +335,19 @@ check. ## Streams A stream authorized when it opened is not authorized forever. `StreamGuard` -re-presents the credential on the host's re-check cadence and terminates on -refusal, rather than draining a snapshot computed under authority that has since -been replaced — the failure mode it exists to prevent is the silent one, where +re-presents the credential **before every message** and terminates on refusal, +rather than draining a snapshot computed under authority that has since been +replaced — the failure mode it exists to prevent is the silent one, where nothing errors and the data keeps arriving. +There was a re-check cadence here, and it was a weakening of the rule rather +than an implementation of it: revoke the installation one second after a +successful check and every message for the next interval still left, with expiry +and source unavailability equally invisible for that whole window. The condition +is per emission, so the check is per emission. + ```go guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ - Interval: hostRecheckCadence, Recheck: func(ctx context.Context) error { // The same verification an ordinary call performs, against the // issuer's state as it is NOW — not the state the stream opened with. @@ -297,9 +366,18 @@ for message := range messages { ``` It guards emission rather than running a timer: an idle stream discloses -nothing, so there is nothing to refuse. Termination is sticky — a caller that -loops past the first refusal is not handed a second chance to emit, and the -authority coming back does not resurrect the stream. +nothing, so there is nothing to refuse. **Any** error from `Recheck` terminates, +including one that is neither `ErrRevoked` nor `ErrInvalid` — a live source that +could not be reached has not said the credential is good, and treating "I could +not ask" as a pass is how a stream outlives its authority with clean logs. +Termination is sticky: a caller that loops past the first refusal is not handed +a second chance to emit, and the authority coming back does not resurrect the +stream. + +What this costs is one live authorization check per message. That is the price +of the guarantee, and it is why the guard is for streams whose messages carry +authority rather than for every stream. A stream that cannot pay it does not get +the guarantee; it does not get a cadence instead. ## Cache partitions @@ -354,7 +432,20 @@ another partition's key. module re-exports; a consumer that was verifying here owes the four sources above, and that is the honest price of there being one implementation. - **Stop searching bindings.** Resolve the sealed binding identity. A superset - of scopes is not a licence to act as a different binding. + of scopes is not a licence to act as a different binding. Note the two types: + `SealedOperationBinding` is what a capability CARRIES (id, revision, + incarnation) and `OperationBinding` is the LIVE state an issuer holds (those + three plus the principal it is granted to, the installation it is granted + within, and whether it is revoked). They are deliberately different, because a + client filling the live type from a token would hand a reader an empty + `PrincipalID` that reads as "granted to nobody" rather than as "the wire does + not say". +- **Stop extracting attribution optionally.** + `GRPCExecutionContextFromIncomingIfPresent` is **deleted**. A + capability-bearing path requires the capability, so a call carrying no + carriers at all is refused exactly like one carrying half of them. A consumer + that relied on `present=false` now fails to compile rather than silently + keeping its old path, which is the point. - **Stop authorizing from bare claims.** Read claims off a `*Verified` and nowhere else. Claims that were never verified, or were mutated after verification, cannot reach an authorization decision. @@ -385,6 +476,13 @@ go test ./... -coverprofile=cover.out -covermode=atomic -coverpkg=./... go tool cover -func=cover.out | awk '/^total:/ {print $3}' ``` +The `one implementation` sweep is a required check and runs at the repository +root: + +```bash +./scripts/check-one-implementation.sh +``` + Until core#691 is released, this module is pinned to a **pseudo-version of core's branch** (`go get github.com/codefly-dev/core@`), not a local `replace`: a pseudo-version is reproducible for anyone who checks the branch diff --git a/workcontext/carrier.go b/workcontext/carrier.go index 6fd84c8..b1ce0a9 100644 --- a/workcontext/carrier.go +++ b/workcontext/carrier.go @@ -73,41 +73,61 @@ func FromHeaders(headers http.Header) (string, error) { return "", fmt.Errorf("%w: %s appears %d times", ErrInvalid, HeaderName, len(values)) } encoded := values[0] - if err := checkCarriedInstallation(headers.Get(InstallationIDHeaderName), - headers.Get(InstallationRevisionHeaderName), encoded); err != nil { + if err := checkCarriedInstallation(headers, encoded); err != nil { return "", err } return encoded, nil } // checkCarriedInstallation refuses a request whose installation carriers -// disagree with the seal. Agreement is required when a carrier is present and -// the carriers are required together: half of a pre-check is a pre-check that -// passes for the wrong reason. -func checkCarriedInstallation(id, revision, encoded string) error { - if id == "" && revision == "" { +// disagree with the seal, or whose carriers are stated more than once. +// +// Cardinality is checked before agreement, which Header.Get made impossible: +// it reads the FIRST value, so `[sealed-id, something-else]` compared equal to +// the seal and the call was accepted while carrying two contradictory +// installations. Whether a later intermediary reads the first or the second is +// not something this module can decide, so an ambiguous carrier is refused +// here — the same contract gRPC already held, now stated once for both. +func checkCarriedInstallation(headers http.Header, encoded string) error { + ids := headers.Values(InstallationIDHeaderName) + revisions := headers.Values(InstallationRevisionHeaderName) + if len(ids) == 0 && len(revisions) == 0 { return nil } - if id == "" || revision == "" { - return fmt.Errorf( - "%w: %s and %s travel together; one was presented without the other", - ErrInvalid, InstallationIDHeaderName, InstallationRevisionHeaderName, - ) + for _, carrier := range []struct { + name string + values []string + }{ + {InstallationIDHeaderName, ids}, + {InstallationRevisionHeaderName, revisions}, + } { + if len(carrier.values) == 0 { + return fmt.Errorf( + "%w: %s and %s travel together; one was presented without the other", + ErrInvalid, InstallationIDHeaderName, InstallationRevisionHeaderName, + ) + } + if len(carrier.values) != 1 { + return fmt.Errorf( + "%w: %s requires exactly one value and carries %d", + ErrInvalid, carrier.name, len(carrier.values), + ) + } } sealedID, sealedRevision, err := SealedInstallation(encoded) if err != nil { return err } - if id != sealedID { + if ids[0] != sealedID { return fmt.Errorf( "%w: %s carries %q and the capability is sealed to %q", - ErrInvalid, InstallationIDHeaderName, id, sealedID, + ErrInvalid, InstallationIDHeaderName, ids[0], sealedID, ) } - if revision != sealedRevision { + if revisions[0] != sealedRevision { return fmt.Errorf( "%w: %s carries %q and the capability is sealed to %q", - ErrInvalid, InstallationRevisionHeaderName, revision, sealedRevision, + ErrInvalid, InstallationRevisionHeaderName, revisions[0], sealedRevision, ) } return nil @@ -116,29 +136,30 @@ func checkCarriedInstallation(id, revision, encoded string) error { // SealedInstallation reads the installation a capability is sealed to, for a // transport that names it beside the capability. // +// It is the WHOLE seal check and not an installation check. It used to read +// just the installation id and revision, which made "Attach refuses an unsealed +// capability" true of two fields out of four: a capability with no principal +// epoch, no build incarnation, or a binding naming an id at no revision was +// attached and travelled, and the missing field is the one an attacker would +// choose to leave out. Every carrier in this module — HTTP attach, HTTP read, +// outgoing gRPC metadata, incoming gRPC metadata — goes through here, so there +// is one answer to "is this sealed" rather than one per transport. +// // It reads the capability's own content without checking the signature, which // is sound only because nothing trusts the result: the carrier it fills is a // pre-check, and a receiver that preferred it over the sealed claim would have -// made the carrier into authority. A capability with no readable seal is an -// error here, so no transport can attach one while leaving the installation -// blank. +// made the carrier into authority. func SealedInstallation(encoded string) (id string, revision string, err error) { - claims, err := claimsOf(encoded) + _, seal, _, err := sealOf(encoded) if err != nil { return "", "", err } - seal := claims.GetSeal() - if seal.GetInstallationId() == "" || seal.GetInstallationRevision() == 0 { - return "", "", fmt.Errorf("%w: the capability names no installation", ErrUnsealed) - } - return seal.GetInstallationId(), strconv.FormatUint(seal.GetInstallationRevision(), 10), nil + return seal.InstallationID, strconv.FormatUint(seal.InstallationRevision, 10), nil } // claimsOf decodes a capability's claims WITHOUT establishing any trust in -// them. Nothing in this module authorizes from what it returns: the two callers -// are the pre-check carriers above, and the mint client reading the lifetime -// and the seal of a capability the host just handed it over an authenticated -// channel. +// them. Nothing in this module authorizes from what it returns: its one caller +// is sealOf, which the pre-check carriers and the mint client reach it through. // // It decodes core's encoding with core's generated type, so there is no second // reading of the wire here. The one thing it must not become is a verification: @@ -182,15 +203,21 @@ func claimsOf(encoded string) (*Claims, error) { return claims, nil } -// sealOf reads the claims, the seal and the operation binding of a capability -// the host has just issued over an authenticated channel. +// sealOf reads the claims, the seal and the sealed operation binding of a +// capability, and is the one structural seal check in this module. // -// It requires a whole seal: every field, and for an operation binding all three -// of its fields or none of them. A partial seal is refused here and not carried, -// because a capability sealed to an installation with no revision, or to a -// binding id at no revision, names an authority nobody approved — and the field -// that is missing is the one an attacker would choose to leave out. -func sealOf(encoded string) (*Claims, Seal, *OperationBinding, error) { +// It requires a whole seal: every field of it, and for an operation binding all +// three of its fields or none of them. A partial seal is refused here and not +// carried, because a capability sealed to an installation with no revision, or +// to a binding id at no revision, names an authority nobody approved — and the +// field that is missing is the one an attacker would choose to leave out. +// +// Refusing it here is a preflight and not the authorization: core's verifier +// refuses the same capability at the far end. The preflight is what makes the +// refusal legible — it names the field that is missing, in the process that +// holds the credential, rather than arriving as a mismatch in a service that +// cannot do anything about it. +func sealOf(encoded string) (*Claims, Seal, *SealedOperationBinding, error) { claims, err := claimsOf(encoded) if err != nil { return nil, Seal{}, nil, err @@ -219,16 +246,24 @@ func sealOf(encoded string) (*Claims, Seal, *OperationBinding, error) { if bound == nil { return claims, seal, nil, nil } - binding := &OperationBinding{ - ID: bound.GetBindingId(), - Revision: bound.GetRevision(), - Incarnation: bound.GetIncarnation(), - } - if binding.ID == "" || binding.Revision == 0 || binding.Incarnation == 0 { + if bound.GetBindingId() == "" || bound.GetRevision() == 0 || bound.GetIncarnation() == 0 { return nil, Seal{}, nil, fmt.Errorf( "%w: an operation binding carries its id, revision and incarnation or none of them", ErrUnsealed, ) } - return claims, seal, binding, nil + return claims, seal, cloneSealedBinding(bound), nil +} + +// cloneSealedBinding copies the message, so a caller that mutates what it is +// handed cannot change what a credential reports it is bound to. +func cloneSealedBinding(bound *SealedOperationBinding) *SealedOperationBinding { + if bound == nil { + return nil + } + copied, ok := proto.Clone(bound).(*SealedOperationBinding) + if !ok { + return nil + } + return copied } diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go index 82154f8..4f00740 100644 --- a/workcontext/carrier_test.go +++ b/workcontext/carrier_test.go @@ -147,17 +147,127 @@ func TestFromHeadersRequiresExactlyOneCapability(t *testing.T) { // An operation binding carries its id, revision and incarnation or none of // them: a capability sealed to a binding id at no revision names an authority -// nobody approved. +// nobody approved, and the field that is missing is the one an attacker would +// choose to leave out. +// +// The earlier version of this test supplied only complete and absent bindings, +// so deleting the partial-binding rejection would not have failed it. The +// partial cases are what it is for. func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { a := newAuthority(t) _, _, binding, err := sealOf(a.start(t, mintInput{binding: testBinding})) require.NoError(t, err) require.NotNil(t, binding) - require.Equal(t, OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1}, *binding) + require.Equal(t, testBinding, binding.GetBindingId()) + require.EqualValues(t, 2, binding.GetRevision()) + require.EqualValues(t, 1, binding.GetIncarnation()) _, _, none, err := sealOf(a.start(t, mintInput{})) require.NoError(t, err) require.Nil(t, none, "a session that exercises no binding seals none") + + // A binding missing one of its three fields is refused, and so is the whole + // capability: a credential is sealed or it is not a credential. + for _, field := range []string{"Binding.ID", "Binding.Revision", "Binding.Incarnation"} { + t.Run(field, func(t *testing.T) { + token := resealWithout(t, a.start(t, mintInput{binding: testBinding}), field) + + _, _, _, err := sealOf(token) + require.ErrorIs(t, err, ErrUnsealed) + require.ErrorContains(t, err, "id, revision and incarnation or none of them") + + // And it never reaches a request, on either transport. + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, token), ErrUnsealed) + require.Empty(t, request.Header.Get(HeaderName)) + _, _, err = SealedInstallation(token) + require.ErrorIs(t, err, ErrUnsealed) + }) + } +} + +// Every field of the seal is required, and the check is the SAME check for +// every carrier. SealedInstallation used to read only the installation id and +// revision, which made "Attach refuses an unsealed capability" true of two +// fields out of four: a capability with no principal epoch or no build +// incarnation was attached and travelled. +func TestEverySealedFieldIsRequiredOnEveryCarrier(t *testing.T) { + a := newAuthority(t) + for field, says := range map[string]string{ + "PrincipalEpoch": "no principal epoch", + "InstallationID": "no installation", + "InstallationRevision": "no installation revision", + "BuildIncarnation": "no build incarnation", + } { + t.Run(field, func(t *testing.T) { + token := resealWithout(t, a.start(t, mintInput{}), field) + + _, _, _, err := sealOf(token) + require.ErrorIs(t, err, ErrUnsealed) + require.ErrorContains(t, err, says) + + // The outbound HTTP carrier. + request := httptest.NewRequest(http.MethodGet, "/records", nil) + err = Attach(request, token) + require.ErrorIs(t, err, ErrUnsealed) + require.ErrorContains(t, err, says) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + + // The inbound HTTP carrier, with the installation carriers stated + // so the check is reached at all. + headers := http.Header{} + headers.Set(HeaderName, token) + headers.Set(InstallationIDHeaderName, testInstallation) + headers.Set(InstallationRevisionHeaderName, "3") + _, err = FromHeaders(headers) + require.ErrorIs(t, err, ErrUnsealed) + + // And the one function both gRPC directions go through. + _, _, err = SealedInstallation(token) + require.ErrorIs(t, err, ErrUnsealed) + require.ErrorContains(t, err, says) + }) + } +} + +// An installation carrier stated twice is ambiguous, and ambiguous is refused. +// +// Header.Get reads the FIRST value, so [sealed-id, something-else] compared +// equal to the seal and the call was accepted while carrying two contradictory +// installations. Whether a later intermediary reads the first or the second is +// not something this module can decide — gRPC already required exactly one, and +// now both transports do. +func TestDuplicateInstallationCarriersAreRefused(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + + for name, build := range map[string]func(http.Header){ + "id stated twice, the second disagreeing": func(headers http.Header) { + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationIDHeaderName, "installation-the-caller-added") + headers.Set(InstallationRevisionHeaderName, "3") + }, + "id stated twice, both agreeing": func(headers http.Header) { + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Set(InstallationRevisionHeaderName, "3") + }, + "revision stated twice": func(headers http.Header) { + headers.Set(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationRevisionHeaderName, "3") + headers.Add(InstallationRevisionHeaderName, "4") + }, + } { + t.Run(name, func(t *testing.T) { + headers := http.Header{} + headers.Set(HeaderName, token) + build(headers) + _, err := FromHeaders(headers) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "exactly one value") + }) + } } // A token in another encoding is refused as that, by name, in the one place diff --git a/workcontext/core.go b/workcontext/core.go index a5ad65b..89d89a7 100644 --- a/workcontext/core.go +++ b/workcontext/core.go @@ -32,10 +32,24 @@ type Verified = corework.Verified // sealed values against it for exact equality. type Seal = corework.Seal -// OperationBinding is the live state of one unit of authority, resolved by its -// opaque id — never searched for by the scopes it contains. +// OperationBinding is the LIVE state of one unit of authority, as the issuer +// holds it: resolved by its opaque id — never searched for by the scopes it +// contains — and carrying the principal it is granted to and the installation +// it is granted within, because an id that exists is not an id somebody holds. +// +// It is what a verifier's seal source answers, not what a capability carries. +// SealedOperationBinding is the carried half, and the two are deliberately +// different types: the capability names three fields, the live state names six, +// and a client that filled the live type from a token would be handing a reader +// an empty PrincipalID and InstallationID that read as "granted to nobody, +// nowhere" rather than as "the wire does not say". type OperationBinding = corework.OperationBinding +// SealedOperationBinding is the operation binding a capability carries: the +// binding's id, and its revision and incarnation at mint time. It is core's own +// message for that, so there is no second spelling of what travels. +type SealedOperationBinding = basev0.WorkOperationBindingV1 + // SealSource answers the live values a seal is compared against. type SealSource = corework.SealSource diff --git a/workcontext/go.mod b/workcontext/go.mod index 7248b6b..b162bcc 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8 + github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 85b3890..9f1c165 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8 h1:rQW1NUyhFruE4iqiJF8dW3N4HlHuyI8+4niFaIWfxc8= -github.com/codefly-dev/core v0.7.2-0.20261002174635-56a98dd580f8/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc h1:En8Qs79umv99OMKG+BjM1OYeR2xhvGWoPSADfEU93jc= +github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/execution_context_grpc.go b/workcontext/grpctransport/execution_context_grpc.go index d138b6e..2804cfa 100644 --- a/workcontext/grpctransport/execution_context_grpc.go +++ b/workcontext/grpctransport/execution_context_grpc.go @@ -187,38 +187,6 @@ func checkIncomingInstallation( return nil } -// GRPCExecutionContextFromIncomingIfPresent supports compatibility boundaries -// where execution attribution is optional. No carrier returns present=false; -// a partial or duplicate carrier is still an error. -func GRPCExecutionContextFromIncomingIfPresent( - ctx context.Context, -) (execution ExecutionContext, present bool, err error) { - if ctx == nil { - return ExecutionContext{}, false, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) - } - values, ok := metadata.FromIncomingContext(ctx) - if !ok { - return ExecutionContext{}, false, nil - } - carried := 0 - for _, carrier := range []string{ - workContextGRPCMetadataName, - operationIDGRPCMetadataName, - installationIDGRPCMetadataName, - installationRevisionGRPCMetadataName, - } { - carried += len(values.Get(carrier)) - } - if carried == 0 { - return ExecutionContext{}, false, nil - } - execution, err = GRPCExecutionContextFromIncoming(ctx) - if err != nil { - return ExecutionContext{}, false, err - } - return execution, true, nil -} - func validateOperationID(operationID string) error { if operationID == "" { return fmt.Errorf("%w: operation ID is required", workcontext.ErrInvalid) diff --git a/workcontext/grpctransport/execution_context_grpc_test.go b/workcontext/grpctransport/execution_context_grpc_test.go index 7a22f70..10d1b01 100644 --- a/workcontext/grpctransport/execution_context_grpc_test.go +++ b/workcontext/grpctransport/execution_context_grpc_test.go @@ -111,21 +111,6 @@ func TestGRPCExecutionContextRejectsMissingOrNonCanonicalOperationID(t *testing. require.ErrorContains(t, err, "operation ID requires exactly one value") } -func TestGRPCExecutionContextOptionalExtractionDistinguishesAbsentFromPartial(t *testing.T) { - execution, present, err := GRPCExecutionContextFromIncomingIfPresent(context.Background()) - require.NoError(t, err) - require.False(t, present) - require.Empty(t, execution.OperationID()) - - partial := metadata.NewIncomingContext( - context.Background(), - metadata.Pairs(operationIDGRPCMetadataName, "operation-1"), - ) - _, present, err = GRPCExecutionContextFromIncomingIfPresent(partial) - require.ErrorContains(t, err, "Work Context requires exactly one value") - require.False(t, present) -} - // Deliverable 3 on the gRPC side: the sealed installation travels beside the // capability on every call, under the same names HTTP uses, so a callee can // refuse before it decodes anything. @@ -182,19 +167,25 @@ func TestIncomingInstallationCarriersAreHeldToTheSeal(t *testing.T) { } } -// A partial carrier set is an error rather than an absence: the optional- -// attribution boundary exists for a call that carries nothing at all, not for -// one that carries half of an authority. -func TestIfPresentTreatsAPartialInstallationCarrierAsAnError(t *testing.T) { - _, present, err := GRPCExecutionContextFromIncomingIfPresent( - metadata.NewIncomingContext(context.Background(), metadata.MD{}), - ) - require.NoError(t, err) - require.False(t, present) - - _, _, err = GRPCExecutionContextFromIncomingIfPresent(metadata.NewIncomingContext( - context.Background(), - metadata.Pairs(workcontext.InstallationIDHeaderName, sealedInstallationID), - )) - require.ErrorIs(t, err, workcontext.ErrInvalid) +// There is no optional extractor any more. A capability-bearing path requires +// the capability, so a call carrying no carriers at all is refused exactly like +// one carrying half of them — the compatibility boundary that returned +// present=false is deleted, and a consumer that relied on it now fails to +// compile rather than silently keeping its old path. +func TestExtractionRefusesACallThatCarriesNothing(t *testing.T) { + for name, incoming := range map[string]context.Context{ + "no metadata at all": context.Background(), + "empty metadata": metadata.NewIncomingContext( + context.Background(), metadata.MD{}, + ), + "installation only": metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(workcontext.InstallationIDHeaderName, sealedInstallationID), + ), + } { + t.Run(name, func(t *testing.T) { + _, err := GRPCExecutionContextFromIncoming(incoming) + require.ErrorIs(t, err, workcontext.ErrInvalid) + }) + } } diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index d761db0..d3a448a 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -90,6 +90,12 @@ func newHopAuthority(t *testing.T) *hopAuthority { require.NoError(t, err) seals := corework.NewMemorySealSource() require.NoError(t, seals.Put(hopPrincipal, hopSeal())) + // The delegated actor needs a live epoch of its own. An actor's authority is + // narrowed independently of the owner's — the owner here is one service and + // the actor another — so core seals the actor's epoch per hop and refuses a + // hop that carries none. Recording only the owner's seal left the actor + // unrevocable, which is a capability nobody can withdraw. + require.NoError(t, seals.PutEpoch(hopActor, 1)) return &hopAuthority{ minter: &corework.Authority{ Issuer: hopIssuer, KeyID: hopKeyID, Key: privateKey, diff --git a/workcontext/mint.go b/workcontext/mint.go index 359b867..8319970 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -3,6 +3,7 @@ package workcontext import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -15,6 +16,8 @@ import ( "strings" "sync" "time" + + corework "github.com/codefly-dev/core/workcontext" ) // Bounds on everything the mint endpoint can hand back. A credential is the @@ -97,21 +100,27 @@ func (path ProjectedTokenFile) ProjectedToken() (string, error) { return token, nil } -// AuthorityPin is the boot-read authority a renewal is checked against. The +// AuthorityPin is the boot-read authority every mint is checked against. The // root SDK's *codefly.Authority satisfies it. // -// It exists so a renewal cannot re-seal a credential to a value that drifted -// after boot. The host is the authority for the audience and the binding a -// process mints against; a value that changes under a running process is an -// error, and the renewal is exactly the moment that error would otherwise be -// laundered into a new, differently-sealed credential. +// It exists so a mint cannot seal a credential to a value that drifted after +// boot. The host is the authority for the audience and the binding a process +// mints against; a value that changes under a running process is an error, and +// a mint is exactly the moment that error would otherwise be laundered into a +// new, differently-sealed credential. +// +// It is checked before the FIRST mint too, and not only before a renewal: a +// process may construct its client at boot and make its first call an hour +// later, and the value can have moved in between. type AuthorityPin interface { Recheck(ctx context.Context) error } // MintOptions configures the one credential a process obtains per execution. type MintOptions struct { - // URL is the host's mint endpoint, absolute, no query and no fragment. + // URL is the host's mint endpoint: absolute HTTPS, no query, no fragment, + // no userinfo. Plain HTTP is refused — the projected service-account token + // travels on this request as a bearer credential. URL string // Audience is the Work Context audience to mint for, as the host names it. // It is read from the process's boot-read authority, never typed. @@ -123,11 +132,14 @@ type MintOptions struct { // for, which the host states. Sent so the host can refuse a projection // aimed at something else rather than reviewing whatever it is handed. ProjectionAudience string - // Authority, when set, is rechecked before every renewal. + // Authority, when set, is rechecked before every mint, including the first. + // A process that resolved its audience or binding through ReadAuthority + // passes it here; without it there is no pin for this client to check and + // the drift refusal is not available to it. Authority AuthorityPin - // HTTPClient defaults to a client that refuses to follow redirects: a - // redirected mint request is a credential request sent somewhere the - // configuration did not name. + // HTTPClient is used as given except that redirects are refused on a copy + // of it, whatever it was configured to do. A redirected mint request is a + // credential request sent somewhere the configuration did not name. HTTPClient *http.Client // RequestTimeout bounds one mint request. RequestTimeout time.Duration @@ -139,13 +151,22 @@ type MintOptions struct { } // Credential is the sealed capability a process holds for its execution. It is -// obtained once by Mint and replaced only by renewal at expiry. +// obtained once by Mint and replaced only by renewal at expiry or by Refresh +// after the host has refused it. type Credential struct { token string seal Seal - binding *OperationBinding + binding *SealedOperationBinding issuedAt time.Time expiresAt time.Time + + // generation identifies which credential of this client's life this is. It + // is what Refresh is told about, so a caller holding a credential the host + // has refused asks for a replacement for THAT one — and two callers + // refused on the same credential get one replacement rather than two + // mints. It is deliberately not exported: it means nothing outside the + // client that issued it. + generation uint64 } // Token returns the opaque signed capability, as it travels. It is a string and @@ -158,13 +179,11 @@ func (c Credential) Token() string { return c.token } func (c Credential) Seal() Seal { return c.seal } // OperationBinding returns the one binding this credential may act through, or -// nil. -func (c Credential) OperationBinding() *OperationBinding { - if c.binding == nil { - return nil - } - binding := *c.binding - return &binding +// nil. It is the binding AS SEALED — the three fields the capability carries — +// and not the live state of that binding, which only the issuer holds and which +// core's OperationBinding describes. +func (c Credential) OperationBinding() *SealedOperationBinding { + return cloneSealedBinding(c.binding) } // IssuedAt reports when the host minted it. @@ -179,6 +198,15 @@ func (c Credential) Attach(request *http.Request) error { return Attach(request, c.token) } +// MintCounts is what a process's credential life cost: one first mint, a +// renewal per expiry reached, and a refresh per distinct credential the host +// refused. A correct execution reports exactly one mint. +type MintCounts struct { + Mints uint64 + Renewals uint64 + Refreshes uint64 +} + // MintClient obtains and renews one credential for one execution. // // What it does not do is the point of it. There is no heartbeat: nothing here @@ -195,8 +223,8 @@ type MintClient struct { httpClient *http.Client now func() time.Time credential *Credential - mints uint64 - renewals uint64 + generation uint64 + counts MintCounts } // NewMintClient validates configuration without performing any I/O. @@ -235,13 +263,9 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if now == nil { now = time.Now } - client := options.HTTPClient - if client == nil { - client = &http.Client{ - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } + client, err := secureMintClient(options.HTTPClient) + if err != nil { + return nil, err } options.URL = endpoint options.RequestTimeout = timeout @@ -249,9 +273,85 @@ func NewMintClient(options MintOptions) (*MintClient, error) { return &MintClient{options: options, httpClient: client, now: now}, nil } +// secureMintClient returns the client every mint request is made with: +// the caller's, copied, with redirects refused on the copy. +// +// Refusing them on a client of our own was not enough. The projection is on the +// request as a bearer token, and Go's own client forwards Authorization across a +// redirect to the same host — so a caller that supplied an ordinary +// &http.Client{} disclosed the projection to whatever that host redirected to, +// before anything here could classify the answer. The refusal is therefore +// installed on EVERY client, before any request leaves. It is installed on a +// copy so a caller that uses its client elsewhere keeps its own redirect +// behaviour there. +func secureMintClient(supplied *http.Client) (*http.Client, error) { + client := &http.Client{} + if supplied != nil { + copied := *supplied + client = &copied + } + if err := checkTransportAuthenticates(client.Transport); err != nil { + return nil, err + } + client.CheckRedirect = refuseMintRedirect + return client, nil +} + +// refuseMintRedirect is called by net/http BEFORE the redirected request is +// sent, and returning an error means it is never sent. That is the whole +// requirement: the destination of a redirect must receive no request at all, +// because the first one carried the projected service-account token and the +// second would carry it to an address nothing configured. +// +// It refuses rather than returning http.ErrUseLastResponse so the outcome is a +// named refusal instead of a 3xx that later checks have to recognise, and it +// carries ErrMintRefused rather than ErrMintUnavailable: retrying would send +// the projection again. +func refuseMintRedirect(request *http.Request, via []*http.Request) error { + return fmt.Errorf( + "%w: the mint endpoint redirected to %s after %d request(s); a credential request is not followed to an address the configuration did not name", + ErrMintRefused, request.URL.Redacted(), len(via), + ) +} + +// checkTransportAuthenticates refuses a transport configured not to verify the +// endpoint's certificate. HTTPS whose certificate nobody checks is plaintext +// with extra steps, and this is the one request that carries the projection. +// +// A RoundTripper this package cannot inspect is not refused — it cannot be — +// but it cannot downgrade the scheme either, and the URL check above requires +// https. +func checkTransportAuthenticates(transport http.RoundTripper) error { + settings, ok := transport.(*http.Transport) + if !ok { + return nil + } + if settings.TLSClientConfig != nil && settings.TLSClientConfig.InsecureSkipVerify { + return fmt.Errorf( + "%w: the mint client's transport does not verify the endpoint's certificate", + ErrMintRefused, + ) + } + if settings.TLSClientConfig != nil && + settings.TLSClientConfig.MinVersion != 0 && + settings.TLSClientConfig.MinVersion < tls.VersionTLS12 { + return fmt.Errorf( + "%w: the mint client's transport permits TLS below 1.2", + ErrMintRefused, + ) + } + return nil +} + // Credential returns the credential for this moment, minting the first one and // renewing a credential that has reached its renewal point. Every caller goes -// through it, so there is no path on which a stale credential is used. +// through it, so no caller obtains a credential this client knows to be past +// its renewal point. +// +// It is not a guarantee about a credential a caller already holds: a Credential +// is a value, so one read out of here and kept can be attached after the host +// has moved the state under it. That is what Refresh is for, and it is why a +// receiver verifies rather than trusting that a caller re-asked. // // It holds one lock across the mint request, so concurrent callers wait rather // than each minting their own. That is the point: a process obtains one @@ -259,9 +359,9 @@ func NewMintClient(options MintOptions) (*MintClient, error) { // one mint per goroutine would be the heartbeat again under another name. The // request timeout bounds the wait. // -// Renewal re-reads the projected token and rechecks the boot-read authority +// Every mint re-reads the projected token and rechecks the boot-read authority // first. A projection rotated under the running process is therefore picked up -// without a restart, and an authority value that drifted refuses the renewal +// without a restart, and an authority value that drifted refuses the mint // instead of being sealed into a new credential. func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if c == nil { @@ -276,7 +376,74 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { return *c.credential, nil } renewal := c.credential != nil - if renewal && c.options.Authority != nil { + credential, err := c.obtainLocked(ctx) + if err != nil { + return Credential{}, err + } + if renewal { + c.counts.Renewals++ + } else { + c.counts.Mints++ + } + return credential, nil +} + +// Refresh replaces a credential the host has refused — ErrRevoked from a +// verifier at the far end, which means the credential was sound when it was +// minted and the state moved under it. +// +// Without it there was no way back: Credential answers from the credential it +// holds until that credential's own renewal point, so a process whose +// installation revision changed served refusals until a lead that the host +// chooses — hours, for a long-lived credential — had elapsed. +// +// It is tied to the credential that was refused, not to the clock. A caller +// passes back the Credential it was refused on; if this client has already +// replaced that one, the replacement is returned and nothing is minted. So a +// burst of concurrent refusals on one credential produces exactly one +// replacement mint, and a caller holding a credential two generations old +// cannot roll the client backwards by asking about it. +func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credential, error) { + if c == nil { + return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) + } + if ctx == nil { + return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) + } + if refused.generation == 0 { + return Credential{}, fmt.Errorf( + "%w: refresh needs the credential that was refused, as this client issued it", + ErrMintRefused, + ) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.credential == nil { + return Credential{}, fmt.Errorf( + "%w: this client has issued no credential to refresh", ErrMintRefused, + ) + } + if c.credential.generation != refused.generation { + // Somebody else was refused on the same credential and has already + // replaced it. Minting again here would be the second mint for one + // revocation, which is the thing this client exists not to do. + return *c.credential, nil + } + credential, err := c.obtainLocked(ctx) + if err != nil { + return Credential{}, err + } + c.counts.Refreshes++ + return credential, nil +} + +// obtainLocked rechecks the pinned authority, mints, and installs the result as +// the credential this client holds. It is the one path that writes +// c.credential, so a mint that is refused leaves the previous credential in +// place rather than clearing it: a refused renewal must not turn into "this +// process holds nothing", which reads as a boot failure somewhere it is not. +func (c *MintClient) obtainLocked(ctx context.Context) (Credential, error) { + if c.options.Authority != nil { if err := c.options.Authority.Recheck(ctx); err != nil { return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) } @@ -285,25 +452,23 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if err != nil { return Credential{}, err } + c.generation++ + credential.generation = c.generation c.credential = &credential - if renewal { - c.renewals++ - } else { - c.mints++ - } return credential, nil } -// Counts reports how many first mints and how many renewals this process has -// performed. A correct execution reports exactly one mint; renewals are -// expected and bounded by the credential's lifetime, not by a beat. -func (c *MintClient) Counts() (mints uint64, renewals uint64) { +// Counts reports what this process's credential life cost. A correct execution +// reports exactly one mint; renewals are expected and bounded by the +// credential's lifetime, not by a beat, and a refresh happens only when the +// host has refused the credential being held. +func (c *MintClient) Counts() MintCounts { if c == nil { - return 0, 0 + return MintCounts{} } c.mu.Lock() defer c.mu.Unlock() - return c.mints, c.renewals + return c.counts } // dueForRenewalLocked is true once the credential has entered its renewal lead. @@ -352,6 +517,11 @@ func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { request.Header.Set("Authorization", "Bearer "+projected) response, err := c.httpClient.Do(request) if err != nil { + // A refused redirect arrives here already named. It is a refusal and + // not an outage: retrying it would send the projection again. + if errors.Is(err, ErrMintRefused) { + return Credential{}, err + } return Credential{}, fmt.Errorf("%w: %v", ErrMintUnavailable, err) } defer func() { @@ -359,11 +529,15 @@ func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { }() if response.Request != nil && response.Request.URL != nil && response.Request.URL.String() != c.options.URL { - // The projected token was already sent to whatever answered. Treating a - // redirect as retryable would send it again; treating it as a refusal - // stops the process, which is the only safe outcome once a credential - // request has left for an address nothing configured. - return Credential{}, fmt.Errorf("%w: mint request was redirected", ErrMintRefused) + // The redirect refusal above is what prevents a second request, and a + // transport that redirects inside itself never calls it. If the answer + // came from anywhere other than the configured URL, the projected token + // has already been seen by something nothing configured, and stopping + // the process is the only safe outcome. + return Credential{}, fmt.Errorf( + "%w: the mint response came from %s, not the configured endpoint", + ErrMintRefused, response.Request.URL.Redacted(), + ) } if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) @@ -426,13 +600,57 @@ func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { ErrMintRefused, claims.GetAudience(), c.options.Audience, ) } - return Credential{ + credential := Credential{ token: token, seal: seal, binding: binding, issuedAt: time.Unix(claims.GetIssuedAtUnix(), 0).UTC(), expiresAt: time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), - }, nil + } + if err := c.checkWindow(credential); err != nil { + return Credential{}, err + } + return credential, nil +} + +// checkWindow refuses a credential that is unusable the moment it arrives. +// +// The renewal check only ever looked at the credential already held, so a +// response that was delayed, or minted against a clock well behind this +// process's, was installed as a success and then refused by every receiver — +// with the mint counted as having worked. A credential is checked against its +// own window before it is installed, with core's own skew so the tolerance here +// is the verifier's and not a second number. +func (c *MintClient) checkWindow(credential Credential) error { + if !credential.expiresAt.After(credential.issuedAt) { + return fmt.Errorf( + "%w: the minted credential expires at or before it was issued (%s to %s)", + ErrMintRefused, credential.issuedAt, credential.expiresAt, + ) + } + now := c.now().UTC() + if now.Before(credential.issuedAt.Add(-corework.DefaultSkew)) { + return fmt.Errorf( + "%w: the minted credential is not valid until %s, and it is %s", + ErrMintRefused, credential.issuedAt, now, + ) + } + if !now.Before(credential.expiresAt) { + return fmt.Errorf( + "%w: the minted credential expired at %s, and it is %s", + ErrMintRefused, credential.expiresAt, now, + ) + } + if c.dueForRenewalLocked(credential) { + // Installing it would put the client straight back into a renewal, and + // the next response would be the same one: a mint loop, one audit event + // per iteration, which is the heartbeat this client replaced. + return fmt.Errorf( + "%w: the minted credential's whole lifetime is inside the renewal lead (%s to %s)", + ErrMintRefused, credential.issuedAt, credential.expiresAt, + ) + } + return nil } // mintRequest is everything the client tells the host, which is only what it @@ -452,13 +670,18 @@ type mintResponse struct { BuildIncarnation string `json:"build_incarnation,omitempty"` } +// validateMintURL requires absolute HTTPS with no userinfo, query or fragment. +// +// Plaintext HTTP used to be accepted here, which made the strongest reason this +// endpoint exists — that the projected service-account token is presented to it +// and nothing else — contingent on a configuration value. A credential request +// goes over an authenticated channel or it does not go. func validateMintURL(raw string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Host == "" || parsed.User != nil || - parsed.Fragment != "" || parsed.RawQuery != "" || - parsed.Scheme != "http" && parsed.Scheme != "https" { + parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Scheme != "https" { return "", fmt.Errorf( - "%w: mint URL must be an absolute HTTP(S) URL without credentials, query, or fragment", + "%w: mint URL must be an absolute https URL without credentials, query, or fragment", ErrMintRefused, ) } diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 7303040..8a8ae31 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -2,29 +2,39 @@ package workcontext import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "slices" + "sync" "sync/atomic" "testing" "time" + corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" ) // mintHost is the host's mint endpoint, counting what it is asked for. The // count is the acceptance criterion: a process that runs for an hour must not // have announced itself every fifteen seconds. +// +// It is served over TLS, because that is the only way the client will talk to +// it: the projected service-account token is a bearer credential on this +// request, and MintOptions.URL refuses anything but https. type mintHost struct { t *testing.T server *httptest.Server requests atomic.Uint64 presented atomic.Value // the last projected token the host was shown + bodies chan []byte // every request body, as the client actually sent it now func() time.Time lifetime time.Duration audience string @@ -37,6 +47,11 @@ type mintHost struct { // mintAudience, when set, makes the host answer with a credential for an // audience other than the one asked for. mintAudience string + // mintedAt and mintedFor override when and for how long the host mints, so + // a test can be handed a credential that is already expired, not yet valid, + // or shorter-lived than the renewal lead. + mintedAt *time.Time + mintedFor *time.Duration } func newMintHost(t *testing.T, now func() time.Time) *mintHost { @@ -44,8 +59,9 @@ func newMintHost(t *testing.T, now func() time.Time) *mintHost { host := &mintHost{ t: t, now: now, lifetime: 15 * time.Minute, audience: testAudience, authority: newAuthority(t), + bodies: make(chan []byte, 64), } - host.server = httptest.NewServer(http.HandlerFunc(host.handle)) + host.server = httptest.NewTLSServer(http.HandlerFunc(host.handle)) t.Cleanup(host.server.Close) return host } @@ -57,8 +73,17 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(h.refuseWith) return } + raw, err := readAll(request) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + select { + case h.bodies <- raw: + default: + } var body mintRequest - if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + if err := json.Unmarshal(raw, &body); err != nil { writer.WriteHeader(http.StatusBadRequest) return } @@ -66,10 +91,18 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { if h.mintAudience != "" { audience = h.mintAudience } + mintedAt := h.now() + if h.mintedAt != nil { + mintedAt = *h.mintedAt + } + lifetime := h.lifetime + if h.mintedFor != nil { + lifetime = *h.mintedFor + } // The host mints with core's Authority, because that is what the host does. // A test endpoint that assembled a token itself would be a second // implementation wearing a test's clothes. - token := h.authority.startAt(h.t, h.now(), mintInput{audience: audience, binding: h.binding}, h.lifetime) + token := h.authority.startAt(h.t, mintedAt, mintInput{audience: audience, binding: h.binding}, lifetime) response := mintResponse{WorkContext: token, InstallationID: testInstallation} if h.echoWrong { response.InstallationID = "installation-the-host-did-not-seal" @@ -78,6 +111,23 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { require.NoError(h.t, json.NewEncoder(writer).Encode(response)) } +func readAll(request *http.Request) ([]byte, error) { + defer func() { + _ = request.Body.Close() + }() + return io.ReadAll(io.LimitReader(request.Body, maxMintResponseBytes)) +} + +// certPoolOf trusts exactly these test servers and nothing else, so a request +// that reaches the wrong one fails the assertion rather than the handshake. +func certPoolOf(servers ...*httptest.Server) *x509.CertPool { + pool := x509.NewCertPool() + for _, server := range servers { + pool.AddCert(server.Certificate()) + } + return pool +} + // projectedFile writes a rotating projected token and hands back its path. func projectedFile(t *testing.T, contents string) string { t.Helper() @@ -94,6 +144,10 @@ func newTestMintClient(t *testing.T, host *mintHost, path string, now func() tim ProjectedToken: ProjectedTokenFile(path), ProjectionAudience: "accounts", Now: now, + // The test server's own client, which trusts the server's certificate + // and nothing else. It is a SUPPLIED client, so every test here also + // exercises the path where the caller brought its own. + HTTPClient: host.server.Client(), } for _, option := range options { option(&settings) @@ -128,26 +182,56 @@ func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { require.Equal(t, first.Token(), again.Token()) } require.EqualValues(t, 1, host.requests.Load(), "holding a credential must not talk to the host") - - mints, renewals := client.Counts() - require.EqualValues(t, 1, mints) - require.EqualValues(t, 0, renewals) + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) // An hour of a fifteen-minute credential, renewed three minutes before each // expiry: one mint and five renewals, six requests in total. The heartbeat // this replaces made 240 in the same hour, one per surface every fifteen // seconds, each recording that nothing had changed. + // + // Six endpoint requests is what this test can observe. Whether the host + // records one mint audit event or six is the host's accounting and not + // something an SDK test can assert — see the PR body. for minute := 12; minute <= 60; minute++ { clock = testClock.Add(time.Duration(minute) * time.Minute) _, err := client.Credential(t.Context()) require.NoError(t, err) } - mints, renewals = client.Counts() - require.EqualValues(t, 1, mints, "exactly one first mint per execution") - require.EqualValues(t, 5, renewals) + require.Equal(t, MintCounts{Mints: 1, Renewals: 5}, client.Counts()) require.EqualValues(t, 6, host.requests.Load()) } +// A process whose first request burst is concurrent still mints once. A client +// that minted per goroutine would be the heartbeat again under another name, +// and "one credential per execution" would hold only for a process that +// happened to make its first call from one goroutine. +func TestTheFirstMintIsOneMintUnderConcurrentFirstCalls(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + const callers = 24 + tokens := make([]string, callers) + var waiting sync.WaitGroup + for caller := range callers { + waiting.Add(1) + go func() { + defer waiting.Done() + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + tokens[caller] = credential.Token() + }() + } + waiting.Wait() + + require.EqualValues(t, 1, host.requests.Load(), + "%d concurrent first calls must produce one mint", callers) + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) + for caller := range callers { + require.Equal(t, tokens[0], tokens[caller], "every caller holds the same credential") + } +} + // The projection is rotated under the running process, so the copy read at // boot is expired long before the process is. Re-reading it before every // renewal is what makes the rotation a non-event; caching it would make the @@ -169,39 +253,57 @@ func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { require.NoError(t, err) require.Equal(t, "Bearer projected-token-after-rotation", host.presented.Load(), "the renewal must present the rotated projection, not the one read at boot") - - mints, renewals := client.Counts() - require.EqualValues(t, 1, mints) - require.EqualValues(t, 1, renewals) + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, client.Counts()) } -// An authority value that drifted under the running process refuses the -// renewal. This is the moment the drift would otherwise be laundered: the -// process would mint a new credential sealed to a value the host never -// approved for it, and nothing downstream could tell. -func TestRenewalRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { +// An authority value that drifted under the running process refuses the mint. +// This is the moment the drift would otherwise be laundered: the process would +// mint a credential sealed to a value the host never approved for it, and +// nothing downstream could tell. +// +// It is checked before the FIRST mint too. A process can construct its client +// at boot and make its first call an hour later, and the value can have moved +// in between; a check that began at the first renewal left exactly that window +// open. +func TestEveryMintRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { clock := testClock now := func() time.Time { return clock } host := newMintHost(t, now) - drifted := errors.New("audience changed under a running process") pin := &stubAuthority{} client := newTestMintClient(t, host, projectedFile(t, "projected"), now, func(options *MintOptions) { options.Authority = pin }) _, err := client.Credential(t.Context()) - require.NoError(t, err, "the first mint is the boot read, so there is nothing to have drifted from") - require.EqualValues(t, 0, pin.checks.Load(), "the first mint is not a renewal") + require.NoError(t, err) + require.EqualValues(t, 1, pin.checks.Load(), "the first mint checks the pin too") - pin.err = drifted + pin.err = errors.New("audience changed under a running process") clock = testClock.Add(14 * time.Minute) _, err = client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "audience changed") - require.EqualValues(t, 1, pin.checks.Load()) + require.EqualValues(t, 2, pin.checks.Load()) require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") } +// The first mint, specifically: a client constructed at boot whose pin has +// already moved by the time anything asks for a credential must not mint. +func TestTheFirstMintRefusesAnAlreadyDriftedAuthority(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + pin := &stubAuthority{err: errors.New("binding changed before the first call")} + client := newTestMintClient(t, host, projectedFile(t, "projected"), now, func(options *MintOptions) { + options.Authority = pin + }) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "binding changed") + require.EqualValues(t, 0, host.requests.Load(), + "the pin is checked before the projection is presented to anything") +} + type stubAuthority struct { checks atomic.Uint64 err error @@ -212,6 +314,142 @@ func (s *stubAuthority) Recheck(context.Context) error { return s.err } +// A credential the host has refused is replaced on demand, not when its own +// renewal lead arrives. Without this there was no way back: the client answered +// from the credential it held until a lead the HOST chooses had elapsed, so an +// installation revision that moved under a long-lived credential meant hours of +// refusals with nothing a process could do about it. +func TestARefusedCredentialIsReplacedOnDemand(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // Nothing about the clock has changed, so Credential would answer from the + // credential already held — which is the one the far end refused. + same, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, held.Token(), same.Token()) + require.EqualValues(t, 1, host.requests.Load()) + + replaced, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + require.NotEqual(t, held.Token(), replaced.Token()) + require.EqualValues(t, 2, host.requests.Load()) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + + // And the replacement is what every later caller gets. + after, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, replaced.Token(), after.Token()) +} + +// Concurrent refusals on ONE credential produce ONE replacement mint. A +// refresh that minted per caller would turn a single revocation into a burst of +// audit events, which is the failure this whole client replaced. +func TestConcurrentRefusalsOnOneCredentialMintOnce(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + const callers = 16 + replaced := make([]string, callers) + var waiting sync.WaitGroup + for caller := range callers { + waiting.Add(1) + go func() { + defer waiting.Done() + credential, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + replaced[caller] = credential.Token() + }() + } + waiting.Wait() + + require.EqualValues(t, 2, host.requests.Load(), + "one first mint and one replacement for %d refusals on the same credential", callers) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + for caller := range callers { + require.Equal(t, replaced[0], replaced[caller]) + require.NotEqual(t, held.Token(), replaced[caller]) + } +} + +// A refresh is tied to the credential that was refused, so a caller holding a +// stale one cannot roll the client backwards by asking about it. +func TestRefreshIsTiedToTheRefusedCredential(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + second, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.EqualValues(t, 2, host.requests.Load()) + + // Asking again about the credential that was already replaced returns the + // replacement and mints nothing. + again, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.Equal(t, second.Token(), again.Token()) + require.EqualValues(t, 2, host.requests.Load()) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + + // A Credential this client never issued is not something to refresh + // against: it names no generation of this client's life. + _, err = client.Refresh(t.Context(), Credential{}) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "the credential that was refused") + + fresh := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err = fresh.Refresh(t.Context(), second) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "issued no credential") +} + +// A refresh rechecks the pin and re-reads the projection, exactly as a renewal +// does: it is a mint, and every mint goes through the same door. +func TestRefreshRechecksThePinAndRereadsTheProjection(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + path := projectedFile(t, "projected-before") + pin := &stubAuthority{} + client := newTestMintClient(t, host, path, now, func(options *MintOptions) { + options.Authority = pin + }) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, pin.checks.Load()) + + require.NoError(t, os.WriteFile(path, []byte("projected-after"), 0o600)) + replaced, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + require.EqualValues(t, 2, pin.checks.Load()) + require.Equal(t, "Bearer projected-after", host.presented.Load()) + + // A drifted pin refuses the refresh, and the credential already held stays + // held: a refused replacement must not leave the process holding nothing, + // which would read as a boot failure somewhere it is not. + pin.err = errors.New("audience changed under a running process") + _, err = client.Refresh(t.Context(), replaced) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") + + pin.err = nil + still, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, replaced.Token(), still.Token(), + "a refused replacement leaves the credential the process already holds") +} + // The host may echo the sealed values for a log. An echo that disagrees with // the signature is a refusal: the only installation that means anything is the // signed one, and a client that preferred the echo would hold a credential @@ -242,6 +480,60 @@ func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { require.ErrorContains(t, err, "minted for audience") } +// A credential is checked against its OWN window before it is installed. +// +// The renewal check only ever looked at the credential already held, so a +// response that was delayed, or minted against a clock well behind this +// process's, was installed as a success — counted as a working mint — and then +// refused by every receiver. The three cases are the three ways that happens. +func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { + now := func() time.Time { return testClock } + short := 4 * time.Second + + for name, arrange := range map[string]struct { + mintedAt time.Time + mintedFor time.Duration + says string + }{ + "already expired": { + mintedAt: testClock.Add(-time.Hour), mintedFor: 15 * time.Minute, + says: "expired at", + }, + "not yet valid": { + mintedAt: testClock.Add(time.Hour), mintedFor: 15 * time.Minute, + says: "not valid until", + }, + "whole lifetime inside the renewal lead": { + mintedAt: testClock, mintedFor: short, + says: "inside the renewal lead", + }, + } { + t.Run(name, func(t *testing.T) { + host := newMintHost(t, now) + mintedAt, mintedFor := arrange.mintedAt, arrange.mintedFor + host.mintedAt, host.mintedFor = &mintedAt, &mintedFor + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, arrange.says) + require.Equal(t, MintCounts{}, client.Counts(), + "a credential that was never usable is not a mint that worked") + }) + } + + // A response minted inside core's own clock skew is NOT refused: the + // tolerance here is the verifier's, so a client stricter than the thing + // that will accept the credential would refuse credentials that work. + host := newMintHost(t, now) + within := testClock.Add(corework.DefaultSkew / 2) + lifetime := 15 * time.Minute + host.mintedAt, host.mintedFor = &within, &lifetime + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err := client.Credential(t.Context()) + require.NoError(t, err, "a credential minted inside core's skew is usable") +} + // A refusal the host will give again is not retryable, and an outage is. A // client that confused them would either spin against a permanent refusal or // give up on a transient one. @@ -289,12 +581,30 @@ func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { // A mint request carries what the process wants and nothing about who it is. // Principal, installation and build are the host's to establish; a field for // any of them would be a field a process could lie in. +// +// It reads the body the HOST received, not a struct this test marshalled. The +// earlier version marshalled mintRequest itself, which proved only that the +// struct has two fields — it would have passed while the client added a +// self-reported principal to the request on its way out. func TestMintRequestSelfReportsNoIdentity(t *testing.T) { - encoded, err := json.Marshal(mintRequest{Audience: testAudience, ProjectionAudience: "accounts"}) + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) require.NoError(t, err) + + var sent []byte + select { + case sent = <-host.bodies: + default: + t.Fatal("the host received no request body") + } var fields map[string]any - require.NoError(t, json.Unmarshal(encoded, &fields)) + require.NoError(t, json.Unmarshal(sent, &fields)) require.Equal(t, []string{"audience", "projection_audience"}, keysOf(fields)) + require.Equal(t, testAudience, fields["audience"]) + require.Equal(t, "accounts", fields["projection_audience"]) } func keysOf(fields map[string]any) []string { @@ -306,32 +616,117 @@ func keysOf(fields map[string]any) []string { return slices.Compact(names) } -// A credential request that was answered by an address nothing configured has -// already left with the projected token attached. Retrying would send it again, -// so a redirect is a permanent refusal and the process must not serve. -func TestMintRefusesARedirectedRequest(t *testing.T) { +// The redirect target must receive NO REQUEST AT ALL. +// +// The old test asserted the returned classification after the redirect had +// already been followed, which is the wrong thing entirely: by then the +// projected service-account token had been sent to an address nothing +// configured, and naming the outcome afterwards changes nothing. What this +// asserts is containment — zero requests at the destination — and it asserts it +// for a SUPPLIED client, which is where the hole was: Go's own client forwards +// Authorization across a redirect to the same host, so a caller that brought an +// ordinary &http.Client{} disclosed the projection before this package could +// classify anything. +func TestARedirectedMintSendsNothingToTheDestination(t *testing.T) { now := func() time.Time { return testClock } - elsewhere := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + var elsewhereRequests atomic.Uint64 + elsewhere := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + elsewhereRequests.Add(1) writer.WriteHeader(http.StatusOK) })) t.Cleanup(elsewhere.Close) - redirector := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - http.Redirect(writer, request, elsewhere.URL, http.StatusTemporaryRedirect) - })) + + redirector := httptest.NewTLSServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, elsewhere.URL, http.StatusTemporaryRedirect) + })) t.Cleanup(redirector.Close) - client, err := NewMintClient(MintOptions{ - URL: redirector.URL, - Audience: testAudience, - ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), - ProjectionAudience: "accounts", - Now: now, - HTTPClient: &http.Client{}, - }) + // One TLS configuration that trusts BOTH servers, so nothing but the + // refusal stops the second request. A client that could not verify the + // destination's certificate would pass this test for the wrong reason. + trusted := &tls.Config{ + RootCAs: certPoolOf(redirector, elsewhere), + MinVersion: tls.VersionTLS12, + } + + for name, supplied := range map[string]*http.Client{ + // The case that disclosed the projection: a caller-supplied client with + // no redirect policy of its own, so net/http's default applied. + "no redirect policy of its own": { + Transport: &http.Transport{TLSClientConfig: trusted}, + }, + // And a caller that explicitly decided to follow redirects. For a + // credential request that decision is not the caller's to make. + "a policy that follows redirects": { + Transport: &http.Transport{TLSClientConfig: trusted}, + CheckRedirect: func(*http.Request, []*http.Request) error { return nil }, + }, + } { + t.Run(name, func(t *testing.T) { + elsewhereRequests.Store(0) + client, err := NewMintClient(MintOptions{ + URL: redirector.URL, + Audience: testAudience, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "accounts", + Now: now, + HTTPClient: supplied, + }) + require.NoError(t, err) + + _, err = client.Credential(t.Context()) + + // Containment first, and on its own line: this is the assertion + // that matters. A test that checked the returned error before the + // request count would report the classification of a disclosure + // that had already happened. + require.EqualValues(t, 0, elsewhereRequests.Load(), + "the redirect destination must receive no request: the first one carried the projection") + require.ErrorIs(t, err, ErrMintRefused) + require.NotErrorIs(t, err, ErrMintUnavailable, + "a redirected credential request is not something to retry") + require.ErrorContains(t, err, "redirected") + require.Equal(t, MintCounts{}, client.Counts()) + }) + } + + // The client the SDK builds when the caller supplies none cannot reach a + // test server's certificate, so its refusal is asserted on the policy + // itself rather than over the wire. + byDefault, err := secureMintClient(nil) require.NoError(t, err) - _, err = client.Credential(t.Context()) - require.ErrorIs(t, err, ErrMintRefused) - require.NotErrorIs(t, err, ErrMintUnavailable) + request, err := http.NewRequest(http.MethodGet, elsewhere.URL, nil) + require.NoError(t, err) + require.ErrorIs(t, byDefault.CheckRedirect(request, nil), ErrMintRefused) +} + +// A supplied client keeps its own redirect behaviour everywhere else. The +// refusal is installed on a copy, so a caller that shares one client between +// the mint endpoint and its ordinary traffic does not have the rest of its +// traffic silently stop following redirects. +func TestSecuringTheMintClientDoesNotMutateTheCallersClient(t *testing.T) { + supplied := &http.Client{} + secured, err := secureMintClient(supplied) + require.NoError(t, err) + require.NotNil(t, secured.CheckRedirect) + require.Nil(t, supplied.CheckRedirect, + "the caller's client must not be reconfigured out from under it") + + // And a client that had its own redirect policy loses it HERE and only + // here: a credential request is not followed, whatever the caller decided + // for its other traffic. + followed := false + opinionated := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + followed = true + return nil + }} + secured, err = secureMintClient(opinionated) + require.NoError(t, err) + request, err := http.NewRequest(http.MethodGet, "https://example.invalid/_mint", nil) + require.NoError(t, err) + require.ErrorIs(t, secured.CheckRedirect(request, nil), ErrMintRefused) + require.False(t, followed) } func TestNewMintClientValidatesItsConfiguration(t *testing.T) { @@ -345,15 +740,32 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { require.NoError(t, err) for name, mutate := range map[string]func(*MintOptions){ - "no url": func(o *MintOptions) { o.URL = "" }, + "no url": func(o *MintOptions) { o.URL = "" }, + // Plaintext is the one that mattered: the projection travels on this + // request as a bearer credential, so http was a disclosure the + // configuration could choose. + "plaintext http": func(o *MintOptions) { o.URL = "http://accounts.internal/_mint" }, "url with query": func(o *MintOptions) { o.URL = "https://accounts.internal/_mint?as=root" }, "url with credentials": func(o *MintOptions) { o.URL = "https://user:pass@accounts.internal/_mint" }, + "url with fragment": func(o *MintOptions) { o.URL = "https://accounts.internal/_mint#f" }, "no audience": func(o *MintOptions) { o.Audience = "" }, "no projection": func(o *MintOptions) { o.ProjectedToken = nil }, "no projection audience": func(o *MintOptions) { o.ProjectionAudience = "" }, "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, + // HTTPS nobody authenticates is plaintext with extra steps, and this is + // the one request that carries the projection. + "transport that skips certificate verification": func(o *MintOptions) { + o.HTTPClient = &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the point of the test + }} + }, + "transport permitting TLS below 1.2": func(o *MintOptions) { + o.HTTPClient = &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS10}, + }} + }, } { t.Run(name, func(t *testing.T) { options := valid @@ -376,18 +788,23 @@ func TestCredentialSurfaceComesFromTheSignedCapability(t *testing.T) { credential, err := client.Credential(t.Context()) require.NoError(t, err) require.Equal(t, testSeal, credential.Seal()) - require.Equal(t, - &OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1}, - credential.OperationBinding(), - ) + + // The binding AS SEALED: the three fields the capability carries. The live + // binding has three more — the principal it is granted to, the installation + // it is granted within, and whether it is revoked — and none of them is on + // the wire, which is why this is a different type from OperationBinding. + bound := credential.OperationBinding() + require.NotNil(t, bound) + require.Equal(t, testBinding, bound.GetBindingId()) + require.EqualValues(t, 2, bound.GetRevision()) + require.EqualValues(t, 1, bound.GetIncarnation()) require.Equal(t, testClock, credential.IssuedAt()) require.Equal(t, testClock.Add(15*time.Minute), credential.ExpiresAt()) // OperationBinding hands back a copy: a caller that mutated it must not be // able to change what the credential reports it is bound to. - mutated := credential.OperationBinding() - mutated.ID = "binding-the-caller-preferred" - require.Equal(t, testBinding, credential.OperationBinding().ID) + bound.BindingId = "binding-the-caller-preferred" + require.Equal(t, testBinding, credential.OperationBinding().GetBindingId()) request := httptest.NewRequest(http.MethodPost, "/records", nil) require.NoError(t, credential.Attach(request)) diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index 4273435..d0bf63b 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -3,12 +3,14 @@ package workcontext import ( "context" "crypto/ed25519" + "encoding/base64" "testing" "time" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) // Every capability in this package's tests is minted by core's Authority, @@ -26,6 +28,16 @@ var ( InstallationRevision: 3, BuildIncarnation: 11, } + // testLiveBinding is the binding as the ISSUER holds it: granted to one + // principal, within one installation. A capability carries only the first, + // third and fourth of these — see SealedOperationBinding. + testLiveBinding = OperationBinding{ + ID: testBinding, + PrincipalID: testPrincipal, + InstallationID: testInstallation, + Revision: 2, + Incarnation: 1, + } ) const ( @@ -48,10 +60,15 @@ type authority struct { // the live values a fresh capability is sealed to. func newAuthority(t *testing.T) *authority { t.Helper() - _, private := corework.FixtureKeyPair() seals := corework.NewMemorySealSource() require.NoError(t, seals.Put(testPrincipal, testSeal)) - require.NoError(t, seals.PutBinding(OperationBinding{ID: testBinding, Revision: 2, Incarnation: 1})) + require.NoError(t, seals.PutBinding(testLiveBinding)) + return newAuthorityOver(t, seals) +} + +func newAuthorityOver(t *testing.T, seals *corework.MemorySealSource) *authority { + t.Helper() + _, private := corework.FixtureKeyPair() now := func() time.Time { return testClock } return &authority{ core: &corework.Authority{ @@ -93,6 +110,55 @@ func (noGrants) Grant(context.Context, string) (*corework.Grant, error) { return nil, corework.ErrInvalid } +// resealWithout rewrites a capability core has just minted, blanking exactly +// one field of its seal, and re-signs it with core's fixture key. +// +// It has to be done this way, and it is not a second implementation. Core's +// minter REFUSES to seal a capability to a zero epoch, revision or incarnation +// — protovalidate rejects the message — which is correct of an issuer and means +// a partial seal cannot be obtained by configuring one. Core's own conformance +// kit builds its `missing-seal` fixture by exactly this move, for exactly this +// reason. Nothing here invents a format: the message is core's, the +// deterministic marshal is core's, the key is core's fixture key, so the +// signature is genuine and the rule under test is the one that refuses the +// token. +// +// The durable home for these is core's conformance kit, since "every field of +// the seal is required" is core's rule and not this module's — see the PR +// discussion. +func resealWithout(t *testing.T, token string, field string) string { + t.Helper() + claims, err := claimsOf(token) + require.NoError(t, err) + require.NotNil(t, claims.GetSeal()) + switch field { + case "PrincipalEpoch": + claims.Seal.PrincipalEpoch = 0 + case "InstallationID": + claims.Seal.InstallationId = "" + case "InstallationRevision": + claims.Seal.InstallationRevision = 0 + case "BuildIncarnation": + claims.Seal.BuildIncarnation = 0 + case "Binding.ID": + require.NotNil(t, claims.GetOperationBinding()) + claims.OperationBinding.BindingId = "" + case "Binding.Revision": + require.NotNil(t, claims.GetOperationBinding()) + claims.OperationBinding.Revision = 0 + case "Binding.Incarnation": + require.NotNil(t, claims.GetOperationBinding()) + claims.OperationBinding.Incarnation = 0 + default: + t.Fatalf("resealWithout has no case for %q, so the field is not being covered", field) + } + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(claims) + require.NoError(t, err) + _, private := corework.FixtureKeyPair() + return base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload)) +} + type mintInput struct { tenant string audience string @@ -144,8 +210,15 @@ func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifeti // child delegates a verified capability one hop, which is how a test obtains a // capability whose effective actor is not its owner. +// +// The actor principal needs a live epoch of its own: an actor's authority is +// narrowed independently of the owner's, so core seals the actor's epoch per +// hop and refuses a hop that carries none. A test that delegates without +// recording one fails at the mint, which is the right place for it to fail. func (a *authority) child(t *testing.T, parent string, principal string, scopes []*basev0.WorkScopeV1) string { t.Helper() + require.NotNil(t, a.seals, "delegation needs a seal source this test can record an actor epoch in") + require.NoError(t, a.seals.PutEpoch(principal, 1)) verified, err := a.verifier(t).Verify(context.Background(), parent) require.NoError(t, err) token, _, err := a.core.Child(context.Background(), verified, corework.ChildInput{ diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index d2f5856..dfed7f6 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -2,6 +2,7 @@ package workcontext import ( "context" + "fmt" "go/ast" "go/parser" "go/token" @@ -37,16 +38,22 @@ var ( // issued it, and it neither signs nor checks a signature. var cryptoImplementationPackages = []string{"crypto/ed25519", "crypto/ecdsa"} -// jsonAllowlist is every non-test file that may import encoding/json, and why. +// jsonAllowlist is every non-test file that may import encoding/json, keyed by +// its path RELATIVE TO THE MODULE ROOT, and why. +// +// The path is exact on purpose. It used to be matched on the base name, so any +// file called mint.go anywhere in the tree — grpctransport/mint.go, a new +// package's mint.go — inherited the allowance, which is the whole allowlist +// undone by `touch`. // // The ban exists because the second implementation this module used to hold // signed a hand-written JSON payload. The one remaining use is not a token at // all: it is the body of the host's mint endpoint, an ordinary HTTP API whose // request is two strings and whose response hands back an opaque capability. // The capability itself never passes through a JSON codec here — it arrives as -// a string and is read with core's generated type — and the second assertion -// below is what holds that apart from the first, by refusing a JSON-tagged -// struct anywhere in the module except that request and that response. +// a string and is read with core's generated type — and the json-tag check +// below is what holds that apart, by refusing a json-tagged struct anywhere in +// the module except that request and that response. var jsonAllowlist = map[string]string{ "mint.go": "the mint endpoint's HTTP request and response bodies, which carry the capability as an opaque string", } @@ -58,6 +65,10 @@ var jsonAllowlist = map[string]string{ // somebody might not leave. var mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} +// coreModulePath is the module whose types this one aliases. An alias has to +// resolve into it, or it is a local type wearing core's name. +const coreModulePath = "github.com/codefly-dev/core" + // TestNoSecondWorkContextImplementation is the gate. // // A wire contract has exactly one implementation, in the repository that owns @@ -66,146 +77,378 @@ var mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} // rule was broken once: a second implementation here signed a hand-written // JSON payload while core signed the deterministic protobuf encoding of the // same message, and because both forms are "." with -// Ed25519, a token from either looked well-formed to the other and then failed -// SIGNATURE verification. "Signature does not verify under key X" reads like a -// rotated key, so keys are what everyone investigated. +// Ed25519, a token from either looked well-formed to the other. +// +// The signature itself was sound — each side signed the bytes it sent, and each +// side verified the bytes it received. What broke was READING the payload before +// verifying it: the key id is a field inside the payload, so a protobuf payload +// read as JSON (or the reverse) yielded no key id or a wrong one, and the +// failure surfaced as "unknown key" or "signature does not verify under key X". +// Both read like a rotated key, so keys are what everyone investigated, for +// days. That is also why ErrNotACoreToken has to be returned BEFORE any key +// lookup: naming the format is the only diagnosis that points at the format. // // A comment would not have stopped that and did not. This does, structurally, // in four parts: nothing here may reach for a signing primitive, nothing may // JSON-encode a capability, nothing may declare a type or function that reads -// as a second WorkContext surface, and the verification entrypoint this module -// exports is driven by core's own conformance fixtures. +// as a second WorkContext surface — including inside a function body, and +// including an alias whose target is not core's — and the verification +// entrypoint this module exports is driven by core's own conformance fixtures. // -// It guards the tree it runs in, which is all a test can do: a branch's CI run -// uses that branch's own tree, so this file never executes on the release lines -// that still carry the deleted implementation. -// scripts/check-one-implementation.sh is the other half — it reads every ref CI -// builds out of the object database — and docs/cutover.md says when those refs -// are retired and by whom. +// It guards the tree it runs in, which is all a test can do, and it walks from +// this module's root — so the rest of the repository, where the deleted +// implementation actually lived, is swept by +// scripts/check-one-implementation.sh instead. That sweep is a required check +// and it is green. func TestNoSecondWorkContextImplementation(t *testing.T) { files := moduleFiles(t) require.NotEmpty(t, files, "the gate scanned no files, so it would pass for an empty module") packages := map[string]bool{} - jsonImporters := []string{} + var findings []string for _, file := range files { packages[filepath.Dir(file.path)] = true - for _, path := range importsOf(file.syntax) { - if slices.Contains(cryptoImplementationPackages, path) { - t.Errorf( - "%s imports %q.\n"+ - "This module signs nothing and verifies nothing: core's workcontext is the only\n"+ - "implementation of the capability. If a signature has to be checked, it is checked\n"+ - "by core's Verifier, reached through the alias in core.go.", - file.path, path, - ) - } - if path == "encoding/json" { - jsonImporters = append(jsonImporters, filepath.Base(file.path)) - } - } - checkDeclarations(t, file) - } - - for _, importer := range jsonImporters { - reason, allowed := jsonAllowlist[importer] - require.True(t, allowed, - "%s imports encoding/json.\n"+ - "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ - "The only JSON in this module is %v — the mint endpoint's HTTP bodies. If a capability\n"+ - "is being JSON-encoded here, that is the second implementation coming back.", - importer, allowedJSONFiles()) - require.NotEmpty(t, reason) + findings = append(findings, inspectForSecondImplementation(file)...) } + require.Empty(t, findings, "%s", strings.Join(findings, "\n\n")) require.GreaterOrEqual(t, len(packages), 2, "the gate found %d package(s) in this module; it is meant to scan every one, "+ "so a package it cannot see is a package the rule does not reach", len(packages)) + + // The allowlist must describe files that exist. An entry for a file that + // has been renamed or deleted is a permission nobody is using and the next + // person reads as precedent. + paths := map[string]bool{} + for _, file := range files { + paths[file.path] = true + } + for allowed := range jsonAllowlist { + require.True(t, paths[allowed], + "the json allowlist names %q, which is not a file in this module", allowed) + } } -// checkDeclarations refuses a declaration that reads as a second WorkContext -// surface, and a json tag outside the mint endpoint's two bodies. -func checkDeclarations(t *testing.T, file sourceFile) { - t.Helper() - for _, declaration := range file.syntax.Decls { - switch declared := declaration.(type) { +// inspectForSecondImplementation is the whole check over one parsed file, +// separated out so the bypasses it is supposed to catch can be driven through +// it directly rather than asserted about by eye. +func inspectForSecondImplementation(file sourceFile) []string { + var findings []string + coreImports := map[string]bool{} + for name, path := range importsOf(file.syntax) { + if slices.Contains(cryptoImplementationPackages, path) { + findings = append(findings, fmt.Sprintf( + "%s imports %q.\n"+ + "This module signs nothing and verifies nothing: core's workcontext is the only\n"+ + "implementation of the capability. If a signature has to be checked, it is checked\n"+ + "by core's Verifier, reached through the alias in core.go.", + file.path, path)) + } + if path == "encoding/json" { + if _, allowed := jsonAllowlist[file.path]; !allowed { + findings = append(findings, fmt.Sprintf( + "%s imports encoding/json.\n"+ + "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ + "The only JSON in this module is %v — the mint endpoint's HTTP bodies, named by exact\n"+ + "path. If a capability is being JSON-encoded here, that is the second implementation\n"+ + "coming back.", + file.path, allowedJSONFiles())) + } + } + if path == coreModulePath || strings.HasPrefix(path, coreModulePath+"/") { + coreImports[name] = true + } + } + findings = append(findings, inspectDeclarations(file, coreImports)...) + findings = append(findings, inspectJSONTags(file)...) + return findings +} + +// inspectDeclarations refuses a declaration that reads as a second WorkContext +// surface, ANYWHERE in the file. +// +// The earlier version walked file.Decls, so it saw only top-level declarations +// — a `type WorkContextToken struct{...}` inside a function body passed it +// untouched. And it accepted any alias at all: `type WorkContextVerifier = +// mylocal.Verifier` satisfied "it is an alias" while aliasing nothing of +// core's. An alias now has to resolve into core's module. +func inspectDeclarations(file sourceFile, coreImports map[string]bool) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + switch declared := node.(type) { case *ast.FuncDecl: if declared.Recv == nil && strings.HasPrefix(declared.Name.Name, "WorkContext") { - t.Errorf( + findings = append(findings, fmt.Sprintf( "%s declares func %s.\n"+ "A WorkContext-named function here is how the second implementation looked. The\n"+ "capability's own operations are core's; this module names what it does to one\n"+ "(Attach, FromHeaders, SealedInstallation) and re-exports core's for the rest.", - file.path, declared.Name.Name, - ) + file.path, declared.Name.Name)) } - case *ast.GenDecl: - for _, spec := range declared.Specs { - typed, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - if typed.Assign == 0 && strings.HasPrefix(typed.Name.Name, "WorkContext") { - t.Errorf( - "%s declares type %s, which is not an alias of core's.\n"+ - "The capability's types are core's. Alias them (type X = corework.X) so there is\n"+ - "one definition, or name what this module adds without naming the capability.", - file.path, typed.Name.Name, - ) - } - checkJSONTags(t, file, typed) + case *ast.TypeSpec: + if !strings.HasPrefix(declared.Name.Name, "WorkContext") { + return true + } + if declared.Assign == 0 { + findings = append(findings, fmt.Sprintf( + "%s declares type %s, which is not an alias of core's.\n"+ + "The capability's types are core's. Alias them (type X = corework.X) so there is\n"+ + "one definition, or name what this module adds without naming the capability.", + file.path, declared.Name.Name)) + return true + } + if !aliasesCore(declared.Type, coreImports) { + findings = append(findings, fmt.Sprintf( + "%s declares type %s as an alias of something that is not core's.\n"+ + "Being an alias is not the guarantee; aliasing CORE'S type is. An alias of a local\n"+ + "type is a second definition with core's name on it, which is worse than a plainly\n"+ + "named one because it reads as a re-export.", + file.path, declared.Name.Name)) } } + return true + }) + return findings +} + +// aliasesCore reports whether an alias's right-hand side names a type in core's +// module, through one of this file's imports of it. +func aliasesCore(target ast.Expr, coreImports map[string]bool) bool { + for { + switch typed := target.(type) { + case *ast.StarExpr: + target = typed.X + case *ast.SelectorExpr: + qualifier, ok := typed.X.(*ast.Ident) + return ok && coreImports[qualifier.Name] + default: + return false + } } } -// checkJSONTags refuses a json-tagged struct outside the mint endpoint. The -// deleted implementation's signed payload was exactly this: a struct whose +// inspectJSONTags refuses a json-tagged struct outside the mint endpoint's two +// bodies, wherever the struct appears. +// +// The deleted implementation's signed payload was exactly this: a struct whose // tags enumerated a capability's fields by hand, which is why a field present // in the proto and absent from the struct was dropped at mint and missing at -// verify. -func checkJSONTags(t *testing.T, file sourceFile, typed *ast.TypeSpec) { - t.Helper() - structure, ok := typed.Type.(*ast.StructType) - if !ok || structure.Fields == nil { - return - } - for _, field := range structure.Fields.List { - if field.Tag == nil { +// verify. The earlier version only looked at top-level named types, so an +// anonymous struct literal or a type declared inside a function carried json +// tags freely. This walks every struct type in the file and allows exactly the +// two the mint endpoint declares — identified by node, so a THIRD type cannot +// borrow the allowance by being declared beside them. +func inspectJSONTags(file sourceFile) []string { + allowed := map[ast.Node]bool{} + for _, declaration := range file.syntax.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { continue } - tag, err := strconv.Unquote(field.Tag.Value) - if err != nil || !strings.Contains(tag, `json:"`) { - continue + for _, spec := range general.Specs { + typed, ok := spec.(*ast.TypeSpec) + if !ok || !slices.Contains(mintEndpointJSONTypes, typed.Name.Name) { + continue + } + if structure, ok := typed.Type.(*ast.StructType); ok { + allowed[structure] = true + } } - if reflect.StructTag(tag).Get("json") == "" { - continue + } + + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + structure, ok := node.(*ast.StructType) + if !ok || structure.Fields == nil || allowed[structure] { + return true } - require.Contains(t, mintEndpointJSONTypes, typed.Name.Name, - "%s declares type %s with json tags.\n"+ - "Only the mint endpoint's bodies (%v) are JSON here. A JSON-tagged struct describing a\n"+ - "capability's fields is the second implementation: core signs the deterministic protobuf\n"+ - "encoding, and a field enumerated by hand is a field dropped at mint and absent at verify.", - file.path, typed.Name.Name, mintEndpointJSONTypes) - return + for _, field := range structure.Fields.List { + if field.Tag == nil { + continue + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil || reflect.StructTag(tag).Get("json") == "" { + continue + } + findings = append(findings, fmt.Sprintf( + "%s declares a struct with json tags outside %v.\n"+ + "Only the mint endpoint's bodies are JSON here. A JSON-tagged struct describing a\n"+ + "capability's fields is the second implementation: core signs the deterministic protobuf\n"+ + "encoding, and a field enumerated by hand is a field dropped at mint and absent at verify.", + file.path, mintEndpointJSONTypes)) + return true + } + return true + }) + return findings +} + +// TestTheGateCatchesItsOwnBypasses drives the checks over source written to +// slip past them. +// +// Every case here passed the earlier version of the gate. They are not +// hypothetical readings of it: the base-name allowlist, the top-level-only +// declaration walk, the top-level-only tag walk and the unchecked alias target +// were each a way to reintroduce the deleted implementation with the gate +// green, which is worse than no gate because it is evidence nobody re-reads. +func TestTheGateCatchesItsOwnBypasses(t *testing.T) { + for name, bypass := range map[string]struct { + path string + source string + says string + }{ + "a nested file inheriting the mint.go allowance": { + path: "grpctransport/mint.go", + source: `package grpctransport +import "encoding/json" +var _ = json.Marshal`, + says: "imports encoding/json", + }, + "an anonymous struct carrying json tags": { + path: "carrier.go", + source: `package workcontext +func payload() any { + return struct { + Scopes []string ` + "`json:\"scopes\"`" + ` + }{} +}`, + says: "json tags outside", + }, + "a json-tagged type declared inside a function": { + path: "carrier.go", + source: `package workcontext +func payload() any { + type sealedClaims struct { + Installation string ` + "`json:\"installation_id\"`" + ` + } + return sealedClaims{} +}`, + says: "json tags outside", + }, + "a WorkContext type declared inside a function": { + path: "carrier.go", + source: `package workcontext +func build() any { + type WorkContextToken struct{ Payload string } + return WorkContextToken{} +}`, + says: "is not an alias of core's", + }, + "a WorkContext function declared below the imports": { + path: "carrier.go", + source: `package workcontext +func WorkContextSign(payload []byte) []byte { return payload }`, + says: "declares func WorkContextSign", + }, + "an alias of a type that is not core's": { + path: "core.go", + source: `package workcontext +import local "github.com/codefly-dev/sdk-go/workcontext/internal/legacy" +type WorkContextVerifier = local.Verifier`, + says: "alias of something that is not core's", + }, + "an alias of a bare local type": { + path: "core.go", + source: `package workcontext +type verifier struct{} +type WorkContextVerifier = verifier`, + says: "alias of something that is not core's", + }, + "a signing primitive": { + path: "carrier.go", + source: `package workcontext +import "crypto/ed25519" +var _ = ed25519.Sign`, + says: `imports "crypto/ed25519"`, + }, + } { + t.Run(name, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, bypass.path, bypass.source)) + require.NotEmpty(t, findings, "the gate accepted source it must refuse") + require.Contains(t, strings.Join(findings, "\n"), bypass.says) + }) + } + + // And the two things that ARE allowed stay allowed, so the gate is not + // green by refusing everything. + for name, allowed := range map[string]struct { + path string + source string + }{ + "the mint endpoint's own bodies": { + path: "mint.go", + source: `package workcontext +import "encoding/json" +type mintRequest struct { + Audience string ` + "`json:\"audience\"`" + ` +} +type mintResponse struct { + WorkContext string ` + "`json:\"work_context\"`" + ` +} +var _ = json.Marshal`, + }, + "an alias of core's type": { + path: "core.go", + source: `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type WorkContextVerifier = corework.Verifier`, + }, + } { + t.Run(name, func(t *testing.T) { + require.Empty(t, inspectForSecondImplementation(parseSource(t, allowed.path, allowed.source))) + }) } } +func parseSource(t *testing.T, path string, source string) sourceFile { + t.Helper() + syntax, err := parser.ParseFile(token.NewFileSet(), path, source, parser.SkipObjectResolution) + require.NoError(t, err) + return sourceFile{path: path, syntax: syntax} +} + // TestWorkContextConformance drives core's own fixtures against the -// verification entrypoint this module exports, which is core's Verifier. +// verification entrypoint THIS MODULE exports. +// +// It is built field by field from the kit's settings rather than through +// conformance.Settings.Verifier(), which returns core's own verifier: running +// the fixtures against that tests core, which core already tests, and would +// have passed unchanged beside the second implementation this PR deletes. The +// verifier under test is constructed as `&Verifier{...}` — this module's +// exported name — so the thing a consumer reaches for is the thing the +// fixtures are driven through. // -// It is the half of the gate the static checks cannot make: a module could -// import no signing primitive and still hand a consumer a verifier that -// answered differently from core's. The decisive fixture is the foreign -// encoding — a JSON-shaped token that must be refused BEFORE its signature is -// checked, with ErrNotACoreToken. A second implementation refuses that token -// too, but as a signature failure, which is the misdiagnosis this whole rule -// exists to prevent. +// What it proves is BEHAVIOURAL conformance: this entrypoint reaches core's +// accept/refuse decision with core's named reason on every fixture. Because +// Verifier is a true alias the identity follows, but the kit cannot see that — +// an equivalent second implementation would pass the same fixtures. The +// identity half is the compile-time assertion at the top of this file plus the +// static gate above; the fixtures are the cheaper half of the guarantee, not +// the whole of it. +// +// The decisive fixture is the foreign encoding — a JSON-shaped token that must +// be refused BEFORE its signature is checked, with ErrNotACoreToken. A second +// implementation refuses that token too, but as a signature failure, which is +// the misdiagnosis this whole rule exists to prevent. func TestWorkContextConformance(t *testing.T) { settings := conformance.New(time.Now()) - verifier := settings.Verifier() + exported := &Verifier{ + Issuer: settings.Issuer, + Audience: settings.Audience, + // Settings.Keys is map[string][]byte so holding settings needs no + // signing import; the verifier's own field is the typed one. + Keys: corework.FixtureKeys(), + Revisions: settings.Revisions, + // One replay store for the whole run: the kit presents the single-use + // grant fixture twice and requires ErrReplayed, which a fresh store per + // call would turn into a pass on everything else and a failure there. + Replay: settings.Replay, + Grants: settings.Grants, + Seals: settings.Seals, + Now: settings.Now, + } conformance.RunWith(t, settings, func(ctx context.Context, token string) error { - _, err := verifier.Verify(ctx, token) + _, err := exported.Verify(ctx, token) return err }) } @@ -217,7 +460,9 @@ type sourceFile struct { // moduleFiles parses every non-test Go file in this module. The walk starts at // the module root, which is this package's directory, so a new package added -// beside grpctransport is scanned without anyone remembering to list it. +// beside grpctransport is scanned without anyone remembering to list it. Paths +// are slash-separated and relative to that root, which is what the allowlist is +// keyed by. func moduleFiles(t *testing.T) []sourceFile { t.Helper() var files []sourceFile @@ -239,20 +484,26 @@ func moduleFiles(t *testing.T) []sourceFile { if parseErr != nil { return parseErr } - files = append(files, sourceFile{path: path, syntax: syntax}) + files = append(files, sourceFile{path: filepath.ToSlash(path), syntax: syntax}) return nil })) return files } -func importsOf(file *ast.File) []string { - paths := make([]string, 0, len(file.Imports)) +// importsOf returns this file's imports by the local name each is reachable +// under, which is what an alias's qualifier has to be checked against. +func importsOf(file *ast.File) map[string]string { + paths := make(map[string]string, len(file.Imports)) for _, imported := range file.Imports { path, err := strconv.Unquote(imported.Path.Value) if err != nil { continue } - paths = append(paths, path) + name := path[strings.LastIndex(path, "/")+1:] + if imported.Name != nil { + name = imported.Name.Name + } + paths[name] = path } return paths } diff --git a/workcontext/stream.go b/workcontext/stream.go index f90fb48..94b0b30 100644 --- a/workcontext/stream.go +++ b/workcontext/stream.go @@ -5,20 +5,6 @@ import ( "errors" "fmt" "sync" - "time" -) - -// Bounds on the re-check cadence. One shorter than a second would re-present a -// credential faster than any authority changes its mind; one longer than the -// longest credential a host issues would never fire inside a single stream, so -// the stream would serve its whole life on the check that opened it. -// -// They are client-side sanity bounds and not a statement about the credential: -// the cadence itself is the host's to state, because the host is what decides -// how long an authorization answer stays true. -const ( - minStreamRecheckInterval = time.Second - maxStreamRecheckInterval = 15 * time.Minute ) // ErrStreamTerminated marks a stream that a re-check refused. It is returned to @@ -29,20 +15,17 @@ var ErrStreamTerminated = errors.New("Codefly Work Context stream terminated") // StreamGuardOptions configures one stream's hold on its credential. type StreamGuardOptions struct { - // Interval is the host's re-check cadence. It is the host's, not a number - // chosen here: the host is what decides how long an authorization answer - // stays true, and a stream that re-checks more slowly than that is a stream - // serving from a snapshot the host has already revised. - Interval time.Duration - // Recheck re-presents the credential and returns the host's answer. It is - // the same verification an ordinary call performs, against expectations - // read at the moment of the check — not against the expectations the stream + // the same verification an ordinary call performs, against live state read + // at the moment of the check — not against the expectations the stream // opened with, which is the whole point. + // + // Any error terminates the stream, and that includes an error that is + // neither ErrRevoked nor ErrInvalid. A live source that cannot be reached + // has not said the credential is good; treating "I could not ask" as a + // pass is how a stream outlives the authority it was opened under while + // every log line stays clean. Recheck func(context.Context) error - - // Now is the clock, for tests. - Now func() time.Time } // StreamGuard holds a stream to its credential for the stream's whole life. @@ -53,48 +36,50 @@ type StreamGuardOptions struct { // drain a snapshot computed under authority that no longer exists. Nothing in // the stream fails, no error is logged, and the data keeps arriving. // -// It guards emission rather than running a timer. An idle stream discloses -// nothing, so there is nothing to refuse; a stream that keeps emitting is -// re-checked on the cadence and terminates the moment the answer changes. +// It guards EVERY emission. There was a re-check cadence here, bounded at +// fifteen minutes, and it was a weakening of the rule rather than an +// implementation of it: revoke the installation one second after a successful +// check and every message for the next fifteen minutes still left, with expiry +// and source unavailability equally invisible for that interval. The condition +// is per emission, so the check is per emission. +// +// What that costs is one live authorization check per message, which is the +// price of the guarantee and is why the guard is for streams whose messages +// carry authority rather than for every stream. A stream that cannot pay it +// does not get the guarantee; it does not get a cadence instead. +// +// An idle stream discloses nothing, so there is nothing to refuse: the guard +// does no work until something is about to be sent. type StreamGuard struct { mu sync.Mutex - interval time.Duration recheck func(context.Context) error - now func() time.Time - checkedAt time.Time terminated error } -// NewStreamGuard validates the cadence and performs no I/O. The first +// NewStreamGuard validates its configuration and performs no I/O. The first // BeforeSend re-checks, so a stream never emits its first message on the // strength of the check that opened it. func NewStreamGuard(options StreamGuardOptions) (*StreamGuard, error) { if options.Recheck == nil { return nil, fmt.Errorf("%w: a stream guard needs a re-check", ErrInvalid) } - if options.Interval < minStreamRecheckInterval || options.Interval > maxStreamRecheckInterval { - return nil, fmt.Errorf( - "%w: stream re-check interval must be between %s and %s", - ErrInvalid, minStreamRecheckInterval, maxStreamRecheckInterval, - ) - } - now := options.Now - if now == nil { - now = time.Now - } - return &StreamGuard{interval: options.Interval, recheck: options.Recheck, now: now}, nil + return &StreamGuard{recheck: options.Recheck}, nil } -// BeforeSend is called before every message a stream emits. It re-presents the -// credential when the cadence has elapsed and returns the refusal when the -// host gives one. A non-nil error terminates the stream: the message must not -// be sent, and nothing after it may be either. +// BeforeSend is called before every message a stream emits, and re-presents the +// credential every time. A non-nil error terminates the stream: the message +// must not be sent, and nothing after it may be either. // // A refusal is returned wrapped, so a caller can tell a credential the state // moved under (ErrRevoked — a fresh credential would be accepted) from one that -// never becomes valid (ErrInvalid). Neither resumes this stream. The -// distinction is for the caller's decision about whether to open another, which -// is a decision about one new stream and not a licence to keep this one alive. +// never becomes valid (ErrInvalid) from a live source that could not be reached +// at all. None of the three resumes this stream. The distinction is for the +// caller's decision about whether to open another, which is a decision about +// one new stream and not a licence to keep this one alive. +// +// It holds the guard's lock across the check, so a stream emitting from several +// goroutines cannot have one of them send on the strength of a check another +// goroutine is still waiting on. func (g *StreamGuard) BeforeSend(ctx context.Context) error { if g == nil { return fmt.Errorf("%w: nil stream guard", ErrInvalid) @@ -107,10 +92,6 @@ func (g *StreamGuard) BeforeSend(ctx context.Context) error { if g.terminated != nil { return g.terminated } - now := g.now().UTC() - if !g.checkedAt.IsZero() && now.Sub(g.checkedAt) < g.interval { - return nil - } if err := g.recheck(ctx); err != nil { // Both sentinels stay in the chain: the caller needs to know the stream // is over AND whether a fresh credential would be accepted, and @@ -118,7 +99,6 @@ func (g *StreamGuard) BeforeSend(ctx context.Context) error { g.terminated = fmt.Errorf("%w: %w", ErrStreamTerminated, err) return g.terminated } - g.checkedAt = now return nil } diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go index d3e5c16..b295c3f 100644 --- a/workcontext/stream_test.go +++ b/workcontext/stream_test.go @@ -2,6 +2,7 @@ package workcontext import ( "context" + "errors" "testing" "time" @@ -12,19 +13,18 @@ import ( // opened under an installation revision the host has since replaced, still // draining a snapshot computed under authority that no longer exists. Nothing // in the stream fails, nothing is logged, and the data keeps arriving. -func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { - clock := testClock +// +// The check is per emission, so the revocation lands IMMEDIATELY after a +// successful check and the very next message is refused. Under the cadence this +// replaced — bounded at fifteen minutes — that message and every message for +// the next fifteen minutes still left. +func TestStreamTerminatesOnTheFirstMessageAfterTheCredentialIsSuperseded(t *testing.T) { a := newAuthority(t) token := a.start(t, mintInput{}) verifier := a.verifier(t) - // The host's live view of the installation revision, which it moves while - // the stream is open. The re-check is core's verify against it, which is - // what an ordinary call performs. rechecks := 0 guard, err := NewStreamGuard(StreamGuardOptions{ - Interval: 30 * time.Second, - Now: func() time.Time { return clock }, Recheck: func(ctx context.Context) error { rechecks++ _, verifyErr := verifier.Verify(ctx, token) @@ -33,47 +33,93 @@ func TestStreamTerminatesWhenTheCredentialIsSuperseded(t *testing.T) { }) require.NoError(t, err) - // The first message is re-checked rather than riding on the check that - // opened the stream. - require.NoError(t, guard.BeforeSend(t.Context())) - require.Equal(t, 1, rechecks) - - // Messages inside the cadence cost nothing. - clock = clock.Add(20 * time.Second) - require.NoError(t, guard.BeforeSend(t.Context())) - require.Equal(t, 1, rechecks) - - // The cadence elapses and the credential is still current. - clock = clock.Add(20 * time.Second) - require.NoError(t, guard.BeforeSend(t.Context())) - require.Equal(t, 2, rechecks) - require.NoError(t, guard.Terminated()) + // Every message is re-checked, including the first: a stream never emits on + // the strength of the check that opened it. + for message := 1; message <= 4; message++ { + require.NoError(t, guard.BeforeSend(t.Context())) + require.Equal(t, message, rechecks, + "message %d must have re-presented the credential", message) + } - // The host moves the installation on. The next message past the cadence is - // refused, and the refusal says the state moved under the credential - // (ErrRevoked) rather than that the credential is malformed, so the caller - // knows a fresh credential would be accepted. + // The host moves the installation on, with no time passing at all. The very + // next message is refused, and the refusal says the state moved under the + // credential (ErrRevoked) rather than that the credential is malformed, so + // the caller knows a fresh credential would be accepted. moved := testSeal moved.InstallationRevision++ require.NoError(t, a.seals.Put(testPrincipal, moved)) - clock = clock.Add(31 * time.Second) + err = guard.BeforeSend(t.Context()) require.ErrorIs(t, err, ErrStreamTerminated) require.ErrorIs(t, err, ErrRevoked) + require.Equal(t, 5, rechecks, "the refusal came from a check, not from a cached answer") require.ErrorIs(t, guard.Terminated(), ErrStreamTerminated) } +// Expiry is visible for the same reason revocation is: the check is per +// emission, so a credential that runs out mid-stream stops the stream at the +// next message. The verifier's own clock moves — the earlier version of this +// test advanced a clock the guard held and left the verifier's fixed, so it +// provided no expiry evidence at all. +func TestStreamTerminatesWhenTheCredentialExpiresMidStream(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) // a fifteen-minute credential + + verifying := testClock + verifier := a.verifier(t) + verifier.Now = func() time.Time { return verifying } + + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + _, verifyErr := verifier.Verify(ctx, token) + return verifyErr + }, + }) + require.NoError(t, err) + require.NoError(t, guard.BeforeSend(t.Context())) + + verifying = testClock.Add(16 * time.Minute) + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, ErrInvalid, "an expired credential is invalid, not revoked") +} + +// A live source that could not be reached has not said the credential is good. +// Treating "I could not ask" as a pass is how a stream outlives the authority +// it was opened under while every log line stays clean, so an unavailable +// source terminates the stream exactly as a refusal does. +func TestStreamTerminatesWhenTheLiveSourceIsUnavailable(t *testing.T) { + unavailable := errors.New("installation state store unreachable") + answers := []error{nil, unavailable} + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(context.Context) error { + answer := answers[0] + if len(answers) > 1 { + answers = answers[1:] + } + return answer + }, + }) + require.NoError(t, err) + + require.NoError(t, guard.BeforeSend(t.Context())) + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, unavailable, + "the reason stays in the chain: the caller needs to tell an outage from a revocation") + require.NotErrorIs(t, err, ErrRevoked) +} + // A terminated stream stays terminated. A caller that loops past the first -// refusal must not be handed a second chance to emit, and restoring the old -// revision must not resurrect a stream that has already been refused: the -// messages it would send were computed under authority that was withdrawn. +// refusal must not be handed a second chance to emit, and the authority coming +// back must not resurrect a stream that has already been refused: the messages +// it would send were computed under authority that was withdrawn. func TestATerminatedStreamIsNotResumed(t *testing.T) { - clock := testClock refuse := true + rechecks := 0 guard, err := NewStreamGuard(StreamGuardOptions{ - Interval: time.Second, - Now: func() time.Time { return clock }, Recheck: func(context.Context) error { + rechecks++ if refuse { return ErrRevoked } @@ -82,40 +128,24 @@ func TestATerminatedStreamIsNotResumed(t *testing.T) { }) require.NoError(t, err) - first := guard.BeforeSend(t.Context()) - require.ErrorIs(t, first, ErrStreamTerminated) + require.ErrorIs(t, guard.BeforeSend(t.Context()), ErrStreamTerminated) + require.Equal(t, 1, rechecks) refuse = false - clock = clock.Add(time.Hour) for range 3 { require.ErrorIs(t, guard.BeforeSend(t.Context()), ErrStreamTerminated, "a stream is terminated once, and the authority coming back does not undo it") } + require.Equal(t, 1, rechecks, "a terminated stream asks nothing further") } -func TestStreamGuardValidatesItsCadence(t *testing.T) { - _, err := NewStreamGuard(StreamGuardOptions{Interval: time.Minute}) +func TestStreamGuardValidatesItsConfiguration(t *testing.T) { + _, err := NewStreamGuard(StreamGuardOptions{}) require.ErrorIs(t, err, ErrInvalid) require.ErrorContains(t, err, "needs a re-check") - for name, interval := range map[string]time.Duration{ - "zero": 0, - "sub-second": 500 * time.Millisecond, - "beyond the bound": maxStreamRecheckInterval + time.Second, - } { - t.Run(name, func(t *testing.T) { - _, err := NewStreamGuard(StreamGuardOptions{ - Interval: interval, - Recheck: func(context.Context) error { return nil }, - }) - require.ErrorIs(t, err, ErrInvalid) - require.ErrorContains(t, err, "interval") - }) - } - guard, err := NewStreamGuard(StreamGuardOptions{ - Interval: time.Minute, - Recheck: func(context.Context) error { return nil }, + Recheck: func(context.Context) error { return nil }, }) require.NoError(t, err) require.ErrorIs(t, guard.BeforeSend(nil), ErrInvalid) From c035e5e382b3c9e7f584e5e53c4963429b837d3b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:54:49 -0400 Subject: [PATCH 08/35] fix(workcontext)!: core's Inspect owns the seal rule, the client owns the transport, and the gate refuses capabilities rather than names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second, independent review (layer 5) found two blockers and eight majors. This is all of them. Three of its findings were about this module having re-implemented a decision rather than about a missing check, and those are fixed by deleting code. The second parser is gone. carrier.go hand-parsed the envelope and applied its own structural seal rule, and that rule DISAGREED with core's own fixtures: seal-without-installation was ErrUnsealed here and ErrInvalid there (core's schema reaches it first), a zero epoch, revision or incarnation was ErrUnsealed here and ErrInvalid there, and the actor chain was never read at all — so core's actor-without-epoch fixture, a principal nobody can revoke, was attached by Attach, read by FromHeaders and carried by both gRPC directions while AGENTS.md claimed otherwise. One condition with two messages is the fragmentation the one-implementation rule exists to end, relocated from the signature to the seal. core#691 was asked for one unverified inspection entry point and shipped it: corework.Inspect now answers every structural question — shape, encoding, schema, attenuation, grant shape, a seal naming an installation, an epoch on every actor hop — and readClaims runs after it, reads two values and decides nothing. TestTheSDKParsePathsAgreeWithCore drives all 25 fixtures through this module's own paths: the 8 structural refusals must reach core's declared sentinel, the other 17 must pass. The mint transport is the client's. An *http.Client option could not be made safe by inspecting it: a nil Transport means the global, mutable http.DefaultTransport; a wrapping RoundTripper is opaque; DialTLSContext bypasses TLSClientConfig; and a caller keeping the *http.Transport pointer can turn verification off after construction, because copying a client shares it. Each sent the projected bearer over an unauthenticated channel. The option is replaced by RootCAs, and the client builds the transport: TLS 1.2 minimum, verification on, no dialer, no proxy, redirects refused. The audience is read from the pin. A free string beside an optional Authority made the drift check guard a value the mint did not use — Recheck could pass while the credential was minted for whatever the caller typed. Authority is now required and MintOptions.Audience names the pinned value; credentialFrom requires the signed audience to be the one the pin answered. A failed renewal no longer manufactures an outage. A held credential with time left is served; only an expired one is an error. Waiting is on a channel rather than the mutex, so a caller's ctx means something, and a failure holds off the next attempt, doubling to a minute — previously each caller retried serially with no backoff, which is one request per caller at exactly the wrong moment. The hold-off bounds Refresh too, which the generation check cannot: a receiver whose live state lags refuses each fresh credential, and every refusal is a new generation. The gate refuses capabilities, not names. It listed two package paths and a case-sensitive prefix, so a faithful clone of core's signer was green, as were x/crypto, crypto/rsa, crypto/hmac, the bare crypto package (crypto.Signer signs Ed25519 with no ed25519 import), x509.ParsePKCS8PrivateKey through an allowed import, protojson, and the deleted implementation's own workContextPayload. Now any signature, MAC or JOSE primitive is refused wherever it is, tls/x509 are allowed only in mint.go and only for the symbols a transport needs, proto marshalling is allowed only where it encodes a scope, and names are matched case-insensitively over every declaration kind. The shell sweep lost its quote-anchored pattern and its "same file as WorkContext" conjunction, which a signer defeats by living in two files. Streams: the README's own recipe was the bug. It called Verify on every emission, Verify consumes a single-use nonce, and every grant capability is single-use — so a grant-opened stream died with ErrReplayed at its first message. core shipped (*Verifier).Recheck, which re-reads liveness and never touches the replay store; RecheckWith hands a caller that call, and grpctransport.Guard wraps a grpc.ServerStream so SendMsg IS the re-check followed by the send, rather than something each author must remember. HTTP inbound requires both installation carriers, exactly once each, as gRPC already did: carrying neither was the optional-carrier escape hatch surviving on HTTP after it was deleted on gRPC, and it skipped the seal check entirely. Also: the sweep moved into go.yml, the workflow that builds every ref this repository publishes, under the job name the active ruleset already requires; AGENTS.md's no-back-port rule is deleted, because for this deletion it is what kept the implementation alive on three refs; checkWindow tests not_before, which is the claim core's verifier tests; a credential longer than a ceiling is refused (a labelled stopgap — core has built Authority.MaxTTL); Content-Type is required rather than checked when present; canonicalScopes de-duplicates; the Seal is three fields, core having made SealSource.PrincipalEpoch the only source; ErrNeedsIssuer, ErrNoSeal, ErrNoBinding, Authenticator and Authenticated are deliberately NOT aliased, on core's instruction, with the reason in core.go; and no product name remains in any fixture. core is pinned to 8230f25d, the pushed head of core#692, which carries Inspect and Recheck. At merge it moves to the v0.8.0 tag rather than a pseudo-version: a squash merge orphans a branch sha. Every new test was mutation-verified by reverting the behaviour it covers. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 19 + .github/workflows/one-implementation.yml | 38 -- AGENTS.md | 67 ++- authority_pin_internal_test.go | 59 ++ authority_test.go | 42 -- codefly_test.go | 12 +- scripts/check-one-implementation.sh | 114 +++- workcontext/README.md | 214 +++++-- workcontext/cache_partition.go | 7 + workcontext/carrier.go | 174 +++--- workcontext/carrier_test.go | 106 +++- workcontext/core.go | 23 + workcontext/doc.go | 2 +- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/grpctransport/guarded_stream.go | 67 +++ .../grpctransport/guarded_stream_test.go | 125 ++++ workcontext/grpctransport/two_service_test.go | 4 +- workcontext/mint.go | 542 ++++++++++++----- workcontext/mint_test.go | 564 +++++++++++++----- workcontext/minting_test.go | 15 +- workcontext/one_implementation_test.go | 380 ++++++++++-- workcontext/parse_agreement_test.go | 146 +++++ workcontext/stream.go | 30 +- workcontext/stream_test.go | 75 +++ 25 files changed, 2144 insertions(+), 687 deletions(-) delete mode 100644 .github/workflows/one-implementation.yml create mode 100644 workcontext/grpctransport/guarded_stream.go create mode 100644 workcontext/grpctransport/guarded_stream_test.go create mode 100644 workcontext/parse_agreement_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 94d4cd9..356693c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -42,6 +42,25 @@ jobs: go mod tidy git diff --exit-code -- go.mod go.sum + # The sweep lives HERE, in the workflow that builds every ref this repository + # publishes, rather than in one of its own that ran on main alone. go.yml's + # own `on:` is the statement of which refs are built, so a gate that has to + # hold on all of them belongs beside the build rather than beside a list of + # refs that drifts from it. + # + # The job's name is load-bearing: the active ruleset on main requires the + # status check "no second Work Context implementation" by that exact context, + # so renaming this job would leave a required check that never reports. + one-implementation: + name: no second Work Context implementation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Sweep every tracked Go file for a second implementation + run: ./scripts/check-one-implementation.sh + lint: name: lint runs-on: ubuntu-latest diff --git a/.github/workflows/one-implementation.yml b/.github/workflows/one-implementation.yml deleted file mode 100644 index b232f0a..0000000 --- a/.github/workflows/one-implementation.yml +++ /dev/null @@ -1,38 +0,0 @@ -# The Work Context has exactly one implementation and it lives in -# codefly-dev/core. TestNoSecondWorkContextImplementation guards the workcontext -# module; this sweeps the whole repository, which is the half that test cannot -# reach — it walks from the workcontext module root, and the implementation this -# repository deleted lived at the ROOT in package codefly. -# -# It is a REQUIRED pull-request check and it is green. It was neither: it ran -# only on main and on a schedule, and it swept other refs so that it was red -# while three branches still carried the deleted implementation — a documented -# compatibility period, which is the opposite of the rule it was added to -# enforce. Which refs still carry it, and when they are deleted, belongs to the -# cold-cutover runbook outside this repository; the gate here says only that the -# ref CI is building carries no second implementation, and it says it green. -name: one implementation - -on: - pull_request: - push: - branches: [ main ] - workflow_dispatch: - -concurrency: - group: sdk-go-one-implementation-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - sweep: - name: no second Work Context implementation - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Sweep - run: ./scripts/check-one-implementation.sh diff --git a/AGENTS.md b/AGENTS.md index f150c7d..ead3678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,31 +117,35 @@ see the skill below. variable ships the name with an empty string; `RuntimeValue` reports `false` so it cannot shadow the configuration a caller falls back to. Keep that. - **The Work Context has exactly one implementation and it is not here.** - `core/workcontext` signs and verifies; this module is a client of it and - re-exports its verifier by alias. Nothing here may sign, check a signature or - encode a capability — `TestNoSecondWorkContextImplementation` refuses a - `crypto/ed25519` import, an `encoding/json` import outside `mint.go`'s HTTP - bodies (by exact path), a json-tagged struct that is not those bodies anywhere - in the file including inside a function, and a `WorkContext*` type of our own - — an alias counts only if it resolves into core's module; - `TestWorkContextConformance` drives core's fixtures through a verifier built as - the type we export, which is the behavioural half, the compile-time assertions - being the identity half. A claim added to the wire is added to the proto in - core, never to an encoder here. This repository once held a second - implementation signing hand-written JSON. The signatures were sound — each - side signed and verified the bytes it handled — but the key id is a field - *inside* the payload, so reading a protobuf payload as JSON yielded no key id - and the failure surfaced as "unknown key"/"signature does not verify", which - reads like a rotated key. That - test walks from the `workcontext` module root, and the deleted implementation - lived at the repository root, so `scripts/check-one-implementation.sh` sweeps - every tracked Go file in both modules as a **required check that is green**. - There is no compatibility period in it: refs outside this repository's build - that still carry it belong to the cold-cutover runbook, which lives elsewhere. -- **A credential is sealed or it is not a credential.** Every field of - `workcontext.Seal` is required at mint and at verify, an operation binding - carries its id, revision and incarnation or none of them, and `Attach` - refuses to put an unsealed capability on a request. + `core/workcontext` signs, verifies, and — since this was asked for — answers + the *structural* question too, through `Inspect`. Nothing here may sign, check + a signature, encode a capability **or decide what a capability is**: a local + seal rule is a second implementation even when it signs nothing, and ours + disagreed with core's own fixtures about which sentinel three refusals earn. + `TestNoSecondWorkContextImplementation` refuses by capability rather than by + name — any signature, MAC or JOSE primitive, anything under `x/crypto`, + `protojson`, a `proto.Marshal` outside the one digest, `crypto/tls`/`x509` + outside the mint transport or using a symbol beyond it, an `encoding/json` + outside `mint.go` by exact path, a json-tagged struct anywhere else, and any + declaration whose name begins with `workcontext` case-insensitively unless it + aliases into core's module. `TestTheGateCatchesItsOwnBypasses` holds the gate + to that claim; `TestTheSDKParsePathsAgreeWithCore` drives every core fixture + through our own parse paths and requires core's sentinel. A claim added to the + wire is added to the proto in core, never to an encoder here. That test walks + from the `workcontext` module root, and the deleted implementation lived at + the repository root, so `scripts/check-one-implementation.sh` sweeps every + tracked Go file in both modules — as a job in `go.yml`, the workflow that + builds every ref this repository publishes, required on `main` by a ruleset. + There is no compatibility period: a release line that cannot meet the rule is + **retired by the owner**, not exempted. +- **A credential is sealed or it is not a credential, and core says what that + means.** Every field of the seal is required at mint and at verify, an + operation binding carries its id, revision and incarnation or none of them, + and **every actor hop carries an epoch** — a hop without one is a principal + nobody can revoke. `Attach` refuses to put an unsealed capability on a + request, and it refuses it with `corework.Inspect`'s answer: this claim was + false for a while precisely because we were answering it ourselves and never + read the actor chain. - **An authority-bearing value is read once.** A principal, binding or audience comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for a pinned name, so a drift is an error rather than a reload — the process has @@ -150,10 +154,15 @@ see the skill below. receipt written after the commit leaves a window where the effect exists and the receipt does not, and a recovery landing there reads "no receipt" for an effect that already happened. That is why `receipts.Record` takes a `Tx`. -- **`compat/**` branches are published artifacts.** Consumers pin them when - `main` holds an unreleased breaking change, so CI builds them like `main`. - Never back-port a breaking deletion into one: it breaks the consumer the line - exists for. A line is retired, by the owner, once its last consumer repins. +- **`compat/**` branches are published artifacts and are held to these rules, + not exempted from them.** Consumers pin them when `main` holds an unreleased + breaking change, so CI builds them like `main` — and the one-implementation + sweep runs in `go.yml` for exactly that reason. A line that cannot meet a rule + is **retired** by the owner, not granted a period during which the rule is + false of this repository. There was a bullet here forbidding the back-port of + a breaking deletion into a release line; it is deleted, because for the + deleted Work Context implementation it is what kept the implementation alive + on three refs CI builds. ## Procedures diff --git a/authority_pin_internal_test.go b/authority_pin_internal_test.go index 190eb66..407bb67 100644 --- a/authority_pin_internal_test.go +++ b/authority_pin_internal_test.go @@ -1,6 +1,8 @@ package codefly import ( + "fmt" + "sync" "testing" "github.com/stretchr/testify/require" @@ -49,3 +51,60 @@ func TestPinningAnAuthoritySetIsWholeOrNothing(t *testing.T) { // twice is allowed and must agree, which is the same comparison. require.NoError(t, pinAuthorityValues(map[AuthorityValueName]string{first: "one"})) } + +// Concurrent installs of DIFFERENT values for one name: exactly one wins, the +// pin is the winner's, and every loser is told the value changed. +// +// The external version of this test was not evidence. It ran several readers +// against a constant environment value, so every reader resolved the same +// string and they agreed whatever the code did — it passed against the racy +// pre-fix version too. The race is only observable when the readers resolve +// DIFFERENT values, which is what a configuration reload between two reads +// produces and what this drives directly. +// +// Against the old unconditional install every goroutine succeeded and the last +// writer won, so the process ended up holding several *Authority values that +// disagreed about the audience it runs under, with the race detector seeing +// nothing because every access was correctly locked. +func TestConcurrentPinsOfDifferentValuesLeaveOneWinner(t *testing.T) { + name := AuthorityValueName{Name: "pin-race", Key: "audience"} + t.Cleanup(func() { + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + delete(authorityPins, name) + }) + + const readers = 32 + outcomes := make(chan error, readers) + proposed := make([]string, readers) + var waiting sync.WaitGroup + start := make(chan struct{}) + for reader := range readers { + proposed[reader] = fmt.Sprintf("audience-%02d", reader) + waiting.Add(1) + go func() { + defer waiting.Done() + <-start // all of them at once, so the interleaving is real + outcomes <- pinAuthorityValues(map[AuthorityValueName]string{name: proposed[reader]}) + }() + } + close(start) + waiting.Wait() + close(outcomes) + + won := 0 + for err := range outcomes { + if err == nil { + won++ + continue + } + require.ErrorIs(t, err, ErrAuthorityValueChanged, + "a loser must be told the value changed, not given some other error") + } + require.Equal(t, 1, won, + "%d concurrent installs of different values must leave exactly one winner", readers) + + pinned, ok := authorityPin(name) + require.True(t, ok) + require.Contains(t, proposed, pinned, "the pin is one of the proposed values") +} diff --git a/authority_test.go b/authority_test.go index cc6ea44..0d9d908 100644 --- a/authority_test.go +++ b/authority_test.go @@ -1,7 +1,6 @@ package codefly_test import ( - "sync" "testing" codefly "github.com/codefly-dev/sdk-go" @@ -110,47 +109,6 @@ func TestReadAuthorityFailsWholeWhenOneNameIsMissing(t *testing.T) { require.Equal(t, "example.moved-after-the-failed-read", value) } -// Two processes-worth of concurrent reads of one name must not end up holding -// two authorities that disagree. Whichever value wins, every Authority that was -// handed back carries it; the losers are refused. -func TestConcurrentAuthorityReadsNeverDisagree(t *testing.T) { - t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_RACE__AUDIENCE", "example.audience") - require.NoError(t, codefly.LoadEnvironmentVariables()) - name := codefly.AuthorityValueName{Name: "mint-race", Key: "audience"} - - const readers = 16 - values := make(chan string, readers) - var waiting sync.WaitGroup - for range readers { - waiting.Add(1) - go func() { - defer waiting.Done() - authority, err := codefly.ReadAuthority(t.Context(), name) - if err != nil { - return - } - value, err := authority.Value(name.Name, name.Key) - if err != nil { - return - } - values <- value - }() - } - waiting.Wait() - close(values) - - var agreed string - for value := range values { - if agreed == "" { - agreed = value - continue - } - require.Equal(t, agreed, value, - "two concurrent reads produced authorities that disagree about %s", name) - } - require.NotEmpty(t, agreed, "at least one read must succeed") -} - // A name the process never declared is not answerable from a frozen set. // Declaring the set up front is what makes "read once" checkable at all. func TestAuthorityRefusesANameItNeverRead(t *testing.T) { diff --git a/codefly_test.go b/codefly_test.go index dd3d4f8..b09d1d6 100644 --- a/codefly_test.go +++ b/codefly_test.go @@ -230,11 +230,11 @@ modules: writeFile(t, filepath.Join(root, "modules", "platform", "module.codefly.yaml"), `kind: module name: platform services: - - name: warden + - name: records `) - serviceDir := filepath.Join(root, "modules", "platform", "services", "warden") + serviceDir := filepath.Join(root, "modules", "platform", "services", "records") writeFile(t, filepath.Join(serviceDir, "service.codefly.yaml"), `kind: service -name: warden +name: records version: 0.0.0 agent: kind: codefly:service @@ -248,7 +248,7 @@ endpoints: instance, err := codefly.For(ctx). Module("platform"). - Service("warden"). + Service("records"). Endpoint("rest"). ResolveNetworkInstance() assert.NoError(t, err) @@ -256,7 +256,7 @@ endpoints: ctx, "sdk-endpoint-test", "platform", - "warden", + "records", "", &basev0.Endpoint{Name: "rest", Api: "rest"}, ) @@ -266,7 +266,7 @@ endpoints: scoped, err := codefly.For(ctx). Module("platform"). - Service("warden"). + Service("records"). Endpoint("rest"). NamingScope("agent-test"). ResolveNetworkInstance() diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 7ac06cf..5cd9eec 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -3,65 +3,128 @@ # repository: it is codefly-dev/core/workcontext. This sweeps the WHOLE # repository — both modules, every tracked Go file — for a second one. # -# It is a required pull-request check and it is green. There is no compatibility -# period here and no countdown: a second implementation on a ref this repository -# builds is a failure to fix, not a state to document. +# It runs as a job in go.yml, which is the workflow that builds every ref this +# repository publishes (main and compat/**), so the gate holds wherever the +# build holds. It is required on main by an active ruleset, under the check name +# "no second Work Context implementation". There is no compatibility period: a +# second implementation on a ref this repository builds is a failure to fix, and +# a release line that cannot meet the rule is retired by the owner rather than +# exempted. # # It is not the same check as TestNoSecondWorkContextImplementation, which walks # from the workcontext module root and therefore cannot see the rest of the # repository. The implementation this repository deleted lived at the ROOT, in # package codefly, which is exactly the place that test's walk does not reach. -# Hence a sweep over every tracked file rather than over one module. # -# The predicate is narrow on purpose: a non-test Go file that imports a signing -# primitive AND mentions the Work Context. Importing ed25519 for something else -# is not this; mentioning the capability without signing it is not this either. -# Both together is a mint or a verify, wherever in the tree it lives. +# THE PREDICATE IS AN ABSOLUTE BAN ON SIGNING, with a named allowlist — not a +# conjunction. It used to be "imports a signing primitive AND mentions +# WorkContext", which a second implementation defeats by putting the signer in +# one file and the wrapper in another; and its pattern was anchored on the +# opening quote, so "golang.org/x/crypto/ed25519" did not match at all. Now any +# import of a signature, a MAC or a JOSE/JWT library is a finding wherever it +# is, and the exceptions are listed by path with a reason. crypto/sha256 is +# deliberately not banned: a hash is not a signature, and two digests here need +# one. # # Usage: # scripts/check-one-implementation.sh # the working tree (what CI runs) # scripts/check-one-implementation.sh ... # named refs, for an operator # -# The second form exists for whoever is retiring a ref that still carries the -# deleted implementation. Which refs those are, and when they are deleted, is -# the cold-cutover runbook's business and it lives outside this repository: this -# repository holds the rule, not a consumer inventory. +# The second form is for whoever retires a ref that still carries the deleted +# implementation. Which refs those are is the cold-cutover runbook's business and +# it lives outside this repository: this repository holds the rule, not a +# consumer inventory. set -euo pipefail -primitives='crypto/ed25519|crypto/ecdsa' -subject='WorkContext' +# Any of these in an import path is a signature, a MAC, or a library that mints +# bearer tokens. The bare "crypto" package is included: crypto.Signer signs +# Ed25519 with no ed25519 import anywhere. +primitives='"crypto"|crypto/ed25519|crypto/ecdsa|crypto/rsa|crypto/dsa|crypto/hmac|crypto/ecdh|crypto/elliptic|crypto/subtle|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' +# Second encodings of the message. protojson is a complete JSON encoding of a +# protobuf message on its own, which is how the deleted implementation's payload +# would come back without an encoding/json import. +encoders='encoding/protojson|encoding/gob|encoding/asn1|encoding/xml' status=0 +# allowed — the exceptions, each with a reason in the comment. +# The mint client builds and owns its own TLS transport, which needs tls for the +# configuration and x509 for the caller's root pool. Nothing else here may. +allowed() { + case "$1|$2" in + 'workcontext/mint.go|'*'crypto/tls'*) return 0 ;; + 'workcontext/mint.go|'*'crypto/x509'*) return 0 ;; + 'tls.go|'*'crypto/tls'*) return 0 ;; + 'tls.go|'*'crypto/x509'*) return 0 ;; + # The receipts digest canonicalises a receipt REQUEST, never a capability. + 'receipts/digest.go|'*'encoding/protojson'*) return 0 ;; + esac + return 1 +} + +# findings -> prints each offending import +offending_imports() { + local path="$1" file="$2" line + while IFS= read -r line; do + case "$line" in + *'crypto/tls'*|*'crypto/x509'*|*'crypto/sha256'*|*'crypto/md5'*|*'crypto/sha1'*|*'crypto/sha512'*|*'crypto/rand'*) + # Hashes, randomness and TLS plumbing are not signatures. tls/x509 are + # still held to the allowlist below. + case "$line" in + *'crypto/tls'*|*'crypto/x509'*) + allowed "$path" "$line" || printf '%s\n' "$line" + ;; + esac + continue + ;; + esac + if printf '%s' "$line" | grep -Eq "($primitives)"; then + allowed "$path" "$line" || printf '%s\n' "$line" + continue + fi + if printf '%s' "$line" | grep -Eq "($encoders)"; then + allowed "$path" "$line" || printf '%s\n' "$line" + fi + done < "$file" +} + carrying_in_tree() { - local found="" + local found="" tmp + tmp=$(mktemp) while IFS= read -r path; do case "$path" in *_test.go) continue ;; *.go) ;; *) continue ;; esac - if grep -Eq "\"($primitives)\"" "$path" && grep -q "$subject" "$path"; then + grep -E '^\s*(import\s+)?(_\s+|[A-Za-z0-9_]+\s+)?"' "$path" > "$tmp" 2>/dev/null || : > "$tmp" + local bad + bad=$(offending_imports "$path" "$tmp") + if [ -n "$bad" ]; then found="$found $path" fi done <<< "$(git ls-files -- '*.go')" + rm -f "$tmp" printf '%s' "$found" } carrying_in_ref() { - local ref="$1" found="" + local ref="$1" found="" tmp + tmp=$(mktemp) while IFS= read -r path; do case "$path" in *_test.go) continue ;; *.go) ;; *) continue ;; esac - local blob - blob=$(git cat-file blob "$ref:$path" 2>/dev/null) || continue - if printf '%s' "$blob" | grep -Eq "\"($primitives)\"" && - printf '%s' "$blob" | grep -q "$subject"; then + git cat-file blob "$ref:$path" 2>/dev/null | + grep -E '^\s*(import\s+)?(_\s+|[A-Za-z0-9_]+\s+)?"' > "$tmp" 2>/dev/null || : > "$tmp" + local bad + bad=$(offending_imports "$path" "$tmp") + if [ -n "$bad" ]; then found="$found $path" fi done <<< "$(git ls-tree -r --name-only "$ref")" + rm -f "$tmp" printf '%s' "$found" } @@ -97,8 +160,13 @@ if [ "$status" -ne 0 ]; then cat >&2 <<'MSG' A wire contract has exactly one implementation, in the repository that owns the -type: codefly-dev/core/workcontext. The files above sign or verify a Work -Context here instead. +type: codefly-dev/core/workcontext. The files above import a signature, a MAC, a +token library or a second encoding of the message. + +There is no "and it also mentions WorkContext" condition any more: that was +satisfied by splitting a signer across two files. If a file above has an honest +need for one of these, add it to allowed() by path WITH A REASON, so the next +reader sees the argument rather than the exception. Delete them and reach core's Verifier through workcontext/core.go. This repository once held a second implementation signing hand-written JSON. The diff --git a/workcontext/README.md b/workcontext/README.md index 2577d09..9ab1776 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -43,22 +43,45 @@ verify on every live path. A comment would not have stopped that, and did not. Two tests do. **`TestNoSecondWorkContextImplementation`** parses every non-test file in this -module and fails if any of them: - -- imports `crypto/ed25519` or `crypto/ecdsa` — this module neither signs nor - checks a signature, so nothing here needs a signing primitive; -- imports `encoding/json` outside the one allowlisted file, `mint.go`, which - speaks the mint endpoint's HTTP bodies and never encodes a capability; -- declares a json-tagged struct other than the mint endpoint's request and - response — a struct whose tags enumerate a capability's fields by hand is - exactly the deleted implementation, and is how a field present in the proto - came to be missing on the wire; -- declares a type or func named `WorkContext*` that is not an alias of core's. +module and refuses it **by what the code can do, not by what it is called**. +That distinction is the whole of it: the first version listed two package paths +and a case-sensitive name prefix, and a faithful clone of core's signer — +`proto.MarshalOptions{Deterministic: true}` plus `golang.org/x/crypto/ed25519` +— was green, as were `crypto/rsa`, `crypto/hmac`, the bare `crypto` package +(whose `crypto.Signer` signs Ed25519 with no ed25519 import), `protojson`, and +the deleted implementation's own type name `workContextPayload`. + +It fails a file that: + +- imports **any** signature, MAC or key-agreement primitive, anything under + `golang.org/x/crypto/`, or any JOSE/JWT/token library, whatever the paths are. + `crypto/sha256` is deliberately fine — a hash is not a signature; +- imports `crypto/tls` or `crypto/x509` anywhere but `mint.go`, which builds the + mint client's own transport — **and uses only the symbols a transport needs**, + so `x509.ParsePKCS8PrivateKey` followed by a `crypto.Signer` assertion, the + shortest signer that imports no primitive at all, is a finding; +- imports `encoding/json` outside `mint.go` — matched by **exact path relative + to the module root**, since a base-name match let any nested `mint.go` inherit + the allowance; +- calls `proto.Marshal` or `proto.MarshalOptions` outside the one file that + encodes a scope for a cache digest. Encoding the message is half a signer; +- declares a json-tagged struct other than the mint endpoint's two bodies, + **anywhere in the file** including an anonymous struct or a type inside a + function; +- declares anything — func, method, var, const or type — whose name begins with + `workcontext` **case-insensitively**, unless it is in a named allowlist with a + reason, or is a type alias **whose target resolves into + `github.com/codefly-dev/core`**. It also pins the aliases by assignment (`var _ *corework.Verifier = (*Verifier)(nil)`), which compiles only while `Verifier` is core's type and not a local copy with the same fields. +**`TestTheGateCatchesItsOwnBypasses`** drives twenty-one hostile sources through +the checker and two that must stay allowed, so the gate is held to its claim +rather than trusted about it. Each of the standard bypasses above was measured +to pass the previous version and fail this one. + **`TestWorkContextConformance`** runs `core/workcontext/conformance.RunWith` against a verifier built **field by field from the kit's settings as this module's exported `Verifier`** — not through `conformance.Settings.Verifier()`, @@ -88,6 +111,33 @@ prove identity — an equivalent second implementation would pass the same fixtures. Identity is the compile-time assertions (`var _ *corework.Verifier = (*Verifier)(nil)`) and the static gate. Two halves, claimed separately. +**`TestTheSDKParsePathsAgreeWithCore`** drives every fixture in core's kit +through **this module's own parse paths** and requires core's declared sentinel +for each. + +This is the test the conformance run is not. Driving fixtures through +`&Verifier{}` exercises core, because `Verifier` is an alias of core's type — +and the module's real decisions are in `SealedInstallation`, `FromHeaders`, +`Attach` and `credentialFrom`. Three divergences lived exactly there, and all +three are now core's answer rather than a local one: + +| fixture | core | this module, before | +| --- | --- | --- | +| `seal-without-installation` | `ErrInvalid` (the schema reaches it first) | `ErrUnsealed`, with a test pinning the divergence | +| `actor-without-epoch` | `ErrUnsealed` | **accepted** — the actor chain was never read, so a principal nobody can revoke went on the wire | +| zero epoch / revision / incarnation, partial binding | `ErrInvalid` | `ErrUnsealed` | + +The fix was not to sync the rule. **`corework.Inspect` now owns every +structural decision** — shape, encoding, schema, attenuation, grant shape, a +seal naming an installation, an epoch on every actor hop — and this module's +`readClaims` runs after it, reads two values, and decides nothing. Core added +`Inspect` for this, when asked; the eight structural refusals must reach core's +sentinel here, and the other seventeen must **pass**, because an unverified +inspection that refused a signature or a live-state failure would be claiming to +have verified something it cannot see. `Inspect` checks no signature at all — +core's own test asserts a token re-signed with a key nobody holds passes it — so +nil means *shaped right*, never *permitted*. + **And one sweep, because the test above walks from this module's root.** The implementation this repository deleted lived at the REPOSITORY root, in package `codefly` — exactly where that walk does not reach. @@ -147,27 +197,44 @@ authority, err := codefly.ReadAuthority(ctx, if err != nil { return err // a process that cannot establish its identity must not serve } -audience, _ := authority.Value("platform", "work-context-audience") mintURL, _ := authority.Value("platform", "mint-url") client, err := workcontext.NewMintClient(workcontext.MintOptions{ - URL: mintURL, - Audience: audience, + URL: mintURL, + Authority: authority, // REQUIRED: rechecked before every mint, including the first + // The audience is NAMED, not passed. It is read from the pin on every + // mint, so the value that reaches the host is the pinned value. + Audience: workcontext.AuthorityValue{ + Name: "platform", Key: "work-context-audience", + }, ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), ProjectionAudience: projectionAudience, - Authority: authority, // rechecked before EVERY mint, including the first + RootCAs: platformRoots, // nil means the system pool }) ``` -`URL` must be **absolute https** with no userinfo, query or fragment. The +**`Authority` is required and the audience is read from it.** A free-string +audience beside an optional pin made the drift check guard a value the mint did +not use: `Recheck` could pass while the credential was minted for whatever +string the caller had typed. Reading it through `Value` on every mint is what +makes the pin load-bearing, and `credentialFrom` then requires the audience the +host SIGNED to be the audience the pin answered. + +`URL` must be **absolute https** with no userinfo, query or fragment: the projected service-account token travels on that request as a bearer credential, -so plaintext is a disclosure the configuration must not be able to choose; a -supplied `HTTPClient` whose transport skips certificate verification, or permits -TLS below 1.2, is refused for the same reason. Redirects are refused on **every** -client, on a copy of the one you supplied, before any request leaves: Go's own -client forwards `Authorization` across a redirect to the same host, so a -redirect that was merely classified afterwards would be classified after the -projection had already gone somewhere nothing configured. +so plaintext is a disclosure the configuration must not be able to choose. + +**The transport is the client's, and you cannot supply one.** `RootCAs` is the +only thing a caller says about it. An `*http.Client` option was a hole that +inspecting the client could not close: a nil `Transport` means the global, +mutable `http.DefaultTransport`; a wrapping `RoundTripper` is opaque; a +`DialTLSContext` bypasses `TLSClientConfig` entirely; and a caller keeping the +`*http.Transport` pointer can turn verification off after construction, because +copying an `http.Client` shares its `Transport`. Each of those sent the +projection over a channel nobody authenticated. So the client builds the +transport — TLS 1.2 minimum, verification on, no custom dialer, no proxy — and +refuses redirects, because Go's own client forwards `Authorization` across a +redirect to the same host. Then, on every outbound request: @@ -197,10 +264,37 @@ which is why a process that resolved its audience through `ReadAuthority` should pass it. A credential is also checked against **its own window** before it is installed: -an expired or not-yet-valid response, or one whose whole lifetime sits inside the -renewal lead, is refused rather than held and counted as a mint that worked. The -tolerance is `core/workcontext.DefaultSkew`, so the client is not stricter than -the verifier that will accept the credential. +an expired or not-yet-valid response, one whose whole lifetime sits inside the +renewal lead, or one valid for longer than `MaxCredentialLifetime`, is refused +rather than held and counted as a mint that worked. The window tested is +**`not_before`** — the claim core's verifier tests; testing `issued_at` was +testing a different window from the one the credential would be judged against +— and the tolerance is `core/workcontext.DefaultSkew`, so the client is not +stricter than the verifier that will accept the credential. + +### A failed renewal is not an outage + +**A held credential that is still valid is served even when a renewal fails.** +Entering the renewal lead and getting a 503 used to return an error to every +caller while the credential in hand had minutes left — an outage manufactured +out of a credential that still worked. A renewal failure is only an error once +the credential has actually expired. + +One mint at a time, and **the wait is cancellable**: callers wait on a channel +rather than on the client's mutex, so a caller whose `ctx` is cancelled stops +waiting instead of blocking on a request it is not making. After a failure the +next attempt is **held off**, doubling to a minute. Without that, a host +answering 503 received one request per caller per call — the heartbeat under +another name, arriving exactly when the host was least able to serve it. The +hold-off bounds `Refresh` too, which the generation check alone does not: a +receiver whose live state lags refuses each FRESH credential, and every refusal +is a new generation. + +`MaxCredentialLifetime` (default 24h) refuses a credential minted for longer +than this process will hold one. **It is a stopgap and labelled as one**: core +has built `Authority.MaxTTL` with a one-hour default, which is the right side of +the wire, and this ceiling goes when that is in a pinned release. A client-side +cap bounds only the clients that implement it. ### When the host refuses the credential you hold @@ -231,6 +325,14 @@ the host may refuse on cheaply, and never authority**: the installation that governs a call is the sealed one. On the incoming side the SDK refuses when a carrier disagrees with the sealed claim rather than preferring either side. +**Both carriers are required, exactly once each, on both transports.** Carrying +neither used to be allowed on HTTP, which was the optional-carrier escape hatch +surviving there after it was deleted on gRPC — "one contract for both +transports" was false, and a sender that attached nothing skipped the pre-check +entirely. Cardinality is checked before agreement, because `Header.Get` reads +only the first value: `[sealed-id, something-else]` compared equal to the seal +while the request carried two contradictory installations. + ## Verifying Verification is core's, and what this module exports is core's: @@ -308,9 +410,9 @@ either import path. | Sentinel | Means | What the caller does | | --- | --- | --- | -| `ErrInvalid` | wrong and cannot become right — bad signature, another audience, another installation, another build, another binding id, a malformed capability | refuse; 401 | -| `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, or the authorization revision moved | mint again, retry once | -| `ErrUnsealed` | the capability carries no seal, or names no installation | refuse; it is not a credential | +| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, a seal naming no installation, a zero epoch/revision/incarnation, a partial binding, a malformed capability, an expired or not-yet-valid window | refuse; 401 | +| `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, an actor hop's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, **a binding granted to another principal or in another installation**, an installation the principal no longer holds, or the authorization revision moved | mint again, retry once | +| `ErrUnsealed` | the capability carries no seal, or an actor hop carries no epoch — a principal nobody can revoke | refuse; it is not a credential | | `ErrNotACoreToken` | the payload is not this encoding at all — most usefully, a JSON one | refuse, and do **not** report a signature problem | | `ErrReplayed` | a single-use capability was presented twice | refuse; this is the resume contract, not a forgery | @@ -346,25 +448,43 @@ successful check and every message for the next interval still left, with expiry and source unavailability equally invisible for that whole window. The condition is per emission, so the check is per emission. +**Use `RecheckWith`, never `Verify`.** The recipe this README carried called +`verifier.Verify` on every emission. Core's `Verify` **consumes** a single-use +nonce and every grant capability is single-use, so a grant-opened stream died +with `ErrReplayed` at its **first** message — and the error read like a replay +attack rather than like the guard eating its own credential. Core added +`(*Verifier).Recheck` for this: it re-reads the window, the authorization +revision, the seal, every actor hop's epoch, the operation binding and the +issuer's grant record, and it never touches the replay store. It takes a +`*Verified`, so it cannot be anybody's first check. + ```go +// verified came from verifier.Verify once, when the stream opened. guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ - Recheck: func(ctx context.Context) error { - // The same verification an ordinary call performs, against the - // issuer's state as it is NOW — not the state the stream opened with. - _, err := verifier.Verify(ctx, token) - return err - }, + Recheck: workcontext.RecheckWith(verifier, verified), }) + +// And let the STREAM enforce it, rather than remembering to call it. +guarded, err := grpctransport.Guard(stream, guard) +if err != nil { + return err +} for message := range messages { - if err := guard.BeforeSend(ctx); err != nil { - return err // terminate; do not resume - } - if err := stream.Send(message); err != nil { - return err + if err := guarded.SendMsg(message); err != nil { + return err // terminated; do not resume } } ``` +`grpctransport.Guard` wraps a `grpc.ServerStream` so `SendMsg` **is** the +re-check followed by the send. A guard on its own is advice: `BeforeSend` has to +be called, and "before each emission" was therefore something each author had +to remember — the same shape as the optional carrier this PR deleted, a rule +that holds wherever somebody thought of it. A handler writing through the +wrapper cannot emit without the check. `Guard` refuses a nil guard rather than +treating it as "no guarding wanted", because a wrapper that silently did +nothing would be worse than none: the call site would read as guarded. + It guards emission rather than running a timer: an idle stream discloses nothing, so there is nothing to refuse. **Any** error from `Recheck` terminates, including one that is neither `ErrRevoked` nor `ErrInvalid` — a live source that @@ -374,10 +494,14 @@ Termination is sticky: a caller that loops past the first refusal is not handed a second chance to emit, and the authority coming back does not resurrect the stream. -What this costs is one live authorization check per message. That is the price -of the guarantee, and it is why the guard is for streams whose messages carry -authority rather than for every stream. A stream that cannot pay it does not get -the guarantee; it does not get a cadence instead. +What this costs is one live authorization check per message — against an +RPC-backed `SealSource`, one round trip per emitted item. That is the price of +the guarantee, and it is why the guard is for streams whose messages carry +authority rather than for every stream. **Decide it explicitly rather than +discovering it from a latency graph**: per emission catches a revocation at the +next message, per stream catches one only at the start, and core deliberately +does not choose for you. A stream that cannot pay per-emission does not get the +per-emission guarantee; what it must not get is a cadence that reads like one. ## Cache partitions diff --git a/workcontext/cache_partition.go b/workcontext/cache_partition.go index ca1fd65..879a722 100644 --- a/workcontext/cache_partition.go +++ b/workcontext/cache_partition.go @@ -218,6 +218,13 @@ func canonicalScopes(scopes []*basev0.WorkScopeV1) ([][]byte, error) { encoded = append(encoded, raw) } slices.SortFunc(encoded, bytes.Compare) + // Identical scopes collapse. Two equal scopes encode to equal bytes, so + // without this a caller holding the same scope twice — which an + // attenuation chain produces readily — lands in a different partition from + // one holding it once, for the same authority. That is cache fragmentation + // rather than a wrong answer, but it is silent, and the digest is supposed + // to be a function of the authority rather than of how it was assembled. + encoded = slices.CompactFunc(encoded, bytes.Equal) return encoded, nil } diff --git a/workcontext/carrier.go b/workcontext/carrier.go index b1ce0a9..6ca8074 100644 --- a/workcontext/carrier.go +++ b/workcontext/carrier.go @@ -79,34 +79,29 @@ func FromHeaders(headers http.Header) (string, error) { return encoded, nil } -// checkCarriedInstallation refuses a request whose installation carriers -// disagree with the seal, or whose carriers are stated more than once. +// checkCarriedInstallation requires BOTH installation carriers, exactly once +// each, and holds them to the seal. // -// Cardinality is checked before agreement, which Header.Get made impossible: -// it reads the FIRST value, so `[sealed-id, something-else]` compared equal to -// the seal and the call was accepted while carrying two contradictory +// Carrying neither used to be allowed, and that was the optional-carrier escape +// hatch surviving on HTTP after it was deleted on gRPC: gRPC requires exactly +// one of each, so "one contract for both transports" was false, and a sender +// that attached nothing skipped the seal check entirely. Attach always sets +// both, so any sender using this SDK already satisfies it; one that does not is +// a sender whose pre-check nobody can perform. +// +// Cardinality is checked before agreement, which Header.Get made impossible: it +// reads the FIRST value, so `[sealed-id, something-else]` compared equal to the +// seal and the call was accepted while carrying two contradictory // installations. Whether a later intermediary reads the first or the second is -// not something this module can decide, so an ambiguous carrier is refused -// here — the same contract gRPC already held, now stated once for both. +// not something this module can decide, so an ambiguous carrier is refused. func checkCarriedInstallation(headers http.Header, encoded string) error { - ids := headers.Values(InstallationIDHeaderName) - revisions := headers.Values(InstallationRevisionHeaderName) - if len(ids) == 0 && len(revisions) == 0 { - return nil - } for _, carrier := range []struct { name string values []string }{ - {InstallationIDHeaderName, ids}, - {InstallationRevisionHeaderName, revisions}, + {InstallationIDHeaderName, headers.Values(InstallationIDHeaderName)}, + {InstallationRevisionHeaderName, headers.Values(InstallationRevisionHeaderName)}, } { - if len(carrier.values) == 0 { - return fmt.Errorf( - "%w: %s and %s travel together; one was presented without the other", - ErrInvalid, InstallationIDHeaderName, InstallationRevisionHeaderName, - ) - } if len(carrier.values) != 1 { return fmt.Errorf( "%w: %s requires exactly one value and carries %d", @@ -118,16 +113,16 @@ func checkCarriedInstallation(headers http.Header, encoded string) error { if err != nil { return err } - if ids[0] != sealedID { + if id := headers.Get(InstallationIDHeaderName); id != sealedID { return fmt.Errorf( "%w: %s carries %q and the capability is sealed to %q", - ErrInvalid, InstallationIDHeaderName, ids[0], sealedID, + ErrInvalid, InstallationIDHeaderName, id, sealedID, ) } - if revisions[0] != sealedRevision { + if revision := headers.Get(InstallationRevisionHeaderName); revision != sealedRevision { return fmt.Errorf( "%w: %s carries %q and the capability is sealed to %q", - ErrInvalid, InstallationRevisionHeaderName, revisions[0], sealedRevision, + ErrInvalid, InstallationRevisionHeaderName, revision, sealedRevision, ) } return nil @@ -136,14 +131,13 @@ func checkCarriedInstallation(headers http.Header, encoded string) error { // SealedInstallation reads the installation a capability is sealed to, for a // transport that names it beside the capability. // -// It is the WHOLE seal check and not an installation check. It used to read -// just the installation id and revision, which made "Attach refuses an unsealed -// capability" true of two fields out of four: a capability with no principal -// epoch, no build incarnation, or a binding naming an id at no revision was -// attached and travelled, and the missing field is the one an attacker would -// choose to leave out. Every carrier in this module — HTTP attach, HTTP read, -// outgoing gRPC metadata, incoming gRPC metadata — goes through here, so there -// is one answer to "is this sealed" rather than one per transport. +// It is CORE'S whole structural check and not an installation check. It used to +// read just the installation id and revision, which made "Attach refuses an +// unsealed capability" true of two fields out of four; then it grew a local +// rule, which disagreed with core's fixtures about which sentinel each refusal +// earns. Now it asks corework.Inspect, so there is one answer to "is this +// sealed" for every carrier in this module — HTTP attach, HTTP read, outgoing +// gRPC metadata, incoming gRPC metadata — and that answer is core's. // // It reads the capability's own content without checking the signature, which // is sound only because nothing trusts the result: the carrier it fills is a @@ -157,45 +151,45 @@ func SealedInstallation(encoded string) (id string, revision string, err error) return seal.InstallationID, strconv.FormatUint(seal.InstallationRevision, 10), nil } -// claimsOf decodes a capability's claims WITHOUT establishing any trust in -// them. Nothing in this module authorizes from what it returns: its one caller -// is sealOf, which the pre-check carriers and the mint client reach it through. +// readClaims decodes a capability's claims AFTER core has judged its structure, +// and applies no rule of its own. // -// It decodes core's encoding with core's generated type, so there is no second -// reading of the wire here. The one thing it must not become is a verification: -// there is no signature check in it and there must never be one, because a -// second place that checks a signature is a second implementation whatever it -// is called. -func claimsOf(encoded string) (*Claims, error) { - if strings.TrimSpace(encoded) == "" { - return nil, fmt.Errorf("%w: empty capability", ErrInvalid) - } - if len(encoded) > MaxTokenBytes { - return nil, fmt.Errorf("%w: capability exceeds %d bytes", ErrInvalid, MaxTokenBytes) - } - payload, signature, found := strings.Cut(encoded, ".") - if !found || payload == "" || signature == "" { - return nil, fmt.Errorf("%w: capability is not .", ErrInvalid) +// This is all that is left of a parser that had become a second +// implementation. It used to hand-parse the envelope and then apply its own +// structural seal rule, and that rule DISAGREED with core's own fixtures: +// seal-without-installation was ErrUnsealed here and ErrInvalid there (core's +// schema reaches it first), a zero epoch, revision or incarnation was +// ErrUnsealed here and ErrInvalid there, and the actor chain was never read at +// all — so core's actor-without-epoch fixture, a principal nobody can revoke, +// was attached and carried. One condition with two messages is the exact +// fragmentation the one-implementation rule exists to end, and it had been +// relocated from the signature to the seal. +// +// So every structural DECISION is corework.Inspect's, and what remains here is +// reading two values out of a capability core has already said is shaped like a +// sealed capability: the installation, for the pre-check carriers, and the +// window, for the mint client reading the lifetime of a credential the host +// just issued it. Nothing here decides anything, and nothing trusts the result: +// Inspect itself checks no signature — core's own test asserts that a token +// re-signed with a key nobody holds passes it — so nil means "shaped right" and +// never "permitted". +// +// The residue shrinks to nothing when core's Inspect returns the claims it has +// already decoded; that is agreed and not yet in a pinned commit. Until then +// this decode runs SECOND, after Inspect, so it can never be the thing that +// answers a question. +func readClaims(encoded string) (*Claims, error) { + if err := corework.Inspect(encoded); err != nil { + return nil, err } + payload, _, _ := strings.Cut(encoded, ".") raw, err := base64.RawURLEncoding.DecodeString(payload) if err != nil { + // Unreachable: Inspect decoded the same bytes. Returning core's + // sentinel rather than panicking keeps the failure legible if core's + // envelope ever stops being .. return nil, fmt.Errorf("%w: payload is not base64url: %v", ErrInvalid, err) } - if _, err := base64.RawURLEncoding.DecodeString(signature); err != nil { - return nil, fmt.Errorf("%w: signature is not base64url: %v", ErrInvalid, err) - } - // Before unmarshalling: is this even this encoding? Core's check, not a - // second one — so a token in another format is named here exactly as the - // verifier would name it (ErrNotACoreToken), rather than reported as a - // payload that failed to unmarshal. Two different messages for one - // condition is the diagnostic fragmentation that produced this rule. - // - // A nil return means only that the payload is not visibly another format. - // Nothing here checks a signature, and nothing that follows trusts the - // result. - if err := corework.CheckEncoding(raw); err != nil { - return nil, err - } claims := &Claims{} if err := proto.Unmarshal(raw, claims); err != nil { return nil, fmt.Errorf("%w: payload is not a WorkContextV1: %v", ErrInvalid, err) @@ -203,56 +197,28 @@ func claimsOf(encoded string) (*Claims, error) { return claims, nil } -// sealOf reads the claims, the seal and the sealed operation binding of a -// capability, and is the one structural seal check in this module. -// -// It requires a whole seal: every field of it, and for an operation binding all -// three of its fields or none of them. A partial seal is refused here and not -// carried, because a capability sealed to an installation with no revision, or -// to a binding id at no revision, names an authority nobody approved — and the -// field that is missing is the one an attacker would choose to leave out. +// sealOf reads the seal and the sealed operation binding of a capability whose +// structure core has approved. // -// Refusing it here is a preflight and not the authorization: core's verifier -// refuses the same capability at the far end. The preflight is what makes the -// refusal legible — it names the field that is missing, in the process that -// holds the credential, rather than arriving as a mismatch in a service that -// cannot do anything about it. +// It applies no rule. Every refusal a capability's own bytes can earn — +// another encoding, a bad envelope, a schema violation, a seal naming no +// installation, a zero epoch, revision or incarnation, a partial binding, a +// widening hop, an actor hop with no epoch — is corework.Inspect's answer, with +// corework's sentinel. TestTheSDKParsePathsAgreeWithCore drives every fixture +// in core's kit through this path and requires core's declared sentinel for +// each, so the agreement is tested rather than described. func sealOf(encoded string) (*Claims, Seal, *SealedOperationBinding, error) { - claims, err := claimsOf(encoded) + claims, err := readClaims(encoded) if err != nil { return nil, Seal{}, nil, err } sealed := claims.GetSeal() - if sealed == nil { - return nil, Seal{}, nil, fmt.Errorf("%w: capability carries no seal", ErrUnsealed) - } seal := Seal{ - PrincipalEpoch: sealed.GetPrincipalEpoch(), InstallationID: sealed.GetInstallationId(), InstallationRevision: sealed.GetInstallationRevision(), BuildIncarnation: sealed.GetBuildIncarnation(), } - switch { - case seal.PrincipalEpoch == 0: - return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no principal epoch", ErrUnsealed) - case seal.InstallationID == "": - return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no installation", ErrUnsealed) - case seal.InstallationRevision == 0: - return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no installation revision", ErrUnsealed) - case seal.BuildIncarnation == 0: - return nil, Seal{}, nil, fmt.Errorf("%w: the seal names no build incarnation", ErrUnsealed) - } - bound := claims.GetOperationBinding() - if bound == nil { - return claims, seal, nil, nil - } - if bound.GetBindingId() == "" || bound.GetRevision() == 0 || bound.GetIncarnation() == 0 { - return nil, Seal{}, nil, fmt.Errorf( - "%w: an operation binding carries its id, revision and incarnation or none of them", - ErrUnsealed, - ) - } - return claims, seal, cloneSealedBinding(bound), nil + return claims, seal, cloneSealedBinding(claims.GetOperationBinding()), nil } // cloneSealedBinding copies the message, so a caller that mutates what it is diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go index 4f00740..fb63866 100644 --- a/workcontext/carrier_test.go +++ b/workcontext/carrier_test.go @@ -48,12 +48,20 @@ func TestAttachCarriesTheCapabilityAndTheSealedInstallation(t *testing.T) { // A credential is sealed or it is not a credential. Finding that out before the // call is made is the point: at the far end the refusal names an installation // mismatch for a capability that named no installation at all. +// +// The sentinel is CORE'S, taken from core's own fixture rather than written +// here. This test used to assert ErrUnsealed for both, which was wrong for +// seal-without-installation — core answers ErrInvalid there, because the schema +// reaches it before the structural check — and pinning the wrong sentinel is +// how the divergence stayed invisible. func TestAttachRefusesAnUnsealedCapability(t *testing.T) { - for _, name := range []string{"missing-seal", "seal-without-installation"} { + for _, name := range []string{"missing-seal", "seal-without-installation", "actor-without-epoch"} { t.Run(name, func(t *testing.T) { + declared := fixture(t, name) request := httptest.NewRequest(http.MethodGet, "/records", nil) - err := Attach(request, fixture(t, name).Token) - require.ErrorIs(t, err, ErrUnsealed) + err := Attach(request, declared.Token) + require.ErrorIs(t, err, declared.Err, + "core declares %v for %q: %s", declared.Err, name, declared.Reason) require.Empty(t, request.Header.Get(HeaderName), "a refused capability must not be left on the request") }) @@ -114,12 +122,20 @@ func TestIncomingCarriersAreHeldToTheSeal(t *testing.T) { }) } - // Carrying neither is allowed: a sender that attaches nothing beside the - // capability has stated nothing to disagree with, and the seal governs - // regardless. - headers := http.Header{} - headers.Set(HeaderName, token) - carried, err := FromHeaders(headers) + // Carrying NEITHER is refused too, which it was not: that was the + // optional-carrier escape hatch surviving on HTTP after it was deleted on + // gRPC, and it skipped the seal check altogether. Attach always sets both, + // so a sender using this SDK satisfies it. + bare := http.Header{} + bare.Set(HeaderName, token) + _, err := FromHeaders(bare) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "requires exactly one value") + + // And what Attach produces round-trips. + attached := httptest.NewRequest(http.MethodGet, "/records", nil) + require.NoError(t, Attach(attached, token)) + carried, err := FromHeaders(attached.Header) require.NoError(t, err) require.Equal(t, token, carried) } @@ -172,16 +188,20 @@ func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { t.Run(field, func(t *testing.T) { token := resealWithout(t, a.start(t, mintInput{binding: testBinding}), field) + // ErrInvalid, not ErrUnsealed: the schema refuses a zero revision + // or a blank id before the structural check sees it, and the + // sentinel a pre-check reports has to be the one the verifier + // would report. The structural check below it is what catches the + // shapes the schema permits. _, _, _, err := sealOf(token) - require.ErrorIs(t, err, ErrUnsealed) - require.ErrorContains(t, err, "id, revision and incarnation or none of them") + require.ErrorIs(t, err, ErrInvalid) // And it never reaches a request, on either transport. request := httptest.NewRequest(http.MethodGet, "/records", nil) - require.ErrorIs(t, Attach(request, token), ErrUnsealed) + require.ErrorIs(t, Attach(request, token), ErrInvalid) require.Empty(t, request.Header.Get(HeaderName)) _, _, err = SealedInstallation(token) - require.ErrorIs(t, err, ErrUnsealed) + require.ErrorIs(t, err, ErrInvalid) }) } } @@ -191,46 +211,84 @@ func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { // revision, which made "Attach refuses an unsealed capability" true of two // fields out of four: a capability with no principal epoch or no build // incarnation was attached and travelled. +// +// The sentinel for each is ErrInvalid, and that is not a detail. A zero epoch, +// revision or incarnation is refused by the SCHEMA, which core's verifier runs +// before its structural seal check — so ErrInvalid is what a verifier at the +// far end reports, and a pre-check reporting ErrUnsealed for the same bytes was +// one condition with two messages. TestTheSDKParsePathsAgreeWithCore holds that +// to core's own fixtures rather than to this comment. func TestEverySealedFieldIsRequiredOnEveryCarrier(t *testing.T) { a := newAuthority(t) for field, says := range map[string]string{ - "PrincipalEpoch": "no principal epoch", - "InstallationID": "no installation", - "InstallationRevision": "no installation revision", - "BuildIncarnation": "no build incarnation", + "PrincipalEpoch": "seal.principal_epoch", + "InstallationID": "seal.installation_id", + "InstallationRevision": "seal.installation_revision", + "BuildIncarnation": "seal.build_incarnation", } { t.Run(field, func(t *testing.T) { token := resealWithout(t, a.start(t, mintInput{}), field) _, _, _, err := sealOf(token) - require.ErrorIs(t, err, ErrUnsealed) + require.ErrorIs(t, err, ErrInvalid) require.ErrorContains(t, err, says) // The outbound HTTP carrier. request := httptest.NewRequest(http.MethodGet, "/records", nil) err = Attach(request, token) - require.ErrorIs(t, err, ErrUnsealed) - require.ErrorContains(t, err, says) + require.ErrorIs(t, err, ErrInvalid) require.Empty(t, request.Header.Get(HeaderName), "a refused capability must not be left on the request") - // The inbound HTTP carrier, with the installation carriers stated - // so the check is reached at all. + // The inbound HTTP carrier. Both carriers are stated, because both + // are now required — and the seal check runs whether or not they + // are, which is what finding 7 was about. headers := http.Header{} headers.Set(HeaderName, token) headers.Set(InstallationIDHeaderName, testInstallation) headers.Set(InstallationRevisionHeaderName, "3") _, err = FromHeaders(headers) - require.ErrorIs(t, err, ErrUnsealed) + require.ErrorIs(t, err, ErrInvalid) // And the one function both gRPC directions go through. _, _, err = SealedInstallation(token) - require.ErrorIs(t, err, ErrUnsealed) + require.ErrorIs(t, err, ErrInvalid) require.ErrorContains(t, err, says) }) } } +// An actor hop with no epoch is a principal nobody can revoke, and it never +// reaches the wire. +// +// sealOf did not read the actor chain at all, so core's own +// actor-without-epoch fixture — which core refuses as ErrUnsealed — was +// attached, read back and carried by both gRPC directions, while AGENTS.md said +// "Attach refuses to put an unsealed capability on a request". The field is a +// POINTER on the wire: the getter collapses "no epoch" into "epoch 0", so only +// the nil check sees the difference. +func TestAnActorHopWithNoEpochNeverReachesTheWire(t *testing.T) { + declared := fixture(t, "actor-without-epoch") + require.ErrorIs(t, declared.Err, ErrUnsealed, "core's own declaration") + + _, _, _, err := sealOf(declared.Token) + require.ErrorIs(t, err, ErrUnsealed) + require.ErrorContains(t, err, "carries no epoch") + + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, declared.Token), ErrUnsealed) + require.Empty(t, request.Header.Get(HeaderName)) + + _, _, err = SealedInstallation(declared.Token) + require.ErrorIs(t, err, ErrUnsealed) + + // A delegated capability whose hops DO carry epochs is unaffected: the + // check is the missing field, not the presence of a chain. + delegated := fixture(t, "delegated") + _, _, _, err = sealOf(delegated.Token) + require.NoError(t, err) +} + // An installation carrier stated twice is ambiguous, and ambiguous is refused. // // Header.Get reads the FIRST value, so [sealed-id, something-else] compared diff --git a/workcontext/core.go b/workcontext/core.go index 89d89a7..4ad9468 100644 --- a/workcontext/core.go +++ b/workcontext/core.go @@ -73,6 +73,13 @@ type ( // wire. type Claims = basev0.WorkContextV1 +// Core's verify-only Authenticator and Authenticated are deliberately not +// aliased either. They require the issuer's revision and seal sources, which is +// precisely what a client does not hold; a client that could supply them is not +// a client. Re-exporting them here would put the weaker of two strengths in the +// same namespace as the stronger, one import away from each other, which is the +// silent downgrade core objected to when it was first asked for that path. + // The refusals, core's. This module defines no second taxonomy of them: a // caller writes errors.Is against these and gets the same answer whichever // package it names them through. @@ -110,6 +117,22 @@ var ( // ErrReplayed is a single-use capability presented twice. ErrReplayed = corework.ErrReplayed + + // Core exports three more sentinels that are deliberately NOT aliased here, + // on core's own instruction when asked: + // + // - ErrNeedsIssuer comes only from core's verify-only Authenticator, when + // a capability carries a grant hop. Verifier.Verify never returns it, so + // a client matching it would be writing a branch nothing reaches. + // - ErrNoSeal and ErrNoBinding are what a SealSource IMPLEMENTATION + // returns, not what a client matches. + // + // Aliasing them would widen this module's surface to a shape its consumers + // cannot reach, which reads as "this is available here" about three things + // that are not. A consumer that implements a seal source, or adopts the + // verify-only entrypoint, imports core/workcontext directly for them — and + // at that point it is not a client of the capability but a participant in + // it. ) // ScopeContained and ScopesAttenuate are core's scope algebra. A service diff --git a/workcontext/doc.go b/workcontext/doc.go index b0697a4..b6bf16d 100644 --- a/workcontext/doc.go +++ b/workcontext/doc.go @@ -30,6 +30,6 @@ // beside it as a pre-check the far end may refuse on cheaply. // - CachePartition: the identity partition a cache stack scopes entries to, // which never spans an installation revision. -// - StreamGuard: a stream re-presents its credential on the host's cadence +// - StreamGuard: a stream re-presents its credential before every message // and terminates on refusal. package workcontext diff --git a/workcontext/go.mod b/workcontext/go.mod index b162bcc..dd9b1cc 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc + github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 9f1c165..f1370ef 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc h1:En8Qs79umv99OMKG+BjM1OYeR2xhvGWoPSADfEU93jc= -github.com/codefly-dev/core v0.7.2-0.20261002204321-80208d6c50cc/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09 h1:NG3ARD3wrLATOYCEWeGpWIDT29YOi/tigwWbaph4BnQ= +github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go new file mode 100644 index 0000000..cd805c1 --- /dev/null +++ b/workcontext/grpctransport/guarded_stream.go @@ -0,0 +1,67 @@ +package grpctransport + +import ( + "fmt" + + "google.golang.org/grpc" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// GuardedServerStream re-presents a stream's credential before every message +// the stream sends, by being the thing that sends them. +// +// A StreamGuard on its own is advice. BeforeSend has to be called, and whether +// it is called before each emission depends on each author remembering to call +// it — which is the same shape as the optional carrier this PR deleted: a rule +// that holds wherever somebody thought of it. Wrapping the stream makes the +// check structural. A handler that writes through this cannot emit without the +// re-check, because Send IS the re-check followed by the send. +// +// It wraps grpc.ServerStream, so it also serves the generated per-service +// stream types: a generated server stream embeds grpc.ServerStream, and +// handing it one of these makes every SendMsg go through the guard. +type GuardedServerStream struct { + grpc.ServerStream + guard *workcontext.StreamGuard +} + +// Guard wraps a server stream so every message it sends is preceded by a live +// re-check. A nil guard is refused rather than treated as "no guarding +// wanted": a wrapper that silently did nothing would be worse than no wrapper, +// because the call site would read as guarded. +func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { + if stream == nil { + return nil, fmt.Errorf("%w: no stream to guard", workcontext.ErrInvalid) + } + if guard == nil { + return nil, fmt.Errorf("%w: no stream guard", workcontext.ErrInvalid) + } + return &GuardedServerStream{ServerStream: stream, guard: guard}, nil +} + +// SendMsg re-checks, then sends. A refusal is returned and the message is NOT +// sent: the whole point is that a message computed under withdrawn authority +// does not leave. +// +// The error is the guard's, wrapped, so a caller can tell a terminated stream +// from a transport failure — and ErrStreamTerminated is sticky, so a handler +// that loops past the first refusal keeps being refused rather than getting a +// second chance to emit. +func (s *GuardedServerStream) SendMsg(message any) error { + if s == nil || s.guard == nil { + return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + } + if err := s.guard.BeforeSend(s.Context()); err != nil { + return err + } + return s.ServerStream.SendMsg(message) +} + +// Terminated reports the refusal that ended the stream, or nil. +func (s *GuardedServerStream) Terminated() error { + if s == nil { + return nil + } + return s.guard.Terminated() +} diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go new file mode 100644 index 0000000..856025b --- /dev/null +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -0,0 +1,125 @@ +package grpctransport + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// recordingStream is a grpc.ServerStream that records what was sent, so a test +// can assert that a message did NOT leave. +type recordingStream struct { + grpc.ServerStream + ctx context.Context + sent []any +} + +func (s *recordingStream) Context() context.Context { return s.ctx } + +func (s *recordingStream) SendMsg(message any) error { + s.sent = append(s.sent, message) + return nil +} + +func (s *recordingStream) SetHeader(metadata.MD) error { return nil } +func (s *recordingStream) SendHeader(metadata.MD) error { return nil } +func (s *recordingStream) SetTrailer(metadata.MD) {} +func (s *recordingStream) RecvMsg(any) error { return nil } + +// A guarded stream re-checks before EVERY message, and a refused message does +// not leave. +// +// This is the half a StreamGuard on its own cannot provide. BeforeSend has to +// be called, and "before each emission" was therefore something each author +// had to remember — the same shape as the optional carrier this PR deleted, a +// rule that holds wherever somebody thought of it. Here Send IS the re-check +// followed by the send, so a handler cannot emit without it. +func TestAGuardedStreamRechecksBeforeEveryMessage(t *testing.T) { + rechecks := 0 + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + rechecks++ + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + for message := 1; message <= 3; message++ { + require.NoError(t, guarded.SendMsg(message)) + require.Equal(t, message, rechecks, "message %d re-presented the credential", message) + require.Len(t, underlying.sent, message) + } + + // The authority is withdrawn with no time passing at all. The very next + // message is refused AND NOT SENT. + refuse = true + err = guarded.SendMsg("the message that must not leave") + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.ErrorIs(t, err, workcontext.ErrRevoked) + require.Len(t, underlying.sent, 3, + "a message computed under withdrawn authority must not reach the wire") + + // And it stays terminated: a handler looping past the first refusal does + // not get a second chance to emit, and the authority coming back does not + // resurrect the stream. + refuse = false + for range 3 { + require.ErrorIs(t, guarded.SendMsg("still refused"), workcontext.ErrStreamTerminated) + } + require.Len(t, underlying.sent, 3) + require.Equal(t, 4, rechecks, "a terminated stream asks nothing further") + require.ErrorIs(t, guarded.Terminated(), workcontext.ErrStreamTerminated) +} + +// An unreachable live source terminates the stream too. "I could not ask" is +// not "the credential is good", and a stream that treated it as one would +// outlive its authority with clean logs. +func TestAGuardedStreamTerminatesWhenTheLiveSourceIsUnavailable(t *testing.T) { + unavailable := errors.New("installation state store unreachable") + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return unavailable }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + err = guarded.SendMsg("anything") + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.ErrorIs(t, err, unavailable) + require.Empty(t, underlying.sent) +} + +// A wrapper that silently did nothing would be worse than no wrapper, because +// the call site would read as guarded. +func TestGuardRefusesToWrapNothing(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + + _, err = Guard(nil, guard) + require.ErrorIs(t, err, workcontext.ErrInvalid) + + _, err = Guard(&recordingStream{ctx: context.Background()}, nil) + require.ErrorIs(t, err, workcontext.ErrInvalid) + + var absent *GuardedServerStream + require.ErrorIs(t, absent.SendMsg("x"), workcontext.ErrInvalid) + require.NoError(t, absent.Terminated()) +} diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index d3a448a..675b617 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -96,6 +96,9 @@ func newHopAuthority(t *testing.T) *hopAuthority { // hop that carries none. Recording only the owner's seal left the actor // unrevocable, which is a capability nobody can withdraw. require.NoError(t, seals.PutEpoch(hopActor, 1)) + // And the owner's, which the seal no longer carries: one source for every + // principal's epoch, so revocation cannot be undone by storing a seal. + require.NoError(t, seals.PutEpoch(hopPrincipal, 1)) return &hopAuthority{ minter: &corework.Authority{ Issuer: hopIssuer, KeyID: hopKeyID, Key: privateKey, @@ -277,7 +280,6 @@ func newHopTopology(t *testing.T) *hopTopology { // execution, so the seal is the same on both sides of it. func hopSeal() workcontext.Seal { return workcontext.Seal{ - PrincipalEpoch: 3, InstallationID: "installation-hop", InstallationRevision: 12, BuildIncarnation: 9, diff --git a/workcontext/mint.go b/workcontext/mint.go index 8319970..425e593 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" @@ -39,6 +40,21 @@ const ( // minRenewalLeadTime floors the lead so a very short credential still // leaves room for one request and one retry. minRenewalLeadTime = 5 * time.Second + + // defaultMaxCredentialLifetime is the longest credential this client will + // hold. Core checks only that a TTL is positive, so without a ceiling a + // misconfigured host minting a month-long credential is accepted in + // silence. It is generous enough for the lifetimes the mint-once model + // wants — an hour's run needs about 75 minutes at the default lead — and + // short enough that a month is not one of them. + defaultMaxCredentialLifetime = 24 * time.Hour + + // Backoff after a mint that failed. A failed renewal used to return an + // error to every caller while the held credential still had minutes left, + // and each caller then retried serially under the client's lock: one mint + // request per caller, which is the heartbeat under another name. + minMintBackoff = time.Second + maxMintBackoff = time.Minute ) // Errors a mint caller distinguishes. @@ -100,52 +116,96 @@ func (path ProjectedTokenFile) ProjectedToken() (string, error) { return token, nil } -// AuthorityPin is the boot-read authority every mint is checked against. The -// root SDK's *codefly.Authority satisfies it. +// AuthorityPin is the boot-read authority every mint reads its audience from +// and is checked against. The root SDK's *codefly.Authority satisfies it. // -// It exists so a mint cannot seal a credential to a value that drifted after -// boot. The host is the authority for the audience and the binding a process -// mints against; a value that changes under a running process is an error, and -// a mint is exactly the moment that error would otherwise be laundered into a -// new, differently-sealed credential. +// It is REQUIRED, and it answers the audience rather than merely agreeing with +// one. A free-string Audience beside an optional pin was a drift check guarding +// a value the mint did not use: Recheck could pass while the credential was +// minted for whatever string the caller had typed. Reading the audience through +// Value on every mint is what makes the pin load-bearing — the value that +// reaches the host is the pinned value, or the mint fails. // -// It is checked before the FIRST mint too, and not only before a renewal: a -// process may construct its client at boot and make its first call an hour -// later, and the value can have moved in between. +// Value's contract is the pin's: it answers from what was frozen at boot, and a +// name the process did not declare is an error rather than a live lookup. type AuthorityPin interface { + // Recheck re-resolves every pinned value and refuses on the first that + // moved. A value that drifts under a running process is an error, never a + // reload: the process has already minted a credential sealed to the old + // one. Recheck(ctx context.Context) error + + // Value answers one pinned value, by the name and key the workspace + // accessors take. + Value(name string, key string) (string, error) +} + +// AuthorityValue addresses one pinned workspace value, by the name and key +// ReadAuthority declared it under. It is how MintOptions names the audience +// without the audience being a string a caller can type. +type AuthorityValue struct { + Name string + Key string } +func (v AuthorityValue) String() string { return v.Name + "/" + v.Key } + // MintOptions configures the one credential a process obtains per execution. type MintOptions struct { // URL is the host's mint endpoint: absolute HTTPS, no query, no fragment, // no userinfo. Plain HTTP is refused — the projected service-account token // travels on this request as a bearer credential. URL string - // Audience is the Work Context audience to mint for, as the host names it. - // It is read from the process's boot-read authority, never typed. - Audience string + + // Authority is the process's boot-read authority. REQUIRED: it answers the + // audience on every mint and it is rechecked before every mint, including + // the first. + Authority AuthorityPin + + // Audience names the pinned value the Work Context audience is read from. + // REQUIRED. The audience is never a string passed in beside the pin — that + // made the drift check guard a value the mint did not use. + Audience AuthorityValue + // ProjectedToken reads the platform's projection of this process's // service-account token. Required. ProjectedToken ProjectedTokenSource + // ProjectionAudience is the audience the projected token itself was minted // for, which the host states. Sent so the host can refuse a projection // aimed at something else rather than reviewing whatever it is handed. ProjectionAudience string - // Authority, when set, is rechecked before every mint, including the first. - // A process that resolved its audience or binding through ReadAuthority - // passes it here; without it there is no pin for this client to check and - // the drift refusal is not available to it. - Authority AuthorityPin - // HTTPClient is used as given except that redirects are refused on a copy - // of it, whatever it was configured to do. A redirected mint request is a - // credential request sent somewhere the configuration did not name. - HTTPClient *http.Client + + // RootCAs is the only thing a caller may say about the transport: the roots + // that may sign the mint endpoint's certificate. Nil means the system pool. + // + // It replaces an *http.Client, and that is the point. A client is a hole: + // its Transport may be nil (so the global, mutable http.DefaultTransport), + // a wrapper this package cannot inspect, or one with a DialTLSContext that + // bypasses TLSClientConfig entirely — and a caller holding the same + // *http.Transport pointer can turn verification off after construction, + // because a copied http.Client shares it. Inspecting a supplied client + // could not close any of that. So the client builds and owns its transport: + // TLS 1.2 minimum, verification on, no custom dialer, redirects refused. + RootCAs *x509.CertPool + // RequestTimeout bounds one mint request. RequestTimeout time.Duration + // RenewalLead overrides the fraction of a credential's lifetime at which // renewal is attempted. Zero takes the default. RenewalLead float64 + + // MaxCredentialLifetime refuses a credential the host minted for longer + // than this. Zero takes defaultMaxCredentialLifetime. + // + // The ceiling is here because there is none at the minter: core checks only + // that the TTL is positive, so a misconfigured host can mint a credential + // valid for a month and every verifier will accept it. A ceiling in the + // client is the weaker half of that fix — it bounds what THIS process will + // hold, not what the host will issue — and the cap belongs at the minter. + MaxCredentialLifetime time.Duration + // Now is the clock, for tests. Now func() time.Time } @@ -157,6 +217,7 @@ type Credential struct { token string seal Seal binding *SealedOperationBinding + notBefore time.Time issuedAt time.Time expiresAt time.Time @@ -186,6 +247,12 @@ func (c Credential) OperationBinding() *SealedOperationBinding { return cloneSealedBinding(c.binding) } +// NotBefore reports when the credential starts being accepted. It is a +// separate claim from IssuedAt, and it is the one a verifier tests, so a client +// that checked only issued_at was checking a different window from the one its +// credential would be judged against. +func (c Credential) NotBefore() time.Time { return c.notBefore } + // IssuedAt reports when the host minted it. func (c Credential) IssuedAt() time.Time { return c.issuedAt } @@ -225,6 +292,20 @@ type MintClient struct { credential *Credential generation uint64 counts MintCounts + + // inflight is non-nil while one goroutine is minting, and is closed when it + // finishes. Concurrent callers wait on the channel rather than on the mutex + // so the wait is cancellable: holding the lock across the request made + // every caller's ctx meaningless and serialised N callers into N requests + // when the mint kept failing. + inflight chan struct{} + + // backoff holds off the next attempt after a failure, and failures counts + // consecutive ones so the hold-off grows. Without it, a host answering 503 + // received one request per caller per call — the heartbeat under another + // name, arriving exactly when the host is least able to serve it. + backoffUntil time.Time + failures int } // NewMintClient validates configuration without performing any I/O. @@ -233,8 +314,20 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if err != nil { return nil, err } - if strings.TrimSpace(options.Audience) == "" { - return nil, fmt.Errorf("%w: no audience to mint for", ErrMintRefused) + if options.Authority == nil { + return nil, fmt.Errorf( + "%w: a mint client needs the process's boot-read authority; the audience is read from it", + ErrMintRefused, + ) + } + audience := AuthorityValue{ + Name: strings.TrimSpace(options.Audience.Name), + Key: strings.TrimSpace(options.Audience.Key), + } + if audience.Name == "" || audience.Key == "" { + return nil, fmt.Errorf( + "%w: name the pinned value the audience is read from", ErrMintRefused, + ) } if options.ProjectedToken == nil { return nil, fmt.Errorf("%w: no projected token source", ErrMintRefused) @@ -259,42 +352,55 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if lead <= 0 || lead >= 1 { return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrMintRefused) } + ceiling := options.MaxCredentialLifetime + if ceiling == 0 { + ceiling = defaultMaxCredentialLifetime + } + if ceiling <= 0 { + return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrMintRefused) + } now := options.Now if now == nil { now = time.Now } - client, err := secureMintClient(options.HTTPClient) - if err != nil { - return nil, err - } options.URL = endpoint + options.Audience = audience options.RequestTimeout = timeout options.RenewalLead = lead - return &MintClient{options: options, httpClient: client, now: now}, nil + options.MaxCredentialLifetime = ceiling + return &MintClient{ + options: options, + httpClient: mintHTTPClient(options.RootCAs), + now: now, + }, nil } -// secureMintClient returns the client every mint request is made with: -// the caller's, copied, with redirects refused on the copy. +// mintHTTPClient builds the client every mint request is made with. The client +// owns it: there is no way for a caller to supply one. // -// Refusing them on a client of our own was not enough. The projection is on the -// request as a bearer token, and Go's own client forwards Authorization across a -// redirect to the same host — so a caller that supplied an ordinary -// &http.Client{} disclosed the projection to whatever that host redirected to, -// before anything here could classify the answer. The refusal is therefore -// installed on EVERY client, before any request leaves. It is installed on a -// copy so a caller that uses its client elsewhere keeps its own redirect -// behaviour there. -func secureMintClient(supplied *http.Client) (*http.Client, error) { - client := &http.Client{} - if supplied != nil { - copied := *supplied - client = &copied - } - if err := checkTransportAuthenticates(client.Transport); err != nil { - return nil, err +// Inspecting a caller's client could not make this safe, which is why the +// option is gone. A nil Transport means the global, mutable +// http.DefaultTransport; a wrapping RoundTripper is opaque; a DialTLSContext +// bypasses TLSClientConfig altogether; and a caller that keeps the +// *http.Transport pointer can turn verification off after construction, because +// copying an http.Client shares its Transport. All four sent the projected +// service-account token over a channel nobody authenticated. So the only thing +// a caller says about the transport is which roots may sign the endpoint's +// certificate. +func mintHTTPClient(roots *x509.CertPool) *http.Client { + return &http.Client{ + CheckRedirect: refuseMintRedirect, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: roots, // nil means the system pool + MinVersion: tls.VersionTLS12, + }, + ForceAttemptHTTP2: true, + DisableCompression: true, + MaxIdleConnsPerHost: 2, + TLSHandshakeTimeout: maxMintRequestTimeout, + }, } - client.CheckRedirect = refuseMintRedirect - return client, nil } // refuseMintRedirect is called by net/http BEFORE the redirected request is @@ -314,55 +420,26 @@ func refuseMintRedirect(request *http.Request, via []*http.Request) error { ) } -// checkTransportAuthenticates refuses a transport configured not to verify the -// endpoint's certificate. HTTPS whose certificate nobody checks is plaintext -// with extra steps, and this is the one request that carries the projection. -// -// A RoundTripper this package cannot inspect is not refused — it cannot be — -// but it cannot downgrade the scheme either, and the URL check above requires -// https. -func checkTransportAuthenticates(transport http.RoundTripper) error { - settings, ok := transport.(*http.Transport) - if !ok { - return nil - } - if settings.TLSClientConfig != nil && settings.TLSClientConfig.InsecureSkipVerify { - return fmt.Errorf( - "%w: the mint client's transport does not verify the endpoint's certificate", - ErrMintRefused, - ) - } - if settings.TLSClientConfig != nil && - settings.TLSClientConfig.MinVersion != 0 && - settings.TLSClientConfig.MinVersion < tls.VersionTLS12 { - return fmt.Errorf( - "%w: the mint client's transport permits TLS below 1.2", - ErrMintRefused, - ) - } - return nil -} - -// Credential returns the credential for this moment, minting the first one and -// renewing a credential that has reached its renewal point. Every caller goes -// through it, so no caller obtains a credential this client knows to be past -// its renewal point. +// Credential returns the credential to use now: the first one is minted, and +// one that has reached its renewal point is renewed. // -// It is not a guarantee about a credential a caller already holds: a Credential +// It is NOT a guarantee about a credential a caller already holds. A Credential // is a value, so one read out of here and kept can be attached after the host -// has moved the state under it. That is what Refresh is for, and it is why a +// has moved the state under it; that is what Refresh is for, and it is why a // receiver verifies rather than trusting that a caller re-asked. // -// It holds one lock across the mint request, so concurrent callers wait rather -// than each minting their own. That is the point: a process obtains one -// credential per execution, and a process whose first request burst produced -// one mint per goroutine would be the heartbeat again under another name. The -// request timeout bounds the wait. +// **A held credential that is still valid is served even when a renewal +// fails.** Entering the renewal lead and failing used to return an error to +// every caller while the credential in hand had minutes of validity left — an +// outage this client manufactured out of a credential that still worked. A +// renewal failure is only an error once the credential has actually expired. // -// Every mint re-reads the projected token and rechecks the boot-read authority -// first. A projection rotated under the running process is therefore picked up -// without a restart, and an authority value that drifted refuses the mint -// instead of being sealed into a new credential. +// One mint at a time, and the wait is cancellable. Concurrent callers wait on a +// channel rather than on the mutex, so a caller whose ctx is cancelled stops +// waiting, and a failing mint produces one request rather than one per caller. +// After a failure the next attempt is held off, growing to a minute: a process +// obtains one credential per execution, and a process that answered a 503 with +// a request per call would be the heartbeat again under another name. func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if c == nil { return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) @@ -370,22 +447,9 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if ctx == nil { return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) } - c.mu.Lock() - defer c.mu.Unlock() - if c.credential != nil && !c.dueForRenewalLocked(*c.credential) { - return *c.credential, nil - } - renewal := c.credential != nil - credential, err := c.obtainLocked(ctx) - if err != nil { - return Credential{}, err - } - if renewal { - c.counts.Renewals++ - } else { - c.counts.Mints++ - } - return credential, nil + return c.obtain(ctx, mintReasonRenewal, func(held *Credential) bool { + return held == nil || c.dueForRenewalLocked(*held) + }) } // Refresh replaces a credential the host has refused — ErrRevoked from a @@ -394,8 +458,8 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { // // Without it there was no way back: Credential answers from the credential it // holds until that credential's own renewal point, so a process whose -// installation revision changed served refusals until a lead that the host -// chooses — hours, for a long-lived credential — had elapsed. +// installation revision changed served refusals until a lead the host chooses +// had elapsed. // // It is tied to the credential that was refused, not to the clock. A caller // passes back the Credential it was refused on; if this client has already @@ -403,6 +467,12 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { // burst of concurrent refusals on one credential produces exactly one // replacement mint, and a caller holding a credential two generations old // cannot roll the client backwards by asking about it. +// +// It is held to the same backoff as a renewal, which matters for the case a +// generation check alone does not cover: a receiver whose seal source lags +// refuses each FRESH credential too, and every refusal is a new generation. The +// backoff is what bounds that into one mint per hold-off rather than one per +// call. func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credential, error) { if c == nil { return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) @@ -417,45 +487,155 @@ func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credentia ) } c.mu.Lock() - defer c.mu.Unlock() - if c.credential == nil { + issued := c.credential != nil + c.mu.Unlock() + if !issued { return Credential{}, fmt.Errorf( "%w: this client has issued no credential to refresh", ErrMintRefused, ) } - if c.credential.generation != refused.generation { + return c.obtain(ctx, mintReasonRefresh, func(held *Credential) bool { // Somebody else was refused on the same credential and has already // replaced it. Minting again here would be the second mint for one // revocation, which is the thing this client exists not to do. - return *c.credential, nil + return held != nil && held.generation == refused.generation + }) +} + +// mintReason says which counter a successful mint belongs to. The FIRST mint is +// always counted as a mint whatever asked for it. +type mintReason int + +const ( + mintReasonRenewal mintReason = iota + mintReasonRefresh +) + +// obtain is the one path that mints. needed decides, against the credential +// currently held, whether a new one is wanted; it is called under the lock. +func (c *MintClient) obtain( + ctx context.Context, reason mintReason, needed func(held *Credential) bool, +) (Credential, error) { + for { + c.mu.Lock() + held := c.credential + if !needed(held) { + credential := *held + c.mu.Unlock() + return credential, nil + } + usable := held != nil && c.now().UTC().Before(held.expiresAt) + if waiting := c.inflight; waiting != nil { + // Another goroutine is minting. Wait for it rather than queueing a + // second request, and let ctx cancel the wait. + snapshot := Credential{} + if held != nil { + snapshot = *held + } + c.mu.Unlock() + select { + case <-waiting: + continue + case <-ctx.Done(): + if usable { + return snapshot, nil + } + // Both stay in the chain: a caller that cancelled wants to see + // its own cancellation, not only that minting was unavailable. + return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, ctx.Err()) + } + } + if until := c.backoffUntil; !until.IsZero() && c.now().UTC().Before(until) { + // Held off after a failure. Serving the credential in hand is the + // whole point of the hold-off; only an expired one is an error. + if usable { + credential := *held + c.mu.Unlock() + return credential, nil + } + c.mu.Unlock() + return Credential{}, fmt.Errorf( + "%w: the last mint failed and the next attempt is held off until %s", + ErrMintUnavailable, until, + ) + } + done := make(chan struct{}) + c.inflight = done + renewal := held != nil + c.mu.Unlock() + + credential, err := c.mintOnce(ctx) + + c.mu.Lock() + c.inflight = nil + if err != nil { + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + stillUsable := c.credential != nil && c.now().UTC().Before(c.credential.expiresAt) + snapshot := Credential{} + if stillUsable { + snapshot = *c.credential + } + c.mu.Unlock() + close(done) + if stillUsable { + // The credential in hand still works. A renewal that failed is + // not an outage until the thing it was renewing has expired. + return snapshot, nil + } + return Credential{}, err + } + c.failures = 0 + c.backoffUntil = time.Time{} + c.generation++ + credential.generation = c.generation + c.credential = &credential + switch { + case !renewal: + c.counts.Mints++ + case reason == mintReasonRefresh: + c.counts.Refreshes++ + default: + c.counts.Renewals++ + } + c.mu.Unlock() + close(done) + return credential, nil } - credential, err := c.obtainLocked(ctx) +} + +// mintOnce rechecks the pinned authority, reads the audience from it and mints. +// It performs no locking: obtain holds the single-flight slot across it, so the +// request is made without the client's mutex held and a caller's ctx still +// means something. +func (c *MintClient) mintOnce(ctx context.Context) (Credential, error) { + if err := c.options.Authority.Recheck(ctx); err != nil { + return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + } + audience, err := c.options.Authority.Value(c.options.Audience.Name, c.options.Audience.Key) if err != nil { - return Credential{}, err + return Credential{}, fmt.Errorf( + "%w: read the audience from %s: %v", ErrMintRefused, c.options.Audience, err, + ) } - c.counts.Refreshes++ - return credential, nil + if strings.TrimSpace(audience) == "" { + return Credential{}, fmt.Errorf( + "%w: %s is pinned to an empty audience", ErrMintRefused, c.options.Audience, + ) + } + return c.mintLocked(ctx, audience) } -// obtainLocked rechecks the pinned authority, mints, and installs the result as -// the credential this client holds. It is the one path that writes -// c.credential, so a mint that is refused leaves the previous credential in -// place rather than clearing it: a refused renewal must not turn into "this -// process holds nothing", which reads as a boot failure somewhere it is not. -func (c *MintClient) obtainLocked(ctx context.Context) (Credential, error) { - if c.options.Authority != nil { - if err := c.options.Authority.Recheck(ctx); err != nil { - return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) +// mintBackoff grows the hold-off with consecutive failures, to a minute. +func mintBackoff(failures int) time.Duration { + hold := minMintBackoff + for range failures - 1 { + hold *= 2 + if hold >= maxMintBackoff { + return maxMintBackoff } } - credential, err := c.mintLocked(ctx) - if err != nil { - return Credential{}, err - } - c.generation++ - credential.generation = c.generation - c.credential = &credential - return credential, nil + return hold } // Counts reports what this process's credential life cost. A correct execution @@ -481,7 +661,7 @@ func (c *MintClient) dueForRenewalLocked(credential Credential) bool { if remaining <= 0 { return true } - lifetime := credential.expiresAt.Sub(credential.issuedAt) + lifetime := credential.expiresAt.Sub(credential.notBefore) lead := time.Duration(float64(lifetime) * c.options.RenewalLead) if lead < minRenewalLeadTime { lead = minRenewalLeadTime @@ -492,13 +672,13 @@ func (c *MintClient) dueForRenewalLocked(credential Credential) bool { return remaining <= lead } -func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { +func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credential, error) { projected, err := c.options.ProjectedToken.ProjectedToken() if err != nil { return Credential{}, err } body, err := json.Marshal(mintRequest{ - Audience: c.options.Audience, + Audience: audience, ProjectionAudience: c.options.ProjectionAudience, }) if err != nil { @@ -547,11 +727,15 @@ func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { } return Credential{}, fmt.Errorf("%w: mint returned HTTP %d", sentinel, response.StatusCode) } - if contentType := response.Header.Get("Content-Type"); contentType != "" { - mediaType, _, parseErr := mime.ParseMediaType(contentType) - if parseErr != nil || mediaType != "application/json" { - return Credential{}, fmt.Errorf("%w: mint response is not application/json", ErrMintRefused) - } + // Content-Type is REQUIRED, not checked when present. An absent header used + // to be accepted, which made "the response is JSON" something the host + // could decline to state about the one response this process trusts for its + // whole life. + mediaType, _, parseErr := mime.ParseMediaType(response.Header.Get("Content-Type")) + if parseErr != nil || mediaType != "application/json" { + return Credential{}, fmt.Errorf( + "%w: mint response must declare Content-Type: application/json", ErrMintRefused, + ) } payload, err := io.ReadAll(io.LimitReader(response.Body, maxMintResponseBytes+1)) if err != nil { @@ -562,14 +746,14 @@ func (c *MintClient) mintLocked(ctx context.Context) (Credential, error) { "%w: mint response exceeds %d bytes", ErrMintRefused, maxMintResponseBytes, ) } - return c.credentialFrom(payload) + return c.credentialFrom(payload, audience) } // credentialFrom reads the credential out of the response and takes its sealed // values from the token, not from the response body. The host may echo them for // a log, and an echo that disagrees with the signature is a refusal: the only // installation, build and epoch that mean anything are the signed ones. -func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { +func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential, error) { var body mintResponse decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() @@ -594,16 +778,21 @@ func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { ErrMintRefused, body.BuildIncarnation, seal.BuildIncarnation, ) } - if claims.GetAudience() != c.options.Audience { + // The audience the host signed must be the audience read from the pin. + // This is the check an optional pin beside a free-string Audience could not + // make: Recheck passing told nobody that the credential in hand was minted + // for the pinned value rather than for whatever a caller had typed. + if claims.GetAudience() != audience { return Credential{}, fmt.Errorf( - "%w: credential was minted for audience %q, not %q", - ErrMintRefused, claims.GetAudience(), c.options.Audience, + "%w: credential was minted for audience %q, and %s is pinned to %q", + ErrMintRefused, claims.GetAudience(), c.options.Audience, audience, ) } credential := Credential{ token: token, seal: seal, binding: binding, + notBefore: time.Unix(claims.GetNotBeforeUnix(), 0).UTC(), issuedAt: time.Unix(claims.GetIssuedAtUnix(), 0).UTC(), expiresAt: time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), } @@ -613,41 +802,64 @@ func (c *MintClient) credentialFrom(payload []byte) (Credential, error) { return credential, nil } -// checkWindow refuses a credential that is unusable the moment it arrives. +// checkWindow refuses a credential that is unusable the moment it arrives, and +// one valid for longer than this process will hold a credential. +// +// Two things it gets right that the first version did not. It tests +// **not_before**, which is the claim core's verifier tests; testing issued_at +// was testing a different window from the one the credential would be judged +// against, and a host that sets them apart would have had a credential accepted +// here and refused everywhere. And it bounds the lifetime: core checks only +// that a TTL is positive, so a misconfigured host minting a month-long +// credential was installed in silence. // -// The renewal check only ever looked at the credential already held, so a -// response that was delayed, or minted against a clock well behind this -// process's, was installed as a success and then refused by every receiver — -// with the mint counted as having worked. A credential is checked against its -// own window before it is installed, with core's own skew so the tolerance here -// is the verifier's and not a second number. +// The tolerance is core's own skew, so this client is never stricter than the +// verifier that will accept the credential. func (c *MintClient) checkWindow(credential Credential) error { - if !credential.expiresAt.After(credential.issuedAt) { + if !credential.expiresAt.After(credential.notBefore) { + return fmt.Errorf( + "%w: the minted credential expires at or before it becomes valid (%s to %s)", + ErrMintRefused, credential.notBefore, credential.expiresAt, + ) + } + if lifetime := credential.expiresAt.Sub(credential.notBefore); lifetime > c.options.MaxCredentialLifetime { return fmt.Errorf( - "%w: the minted credential expires at or before it was issued (%s to %s)", - ErrMintRefused, credential.issuedAt, credential.expiresAt, + "%w: the minted credential is valid for %s, and this process holds a credential for at most %s", + ErrMintRefused, lifetime, c.options.MaxCredentialLifetime, ) } now := c.now().UTC() - if now.Before(credential.issuedAt.Add(-corework.DefaultSkew)) { + if now.Before(credential.notBefore.Add(-corework.DefaultSkew)) { + // Not yet valid. A clock this far apart is a configuration error on one + // side or the other and will not fix itself, so it is a refusal. return fmt.Errorf( "%w: the minted credential is not valid until %s, and it is %s", - ErrMintRefused, credential.issuedAt, now, + ErrMintRefused, credential.notBefore, now, ) } if !now.Before(credential.expiresAt) { + // Already expired on arrival. This is what a DELAYED response looks + // like, which the next attempt may well not hit, so it is an outage + // and not a refusal — and the backoff is what stops the next attempt + // being immediate. return fmt.Errorf( "%w: the minted credential expired at %s, and it is %s", - ErrMintRefused, credential.expiresAt, now, + ErrMintUnavailable, credential.expiresAt, now, ) } if c.dueForRenewalLocked(credential) { - // Installing it would put the client straight back into a renewal, and + // Installing it would put the client straight back into a renewal and // the next response would be the same one: a mint loop, one audit event // per iteration, which is the heartbeat this client replaced. + // + // ErrMintUnavailable rather than ErrMintRefused, which is the sentinel + // this carried first and got wrong: a merely delayed response produces + // exactly this, and ErrMintRefused means "the host will say the same + // thing again, do not serve". A caller must not stop serving because + // one response arrived late. return fmt.Errorf( - "%w: the minted credential's whole lifetime is inside the renewal lead (%s to %s)", - ErrMintRefused, credential.issuedAt, credential.expiresAt, + "%w: the minted credential's whole remaining lifetime is inside the renewal lead (%s to %s)", + ErrMintUnavailable, credential.notBefore, credential.expiresAt, ) } return nil diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 8a8ae31..6918954 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -52,6 +52,10 @@ type mintHost struct { // or shorter-lived than the renewal lead. mintedAt *time.Time mintedFor *time.Duration + // pin is the process's boot-read authority, which answers the audience. + pin *testPin + // before runs at the top of every request, so a test can hold one open. + before func() } func newMintHost(t *testing.T, now func() time.Time) *mintHost { @@ -59,7 +63,7 @@ func newMintHost(t *testing.T, now func() time.Time) *mintHost { host := &mintHost{ t: t, now: now, lifetime: 15 * time.Minute, audience: testAudience, authority: newAuthority(t), - bodies: make(chan []byte, 64), + bodies: make(chan []byte, 64), pin: newTestPin(), } host.server = httptest.NewTLSServer(http.HandlerFunc(host.handle)) t.Cleanup(host.server.Close) @@ -68,6 +72,9 @@ func newMintHost(t *testing.T, now func() time.Time) *mintHost { func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { h.requests.Add(1) + if h.before != nil { + h.before() + } h.presented.Store(request.Header.Get("Authorization")) if h.refuseWith != 0 { writer.WriteHeader(h.refuseWith) @@ -140,14 +147,14 @@ func newTestMintClient(t *testing.T, host *mintHost, path string, now func() tim t.Helper() settings := MintOptions{ URL: host.server.URL, - Audience: testAudience, + Authority: host.pin, + Audience: testAudienceName, ProjectedToken: ProjectedTokenFile(path), - ProjectionAudience: "accounts", + ProjectionAudience: "projection-audience", Now: now, - // The test server's own client, which trusts the server's certificate - // and nothing else. It is a SUPPLIED client, so every test here also - // exercises the path where the caller brought its own. - HTTPClient: host.server.Client(), + // The only thing a caller may say about the transport: which roots may + // sign the endpoint's certificate. The client builds the rest. + RootCAs: certPoolOf(host.server), } for _, option := range options { option(&settings) @@ -269,22 +276,29 @@ func TestEveryMintRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { clock := testClock now := func() time.Time { return clock } host := newMintHost(t, now) - pin := &stubAuthority{} - client := newTestMintClient(t, host, projectedFile(t, "projected"), now, func(options *MintOptions) { - options.Authority = pin - }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err := client.Credential(t.Context()) require.NoError(t, err) - require.EqualValues(t, 1, pin.checks.Load(), "the first mint checks the pin too") + require.EqualValues(t, 1, host.pin.checks.Load(), "the first mint checks the pin too") - pin.err = errors.New("audience changed under a running process") + host.pin.set(testAudience, errors.New("audience changed under a running process")) clock = testClock.Add(14 * time.Minute) + credential, err := client.Credential(t.Context()) + // The credential in hand has a minute left, so the drift does not make this + // process stop serving — it makes it stop MINTING, which is the point. + require.NoError(t, err) + require.NotEmpty(t, credential.Token()) + require.EqualValues(t, 2, host.pin.checks.Load()) + require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") + + // Once the held credential has expired there is nothing to serve, and the + // drift is the error. + clock = testClock.Add(2 * time.Hour) _, err = client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "audience changed") - require.EqualValues(t, 2, pin.checks.Load()) - require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") + require.EqualValues(t, 1, host.requests.Load()) } // The first mint, specifically: a client constructed at boot whose pin has @@ -292,10 +306,8 @@ func TestEveryMintRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { func TestTheFirstMintRefusesAnAlreadyDriftedAuthority(t *testing.T) { now := func() time.Time { return testClock } host := newMintHost(t, now) - pin := &stubAuthority{err: errors.New("binding changed before the first call")} - client := newTestMintClient(t, host, projectedFile(t, "projected"), now, func(options *MintOptions) { - options.Authority = pin - }) + host.pin.set(testAudience, errors.New("binding changed before the first call")) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err := client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) @@ -304,16 +316,49 @@ func TestTheFirstMintRefusesAnAlreadyDriftedAuthority(t *testing.T) { "the pin is checked before the projection is presented to anything") } -type stubAuthority struct { - checks atomic.Uint64 - err error +// testPin stands in for the process's boot-read authority: it answers the +// pinned audience and counts the rechecks. The root SDK's *codefly.Authority +// has the same two methods. +type testPin struct { + mu sync.Mutex + checks atomic.Uint64 + reads atomic.Uint64 + audience string + err error + valueErr error +} + +func newTestPin() *testPin { return &testPin{audience: testAudience} } + +func (p *testPin) Recheck(context.Context) error { + p.checks.Add(1) + p.mu.Lock() + defer p.mu.Unlock() + return p.err +} + +func (p *testPin) Value(name string, key string) (string, error) { + p.reads.Add(1) + p.mu.Lock() + defer p.mu.Unlock() + if p.valueErr != nil { + return "", p.valueErr + } + if name != testAudienceName.Name || key != testAudienceName.Key { + return "", errors.New("authority-bearing value was not read at boot: " + name + "/" + key) + } + return p.audience, nil } -func (s *stubAuthority) Recheck(context.Context) error { - s.checks.Add(1) - return s.err +func (p *testPin) set(audience string, recheck error) { + p.mu.Lock() + defer p.mu.Unlock() + p.audience = audience + p.err = recheck } +var testAudienceName = AuthorityValue{Name: "platform", Key: "work-context-audience"} + // A credential the host has refused is replaced on demand, not when its own // renewal lead arrives. Without this there was no way back: the client answered // from the credential it held until a lead the HOST chooses had elapsed, so an @@ -420,34 +465,27 @@ func TestRefreshRechecksThePinAndRereadsTheProjection(t *testing.T) { now := func() time.Time { return testClock } host := newMintHost(t, now) path := projectedFile(t, "projected-before") - pin := &stubAuthority{} - client := newTestMintClient(t, host, path, now, func(options *MintOptions) { - options.Authority = pin - }) + client := newTestMintClient(t, host, path, now) held, err := client.Credential(t.Context()) require.NoError(t, err) - require.EqualValues(t, 1, pin.checks.Load()) + require.EqualValues(t, 1, host.pin.checks.Load()) + require.EqualValues(t, 1, host.pin.reads.Load(), "the audience is READ from the pin, not passed in") require.NoError(t, os.WriteFile(path, []byte("projected-after"), 0o600)) replaced, err := client.Refresh(t.Context(), held) require.NoError(t, err) - require.EqualValues(t, 2, pin.checks.Load()) + require.EqualValues(t, 2, host.pin.checks.Load()) + require.EqualValues(t, 2, host.pin.reads.Load()) require.Equal(t, "Bearer projected-after", host.presented.Load()) - // A drifted pin refuses the refresh, and the credential already held stays + // A drifted pin stops the refresh, and the credential already held stays // held: a refused replacement must not leave the process holding nothing, // which would read as a boot failure somewhere it is not. - pin.err = errors.New("audience changed under a running process") - _, err = client.Refresh(t.Context(), replaced) - require.ErrorIs(t, err, ErrMintRefused) - require.ErrorContains(t, err, "audience changed") - - pin.err = nil - still, err := client.Credential(t.Context()) - require.NoError(t, err) - require.Equal(t, replaced.Token(), still.Token(), - "a refused replacement leaves the credential the process already holds") + host.pin.set(testAudience, errors.New("audience changed under a running process")) + still, err := client.Refresh(t.Context(), replaced) + require.NoError(t, err, "a credential with time left is served while minting is refused") + require.Equal(t, replaced.Token(), still.Token()) } // The host may echo the sealed values for a log. An echo that disagrees with @@ -480,32 +518,48 @@ func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { require.ErrorContains(t, err, "minted for audience") } -// A credential is checked against its OWN window before it is installed. +// A credential is checked against its OWN window before it is installed, and +// the sentinel says whether the next attempt could do better. // // The renewal check only ever looked at the credential already held, so a // response that was delayed, or minted against a clock well behind this // process's, was installed as a success — counted as a working mint — and then -// refused by every receiver. The three cases are the three ways that happens. +// refused by every receiver. +// +// The window tested is **not_before**, which is the claim core's verifier +// tests. Testing issued_at was testing a different window from the one the +// credential would be judged against. +// +// And the sentinels matter rather than being decoration. ErrMintRefused means +// "the host will say the same thing again, do not serve"; a merely DELAYED +// response is not that, so an already-expired credential and a lifetime that is +// all lead are ErrMintUnavailable. A clock far enough apart to make a +// credential not-yet-valid, and a credential longer than this process will +// hold, are configuration and will not fix themselves. func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { now := func() time.Time { return testClock } - short := 4 * time.Second for name, arrange := range map[string]struct { mintedAt time.Time mintedFor time.Duration + sentinel error says string }{ - "already expired": { + "already expired, which is what a delayed response looks like": { mintedAt: testClock.Add(-time.Hour), mintedFor: 15 * time.Minute, - says: "expired at", + sentinel: ErrMintUnavailable, says: "expired at", }, - "not yet valid": { + "not yet valid, which is a clock that will not fix itself": { mintedAt: testClock.Add(time.Hour), mintedFor: 15 * time.Minute, - says: "not valid until", + sentinel: ErrMintRefused, says: "not valid until", + }, + "whole remaining lifetime inside the renewal lead": { + mintedAt: testClock, mintedFor: 4 * time.Second, + sentinel: ErrMintUnavailable, says: "inside the renewal lead", }, - "whole lifetime inside the renewal lead": { - mintedAt: testClock, mintedFor: short, - says: "inside the renewal lead", + "longer than this process will hold a credential": { + mintedAt: testClock, mintedFor: 30 * 24 * time.Hour, + sentinel: ErrMintRefused, says: "holds a credential for at most", }, } { t.Run(name, func(t *testing.T) { @@ -515,7 +569,7 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { client := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err := client.Credential(t.Context()) - require.ErrorIs(t, err, ErrMintRefused) + require.ErrorIs(t, err, arrange.sentinel) require.ErrorContains(t, err, arrange.says) require.Equal(t, MintCounts{}, client.Counts(), "a credential that was never usable is not a mint that worked") @@ -532,8 +586,23 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { client := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err := client.Credential(t.Context()) require.NoError(t, err, "a credential minted inside core's skew is usable") + + // And the ceiling is configurable, because what a process should hold is + // the deployment's call — the point is that there IS one, since core checks + // only that a TTL is positive. + host = newMintHost(t, now) + week := 7 * 24 * time.Hour + host.mintedAt, host.mintedFor = &testClockCopy, &week + client = newTestMintClient(t, host, projectedFile(t, "projected"), now, + func(options *MintOptions) { options.MaxCredentialLifetime = 14 * 24 * time.Hour }) + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, testClock.Add(week), credential.ExpiresAt()) } +// testClockCopy is an addressable testClock, for the host overrides above. +var testClockCopy = testClock + // A refusal the host will give again is not retryable, and an outage is. A // client that confused them would either spin against a permanent refusal or // give up on a transient one. @@ -604,7 +673,7 @@ func TestMintRequestSelfReportsNoIdentity(t *testing.T) { require.NoError(t, json.Unmarshal(sent, &fields)) require.Equal(t, []string{"audience", "projection_audience"}, keysOf(fields)) require.Equal(t, testAudience, fields["audience"]) - require.Equal(t, "accounts", fields["projection_audience"]) + require.Equal(t, "projection-audience", fields["projection_audience"]) } func keysOf(fields map[string]any) []string { @@ -618,15 +687,19 @@ func keysOf(fields map[string]any) []string { // The redirect target must receive NO REQUEST AT ALL. // -// The old test asserted the returned classification after the redirect had -// already been followed, which is the wrong thing entirely: by then the -// projected service-account token had been sent to an address nothing -// configured, and naming the outcome afterwards changes nothing. What this -// asserts is containment — zero requests at the destination — and it asserts it -// for a SUPPLIED client, which is where the hole was: Go's own client forwards -// Authorization across a redirect to the same host, so a caller that brought an -// ordinary &http.Client{} disclosed the projection before this package could -// classify anything. +// Two things changed here. The old test asserted the returned classification +// after the redirect had already been followed, which is the wrong thing +// entirely: by then the projected token had been sent to an address nothing +// configured. And the hole the first fix closed by copying a supplied client is +// now closed by not accepting one — a caller cannot supply an http.Client at +// all, because inspecting one could not close a nil Transport (the global, +// mutable http.DefaultTransport), a wrapping RoundTripper, a DialTLSContext +// that bypasses TLSClientConfig, or a caller mutating the shared *Transport +// after construction. +// +// So the client under test here is the one the SDK builds, which is the only +// one there is, and what is asserted is containment: zero requests at the +// destination, asserted before the error is even looked at. func TestARedirectedMintSendsNothingToTheDestination(t *testing.T) { now := func() time.Time { return testClock } var elsewhereRequests atomic.Uint64 @@ -642,99 +715,83 @@ func TestARedirectedMintSendsNothingToTheDestination(t *testing.T) { })) t.Cleanup(redirector.Close) - // One TLS configuration that trusts BOTH servers, so nothing but the - // refusal stops the second request. A client that could not verify the - // destination's certificate would pass this test for the wrong reason. - trusted := &tls.Config{ - RootCAs: certPoolOf(redirector, elsewhere), - MinVersion: tls.VersionTLS12, - } - - for name, supplied := range map[string]*http.Client{ - // The case that disclosed the projection: a caller-supplied client with - // no redirect policy of its own, so net/http's default applied. - "no redirect policy of its own": { - Transport: &http.Transport{TLSClientConfig: trusted}, - }, - // And a caller that explicitly decided to follow redirects. For a - // credential request that decision is not the caller's to make. - "a policy that follows redirects": { - Transport: &http.Transport{TLSClientConfig: trusted}, - CheckRedirect: func(*http.Request, []*http.Request) error { return nil }, - }, - } { - t.Run(name, func(t *testing.T) { - elsewhereRequests.Store(0) - client, err := NewMintClient(MintOptions{ - URL: redirector.URL, - Audience: testAudience, - ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), - ProjectionAudience: "accounts", - Now: now, - HTTPClient: supplied, - }) - require.NoError(t, err) - - _, err = client.Credential(t.Context()) + // The root pool trusts BOTH servers, so nothing but the refusal stops the + // second request: a client that could not verify the destination's + // certificate would pass this test for the wrong reason. + client, err := NewMintClient(MintOptions{ + URL: redirector.URL, + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: now, + RootCAs: certPoolOf(redirector, elsewhere), + }) + require.NoError(t, err) - // Containment first, and on its own line: this is the assertion - // that matters. A test that checked the returned error before the - // request count would report the classification of a disclosure - // that had already happened. - require.EqualValues(t, 0, elsewhereRequests.Load(), - "the redirect destination must receive no request: the first one carried the projection") - require.ErrorIs(t, err, ErrMintRefused) - require.NotErrorIs(t, err, ErrMintUnavailable, - "a redirected credential request is not something to retry") - require.ErrorContains(t, err, "redirected") - require.Equal(t, MintCounts{}, client.Counts()) - }) - } + _, err = client.Credential(t.Context()) - // The client the SDK builds when the caller supplies none cannot reach a - // test server's certificate, so its refusal is asserted on the policy - // itself rather than over the wire. - byDefault, err := secureMintClient(nil) - require.NoError(t, err) - request, err := http.NewRequest(http.MethodGet, elsewhere.URL, nil) - require.NoError(t, err) - require.ErrorIs(t, byDefault.CheckRedirect(request, nil), ErrMintRefused) + require.EqualValues(t, 0, elsewhereRequests.Load(), + "the redirect destination must receive no request: the first one carried the projection") + require.ErrorIs(t, err, ErrMintRefused) + require.NotErrorIs(t, err, ErrMintUnavailable, + "a redirected credential request is not something to retry") + require.ErrorContains(t, err, "redirected") + require.Equal(t, MintCounts{}, client.Counts()) } -// A supplied client keeps its own redirect behaviour everywhere else. The -// refusal is installed on a copy, so a caller that shares one client between -// the mint endpoint and its ordinary traffic does not have the rest of its -// traffic silently stop following redirects. -func TestSecuringTheMintClientDoesNotMutateTheCallersClient(t *testing.T) { - supplied := &http.Client{} - secured, err := secureMintClient(supplied) - require.NoError(t, err) - require.NotNil(t, secured.CheckRedirect) - require.Nil(t, supplied.CheckRedirect, - "the caller's client must not be reconfigured out from under it") - - // And a client that had its own redirect policy loses it HERE and only - // here: a credential request is not followed, whatever the caller decided - // for its other traffic. - followed := false - opinionated := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { - followed = true - return nil - }} - secured, err = secureMintClient(opinionated) - require.NoError(t, err) +// The transport is the SDK's, and a caller cannot reach it. +// +// This is the finding in its sharpest form: the previous version inspected a +// supplied *http.Client and refused the one configuration it could see +// (InsecureSkipVerify on an *http.Transport). Four configurations it could not +// see each sent the projected bearer over an unauthenticated channel. None of +// them is reachable now, because there is no option to supply. +func TestTheMintTransportIsOwnedByTheClient(t *testing.T) { + client := mintHTTPClient(nil) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok, "the client builds a concrete *http.Transport, not a wrapper it cannot reason about") + require.NotNil(t, transport.TLSClientConfig, "a nil TLS config would mean the package defaults") + require.False(t, transport.TLSClientConfig.InsecureSkipVerify) + require.EqualValues(t, tls.VersionTLS12, transport.TLSClientConfig.MinVersion) + require.Nil(t, transport.DialTLSContext, + "a custom TLS dialer bypasses TLSClientConfig entirely") + require.Nil(t, transport.DialContext) + require.Nil(t, transport.Proxy, + "a proxy for a credential request is an address the configuration did not name") + require.NotNil(t, client.CheckRedirect) + + // It is not http.DefaultTransport, which is global and mutable: anything + // in the process could have reconfigured that one. + require.NotSame(t, http.DefaultTransport, client.Transport) + + // Two clients do not share a transport, so nothing one caller does to its + // client can reach another's. + require.NotSame(t, client.Transport, mintHTTPClient(nil).Transport) + + // The root pool is the ONLY thing a caller says about it, and it lands + // where it is used. + pool := x509.NewCertPool() + withRoots := mintHTTPClient(pool) + rooted, ok := withRoots.Transport.(*http.Transport) + require.True(t, ok) + require.Same(t, pool, rooted.TLSClientConfig.RootCAs) + + // And the redirect refusal is on it, as a refusal rather than as + // http.ErrUseLastResponse, so net/http never sends the second request. request, err := http.NewRequest(http.MethodGet, "https://example.invalid/_mint", nil) require.NoError(t, err) - require.ErrorIs(t, secured.CheckRedirect(request, nil), ErrMintRefused) - require.False(t, followed) + require.ErrorIs(t, client.CheckRedirect(request, nil), ErrMintRefused) } func TestNewMintClientValidatesItsConfiguration(t *testing.T) { valid := MintOptions{ - URL: "https://accounts.internal/platform/_mint", - Audience: testAudience, + URL: "https://mint.example/platform/_mint", + Authority: newTestPin(), + Audience: testAudienceName, ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), - ProjectionAudience: "accounts", + ProjectionAudience: "projection-audience", } _, err := NewMintClient(valid) require.NoError(t, err) @@ -744,28 +801,23 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { // Plaintext is the one that mattered: the projection travels on this // request as a bearer credential, so http was a disclosure the // configuration could choose. - "plaintext http": func(o *MintOptions) { o.URL = "http://accounts.internal/_mint" }, - "url with query": func(o *MintOptions) { o.URL = "https://accounts.internal/_mint?as=root" }, - "url with credentials": func(o *MintOptions) { o.URL = "https://user:pass@accounts.internal/_mint" }, - "url with fragment": func(o *MintOptions) { o.URL = "https://accounts.internal/_mint#f" }, - "no audience": func(o *MintOptions) { o.Audience = "" }, + "plaintext http": func(o *MintOptions) { o.URL = "http://mint.example/_mint" }, + "url with query": func(o *MintOptions) { o.URL = "https://mint.example/_mint?as=root" }, + "url with credentials": func(o *MintOptions) { o.URL = "https://user:pass@mint.example/_mint" }, + "url with fragment": func(o *MintOptions) { o.URL = "https://mint.example/_mint#f" }, + // The audience is read from the pin, so there must BE a pin and it must + // be named. A free-string audience beside an optional pin made the + // drift check guard a value the mint did not use. + "no authority pin": func(o *MintOptions) { o.Authority = nil }, + "no audience name": func(o *MintOptions) { o.Audience = AuthorityValue{} }, + "audience name only": func(o *MintOptions) { o.Audience = AuthorityValue{Name: "platform"} }, + "audience key only": func(o *MintOptions) { o.Audience = AuthorityValue{Key: "audience"} }, "no projection": func(o *MintOptions) { o.ProjectedToken = nil }, + "negative lifetime cap": func(o *MintOptions) { o.MaxCredentialLifetime = -time.Hour }, "no projection audience": func(o *MintOptions) { o.ProjectionAudience = "" }, "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, - // HTTPS nobody authenticates is plaintext with extra steps, and this is - // the one request that carries the projection. - "transport that skips certificate verification": func(o *MintOptions) { - o.HTTPClient = &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the point of the test - }} - }, - "transport permitting TLS below 1.2": func(o *MintOptions) { - o.HTTPClient = &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS10}, - }} - }, } { t.Run(name, func(t *testing.T) { options := valid @@ -811,3 +863,191 @@ func TestCredentialSurfaceComesFromTheSignedCapability(t *testing.T) { require.Equal(t, credential.Token(), request.Header.Get(HeaderName)) require.Equal(t, testInstallation, request.Header.Get(InstallationIDHeaderName)) } + +// A renewal that fails does not take a working credential away. +// +// This is the finding in its sharpest form: entering the renewal lead and +// getting a 503 used to return an error to every caller while the credential in +// hand had minutes of validity left. The client manufactured an outage out of a +// credential that still worked, and then — because each caller retried under +// the lock with no hold-off — made one mint request per caller at exactly the +// moment the host was least able to serve them. +func TestAFailedRenewalServesTheHeldCredentialUntilItExpires(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // Inside the renewal lead, with the host refusing. + host.refuseWith = http.StatusServiceUnavailable + clock = testClock.Add(13 * time.Minute) + served, err := client.Credential(t.Context()) + require.NoError(t, err, "a credential with two minutes left is still a credential") + require.Equal(t, held.Token(), served.Token()) + require.EqualValues(t, 2, host.requests.Load(), "it tried once") + + // Ten more callers inside the hold-off make NO further requests. Without + // the hold-off this was one request per caller. + for range 10 { + again, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, held.Token(), again.Token()) + } + require.EqualValues(t, 2, host.requests.Load(), + "a failed mint holds off the next attempt; a request per caller is the heartbeat again") + + // Past the hold-off it tries again — still refused, still serving. + clock = testClock.Add(13*time.Minute + 2*minMintBackoff) + _, err = client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 3, host.requests.Load()) + + // Once the held credential has actually expired there is nothing to serve, + // and the outage is the caller's problem to know about. + clock = testClock.Add(20 * time.Minute) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.Equal(t, MintCounts{Mints: 1}, client.Counts(), + "a failed renewal is not a renewal") + + // And when the host comes back, the renewal happens. + host.refuseWith = 0 + clock = testClock.Add(20*time.Minute + time.Minute) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.NotEqual(t, held.Token(), fresh.Token()) + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, client.Counts()) +} + +// The hold-off grows, so a long outage is not a steady stream of requests. +func TestTheMintHoldOffGrowsWithConsecutiveFailures(t *testing.T) { + require.Equal(t, minMintBackoff, mintBackoff(1)) + require.Equal(t, 2*minMintBackoff, mintBackoff(2)) + require.Equal(t, 4*minMintBackoff, mintBackoff(3)) + require.Equal(t, maxMintBackoff, mintBackoff(30), + "and it is bounded: a hold-off nobody ever leaves is an outage of our own") + require.LessOrEqual(t, mintBackoff(100), maxMintBackoff) +} + +// A caller whose context is cancelled while another goroutine is minting stops +// waiting. The mint used to happen under the client's mutex, which made every +// waiting caller's ctx meaningless: a cancelled request still blocked until the +// request it was not making had finished. +// +// The host holds its answer for a bounded moment rather than indefinitely, and +// releases on cleanup, so a failing assertion cannot wedge the server. +func TestAWaitingCallerHonoursItsContext(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + minted := make(chan error, 1) + go func() { + _, err := client.Credential(context.Background()) + minted <- err + }() + + // Wait until the host has the request in hand, so the call below is + // certainly a waiter rather than the minter. + require.Eventually(t, func() bool { return host.requests.Load() == 1 }, + 2*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(cancelled) + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorIs(t, err, context.Canceled) + require.EqualValues(t, 1, host.requests.Load(), + "a waiting caller does not queue a second request") + + letGo() + require.NoError(t, <-minted) + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) +} + +// A waiting caller that holds a usable credential gets it rather than an error, +// even when its own context is cancelled: the credential in hand is valid, and +// the caller asked for a credential rather than for a mint. +func TestACancelledWaiterStillGetsAUsableHeldCredential(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + clock = testClock.Add(13 * time.Minute) + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 2*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + served, err := client.Credential(cancelled) + require.NoError(t, err, "a valid credential in hand beats a cancelled wait") + require.Equal(t, held.Token(), served.Token()) + + letGo() + <-renewing +} + +// A receiver whose live state lags refuses every fresh credential, and each +// refusal is a NEW generation — so the generation check alone does not bound +// it. The hold-off is what does. +func TestRepeatedRefreshesAreBounded(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // The first refusal gets a replacement. + replaced, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + require.EqualValues(t, 2, host.requests.Load()) + + // Now the host starts failing, and a caller refused on each fresh + // credential keeps coming back. Without the hold-off this is one mint per + // call for as long as the receiver lags. + host.refuseWith = http.StatusServiceUnavailable + current := replaced + for range 20 { + next, err := client.Refresh(t.Context(), current) + require.NoError(t, err, "a credential with time left is served") + current = next + } + require.EqualValues(t, 3, host.requests.Load(), + "twenty refusals inside one hold-off make one request") +} diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index d0bf63b..2bdbe77 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -23,7 +23,6 @@ import ( var ( testClock = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) testSeal = Seal{ - PrincipalEpoch: 7, InstallationID: testInstallation, InstallationRevision: 3, BuildIncarnation: 11, @@ -48,6 +47,9 @@ const ( testPrincipal = "principal-1" testInstallation = "installation-1" testBinding = "binding-1" + // testPrincipalEpoch is the owner's live epoch, which the seal source + // answers rather than the seal carrying it. + testPrincipalEpoch = 7 ) type authority struct { @@ -62,6 +64,12 @@ func newAuthority(t *testing.T) *authority { t.Helper() seals := corework.NewMemorySealSource() require.NoError(t, seals.Put(testPrincipal, testSeal)) + // The owner's epoch is recorded through PutEpoch like any other + // principal's. Seal no longer carries it: it had two sources, and + // revocation could be UNDONE — advancing the epoch left stored seals + // untouched, and storing a seal for an unrelated installation lowered it + // back. One source, owners and actors alike. + require.NoError(t, seals.PutEpoch(testPrincipal, testPrincipalEpoch)) require.NoError(t, seals.PutBinding(testLiveBinding)) return newAuthorityOver(t, seals) } @@ -128,12 +136,15 @@ func (noGrants) Grant(context.Context, string) (*corework.Grant, error) { // discussion. func resealWithout(t *testing.T, token string, field string) string { t.Helper() - claims, err := claimsOf(token) + claims, err := readClaims(token) require.NoError(t, err) require.NotNil(t, claims.GetSeal()) switch field { case "PrincipalEpoch": claims.Seal.PrincipalEpoch = 0 + case "ActorEpoch": + require.NotEmpty(t, claims.GetActorChain(), "no hop to blank") + claims.ActorChain[len(claims.ActorChain)-1].PrincipalEpoch = nil case "InstallationID": claims.Seal.InstallationId = "" case "InstallationRevision": diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index dfed7f6..e596296 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -32,11 +32,79 @@ var ( _ corework.OperationBinding = OperationBinding{} ) -// cryptoImplementationPackages are the packages a second implementation would -// have to reach for. Neither may be imported by anything in this module: this -// module presents capabilities and reads the sealed values off ones the host -// issued it, and it neither signs nor checks a signature. -var cryptoImplementationPackages = []string{"crypto/ed25519", "crypto/ecdsa"} +// bannedImports, bannedImportPrefixes and bannedImportSubstrings are every way +// into a signature or a second encoding of the message. An import matching any +// of them is refused OUTRIGHT — with no "and the file also mentions WorkContext" +// conjunction, because a conjunction is satisfied by putting the signer in one +// file and the wrapper in another. +// +// The previous version of this gate listed exactly "crypto/ed25519" and +// "crypto/ecdsa", which made it a gate about two strings. All of these passed +// it: golang.org/x/crypto/ed25519; crypto/rsa and crypto/hmac; the bare crypto +// package, whose crypto.Signer signs Ed25519 with no ed25519 import anywhere; +// and protojson, which is a second encoding of the message by itself. A +// faithful clone of core's signer — proto.MarshalOptions{Deterministic: true} +// plus x/crypto — was green. +var ( + bannedImports = []string{ + // The bare package: crypto.Signer and crypto.Hash are all a signer + // needs once a key has been parsed out of a PKCS#8 blob. + "crypto", + "crypto/ed25519", "crypto/ecdsa", "crypto/rsa", "crypto/dsa", + "crypto/hmac", "crypto/ecdh", "crypto/elliptic", "crypto/subtle", + // Second encodings of the message. encoding/json is handled separately + // because one file is allowed it for the mint endpoint's HTTP bodies. + "google.golang.org/protobuf/encoding/protojson", + "encoding/gob", "encoding/asn1", "encoding/xml", + } + bannedImportPrefixes = []string{"golang.org/x/crypto/"} + // Any JOSE, JWT or token-library path, whoever publishes it. A capability + // here is core's protobuf envelope; a library that mints bearer tokens has + // no honest use in this module. + bannedImportSubstrings = []string{"jose", "jwt", "jwx", "paseto", "macaroon", "branca"} +) + +// tlsPlumbing is the narrow exception: the mint client builds and owns its own +// transport, which needs crypto/tls for the configuration and crypto/x509 for +// the caller's root pool. Both are refused everywhere else, and even there only +// these SYMBOLS may be used — so x509.ParsePKCS8PrivateKey, which is how a +// signer gets a key without importing ed25519, is a finding rather than a +// permitted use of an allowed import. +// +// crypto/sha256 is deliberately absent from every list: a hash is not a +// signature, and the cache partition's digest preimage needs one. +var tlsPlumbing = map[string]struct { + files []string + symbols []string +}{ + "crypto/tls": { + files: []string{"mint.go"}, + symbols: []string{"Config", "VersionTLS12", "VersionTLS13"}, + }, + "crypto/x509": { + files: []string{"mint.go"}, + symbols: []string{"CertPool", "NewCertPool", "SystemCertPool"}, + }, +} + +// protoMarshalAllowlist is every file that may encode a protobuf message, and +// what it encodes. Encoding the CAPABILITY is the second implementation; this +// one encodes a scope, for a cache digest preimage, and never a WorkContext. +var protoMarshalAllowlist = map[string]string{ + "cache_partition.go": "a WorkScopeV1, for the cache digest preimage — never the capability", +} + +// workContextNameAllowlist is this module's own declarations whose names begin +// with the capability's, with the reason each is not a second surface. The name +// check is case-insensitive and covers every declaration kind, so the module's +// legitimate carrier constants have to be named here rather than slipping +// through on capitalisation. +var workContextNameAllowlist = map[string]string{ + "workContextGRPCMetadataName": "the gRPC metadata key, which is workcontext.HeaderName and not a second name", + "workContext": "the opaque capability string a transport carries", + "workContexts": "the metadata values read for that key", + "WorkContext": "the mint response body's JSON field for the capability string", +} // jsonAllowlist is every non-test file that may import encoding/json, keyed by // its path RELATIVE TO THE MODULE ROOT, and why. @@ -135,58 +203,184 @@ func TestNoSecondWorkContextImplementation(t *testing.T) { func inspectForSecondImplementation(file sourceFile) []string { var findings []string coreImports := map[string]bool{} + plumbing := map[string]string{} // local name -> allowed import path for name, path := range importsOf(file.syntax) { - if slices.Contains(cryptoImplementationPackages, path) { - findings = append(findings, fmt.Sprintf( - "%s imports %q.\n"+ - "This module signs nothing and verifies nothing: core's workcontext is the only\n"+ - "implementation of the capability. If a signature has to be checked, it is checked\n"+ - "by core's Verifier, reached through the alias in core.go.", - file.path, path)) - } - if path == "encoding/json" { - if _, allowed := jsonAllowlist[file.path]; !allowed { - findings = append(findings, fmt.Sprintf( - "%s imports encoding/json.\n"+ - "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ - "The only JSON in this module is %v — the mint endpoint's HTTP bodies, named by exact\n"+ - "path. If a capability is being JSON-encoded here, that is the second implementation\n"+ - "coming back.", - file.path, allowedJSONFiles())) - } - } + findings = append(findings, inspectImport(file, name, path, plumbing)...) if path == coreModulePath || strings.HasPrefix(path, coreModulePath+"/") { coreImports[name] = true } } + findings = append(findings, inspectPlumbingSymbols(file, plumbing)...) + findings = append(findings, inspectProtoEncoding(file)...) findings = append(findings, inspectDeclarations(file, coreImports)...) findings = append(findings, inspectJSONTags(file)...) return findings } +// inspectImport refuses an import by what it can DO, not by whether its path is +// one of two strings. +func inspectImport(file sourceFile, name string, path string, plumbing map[string]string) []string { + if allowed, ok := tlsPlumbing[path]; ok { + if slices.Contains(allowed.files, file.path) { + plumbing[name] = path + return nil + } + return []string{fmt.Sprintf( + "%s imports %q, which only %v may: it is the mint client's own transport plumbing.", + file.path, path, allowed.files)} + } + banned := slices.Contains(bannedImports, path) + for _, prefix := range bannedImportPrefixes { + banned = banned || strings.HasPrefix(path, prefix) + } + for _, fragment := range bannedImportSubstrings { + banned = banned || strings.Contains(strings.ToLower(path), fragment) + } + if banned { + return []string{fmt.Sprintf( + "%s imports %q.\n"+ + "This module signs nothing, verifies nothing and encodes no capability: core's\n"+ + "workcontext is the only implementation. The ban is on what a package can do rather\n"+ + "than on two package names, because crypto.Signer, x/crypto and protojson each build a\n"+ + "complete second implementation without naming ed25519 anywhere.", + file.path, path)} + } + if path == "encoding/json" { + if _, allowed := jsonAllowlist[file.path]; !allowed { + return []string{fmt.Sprintf( + "%s imports encoding/json.\n"+ + "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ + "The only JSON in this module is %v — the mint endpoint's HTTP bodies, named by exact\n"+ + "path. If a capability is being JSON-encoded here, that is the second implementation\n"+ + "coming back.", + file.path, allowedJSONFiles())} + } + } + return nil +} + +// inspectPlumbingSymbols holds crypto/tls and crypto/x509 to the symbols the +// transport actually needs. An allowed import is not an allowed package: the +// shortest route to a signer that imports no signing primitive is +// x509.ParsePKCS8PrivateKey followed by a crypto.Signer assertion, and the +// first half of that lives in a package this module legitimately imports. +func inspectPlumbingSymbols(file sourceFile, plumbing map[string]string) []string { + if len(plumbing) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok { + return true + } + path, isPlumbing := plumbing[qualifier.Name] + if !isPlumbing { + return true + } + if slices.Contains(tlsPlumbing[path].symbols, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s uses %s.%s. Only %v may be used from %q here.\n"+ + "The import is allowed for the mint client's transport, not as a way into the package:\n"+ + "x509.ParsePKCS8PrivateKey plus a crypto.Signer assertion is a complete signer that\n"+ + "imports no signing primitive at all.", + file.path, qualifier.Name, selector.Sel.Name, tlsPlumbing[path].symbols, path)) + return true + }) + return findings +} + +// inspectProtoEncoding refuses a protobuf MARSHAL outside the one file that +// encodes something which is not a capability. Unmarshal and Clone are not +// restricted: reading a capability the host issued is this module's job, and +// writing one is core's. +func inspectProtoEncoding(file sourceFile) []string { + if _, allowed := protoMarshalAllowlist[file.path]; allowed { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok || qualifier.Name != "proto" { + return true + } + if selector.Sel.Name != "Marshal" && selector.Sel.Name != "MarshalOptions" { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls proto.%s.\n"+ + "Encoding the message is half of a signer: core signs the deterministic protobuf\n"+ + "encoding, so proto.MarshalOptions{Deterministic: true} over a capability is the\n"+ + "deleted implementation with a different import list. Only %v may encode, and only\n"+ + "because what it encodes is %v.", + file.path, selector.Sel.Name, allowedProtoMarshalFiles(), protoMarshalReasons())) + return true + }) + return findings +} + // inspectDeclarations refuses a declaration that reads as a second WorkContext -// surface, ANYWHERE in the file. +// surface, ANYWHERE in the file and WHATEVER ITS CAPITALISATION. +// +// Three things the previous version missed. It walked file.Decls, so a +// declaration inside a function body passed untouched. It accepted any alias at +// all, so `type WorkContextVerifier = mylocal.Verifier` satisfied "it is an +// alias" while aliasing nothing of core's. And it matched a case-sensitive +// "WorkContext" prefix on free functions and types only — so the deleted +// implementation's own type name, `workContextPayload`, passed it, as did a +// method, a `var WorkContextSign = func…` and a const. // -// The earlier version walked file.Decls, so it saw only top-level declarations -// — a `type WorkContextToken struct{...}` inside a function body passed it -// untouched. And it accepted any alias at all: `type WorkContextVerifier = -// mylocal.Verifier` satisfied "it is an alias" while aliasing nothing of -// core's. An alias now has to resolve into core's module. +// This is a heuristic and is NOT the load-bearing half of the gate: a name +// proves nothing, which is why the import, symbol and encoding rules above +// refuse the CAPABILITY to sign or encode whatever anything is called. It is +// kept because it catches the lazy case cheaply, and tightened so that this +// module's own carrier names must be allowlisted with a reason rather than pass +// on capitalisation. func inspectDeclarations(file sourceFile, coreImports map[string]bool) []string { var findings []string + named := func(name string) bool { + if _, allowed := workContextNameAllowlist[name]; allowed { + return false + } + return strings.HasPrefix(strings.ToLower(name), "workcontext") + } + report := func(kind string, name string) { + if !named(name) { + return + } + findings = append(findings, fmt.Sprintf( + "%s declares %s %s.\n"+ + "A WorkContext-named declaration here is how the second implementation looked, and its\n"+ + "own payload type was spelled workContextPayload. The capability's operations and types\n"+ + "are core's; this module names what it DOES to one (Attach, FromHeaders,\n"+ + "SealedInstallation) and re-exports core's for the rest. If this is a legitimate\n"+ + "carrier name, add it to workContextNameAllowlist with the reason.", + file.path, kind, name)) + } ast.Inspect(file.syntax, func(node ast.Node) bool { switch declared := node.(type) { case *ast.FuncDecl: - if declared.Recv == nil && strings.HasPrefix(declared.Name.Name, "WorkContext") { - findings = append(findings, fmt.Sprintf( - "%s declares func %s.\n"+ - "A WorkContext-named function here is how the second implementation looked. The\n"+ - "capability's own operations are core's; this module names what it does to one\n"+ - "(Attach, FromHeaders, SealedInstallation) and re-exports core's for the rest.", - file.path, declared.Name.Name)) + kind := "func" + if declared.Recv != nil { + kind = "method" + } + report(kind, declared.Name.Name) + case *ast.ValueSpec: + for _, name := range declared.Names { + report("var/const", name.Name) } case *ast.TypeSpec: - if !strings.HasPrefix(declared.Name.Name, "WorkContext") { + if !named(declared.Name.Name) { return true } if declared.Assign == 0 { @@ -361,6 +555,98 @@ import "crypto/ed25519" var _ = ed25519.Sign`, says: `imports "crypto/ed25519"`, }, + + // Below: the STANDARD bypasses. Each one is a complete second + // implementation that the previous gate — a list of two package paths + // and a case-sensitive name prefix — reported as green. + "the same primitive from x/crypto": { + path: "carrier.go", + source: `package workcontext +import "golang.org/x/crypto/ed25519" +var _ = ed25519.Sign`, + says: `imports "golang.org/x/crypto/ed25519"`, + }, + "a different algorithm": { + path: "carrier.go", + source: `package workcontext +import "crypto/rsa" +var _ = rsa.SignPKCS1v15`, + says: `imports "crypto/rsa"`, + }, + "a MAC instead of a signature": { + path: "carrier.go", + source: `package workcontext +import "crypto/hmac" +var _ = hmac.New`, + says: `imports "crypto/hmac"`, + }, + "the bare crypto package, whose Signer needs no algorithm import": { + path: "carrier.go", + source: `package workcontext +import "crypto" +func sign(key crypto.Signer, payload []byte) ([]byte, error) { + return key.Sign(nil, payload, crypto.Hash(0)) +}`, + says: `imports "crypto"`, + }, + "a key parsed out of PKCS#8 through an allowed import": { + path: "mint.go", + source: `package workcontext +import "crypto/x509" +func key(der []byte) (any, error) { return x509.ParsePKCS8PrivateKey(der) }`, + says: "uses x509.ParsePKCS8PrivateKey", + }, + "the TLS plumbing imported somewhere that is not the mint client": { + path: "carrier.go", + source: `package workcontext +import "crypto/tls" +var _ = tls.Config{}`, + says: `imports "crypto/tls", which only`, + }, + "a second encoding of the message, in JSON, without encoding/json": { + path: "carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/encoding/protojson" +var _ = protojson.Marshal`, + says: "protobuf/encoding/protojson", + }, + "core's own signing encoding, reproduced": { + path: "carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func encode(claims *Claims) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "calls proto.MarshalOptions", + }, + "a JOSE library": { + path: "carrier.go", + source: `package workcontext +import "github.com/go-jose/go-jose/v4" +var _ = jose.NewSigner`, + says: "go-jose", + }, + "the deleted implementation's own type name, uncapitalised": { + path: "carrier.go", + source: `package workcontext +type workContextPayload struct { + Audience string +}`, + says: "declares type workContextPayload", + }, + "a WorkContext method rather than a free function": { + path: "carrier.go", + source: `package workcontext +type thing struct{} +func (thing) WorkContextSign(payload []byte) []byte { return payload }`, + says: "declares method WorkContextSign", + }, + "a WorkContext func bound to a var": { + path: "carrier.go", + source: `package workcontext +var WorkContextSign = func(payload []byte) []byte { return payload }`, + says: "declares var/const WorkContextSign", + }, } { t.Run(name, func(t *testing.T) { findings := inspectForSecondImplementation(parseSource(t, bypass.path, bypass.source)) @@ -508,6 +794,24 @@ func importsOf(file *ast.File) map[string]string { return paths } +func allowedProtoMarshalFiles() []string { + names := make([]string, 0, len(protoMarshalAllowlist)) + for name := range protoMarshalAllowlist { + names = append(names, name) + } + slices.Sort(names) + return names +} + +func protoMarshalReasons() []string { + reasons := make([]string, 0, len(protoMarshalAllowlist)) + for _, reason := range protoMarshalAllowlist { + reasons = append(reasons, reason) + } + slices.Sort(reasons) + return reasons +} + func allowedJSONFiles() []string { names := make([]string, 0, len(jsonAllowlist)) for name := range jsonAllowlist { diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go new file mode 100644 index 0000000..5dc2a9a --- /dev/null +++ b/workcontext/parse_agreement_test.go @@ -0,0 +1,146 @@ +package workcontext + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// structurallyRefused is every fixture in core's kit whose refusal this module +// MUST reach on its own, because it is a property of the capability's own bytes +// rather than of the issuer's live state or of a signature. +// +// Naming them explicitly is the point. Without this list the test below would +// pass by refusing nothing at all: "no disagreement" is satisfied by an +// inspection that accepts everything, and that is exactly how the actor-epoch +// hole survived — sealOf never read the actor chain, so a capability core +// refuses as unsealed went on the wire. +var structurallyRefused = map[string]bool{ + "missing-seal": true, // ErrUnsealed: carries no seal at all + "actor-without-epoch": true, // ErrUnsealed: a hop nobody can revoke + "seal-without-installation": true, // ErrInvalid: the schema refuses it first + "foreign-encoding": true, // ErrNotACoreToken: another format entirely + "empty-token": true, // ErrInvalid: no envelope + "no-separator": true, // ErrInvalid: no envelope + "separator-only": true, // ErrInvalid: empty segments + "payload-not-b64": true, // ErrInvalid: the envelope does not decode +} + +// TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through +// THIS MODULE's parse paths and requires core's declared sentinel for each one. +// +// The eight above are the ones a capability's own bytes earn, so this module +// must reach them with no network and no key. The other seventeen are +// signature, audience or live-state refusals, which an unverified inspection +// cannot see and must therefore PASS: a structural check that refused them +// would be claiming to have verified something it cannot. +// +// This is the test the conformance test is not. Driving fixtures through +// `&Verifier{}` exercises core, because Verifier is an alias of core's type — +// it is core testing core, and it would have passed unchanged beside the +// implementation this PR deletes. The SDK's own decision paths are +// claimsOf/sealOf, SealedInstallation, FromHeaders and credentialFrom, and that +// is where a divergence from core can actually live. Three did: +// +// - seal-without-installation: core answers ErrInvalid, because protovalidate +// reaches it before the structural check. This module answered ErrUnsealed, +// and its own test pinned that divergence as if it were correct. +// - actor-without-epoch: core answers ErrUnsealed. This module never read the +// actor chain and accepted it. +// - a zero epoch, revision or incarnation: core answers ErrInvalid from the +// schema. This module answered ErrUnsealed. +// +// Each of those is "one condition, two messages" — the fragmentation the +// one-implementation rule exists to end — relocated from the signature to the +// seal. The agreement is now TESTED against core's own declarations rather than +// described in a comment, so core changing its order breaks this test here +// rather than surfacing as a mismatched sentinel in somebody's gateway. +// +// It does not make this module's parser correct by construction. One exported +// unverified inspection entry point in core would; that is asked for in +// core#691 and said plainly in the PR body. +func TestTheSDKParsePathsAgreeWithCore(t *testing.T) { + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + require.NotEmpty(t, fixtures) + + seen := map[string]bool{} + for _, fixture := range fixtures { + seen[fixture.Name] = true + t.Run(fixture.Name, func(t *testing.T) { + _, _, _, sealErr := sealOf(fixture.Token) + + if fixture.Outcome == corework.OutcomeAccepted { + require.NoError(t, sealErr, + "core accepts %q, so this module must not refuse it on its own bytes", fixture.Name) + return + } + + if structurallyRefused[fixture.Name] { + require.Error(t, sealErr, + "%q is refused on its own bytes, so this module must refuse it too:\n"+ + " core's reason: %s", fixture.Name, fixture.Reason) + } + if sealErr == nil { + // A refusal that needs the issuer's live state, a key, or the + // replay store is not this module's to reach. Accepting it here + // is correct, and nothing authorizes from the result. + require.False(t, structurallyRefused[fixture.Name]) + return + } + // But WHEN this module refuses, it must refuse for core's reason. + require.ErrorIs(t, sealErr, fixture.Err, + "this module refuses %q as %v; core declares %v.\n"+ + " core's reason: %s\n"+ + " One condition with two messages is the fragmentation this rule exists to end.", + fixture.Name, sealErr, fixture.Err, fixture.Reason) + }) + } + + for name := range structurallyRefused { + require.True(t, seen[name], + "the list names %q, which is not a fixture in core's kit any more — "+ + "a stale entry is a case nobody is covering", name) + } +} + +// And the agreement holds on every carrier, not only in sealOf: the carriers +// are the reason this module parses at all, so a capability core refuses on its +// own bytes must not reach a request through any of them. +func TestNoCarrierAcceptsWhatCoreRefusesOnItsOwnBytes(t *testing.T) { + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + + for _, fixture := range fixtures { + if !structurallyRefused[fixture.Name] { + continue + } + t.Run(fixture.Name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + attachErr := Attach(request, fixture.Token) + require.ErrorIs(t, attachErr, fixture.Err) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + + _, _, installationErr := SealedInstallation(fixture.Token) + require.ErrorIs(t, installationErr, fixture.Err, + "SealedInstallation is the one function every carrier goes through") + + headers := http.Header{} + headers.Set(HeaderName, fixture.Token) + headers.Set(InstallationIDHeaderName, corework.FixtureInstallation) + headers.Set(InstallationRevisionHeaderName, "1") + _, headerErr := FromHeaders(headers) + require.Error(t, headerErr) + require.True(t, + errors.Is(headerErr, fixture.Err) || errors.Is(headerErr, ErrInvalid), + "FromHeaders refused %q as %v, which is neither core's %v nor a carrier mismatch", + fixture.Name, headerErr, fixture.Err) + }) + } +} diff --git a/workcontext/stream.go b/workcontext/stream.go index 94b0b30..5feacfc 100644 --- a/workcontext/stream.go +++ b/workcontext/stream.go @@ -15,10 +15,16 @@ var ErrStreamTerminated = errors.New("Codefly Work Context stream terminated") // StreamGuardOptions configures one stream's hold on its credential. type StreamGuardOptions struct { - // Recheck re-presents the credential and returns the host's answer. It is - // the same verification an ordinary call performs, against live state read - // at the moment of the check — not against the expectations the stream - // opened with, which is the whole point. + // Recheck re-reads the issuer's live state for a capability that is ALREADY + // verified, and returns the host's answer. + // + // Use core's (*Verifier).Recheck. Do NOT use Verify: it consumes a + // single-use nonce, and every grant capability is single-use, so a stream + // opened with one died with ErrReplayed at its first message. This + // module's own README recommended exactly that for a while. Re-checking + // liveness and spending a nonce are two operations and only one of them + // belongs in a loop; core added Recheck for this, and it takes a *Verified + // so it cannot be anybody's first check. // // Any error terminates the stream, and that includes an error that is // neither ErrRevoked nor ErrInvalid. A live source that cannot be reached @@ -28,6 +34,22 @@ type StreamGuardOptions struct { Recheck func(context.Context) error } +// RecheckWith builds the per-emission check from a verifier and a capability +// that has already been verified, so the right call is the easy one. +// +// It exists because the wrong call compiles and reads fine: Verify is the name +// everybody knows, and a guard built on it works in every test that does not +// use a single-use capability and then kills the first grant-opened stream in +// production. +func RecheckWith(verifier *Verifier, verified *Verified) func(context.Context) error { + return func(ctx context.Context) error { + if verifier == nil || verified == nil { + return fmt.Errorf("%w: a stream re-check needs a verifier and a verified capability", ErrInvalid) + } + return verifier.Recheck(ctx, verified) + } +} + // StreamGuard holds a stream to its credential for the stream's whole life. // // A stream authorized when it opened is not authorized forever. The failure it diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go index b295c3f..218720b 100644 --- a/workcontext/stream_test.go +++ b/workcontext/stream_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" "github.com/stretchr/testify/require" ) @@ -154,3 +156,76 @@ func TestStreamGuardValidatesItsConfiguration(t *testing.T) { require.ErrorIs(t, absent.BeforeSend(context.Background()), ErrInvalid) require.NoError(t, absent.Terminated()) } + +// The per-emission re-check must NOT consume a single-use capability. +// +// This is the bug the README shipped: the documented recipe called +// verifier.Verify on every emission, core's Verify consumes a single-use nonce, +// and every grant capability is single-use — so a grant-opened stream died with +// ErrReplayed at its FIRST message. The error even looked like a replay attack +// rather than like the guard eating its own credential. +// +// The fix is core's (*Verifier).Recheck, which re-reads what can move and never +// touches the replay store. RecheckWith is how this module hands a caller that +// call instead of the one that compiles just as well and breaks later. +func TestTheStreamRecheckDoesNotConsumeASingleUseCapability(t *testing.T) { + settings := conformance.New(time.Now()) + verifier := &Verifier{ + Issuer: settings.Issuer, + Audience: settings.Audience, + Keys: corework.FixtureKeys(), + Revisions: settings.Revisions, + Replay: settings.Replay, + Grants: settings.Grants, + Seals: settings.Seals, + Now: settings.Now, + } + + fixtures, err := corework.Fixtures(settings.Now()) + require.NoError(t, err) + var grant corework.Fixture + for _, candidate := range fixtures { + if candidate.SingleUse { + grant = candidate + break + } + } + require.NotEmpty(t, grant.Token, "core's kit has no single-use fixture to test with") + + // The one legitimate consumption. + verified, err := verifier.Verify(t.Context(), grant.Token) + require.NoError(t, err) + + // Now the stream, built the way this module recommends. + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: RecheckWith(verifier, verified), + }) + require.NoError(t, err) + for message := range 5 { + require.NoError(t, guard.BeforeSend(t.Context()), + "message %d must not be refused as a replay of the stream's own credential", message+1) + } + require.NoError(t, guard.Terminated()) + + // And the capability is still spent exactly once overall: presenting it to + // Verify again is a replay, which is what single-use means. + _, err = verifier.Verify(t.Context(), grant.Token) + require.ErrorIs(t, err, ErrReplayed) + + // Whereas a guard built on Verify — the recipe this replaces — dies on its + // first message. Asserted so the regression is a failing test rather than a + // README nobody re-reads. + consuming, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + _, verifyErr := verifier.Verify(ctx, grant.Token) + return verifyErr + }, + }) + require.NoError(t, err) + require.ErrorIs(t, consuming.BeforeSend(t.Context()), ErrReplayed, + "this is the bug: Verify in a loop spends the nonce the loop depends on") +} + +func TestRecheckWithRefusesIncompleteArguments(t *testing.T) { + require.ErrorIs(t, RecheckWith(nil, nil)(context.Background()), ErrInvalid) +} From 923e9099292864e5bd04c3dd893f28c1b4e49d32 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:09:38 -0400 Subject: [PATCH 09/35] fix(workcontext): freshness before any cached credential, and the dynamic review's reproducers as tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamic review (layer 4) EXECUTED the suite, ran per-test mutations and wrote scratch probes. It reproduced four defects and struck three mutation survivors. Two defects were already closed by the previous commit; two were live. Live, and fixed: obtain() now checks freshness for EVERY caller before handing back the held credential. Refresh answered "somebody already replaced that generation" and returned the replacement without looking at it, so a caller refused on an old credential — which is exactly the caller most likely to have held one for a while — was handed a replacement that had since EXPIRED. The generation check is now an extra reason to mint rather than a reason to skip the check: a credential leaves obtain only if it is not due for renewal, whoever asked. The JSON tag exemption belonged to two identifiers rather than to one file, so any file declaring a type named mintRequest or mintResponse inherited it — extra/payload.go could hold a json-tagged struct enumerating a capability's fields by hand, which is the deleted implementation, with the gate green. It is now a property of mint.go's own two declarations, and the gate additionally requires the tag-allowed file to be the json-import-allowed file, so two lists that could drift are one. Already closed, now with the reviewer's own reproducers committed: The effective-transport defect was real at the reviewed head — a nil Transport was approved as uninspectable and net/http substituted http.DefaultTransport, which any code in the process can replace; with an InsecureSkipVerify transport installed there the projected bearer reached an untrusted TLS endpoint over net.Pipe. The previous commit removed the option entirely, so there is nothing to inspect and nothing to substitute. The reproducer is kept, in the review's own net.Pipe form: it makes http.DefaultTransport hostile and asserts the bearer never arrives, plus that the bound transport is not that one. The carrier-absence bypass was closed in the previous commit; its probe is kept in its own right, because what it demonstrates is the SHAPE of the mistake — a pre-check whose strictness depended on what the caller chose to attach. Mutation survivors, struck and replaced: The two ByViewer tests both passed after agent and organization were removed from the viewer digest, so they were evidence for the principal dimension only and were cited for three. Three tests now vary one dimension at a time — the same agent principal at two agent manifests, one actor in two organizations, one id at two kinds — and the review's own cache-old-viewer-fields mutation kills the first two. TestConcurrentAuthorityReadsNeverDisagree was already deleted in the previous commit for the same reason. Also kept from the review: the hour-long acceptance run it supplied and this suite lacked (32 concurrent first calls plus 1,920 calls across a simulated hour produce one endpoint request), and an in-process redirect containment probe that needs no listener, so it is evidence in a sandbox that denies sockets. Both live fixes mutation-verified: reverting the freshness condition fails with "Refresh handed back a credential that expired at 12:15, and it is 12:30", and reverting the file condition fails with "a namesake mintRequest/mintResponse outside mint.go inherits the exemption". Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/cache_partition_test.go | 93 +++++++ workcontext/layer4_reproducers_test.go | 322 +++++++++++++++++++++++++ workcontext/mint.go | 31 ++- workcontext/minting_test.go | 36 ++- workcontext/one_implementation_test.go | 81 ++++--- 5 files changed, 518 insertions(+), 45 deletions(-) create mode 100644 workcontext/layer4_reproducers_test.go diff --git a/workcontext/cache_partition_test.go b/workcontext/cache_partition_test.go index 202c8b3..45edee8 100644 --- a/workcontext/cache_partition_test.go +++ b/workcontext/cache_partition_test.go @@ -264,3 +264,96 @@ func TestCachePartitionWritesAroundAnApprovalGrant(t *testing.T) { } require.Equal(t, 2, seen, "the kit must have offered one session and one grant capability") } + +// The viewer digest's AGENT and ORGANIZATION dimensions, each on its own. +// +// The dynamic review's mutation removed both from the preimage and +// TestCachePartitionByViewerSeparatesSubjectsSharingAView and +// TestCachePartitionByViewerFollowsTheEffectiveActor both still passed — they +// vary the principal id, so they cover the dimension that was always there and +// say nothing about the two that were added. Two tests that survive a mutation +// are two tests that were not evidence for the guarantee they were cited for. +// +// These vary one field at a time, holding everything else equal, so each +// dimension has a test that fails when it leaves the digest. +func TestCachePartitionByViewerSeparatesTheSameAgentPrincipalAtDifferentAgents(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + // One principal, one kind, one organization, two agent manifests. An agent + // is a distinct caller: two deployments of the same agent principal answer + // to different code, so an answer computed for one must not be served to + // the other. + first, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "agent-principal", kind: "agent", agent: "agent-manifest-a", scopes: scopes, + })) + require.NoError(t, err) + second, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "agent-principal", kind: "agent", agent: "agent-manifest-b", scopes: scopes, + })) + require.NoError(t, err) + + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + "the two hold the same scopes, so they share an authorization view", + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "the agent identity is part of the viewer: same principal, different code", + ) +} + +func TestCachePartitionByViewerSeparatesOneActorActingInTwoOrganizations(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + // One principal, two organizations. Authorization is evaluated per + // organization, so a service principal acting in two of them is two + // viewers — sharing a partition would serve one organization's answer to + // the other, which is the worst shape this digest exists to prevent. + first, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "shared-actor", organization: "organization-a", scopes: scopes, + })) + require.NoError(t, err) + second, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "shared-actor", organization: "organization-b", scopes: scopes, + })) + require.NoError(t, err) + + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "authorization is evaluated per organization, so the organization is part of the viewer", + ) +} + +// And the kind, which travels with the id for the same reason: an id is unique +// within a kind, not across kinds. +func TestCachePartitionByViewerSeparatesTheSameIdAtDifferentKinds(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + asService, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "service", scopes: scopes, + })) + require.NoError(t, err) + asAgent, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "agent", agent: "agent-manifest-a", scopes: scopes, + })) + require.NoError(t, err) + + require.NotEqual(t, + partition(t, asService, ByViewer()).Key, + partition(t, asAgent, ByViewer()).Key, + "an id is unique within a kind, not across kinds", + ) +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go new file mode 100644 index 0000000..2297c4f --- /dev/null +++ b/workcontext/layer4_reproducers_test.go @@ -0,0 +1,322 @@ +package workcontext + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// The reproducers from the dynamic review (layer 4), kept as tests rather than +// described in a reply. +// +// They ran in a sandbox with no OS sockets, which is why they reach for +// net.Pipe and real TLS over it instead of httptest: that exercises Go's actual +// TLS and HTTP machinery without a listener. They are kept in that form on +// purpose — a test that cannot run where the next reviewer runs it is a test +// that stops being evidence. + +// D1. The effective transport, not the inspectable one. +// +// At the reviewed head a nil Transport was approved as "a RoundTripper this +// package cannot inspect", and net/http then substitutes http.DefaultTransport +// — which is a package-level variable any code in the process can replace. With +// an InsecureSkipVerify transport installed there, a default MintClient sent +// the projected bearer to an untrusted TLS endpoint and reported success. The +// review observed `Bearer fixture-bearer` arrive. +// +// It cannot happen now because there is nothing to inspect and nothing to +// substitute: the client BINDS a transport it built. This test holds that, by +// making http.DefaultTransport hostile and asserting the bearer never arrives. +func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { + authority := newAuthority(t) + payload, err := json.Marshal(mintResponse{WorkContext: authority.start(t, mintInput{})}) + require.NoError(t, err) + + public, private := corework.FixtureKeyPair() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"mint.example.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, public, private) + require.NoError(t, err) + serverTLS := &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: private}}, + MinVersion: tls.VersionTLS12, + } + + // An endpoint nothing configured, reachable only through the hostile + // default transport, recording every Authorization header it is shown. + received := make(chan string, 4) + hostile := &http.Transport{ + DisableKeepAlives: true, + //nolint:gosec // the hostile configuration IS the test + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: func(context.Context, string, string) (net.Conn, error) { + caller, endpoint := net.Pipe() + go func() { + defer func() { + _ = endpoint.Close() + }() + conn := tls.Server(endpoint, serverTLS) + request, readErr := http.ReadRequest(bufio.NewReader(conn)) + if readErr != nil { + return + } + received <- request.Header.Get("Authorization") + _, _ = io.Copy(io.Discard, request.Body) + _ = request.Body.Close() + response := &http.Response{ + StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + ContentLength: int64(len(payload)), + Body: io.NopCloser(strings.NewReader(string(payload))), + } + _ = response.Write(conn) + }() + return caller, nil + }, + } + previous := http.DefaultTransport + http.DefaultTransport = hostile + t.Cleanup(func() { + http.DefaultTransport = previous + hostile.CloseIdleConnections() + }) + + client, err := NewMintClient(MintOptions{ + URL: "https://mint.example.test/mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "fixture-bearer")), + ProjectionAudience: "projection-audience", + Now: func() time.Time { return testClock }, + }) + require.NoError(t, err) + + // The mint will fail — mint.example.test resolves nowhere through a + // transport this test does not control, which is exactly the point. What + // matters is that it failed WITHOUT the bearer reaching the endpoint the + // hostile default transport dials. + _, err = client.Credential(t.Context()) + require.Error(t, err) + + select { + case bearer := <-received: + t.Fatalf("the hostile default transport carried the projection: the endpoint was shown %q", bearer) + default: + } + + // And structurally: the client's transport is its own, bound at + // construction, so no later substitution of http.DefaultTransport can + // reach it either. + require.NotSame(t, http.DefaultTransport, client.httpClient.Transport) + bound, ok := client.httpClient.Transport.(*http.Transport) + require.True(t, ok) + require.False(t, bound.TLSClientConfig.InsecureSkipVerify) + require.Nil(t, bound.DialContext) + require.Nil(t, bound.DialTLSContext) +} + +// D2. Omitting both installation carriers used to skip the seal check +// altogether, so FromHeaders reported success for a malformed token, one +// carrying no seal, and one whose actor hop carried no epoch. +// +// The review's probe is kept in its own right rather than folded into the +// carrier tests, because what it demonstrates is the SHAPE of the mistake: a +// pre-check whose strictness depended on what the caller had chosen to attach. +func TestOmittingBothCarriersDoesNotSkipTheSealCheck(t *testing.T) { + for name, token := range map[string]string{ + "malformed": "not-a-token", + "missing seal": fixture(t, "missing-seal").Token, + "actor with noepoch": fixture(t, "actor-without-epoch").Token, + "foreign encoding": fixture(t, "foreign-encoding").Token, + } { + t.Run(name, func(t *testing.T) { + bare := http.Header{} + bare.Set(HeaderName, token) + _, err := FromHeaders(bare) + require.Error(t, err, + "a capability with no carriers beside it is still a capability, and still checked") + }) + } +} + +// D3. Refreshing an old generation returned its replacement even after that +// replacement had expired. +// +// The generation check answered "somebody already replaced that one" and handed +// the replacement over without looking at it. A caller refused on an old +// credential — which is exactly the caller most likely to have been holding one +// for a while — was given an expired one. +func TestRefreshingAnOldGenerationDoesNotHandBackAnExpiredReplacement(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + replaced, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.EqualValues(t, 2, host.requests.Load()) + + // Time passes until the replacement has expired outright. + clock = testClock.Add(30 * time.Minute) + require.True(t, clock.After(replaced.ExpiresAt()), "the replacement has expired") + + // A caller that was refused on the FIRST credential comes back. The + // generation has moved on, so nothing needs replacing on that account — + // but what is held is expired, and an expired credential must not be + // handed to anybody. + current, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.True(t, current.ExpiresAt().After(clock), + "Refresh handed back a credential that expired at %s, and it is %s", + current.ExpiresAt(), clock) + require.NotEqual(t, replaced.Token(), current.Token()) + require.EqualValues(t, 3, host.requests.Load(), "it minted rather than handing over a dead credential") + + // The same for Credential, which is the path that always checked. + clock = testClock.Add(90 * time.Minute) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.True(t, fresh.ExpiresAt().After(clock)) +} + +// D4. A type named mintRequest or mintResponse outside the endpoint's own file +// inherited the JSON exemption, so `extra/payload.go` could hold a json-tagged +// struct enumerating a capability's fields by hand — the deleted +// implementation — with the gate green. +func TestTheGateDoesNotLetMintBodyNamesTravel(t *testing.T) { + source := "package extra\n" + + "type mintRequest struct { Principal string `json:\"principal\"` }\n" + + "type mintResponse struct { Epoch uint64 `json:\"epoch\"` }" + for _, path := range []string{"extra/payload.go", "grpctransport/mint.go", "mint_bodies.go"} { + t.Run(path, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, path, source)) + require.NotEmpty(t, findings, + "a namesake mintRequest/mintResponse outside %s inherits the exemption", mintEndpointJSONFile) + require.Contains(t, strings.Join(findings, "\n"), "struct with json tags") + }) + } + + // And the real file keeps its exemption, so the gate is not green by + // refusing everything. + genuine := "package workcontext\n" + + "type mintRequest struct { Audience string `json:\"audience\"` }\n" + + "type mintResponse struct { WorkContext string `json:\"work_context\"` }" + require.Empty(t, inspectForSecondImplementation(parseSource(t, mintEndpointJSONFile, genuine))) +} + +// The review's own redirect containment probe, which it ran with an in-process +// RoundTripper because the sandbox denied listeners. Kept because it asserts +// containment without needing a socket, which the httptest version cannot. +func TestRedirectContainmentWithoutASocket(t *testing.T) { + var destination atomic.Uint64 + authority := newAuthority(t) + payload, err := json.Marshal(mintResponse{WorkContext: authority.start(t, mintInput{})}) + require.NoError(t, err) + + client, err := NewMintClient(MintOptions{ + URL: "https://mint.example.test/mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: func() time.Time { return testClock }, + }) + require.NoError(t, err) + + // Replace the bound transport's round trip, which only a test in this + // package can do: a 307 to another host, then a 200 at the destination. + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host != "mint.example.test" { + destination.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(payload))), + }, nil + } + return &http.Response{ + StatusCode: http.StatusTemporaryRedirect, Request: request, + Header: http.Header{"Location": []string{"https://elsewhere.example.test/mint"}}, + Body: http.NoBody, + }, nil + }) + + _, err = client.Credential(t.Context()) + require.EqualValues(t, 0, destination.Load(), + "the redirect destination received a request carrying the projection") + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "redirected") +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +// The hour-long acceptance run the review supplied and this suite did not have. +// It passed there (1,952 calls, one mint request) and is kept so the arithmetic +// is a gate rather than a reviewer's one-off. +// +// It establishes ENDPOINT ISSUANCE COUNT and not durable host audit events, +// which is the review's own caveat and remains true. +func TestAnHourOfCallsIncludingConcurrentOnesProducesOneMint(t *testing.T) { + clock := testClock + var clockMu sync.RWMutex + now := func() time.Time { + clockMu.RLock() + defer clockMu.RUnlock() + return clock + } + host := newMintHost(t, now) + host.lifetime = 75 * time.Minute // longer than the run, allowing for the lead + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + // A concurrent first burst. + var waiting sync.WaitGroup + for range 32 { + waiting.Add(1) + go func() { + defer waiting.Done() + _, err := client.Credential(context.Background()) + require.NoError(t, err) + }() + } + waiting.Wait() + + // Then an hour of calls, four per simulated second of a minute. + for minute := range 60 { + clockMu.Lock() + clock = testClock.Add(time.Duration(minute) * time.Minute) + clockMu.Unlock() + for range 32 { + _, err := client.Credential(t.Context()) + require.NoError(t, err) + } + } + + require.EqualValues(t, 1, host.requests.Load(), + "an hour of calls on a credential that outlives the run is ONE endpoint request") + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) +} diff --git a/workcontext/mint.go b/workcontext/mint.go index 425e593..3816ae7 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -447,9 +447,7 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if ctx == nil { return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) } - return c.obtain(ctx, mintReasonRenewal, func(held *Credential) bool { - return held == nil || c.dueForRenewalLocked(*held) - }) + return c.obtain(ctx, mintReasonRenewal, nil) } // Refresh replaces a credential the host has refused — ErrRevoked from a @@ -495,10 +493,12 @@ func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credentia ) } return c.obtain(ctx, mintReasonRefresh, func(held *Credential) bool { - // Somebody else was refused on the same credential and has already - // replaced it. Minting again here would be the second mint for one - // revocation, which is the thing this client exists not to do. - return held != nil && held.generation == refused.generation + // The credential that was refused is still the one being held, so it + // has to be replaced. If it is NOT — somebody else was refused on the + // same credential and has already replaced it — this asks for nothing, + // and obtain's own freshness check decides whether the replacement can + // be handed over as it stands. + return held.generation == refused.generation }) } @@ -511,15 +511,24 @@ const ( mintReasonRefresh ) -// obtain is the one path that mints. needed decides, against the credential -// currently held, whether a new one is wanted; it is called under the lock. +// obtain is the one path that mints. +// +// wanted is an EXTRA reason to mint, asked of the credential currently held and +// called under the lock; nil means "no reason beyond freshness". Freshness +// itself is checked here for every caller, which is the point: Refresh used to +// return the held credential whenever its generation had already moved on, +// without looking at whether that replacement was still usable — so a caller +// refused on an old generation was handed a replacement that had since +// EXPIRED. A credential leaves this function only if it is not due for +// renewal, whoever asked and for whatever reason. func (c *MintClient) obtain( - ctx context.Context, reason mintReason, needed func(held *Credential) bool, + ctx context.Context, reason mintReason, wanted func(held *Credential) bool, ) (Credential, error) { for { c.mu.Lock() held := c.credential - if !needed(held) { + if held != nil && !c.dueForRenewalLocked(*held) && + (wanted == nil || !wanted(held)) { credential := *held c.mu.Unlock() return credential, nil diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index 2bdbe77..669a3e0 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -227,18 +227,40 @@ func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifeti // hop and refuses a hop that carries none. A test that delegates without // recording one fails at the mint, which is the right place for it to fail. func (a *authority) child(t *testing.T, parent string, principal string, scopes []*basev0.WorkScopeV1) string { + t.Helper() + return a.childAs(t, parent, hopInput{principal: principal, scopes: scopes}) +} + +// hopInput names the dimensions of one delegation hop. agent and organization +// are separate fields because they are separate dimensions of the viewer: a +// test that varied only the principal left both of them uncovered, which is +// exactly what the dynamic review's mutation showed. +type hopInput struct { + principal string + kind string + agent string + organization string + scopes []*basev0.WorkScopeV1 +} + +func (a *authority) childAs(t *testing.T, parent string, input hopInput) string { t.Helper() require.NotNil(t, a.seals, "delegation needs a seal source this test can record an actor epoch in") - require.NoError(t, a.seals.PutEpoch(principal, 1)) + require.NoError(t, a.seals.PutEpoch(input.principal, 1)) + if input.kind == "" { + input.kind = "service" + } verified, err := a.verifier(t).Verify(context.Background(), parent) require.NoError(t, err) token, _, err := a.core.Child(context.Background(), verified, corework.ChildInput{ - PrincipalID: principal, - PrincipalKind: "service", - DelegationID: "delegation-" + principal, - GrantedScopes: scopes, - Audience: testAudience, - TTL: 10 * time.Minute, + PrincipalID: input.principal, + PrincipalKind: input.kind, + AgentID: input.agent, + OrganizationID: input.organization, + DelegationID: "delegation-" + input.principal, + GrantedScopes: input.scopes, + Audience: testAudience, + TTL: 10 * time.Minute, }) require.NoError(t, err) return token diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index e596296..25483aa 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -127,11 +127,21 @@ var jsonAllowlist = map[string]string{ } // mintEndpointJSONTypes are the only structs in this module that may carry -// json tags. They are the mint endpoint's two bodies. A payload struct pair for -// a capability — the shape of the implementation this module deleted — is a -// third entry here, which is a failing test rather than a review comment -// somebody might not leave. -var mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} +// json tags, and mintEndpointJSONFile is the only file they may be declared in. +// +// The pair matters. Allowing them by NAME alone let any file declare a type +// called mintRequest or mintResponse and inherit the exemption — so +// `extra/payload.go` could hold a json-tagged `mintRequest` enumerating a +// capability's fields by hand, which is precisely the deleted implementation, +// and the gate would be green. The exemption is a property of one file's two +// declarations, not of two identifiers. +// +// A payload struct pair for a capability is a third entry here, which is a +// failing test rather than a review comment somebody might not leave. +var ( + mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} + mintEndpointJSONFile = "mint.go" +) // coreModulePath is the module whose types this one aliases. An alias has to // resolve into it, or it is a local type wearing core's name. @@ -195,6 +205,9 @@ func TestNoSecondWorkContextImplementation(t *testing.T) { require.True(t, paths[allowed], "the json allowlist names %q, which is not a file in this module", allowed) } + require.Contains(t, jsonAllowlist, mintEndpointJSONFile, + "the file allowed to carry json TAGS must be the file allowed to IMPORT encoding/json; "+ + "two lists that can drift are two lists") } // inspectForSecondImplementation is the whole check over one parsed file, @@ -434,22 +447,11 @@ func aliasesCore(target ast.Expr, coreImports map[string]bool) bool { // borrow the allowance by being declared beside them. func inspectJSONTags(file sourceFile) []string { allowed := map[ast.Node]bool{} - for _, declaration := range file.syntax.Decls { - general, ok := declaration.(*ast.GenDecl) - if !ok { - continue - } - for _, spec := range general.Specs { - typed, ok := spec.(*ast.TypeSpec) - if !ok || !slices.Contains(mintEndpointJSONTypes, typed.Name.Name) { - continue - } - if structure, ok := typed.Type.(*ast.StructType); ok { - allowed[structure] = true - } - } + // Only the mint endpoint's own file may hold them. Elsewhere the names earn + // nothing, so the walk below finds the tags and refuses them. + if file.path == mintEndpointJSONFile { + allowed = allowedMintBodies(file) } - var findings []string ast.Inspect(file.syntax, func(node ast.Node) bool { structure, ok := node.(*ast.StructType) @@ -465,11 +467,13 @@ func inspectJSONTags(file sourceFile) []string { continue } findings = append(findings, fmt.Sprintf( - "%s declares a struct with json tags outside %v.\n"+ - "Only the mint endpoint's bodies are JSON here. A JSON-tagged struct describing a\n"+ - "capability's fields is the second implementation: core signs the deterministic protobuf\n"+ - "encoding, and a field enumerated by hand is a field dropped at mint and absent at verify.", - file.path, mintEndpointJSONTypes)) + "%s declares a struct with json tags. Only %v in %s may.\n"+ + "A JSON-tagged struct describing a capability's fields is the second implementation:\n"+ + "core signs the deterministic protobuf encoding, and a field enumerated by hand is a\n"+ + "field dropped at mint and absent at verify. Naming a type mintRequest somewhere else\n"+ + "does not inherit the endpoint's exemption — the exemption is that file's, not the\n"+ + "identifier's.", + file.path, mintEndpointJSONTypes, mintEndpointJSONFile)) return true } return true @@ -477,6 +481,29 @@ func inspectJSONTags(file sourceFile) []string { return findings } +// allowedMintBodies returns the struct nodes of the mint endpoint's two bodies, +// identified by node so a THIRD json-tagged type declared beside them cannot +// borrow the allowance. +func allowedMintBodies(file sourceFile) map[ast.Node]bool { + allowed := map[ast.Node]bool{} + for _, declaration := range file.syntax.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range general.Specs { + typed, ok := spec.(*ast.TypeSpec) + if !ok || !slices.Contains(mintEndpointJSONTypes, typed.Name.Name) { + continue + } + if structure, ok := typed.Type.(*ast.StructType); ok { + allowed[structure] = true + } + } + } + return allowed +} + // TestTheGateCatchesItsOwnBypasses drives the checks over source written to // slip past them. // @@ -506,7 +533,7 @@ func payload() any { Scopes []string ` + "`json:\"scopes\"`" + ` }{} }`, - says: "json tags outside", + says: "struct with json tags", }, "a json-tagged type declared inside a function": { path: "carrier.go", @@ -517,7 +544,7 @@ func payload() any { } return sealedClaims{} }`, - says: "json tags outside", + says: "struct with json tags", }, "a WorkContext type declared inside a function": { path: "carrier.go", From 130c2159ae45ddc259c090662c8012b122445a9e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:33:38 -0400 Subject: [PATCH 10/35] =?UTF-8?q?refactor(workcontext)!:=20re-pin=20to=20c?= =?UTF-8?q?ore=209f68df85=20=E2=80=94=20ErrUnsealed=20is=20deleted,=20the?= =?UTF-8?q?=20seal=20is=20schema-required,=20and=20the=20lifetime=20ceilin?= =?UTF-8?q?g=20is=20the=20host's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core made WorkContextV1.seal and WorkActorV1.principal_epoch schema-required (epoch gte=1) after both reviews filed the optionality as a backward-compatibility hedge. Three consequences land here. ErrUnsealed is no longer aliased, because it no longer exists. Once the schema requires the seal, protovalidate refuses a missing one inside core's decode before any branch that could produce ErrUnsealed runs — and a sentinel no branch can produce is worse than none, since you write a handler and the handler never runs. Four aliases now: ErrInvalid, ErrNotACoreToken, ErrReplayed, ErrRevoked. Every structural seal defect is ErrInvalid: no seal, a seal naming no installation, a zero epoch, revision or incarnation, a partial binding, an actor hop with no epoch. One sentinel, one branch. This module needed almost no edit for that, which is the argument for reading sentinels off core's kit rather than writing them down: three fixtures changed sentinel and every test that asserts declared.Err stayed as it was. readClaims collapses to nothing of its own. Inspect returns (*Inspected, error) with the claims it already decoded, so there is no second decode here at all — just a proto.Clone, because Inspected.Context hands back core's own pointer. MaxTokenBytes is now a re-export of corework.MaxTokenSize rather than a number of ours that happened to agree. The client-side lifetime ceiling is DELETED, and that is core's cap arriving rather than a check being dropped. A review asked for one because core checked only that a TTL was positive; this carried one, labelled a stopgap whose stated condition was "it goes when core has a cap". Core has Authority.MaxTTL with a DefaultMaxTTL of one hour, so the cap is at the minter, where it binds every client rather than the ones that opted in. That ceiling changes #47's acceptance arithmetic, which is worth stating plainly rather than discovering: one mint across an hour of calls needs a lifetime of at least run/(1-lead) — 75 minutes at the default 0.2 lead — so at core's default one-hour cap, "exactly one mint for an hour's run" is NOT reachable. An hour costs one mint and one renewal. The heartbeat this replaced made 240 requests an hour per surface; two is not one and it is also not 240. Both shapes are now asserted, because a consumer sizing this needs to know which one its host chose. The test-side signer is gone. resealWithout built zero-field and partial-binding capabilities by re-signing with core's fixture key, because core's kit had none and core's minter correctly refuses to mint one. Core added zero-principal-epoch, zero-installation-revision, zero-build-incarnation and partial-operation-binding when asked, so those cases are core's fixtures now and no ed25519.Sign remains in this module's tests — which was the gate's weakest edge, since it exempts test files. Also: WorkActorV1.PrincipalEpoch is a plain uint64, not *uint64, so a missing epoch is no longer representable and core's earlier "copy the pointer, not the getter" advice is obsolete. The structural-refusal list is now EXACT rather than a lower bound — the fixtures this module refuses on their own bytes must equal the twelve named, so a structural fixture core adds lands here as a failing test asking to be acknowledged rather than drifting into or out of coverage. No goldens to re-measure in this repository: nothing here digests or signs anything embedding a WorkContextV1. receipts.RequestDigest takes whatever message a consumer passes, so a consumer embedding a capability must re-measure — core's own executionreceipt broke on exactly that. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 17 ++-- workcontext/README.md | 47 +++++++-- workcontext/cache_partition.go | 8 +- workcontext/carrier.go | 68 ++++++------- workcontext/carrier_test.go | 134 +++++++++---------------- workcontext/core.go | 17 +++- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/layer4_reproducers_test.go | 55 ++++++++-- workcontext/mint.go | 32 ------ workcontext/mint_test.go | 33 +++--- workcontext/minting_test.go | 54 ---------- workcontext/parse_agreement_test.go | 67 ++++++++++--- 13 files changed, 256 insertions(+), 282 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ead3678..d6718fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,13 +139,16 @@ see the skill below. There is no compatibility period: a release line that cannot meet the rule is **retired by the owner**, not exempted. - **A credential is sealed or it is not a credential, and core says what that - means.** Every field of the seal is required at mint and at verify, an - operation binding carries its id, revision and incarnation or none of them, - and **every actor hop carries an epoch** — a hop without one is a principal - nobody can revoke. `Attach` refuses to put an unsealed capability on a - request, and it refuses it with `corework.Inspect`'s answer: this claim was - false for a while precisely because we were answering it ourselves and never - read the actor chain. + means.** The seal and every actor hop's epoch are **schema-required**, so a + missing one is a `protovalidate` refusal inside core's decode; an operation + binding carries its id, revision and incarnation or none of them. `Attach` + refuses an unsealed capability with `corework.Inspect`'s answer — this claim + was false for a while precisely because we answered it ourselves and never + read the actor chain. **There is one sentinel for every structural seal + defect, `ErrInvalid`.** `ErrUnsealed` is deleted: once the schema requires + the seal, no branch can produce it, and a sentinel no branch produces invites + a handler that never runs. Read a refusal's sentinel off core's fixture, never + from a constant here. - **An authority-bearing value is read once.** A principal, binding or audience comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for a pinned name, so a drift is an error rather than a reload — the process has diff --git a/workcontext/README.md b/workcontext/README.md index 9ab1776..db7b22d 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -124,9 +124,21 @@ three are now core's answer rather than a local one: | fixture | core | this module, before | | --- | --- | --- | | `seal-without-installation` | `ErrInvalid` (the schema reaches it first) | `ErrUnsealed`, with a test pinning the divergence | -| `actor-without-epoch` | `ErrUnsealed` | **accepted** — the actor chain was never read, so a principal nobody can revoke went on the wire | +| `actor-without-epoch` | `ErrInvalid` | **accepted** — the actor chain was never read, so a principal nobody can revoke went on the wire | | zero epoch / revision / incarnation, partial binding | `ErrInvalid` | `ErrUnsealed` | +**`ErrUnsealed` no longer exists, and that is the end of this class of defect.** +`WorkContextV1.seal` and `WorkActorV1.principal_epoch` are schema-**required** +now, so `protovalidate` refuses a missing seal or a missing actor epoch inside +core's decode before any branch that could have produced `ErrUnsealed` runs. A +sentinel no branch can produce is worse than none — you write a handler and the +handler never runs — so it is deleted, and **every structural seal defect is +`ErrInvalid`**. One sentinel, one branch. + +This module needed no edit for that change, which is the argument for reading +sentinels off core's kit rather than writing them down: three fixtures changed +sentinel and the tests that assert `declared.Err` did not move. + The fix was not to sync the rule. **`corework.Inspect` now owns every structural decision** — shape, encoding, schema, attenuation, grant shape, a seal naming an installation, an epoch on every actor hop — and this module's @@ -264,9 +276,9 @@ which is why a process that resolved its audience through `ReadAuthority` should pass it. A credential is also checked against **its own window** before it is installed: -an expired or not-yet-valid response, one whose whole lifetime sits inside the -renewal lead, or one valid for longer than `MaxCredentialLifetime`, is refused -rather than held and counted as a mint that worked. The window tested is +an expired or not-yet-valid response, or one whose whole lifetime sits inside +the renewal lead, is refused rather than held and counted as a mint that +worked. The window tested is **`not_before`** — the claim core's verifier tests; testing `issued_at` was testing a different window from the one the credential would be judged against — and the tolerance is `core/workcontext.DefaultSkew`, so the client is not @@ -290,11 +302,25 @@ hold-off bounds `Refresh` too, which the generation check alone does not: a receiver whose live state lags refuses each FRESH credential, and every refusal is a new generation. -`MaxCredentialLifetime` (default 24h) refuses a credential minted for longer -than this process will hold one. **It is a stopgap and labelled as one**: core -has built `Authority.MaxTTL` with a one-hour default, which is the right side of -the wire, and this ceiling goes when that is in a pinned release. A client-side -cap bounds only the clients that implement it. +**The lifetime ceiling is the host's, and there is no client-side one.** A +review asked for a client-side cap because core checked only that a TTL was +positive; this carried one, labelled a stopgap whose condition was "it goes +when core has a cap". Core has one — `Authority.MaxTTL`, defaulting to +`DefaultMaxTTL` of **one hour** — so the cap is at the minter, where it binds +every client rather than the ones that opted in, and a host raising it does so +in configuration a reviewer sees. A second ceiling here at a different number +would be the two-places-one-rule failure this whole change is about. + +**That ceiling changes #47's acceptance arithmetic, so read it before sizing.** +One mint across an hour of calls needs a lifetime of at least +`run / (1 - lead)` — 75 minutes at the default 0.2 lead — so **at core's +default one-hour cap, "exactly one mint for an hour's run" is not reachable**: +an hour of calls costs one mint and one renewal. That is the ceiling working. +The heartbeat this replaced made 240 requests an hour per surface; two is not +one, and it is also not 240. Both shapes are asserted +(`TestAnHourOfCallsCostsOneMintAndOneRenewalAtCoresDefaultCeiling` and +`…WhenTheHostMintsLongerThanTheRun`), because a consumer sizing this needs to +know which one its host has chosen. ### When the host refuses the credential you hold @@ -410,9 +436,8 @@ either import path. | Sentinel | Means | What the caller does | | --- | --- | --- | -| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, a seal naming no installation, a zero epoch/revision/incarnation, a partial binding, a malformed capability, an expired or not-yet-valid window | refuse; 401 | +| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, **no seal at all**, a seal naming no installation, a zero epoch/revision/incarnation, a partial binding, **an actor hop with no epoch**, a malformed capability, an expired or not-yet-valid window | refuse; 401 | | `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, an actor hop's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, **a binding granted to another principal or in another installation**, an installation the principal no longer holds, or the authorization revision moved | mint again, retry once | -| `ErrUnsealed` | the capability carries no seal, or an actor hop carries no epoch — a principal nobody can revoke | refuse; it is not a credential | | `ErrNotACoreToken` | the payload is not this encoding at all — most usefully, a JSON one | refuse, and do **not** report a signature problem | | `ErrReplayed` | a single-use capability was presented twice | refuse; this is the resume contract, not a forgery | diff --git a/workcontext/cache_partition.go b/workcontext/cache_partition.go index 879a722..91e6929 100644 --- a/workcontext/cache_partition.go +++ b/workcontext/cache_partition.go @@ -109,7 +109,13 @@ func DeriveCachePartition( claims := verified.Context() seal := claims.GetSeal() if seal.GetInstallationId() == "" || seal.GetInstallationRevision() == 0 { - return CachePartition{}, fmt.Errorf("%w: a cache partition requires the sealed installation", ErrUnsealed) + // Belt and braces, and ErrInvalid because ErrUnsealed no longer exists: + // the seal and its fields are schema-required now, so a *Verified + // cannot carry a partial one — core refused it before anyone could + // verify it. This stays as a guard against a hand-built Verified + // arriving from a future core, and it is ONE sentinel because there is + // now one branch for every structural seal defect. + return CachePartition{}, fmt.Errorf("%w: a cache partition requires the sealed installation", ErrInvalid) } var settings cachePartitionOptions for _, option := range options { diff --git a/workcontext/carrier.go b/workcontext/carrier.go index 6ca8074..c0c1042 100644 --- a/workcontext/carrier.go +++ b/workcontext/carrier.go @@ -1,7 +1,6 @@ package workcontext import ( - "encoding/base64" "fmt" "net/http" "strconv" @@ -29,8 +28,11 @@ const ( InstallationIDHeaderName = "x-codefly-installation-id" InstallationRevisionHeaderName = "x-codefly-installation-revision" - // MaxTokenBytes bounds a capability this module will carry or read. - MaxTokenBytes = 32 * 1024 + // MaxTokenBytes bounds a capability this module will carry or read. It IS + // core's bound, not a number of ours that happens to agree: this module + // invented one because core declared none, which is one more rule kept in + // sync by hand. Core declares it now, so this is a re-export. + MaxTokenBytes = corework.MaxTokenSize ) // Attach installs a capability on an outbound request: the signed capability @@ -151,48 +153,40 @@ func SealedInstallation(encoded string) (id string, revision string, err error) return seal.InstallationID, strconv.FormatUint(seal.InstallationRevision, 10), nil } -// readClaims decodes a capability's claims AFTER core has judged its structure, -// and applies no rule of its own. +// readClaims asks core what a capability is, and reads the claims core has +// already decoded. There is no second decode and no rule of this module's. // // This is all that is left of a parser that had become a second -// implementation. It used to hand-parse the envelope and then apply its own +// implementation. It hand-parsed the envelope and then applied its own // structural seal rule, and that rule DISAGREED with core's own fixtures: -// seal-without-installation was ErrUnsealed here and ErrInvalid there (core's -// schema reaches it first), a zero epoch, revision or incarnation was -// ErrUnsealed here and ErrInvalid there, and the actor chain was never read at -// all — so core's actor-without-epoch fixture, a principal nobody can revoke, -// was attached and carried. One condition with two messages is the exact -// fragmentation the one-implementation rule exists to end, and it had been -// relocated from the signature to the seal. +// seal-without-installation was ErrUnsealed here and ErrInvalid there, a zero +// epoch, revision or incarnation was ErrUnsealed here and ErrInvalid there, and +// the actor chain was never read at all — so core's actor-without-epoch +// fixture, a principal nobody can revoke, was attached and carried. One +// condition with two messages is the fragmentation the one-implementation rule +// exists to end, and it had been relocated from the signature to the seal. // -// So every structural DECISION is corework.Inspect's, and what remains here is -// reading two values out of a capability core has already said is shaped like a -// sealed capability: the installation, for the pre-check carriers, and the -// window, for the mint client reading the lifetime of a credential the host -// just issued it. Nothing here decides anything, and nothing trusts the result: -// Inspect itself checks no signature — core's own test asserts that a token -// re-signed with a key nobody holds passes it — so nil means "shaped right" and -// never "permitted". +// Core's Inspect is the one answer now. It runs the size bound, the envelope, +// CheckEncoding, proto.Unmarshal, protovalidate — which is where the seal and +// every actor epoch are REQUIRED by the schema — and the structural seal +// check, and Verify runs the same decode, so an early refusal here and core's +// verification cannot disagree about what a token is. // -// The residue shrinks to nothing when core's Inspect returns the claims it has -// already decoded; that is agreed and not yet in a pinned commit. Until then -// this decode runs SECOND, after Inspect, so it can never be the thing that -// answers a question. +// Nothing here trusts the result. Inspect checks NO signature, and core has a +// test asserting that a token re-signed with a key nobody holds passes it, so +// nil means "shaped like a sealed capability" and never "permitted". The +// carriers this fills are a pre-check; the receiver verifies. +// +// The claims are CLONED. Inspected.Context hands back core's own pointer, so a +// caller that mutated what it was given would be mutating core's value. func readClaims(encoded string) (*Claims, error) { - if err := corework.Inspect(encoded); err != nil { + inspected, err := corework.Inspect(encoded) + if err != nil { return nil, err } - payload, _, _ := strings.Cut(encoded, ".") - raw, err := base64.RawURLEncoding.DecodeString(payload) - if err != nil { - // Unreachable: Inspect decoded the same bytes. Returning core's - // sentinel rather than panicking keeps the failure legible if core's - // envelope ever stops being .. - return nil, fmt.Errorf("%w: payload is not base64url: %v", ErrInvalid, err) - } - claims := &Claims{} - if err := proto.Unmarshal(raw, claims); err != nil { - return nil, fmt.Errorf("%w: payload is not a WorkContextV1: %v", ErrInvalid, err) + claims, ok := proto.Clone(inspected.Context()).(*Claims) + if !ok { + return nil, fmt.Errorf("%w: inspected claims are not a WorkContextV1", ErrInvalid) } return claims, nil } diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go index fb63866..3adc758 100644 --- a/workcontext/carrier_test.go +++ b/workcontext/carrier_test.go @@ -50,10 +50,13 @@ func TestAttachCarriesTheCapabilityAndTheSealedInstallation(t *testing.T) { // mismatch for a capability that named no installation at all. // // The sentinel is CORE'S, taken from core's own fixture rather than written -// here. This test used to assert ErrUnsealed for both, which was wrong for -// seal-without-installation — core answers ErrInvalid there, because the schema -// reaches it before the structural check — and pinning the wrong sentinel is -// how the divergence stayed invisible. +// here. This test used to assert ErrUnsealed for all of them, which was wrong +// for seal-without-installation even then — core answered ErrInvalid there, +// because the schema reaches it before the structural check — and pinning a +// sentinel of one's own is how the divergence stayed invisible. It is now +// ErrInvalid for every one of them, because the seal and the actor epoch are +// schema-required and ErrUnsealed is deleted; reading the sentinel off the +// fixture is what made this change a no-op here instead of a rewrite. func TestAttachRefusesAnUnsealedCapability(t *testing.T) { for _, name := range []string{"missing-seal", "seal-without-installation", "actor-without-epoch"} { t.Run(name, func(t *testing.T) { @@ -166,9 +169,12 @@ func TestFromHeadersRequiresExactlyOneCapability(t *testing.T) { // nobody approved, and the field that is missing is the one an attacker would // choose to leave out. // -// The earlier version of this test supplied only complete and absent bindings, -// so deleting the partial-binding rejection would not have failed it. The -// partial cases are what it is for. +// The partial case is CORE'S fixture now. This test used to build it by +// re-signing a minted capability with core's fixture key, because core's kit +// had no such fixture and core's minter correctly refuses to mint one. Core +// added `partial-operation-binding` when asked, so the local signer is gone — +// and an `ed25519.Sign` in a test file was the gate's weakest edge, since the +// gate exempts test files. func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { a := newAuthority(t) _, _, binding, err := sealOf(a.start(t, mintInput{binding: testBinding})) @@ -182,28 +188,17 @@ func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { require.NoError(t, err) require.Nil(t, none, "a session that exercises no binding seals none") - // A binding missing one of its three fields is refused, and so is the whole - // capability: a credential is sealed or it is not a credential. - for _, field := range []string{"Binding.ID", "Binding.Revision", "Binding.Incarnation"} { - t.Run(field, func(t *testing.T) { - token := resealWithout(t, a.start(t, mintInput{binding: testBinding}), field) + // A binding carrying an id and nothing else is refused, with core's own + // sentinel, and never reaches a request on any carrier. + declared := fixture(t, "partial-operation-binding") + _, _, _, err = sealOf(declared.Token) + require.ErrorIs(t, err, declared.Err) - // ErrInvalid, not ErrUnsealed: the schema refuses a zero revision - // or a blank id before the structural check sees it, and the - // sentinel a pre-check reports has to be the one the verifier - // would report. The structural check below it is what catches the - // shapes the schema permits. - _, _, _, err := sealOf(token) - require.ErrorIs(t, err, ErrInvalid) - - // And it never reaches a request, on either transport. - request := httptest.NewRequest(http.MethodGet, "/records", nil) - require.ErrorIs(t, Attach(request, token), ErrInvalid) - require.Empty(t, request.Header.Get(HeaderName)) - _, _, err = SealedInstallation(token) - require.ErrorIs(t, err, ErrInvalid) - }) - } + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, declared.Token), declared.Err) + require.Empty(t, request.Header.Get(HeaderName)) + _, _, err = SealedInstallation(declared.Token) + require.ErrorIs(t, err, declared.Err) } // Every field of the seal is required, and the check is the SAME check for @@ -212,83 +207,48 @@ func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { // fields out of four: a capability with no principal epoch or no build // incarnation was attached and travelled. // -// The sentinel for each is ErrInvalid, and that is not a detail. A zero epoch, -// revision or incarnation is refused by the SCHEMA, which core's verifier runs -// before its structural seal check — so ErrInvalid is what a verifier at the -// far end reports, and a pre-check reporting ErrUnsealed for the same bytes was -// one condition with two messages. TestTheSDKParsePathsAgreeWithCore holds that -// to core's own fixtures rather than to this comment. +// Each case is one of CORE'S fixtures and each sentinel is read off the +// fixture rather than written here. That is what made the schema change a +// no-op for this test: the seal became required, three fixtures changed from +// ErrUnsealed to ErrInvalid, and nothing below had to move. func TestEverySealedFieldIsRequiredOnEveryCarrier(t *testing.T) { - a := newAuthority(t) - for field, says := range map[string]string{ - "PrincipalEpoch": "seal.principal_epoch", - "InstallationID": "seal.installation_id", - "InstallationRevision": "seal.installation_revision", - "BuildIncarnation": "seal.build_incarnation", + for _, name := range []string{ + "missing-seal", + "seal-without-installation", + "zero-principal-epoch", + "zero-installation-revision", + "zero-build-incarnation", } { - t.Run(field, func(t *testing.T) { - token := resealWithout(t, a.start(t, mintInput{}), field) + t.Run(name, func(t *testing.T) { + declared := fixture(t, name) + require.ErrorIs(t, declared.Err, ErrInvalid, + "every structural seal defect is one sentinel now: %s", declared.Reason) - _, _, _, err := sealOf(token) - require.ErrorIs(t, err, ErrInvalid) - require.ErrorContains(t, err, says) + _, _, _, err := sealOf(declared.Token) + require.ErrorIs(t, err, declared.Err) // The outbound HTTP carrier. request := httptest.NewRequest(http.MethodGet, "/records", nil) - err = Attach(request, token) - require.ErrorIs(t, err, ErrInvalid) + require.ErrorIs(t, Attach(request, declared.Token), declared.Err) require.Empty(t, request.Header.Get(HeaderName), "a refused capability must not be left on the request") // The inbound HTTP carrier. Both carriers are stated, because both - // are now required — and the seal check runs whether or not they - // are, which is what finding 7 was about. + // are required — and the seal check runs whether or not they are. headers := http.Header{} - headers.Set(HeaderName, token) - headers.Set(InstallationIDHeaderName, testInstallation) - headers.Set(InstallationRevisionHeaderName, "3") + headers.Set(HeaderName, declared.Token) + headers.Set(InstallationIDHeaderName, corework.FixtureInstallation) + headers.Set(InstallationRevisionHeaderName, "1") _, err = FromHeaders(headers) - require.ErrorIs(t, err, ErrInvalid) + require.ErrorIs(t, err, declared.Err) // And the one function both gRPC directions go through. - _, _, err = SealedInstallation(token) - require.ErrorIs(t, err, ErrInvalid) - require.ErrorContains(t, err, says) + _, _, err = SealedInstallation(declared.Token) + require.ErrorIs(t, err, declared.Err) }) } } -// An actor hop with no epoch is a principal nobody can revoke, and it never -// reaches the wire. -// -// sealOf did not read the actor chain at all, so core's own -// actor-without-epoch fixture — which core refuses as ErrUnsealed — was -// attached, read back and carried by both gRPC directions, while AGENTS.md said -// "Attach refuses to put an unsealed capability on a request". The field is a -// POINTER on the wire: the getter collapses "no epoch" into "epoch 0", so only -// the nil check sees the difference. -func TestAnActorHopWithNoEpochNeverReachesTheWire(t *testing.T) { - declared := fixture(t, "actor-without-epoch") - require.ErrorIs(t, declared.Err, ErrUnsealed, "core's own declaration") - - _, _, _, err := sealOf(declared.Token) - require.ErrorIs(t, err, ErrUnsealed) - require.ErrorContains(t, err, "carries no epoch") - - request := httptest.NewRequest(http.MethodGet, "/records", nil) - require.ErrorIs(t, Attach(request, declared.Token), ErrUnsealed) - require.Empty(t, request.Header.Get(HeaderName)) - - _, _, err = SealedInstallation(declared.Token) - require.ErrorIs(t, err, ErrUnsealed) - - // A delegated capability whose hops DO carry epochs is unaffected: the - // check is the missing field, not the presence of a chain. - delegated := fixture(t, "delegated") - _, _, _, err = sealOf(delegated.Token) - require.NoError(t, err) -} - // An installation carrier stated twice is ambiguous, and ambiguous is refused. // // Header.Get reads the FIRST value, so [sealed-id, something-else] compared diff --git a/workcontext/core.go b/workcontext/core.go index 4ad9468..7418bcc 100644 --- a/workcontext/core.go +++ b/workcontext/core.go @@ -96,11 +96,6 @@ var ( // format is never diagnosed as a bad key. ErrNotACoreToken = corework.ErrNotACoreToken - // ErrUnsealed is a capability carrying no seal, or one naming no - // installation. It does not verify, and this package refuses to put one on - // a request. - ErrUnsealed = corework.ErrUnsealed - // ErrRevoked is every sealed mismatch: principal epoch, installation // revision, build incarnation, binding revision or incarnation, a revoked // binding, or an installation the principal no longer holds — and an @@ -118,6 +113,18 @@ var ( // ErrReplayed is a single-use capability presented twice. ErrReplayed = corework.ErrReplayed + // ErrUnsealed is GONE, and its absence is the point. It was aliased here + // for a while, which is worse than not having it: WorkContextV1.seal and + // WorkActorV1.principal_epoch are now schema-REQUIRED, so protovalidate + // refuses a missing seal or a missing actor epoch inside core's decode + // before any branch that could have produced ErrUnsealed runs. A sentinel + // no branch can produce invites a handler that never executes, and an + // unreachable handler reads as cover. + // + // Every structural seal defect is therefore ErrInvalid: no seal, a seal + // naming no installation, a zero epoch, revision or incarnation, a partial + // binding, an actor hop with no epoch. One sentinel, one branch. + // Core exports three more sentinels that are deliberately NOT aliased here, // on core's own instruction when asked: // diff --git a/workcontext/go.mod b/workcontext/go.mod index dd9b1cc..6cf3324 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09 + github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index f1370ef..09bb7a5 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09 h1:NG3ARD3wrLATOYCEWeGpWIDT29YOi/tigwWbaph4BnQ= -github.com/codefly-dev/core v0.7.2-0.20261002233434-8230f25d9c09/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979 h1:mhxENwTDU52blHN352drZkymRzlHCnPkN9nANH2NTQ4= +github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index 2297c4f..b70e95c 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -275,13 +275,50 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } -// The hour-long acceptance run the review supplied and this suite did not have. -// It passed there (1,952 calls, one mint request) and is kept so the arithmetic -// is a gate rather than a reviewer's one-off. +// The hour-long acceptance run the dynamic review supplied and this suite did +// not have. It passed there (1,952 calls, one mint request) and is kept so the +// arithmetic is a gate rather than a reviewer's one-off. // // It establishes ENDPOINT ISSUANCE COUNT and not durable host audit events, // which is the review's own caveat and remains true. -func TestAnHourOfCallsIncludingConcurrentOnesProducesOneMint(t *testing.T) { +// +// # The arithmetic moved, and #47's acceptance criterion moved with it +// +// Core now caps what an authority will mint: Authority.MaxTTL, defaulting to +// DefaultMaxTTL of one hour. One mint across an hour of calls needs a lifetime +// of at least run/(1-lead) — 75 minutes at the default 0.2 lead — so **at +// core's default cap, "exactly one mint for an hour's run" is NOT reachable**. +// An hour of calls costs one mint and one renewal. +// +// That is the host's ceiling working, not a defect, and the acceptance +// criterion has to be read against it: what the mint-once model guarantees is +// a credential's own clock, not a fixed number. The heartbeat this replaced +// made 240 requests an hour per surface. Two is not one, and it is also not +// 240. +// +// Both shapes are asserted, because a consumer sizing this needs to know which +// one its host has chosen. +func TestAnHourOfCallsCostsOneMintAndOneRenewalAtCoresDefaultCeiling(t *testing.T) { + // A credential as long as the host will mint by default. + requests, counts := hourOfCalls(t, time.Hour, 0) + require.EqualValues(t, 2, requests, + "at a one-hour ceiling and a 0.2 lead, an hour of calls is one mint and one renewal") + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, counts) +} + +// And the one-mint shape, when a host raises its own ceiling deliberately — +// which is where that decision belongs, and where a reviewer sees it. +func TestAnHourOfCallsCostsOneMintWhenTheHostMintsLongerThanTheRun(t *testing.T) { + requests, counts := hourOfCalls(t, 90*time.Minute, 2*time.Hour) + require.EqualValues(t, 1, requests, + "an hour of calls on a credential that outlives the run plus its lead is ONE endpoint request") + require.Equal(t, MintCounts{Mints: 1}, counts) +} + +// hourOfCalls runs 32 concurrent first calls and then 32 calls in each of 60 +// simulated minutes, and reports what the endpoint was asked for. +func hourOfCalls(t *testing.T, lifetime time.Duration, maxTTL time.Duration) (uint64, MintCounts) { + t.Helper() clock := testClock var clockMu sync.RWMutex now := func() time.Time { @@ -290,10 +327,10 @@ func TestAnHourOfCallsIncludingConcurrentOnesProducesOneMint(t *testing.T) { return clock } host := newMintHost(t, now) - host.lifetime = 75 * time.Minute // longer than the run, allowing for the lead + host.lifetime = lifetime + host.authority.core.MaxTTL = maxTTL // zero takes core's DefaultMaxTTL client := newTestMintClient(t, host, projectedFile(t, "projected"), now) - // A concurrent first burst. var waiting sync.WaitGroup for range 32 { waiting.Add(1) @@ -305,7 +342,6 @@ func TestAnHourOfCallsIncludingConcurrentOnesProducesOneMint(t *testing.T) { } waiting.Wait() - // Then an hour of calls, four per simulated second of a minute. for minute := range 60 { clockMu.Lock() clock = testClock.Add(time.Duration(minute) * time.Minute) @@ -315,8 +351,5 @@ func TestAnHourOfCallsIncludingConcurrentOnesProducesOneMint(t *testing.T) { require.NoError(t, err) } } - - require.EqualValues(t, 1, host.requests.Load(), - "an hour of calls on a credential that outlives the run is ONE endpoint request") - require.Equal(t, MintCounts{Mints: 1}, client.Counts()) + return host.requests.Load(), client.Counts() } diff --git a/workcontext/mint.go b/workcontext/mint.go index 3816ae7..eb431f3 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -41,14 +41,6 @@ const ( // leaves room for one request and one retry. minRenewalLeadTime = 5 * time.Second - // defaultMaxCredentialLifetime is the longest credential this client will - // hold. Core checks only that a TTL is positive, so without a ceiling a - // misconfigured host minting a month-long credential is accepted in - // silence. It is generous enough for the lifetimes the mint-once model - // wants — an hour's run needs about 75 minutes at the default lead — and - // short enough that a month is not one of them. - defaultMaxCredentialLifetime = 24 * time.Hour - // Backoff after a mint that failed. A failed renewal used to return an // error to every caller while the held credential still had minutes left, // and each caller then retried serially under the client's lock: one mint @@ -196,16 +188,6 @@ type MintOptions struct { // renewal is attempted. Zero takes the default. RenewalLead float64 - // MaxCredentialLifetime refuses a credential the host minted for longer - // than this. Zero takes defaultMaxCredentialLifetime. - // - // The ceiling is here because there is none at the minter: core checks only - // that the TTL is positive, so a misconfigured host can mint a credential - // valid for a month and every verifier will accept it. A ceiling in the - // client is the weaker half of that fix — it bounds what THIS process will - // hold, not what the host will issue — and the cap belongs at the minter. - MaxCredentialLifetime time.Duration - // Now is the clock, for tests. Now func() time.Time } @@ -352,13 +334,6 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if lead <= 0 || lead >= 1 { return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrMintRefused) } - ceiling := options.MaxCredentialLifetime - if ceiling == 0 { - ceiling = defaultMaxCredentialLifetime - } - if ceiling <= 0 { - return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrMintRefused) - } now := options.Now if now == nil { now = time.Now @@ -367,7 +342,6 @@ func NewMintClient(options MintOptions) (*MintClient, error) { options.Audience = audience options.RequestTimeout = timeout options.RenewalLead = lead - options.MaxCredentialLifetime = ceiling return &MintClient{ options: options, httpClient: mintHTTPClient(options.RootCAs), @@ -831,12 +805,6 @@ func (c *MintClient) checkWindow(credential Credential) error { ErrMintRefused, credential.notBefore, credential.expiresAt, ) } - if lifetime := credential.expiresAt.Sub(credential.notBefore); lifetime > c.options.MaxCredentialLifetime { - return fmt.Errorf( - "%w: the minted credential is valid for %s, and this process holds a credential for at most %s", - ErrMintRefused, lifetime, c.options.MaxCredentialLifetime, - ) - } now := c.now().UTC() if now.Before(credential.notBefore.Add(-corework.DefaultSkew)) { // Not yet valid. A clock this far apart is a configuration error on one diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 6918954..bf4e062 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -557,10 +557,6 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { mintedAt: testClock, mintedFor: 4 * time.Second, sentinel: ErrMintUnavailable, says: "inside the renewal lead", }, - "longer than this process will hold a credential": { - mintedAt: testClock, mintedFor: 30 * 24 * time.Hour, - sentinel: ErrMintRefused, says: "holds a credential for at most", - }, } { t.Run(name, func(t *testing.T) { host := newMintHost(t, now) @@ -587,22 +583,22 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { _, err := client.Credential(t.Context()) require.NoError(t, err, "a credential minted inside core's skew is usable") - // And the ceiling is configurable, because what a process should hold is - // the deployment's call — the point is that there IS one, since core checks - // only that a TTL is positive. - host = newMintHost(t, now) - week := 7 * 24 * time.Hour - host.mintedAt, host.mintedFor = &testClockCopy, &week - client = newTestMintClient(t, host, projectedFile(t, "projected"), now, - func(options *MintOptions) { options.MaxCredentialLifetime = 14 * 24 * time.Hour }) - credential, err := client.Credential(t.Context()) - require.NoError(t, err) - require.Equal(t, testClock.Add(week), credential.ExpiresAt()) + // There is no client-side lifetime ceiling any more, and that is core's + // cap arriving rather than a check being dropped. A review asked for one + // because core checked only that a TTL was positive; this carried one, + // labelled a stopgap with its own condition — it goes when core has a cap. + // Core has one now (Authority.MaxTTL, DefaultMaxTTL of an hour), so a + // credential longer than the host permits is refused where it is MINTED, + // which is the side of the wire that binds every client rather than the + // ones that opted in. + // + // What this test asserts about it is therefore core's constant, not a + // number of ours: a host cannot configure its way past it without a + // reviewer seeing the configuration. + require.Equal(t, time.Hour, corework.DefaultMaxTTL, + "the lifetime ceiling lives at the minter; if core's default moves, the README moves with it") } -// testClockCopy is an addressable testClock, for the host overrides above. -var testClockCopy = testClock - // A refusal the host will give again is not retryable, and an outage is. A // client that confused them would either spin against a permanent refusal or // give up on a transient one. @@ -813,7 +809,6 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { "audience name only": func(o *MintOptions) { o.Audience = AuthorityValue{Name: "platform"} }, "audience key only": func(o *MintOptions) { o.Audience = AuthorityValue{Key: "audience"} }, "no projection": func(o *MintOptions) { o.ProjectedToken = nil }, - "negative lifetime cap": func(o *MintOptions) { o.MaxCredentialLifetime = -time.Hour }, "no projection audience": func(o *MintOptions) { o.ProjectionAudience = "" }, "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index 669a3e0..e4994cd 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -3,14 +3,12 @@ package workcontext import ( "context" "crypto/ed25519" - "encoding/base64" "testing" "time" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" ) // Every capability in this package's tests is minted by core's Authority, @@ -118,58 +116,6 @@ func (noGrants) Grant(context.Context, string) (*corework.Grant, error) { return nil, corework.ErrInvalid } -// resealWithout rewrites a capability core has just minted, blanking exactly -// one field of its seal, and re-signs it with core's fixture key. -// -// It has to be done this way, and it is not a second implementation. Core's -// minter REFUSES to seal a capability to a zero epoch, revision or incarnation -// — protovalidate rejects the message — which is correct of an issuer and means -// a partial seal cannot be obtained by configuring one. Core's own conformance -// kit builds its `missing-seal` fixture by exactly this move, for exactly this -// reason. Nothing here invents a format: the message is core's, the -// deterministic marshal is core's, the key is core's fixture key, so the -// signature is genuine and the rule under test is the one that refuses the -// token. -// -// The durable home for these is core's conformance kit, since "every field of -// the seal is required" is core's rule and not this module's — see the PR -// discussion. -func resealWithout(t *testing.T, token string, field string) string { - t.Helper() - claims, err := readClaims(token) - require.NoError(t, err) - require.NotNil(t, claims.GetSeal()) - switch field { - case "PrincipalEpoch": - claims.Seal.PrincipalEpoch = 0 - case "ActorEpoch": - require.NotEmpty(t, claims.GetActorChain(), "no hop to blank") - claims.ActorChain[len(claims.ActorChain)-1].PrincipalEpoch = nil - case "InstallationID": - claims.Seal.InstallationId = "" - case "InstallationRevision": - claims.Seal.InstallationRevision = 0 - case "BuildIncarnation": - claims.Seal.BuildIncarnation = 0 - case "Binding.ID": - require.NotNil(t, claims.GetOperationBinding()) - claims.OperationBinding.BindingId = "" - case "Binding.Revision": - require.NotNil(t, claims.GetOperationBinding()) - claims.OperationBinding.Revision = 0 - case "Binding.Incarnation": - require.NotNil(t, claims.GetOperationBinding()) - claims.OperationBinding.Incarnation = 0 - default: - t.Fatalf("resealWithout has no case for %q, so the field is not being covered", field) - } - payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(claims) - require.NoError(t, err) - _, private := corework.FixtureKeyPair() - return base64.RawURLEncoding.EncodeToString(payload) + "." + - base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload)) -} - type mintInput struct { tenant string audience string diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go index 5dc2a9a..ad6bfc1 100644 --- a/workcontext/parse_agreement_test.go +++ b/workcontext/parse_agreement_test.go @@ -21,24 +21,39 @@ import ( // hole survived — sealOf never read the actor chain, so a capability core // refuses as unsealed went on the wire. var structurallyRefused = map[string]bool{ - "missing-seal": true, // ErrUnsealed: carries no seal at all - "actor-without-epoch": true, // ErrUnsealed: a hop nobody can revoke - "seal-without-installation": true, // ErrInvalid: the schema refuses it first - "foreign-encoding": true, // ErrNotACoreToken: another format entirely - "empty-token": true, // ErrInvalid: no envelope - "no-separator": true, // ErrInvalid: no envelope - "separator-only": true, // ErrInvalid: empty segments - "payload-not-b64": true, // ErrInvalid: the envelope does not decode + // The envelope. + "empty-token": true, // ErrInvalid: nothing at all + "no-separator": true, // ErrInvalid: not . + "separator-only": true, // ErrInvalid: empty segments + "payload-not-b64": true, // ErrInvalid: the envelope does not decode + "foreign-encoding": true, // ErrNotACoreToken: another format entirely + + // The seal, every part of which the schema requires now. + "missing-seal": true, // ErrInvalid: no seal + "seal-without-installation": true, // ErrInvalid: a seal bound to nothing + "zero-principal-epoch": true, // ErrInvalid: zero is not an epoch + "zero-installation-revision": true, // ErrInvalid: zero is not a revision + "zero-build-incarnation": true, // ErrInvalid: zero is not an incarnation + "partial-operation-binding": true, // ErrInvalid: an id at no revision + "actor-without-epoch": true, // ErrInvalid: a principal nobody can revoke } // TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through // THIS MODULE's parse paths and requires core's declared sentinel for each one. // -// The eight above are the ones a capability's own bytes earn, so this module -// must reach them with no network and no key. The other seventeen are -// signature, audience or live-state refusals, which an unverified inspection -// cannot see and must therefore PASS: a structural check that refused them -// would be claiming to have verified something it cannot. +// The twelve above are the ones a capability's own bytes earn, so this module +// must reach them with no network and no key. Every other fixture — signature, +// issuer, audience, WINDOW and live-state refusals — must PASS: a structural +// check that refused them would be claiming to have verified something it +// cannot see. Expired and not-yet-valid are in that second group, which is +// worth stating because it surprises: Inspect checks no window at all, and the +// mint client's own checkWindow is what refuses a credential that arrives +// unusable. +// +// The list is not derived from Inspect's behaviour, deliberately — that would +// be a tautology. It is written down, and the test requires every name on it to +// be a fixture that exists, so a fixture core renames or retires fails here +// rather than quietly dropping out of coverage. // // This is the test the conformance test is not. Driving fixtures through // `&Verifier{}` exercises core, because Verifier is an alias of core's type — @@ -50,11 +65,18 @@ var structurallyRefused = map[string]bool{ // - seal-without-installation: core answers ErrInvalid, because protovalidate // reaches it before the structural check. This module answered ErrUnsealed, // and its own test pinned that divergence as if it were correct. -// - actor-without-epoch: core answers ErrUnsealed. This module never read the -// actor chain and accepted it. +// - actor-without-epoch: this module never read the actor chain and accepted +// the capability outright, so a principal nobody can revoke went on the +// wire. // - a zero epoch, revision or incarnation: core answers ErrInvalid from the // schema. This module answered ErrUnsealed. // +// Every one of those is ErrInvalid at core today — the seal and each actor +// epoch are schema-required, so protovalidate reaches all of them and +// ErrUnsealed is deleted. This test needed no edit for that, which is the +// argument for reading sentinels off the kit rather than writing them down: +// three of core's fixtures changed sentinel and nothing here had to move. +// // Each of those is "one condition, two messages" — the fragmentation the // one-implementation rule exists to end — relocated from the signature to the // seal. The agreement is now TESTED against core's own declarations rather than @@ -107,6 +129,21 @@ func TestTheSDKParsePathsAgreeWithCore(t *testing.T) { "the list names %q, which is not a fixture in core's kit any more — "+ "a stale entry is a case nobody is covering", name) } + + // The list is EXACT, not a lower bound. A fixture this module refuses + // structurally but nobody listed would otherwise drift into coverage + // silently, and one it stops refusing would drift out — and the second is + // the direction that matters, because it is how the actor-epoch hole + // existed in the first place. Core adding a structural fixture should land + // here as a failing test asking to be acknowledged. + refused := map[string]bool{} + for _, fixture := range fixtures { + if _, _, _, err := sealOf(fixture.Token); err != nil { + refused[fixture.Name] = true + } + } + require.Equal(t, structurallyRefused, refused, + "the fixtures this module refuses on their own bytes are not the ones the list names") } // And the agreement holds on every carrier, not only in sealOf: the carriers From 24a833dc01c643dcecf934cf9f821337ca6da1fd Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:03:42 -0400 Subject: [PATCH 11/35] =?UTF-8?q?fix(workcontext)!:=20only=20an=20outage?= =?UTF-8?q?=20serves=20the=20held=20credential=20=E2=80=94=20a=20refusal?= =?UTF-8?q?=20is=20terminal,=20and=20Refresh=20never=20returns=20what=20wa?= =?UTF-8?q?s=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three found that my own fix for the previous round's finding 6 was a security regression, and it is right. "Serve the held credential rather than manufacture an outage" was applied to EVERY error, so the client turned its own terminal outcomes into silent success — and I adjusted a test to assert that, which is the worst shape a test failure can take. obtain now looks at the error. Only ErrMintUnavailable serves the credential in hand. A refusal — ErrMintRefused, an authority drift from Recheck, the final-URL disclosure check whose own comment says stopping the process is the only safe outcome — is LATCHED and terminal: every later call returns it, and MintClient.Refused() exposes it, because a refusal is the one mint outcome a process must act on by not serving and it cannot act on something it has to infer from whichever call happened to be first. Refresh never serves the credential it was refused on. The sequence the review set out is the likely one: the installation is revoked, the receiver answers ErrRevoked, the caller refreshes, the host refuses for the same reason — and the previous revision returned the revoked credential with a nil error for its whole remaining lifetime, so a caller could not tell "replaced" from "here is the revoked one again". A cancelled leader no longer poisons the single-flight. The shared mint runs on the leader's context; a cancellation there counted as a failure and held off every waiter, so at boot one cancelled first request was an outage for everyone, growing toward a minute with each repetition. A caller giving up is not the host failing. A successful refresh is rate-limited, and a success does not reset it. The failure hold-off never engaged against a lagging receiver, because every refusal was on a credential one generation newer: one mint per call, without limit. The test that claimed to cover this used a 503 host, so no new generation ever appeared — it modelled an outage, and asserted that returning the refused credential was success. The client-side lifetime ceiling is RESTORED. Deleting it because core gained Authority.MaxTTL was a weakening: MaxTTL is configurable with no ceiling of its own, core's own error says "raise Authority.MaxTTL deliberately", and Verify does not bound a lifetime at use — so nothing refused a month-long credential. Two layers enforcing one rule is not the duplication this PR is about; that was two implementations of one decision disagreeing about the answer. Credential.Seal returns the CARRIED seal, cloned. It returned core's live Seal type filled by hand from three wire fields — the same conflation already fixed for OperationBinding, and a field-by-field dependency on core's struct that image_digest would have left silently zero. The gate's own bypasses, again. A dot-import collision hid a signing primitive from both halves: importsOf keyed by local name, so two dot imports collided and whichever came first was never inspected, and the shell regex did not match `. "…"` at all. Imports are now (name, path) pairs, dot imports are refused outright, and the shell regex matches them. encoding/base64 is banned outside one file that encodes a cache key, proto.Unmarshal is banned outright, and protowire and anypb are banned — base64 plus Unmarshal is exactly the hand parser that was the last round's blocker. The proto rule resolves its qualifier from the import map rather than matching the identifier "proto", so an alias no longer passes. Seven more bypasses are in the test, 28 cases now. The required sweep covers the published release lines, and that step FAILS. Running it with no arguments made a green check mean only that the working tree was clean. It now fetches compat/** and sweeps them, and the three refs still carrying the deleted implementation make it red. That is the rule applied: retiring them is a merge precondition for this PR, owner-actionable, not a runbook item. Streams: a StreamServerInterceptor, so enforcement is wiring rather than something each handler author remembers — Guard left the unguarded stream in the handler's scope. SendHeader, SetHeader and SetTrailer are guarded too; metadata is a channel out, and a handler that could not send a message could still send a trailer describing one. Product names are gone from codefly_test.go, fixture_test.go and runtime_environment_file_test.go. "Anywhere" means anywhere, and I had applied it to one word — including, as a later review pointed out, to the tree but not to the commit message naming them, which a COMMIT_MESSAGES squash lands on main. So this message does not name them either. Tests the review doubted, fixed: the drift test asserts the refusal its name always claimed; the lagging-receiver test models a lagging receiver; the FromHeaders assertion drops the `|| ErrInvalid` alternative that made it vacuous for eleven of twelve fixtures; the kind test varies one field so it dies when kind leaves the digest; the hour-long test collects goroutine errors instead of calling require off the test goroutine; the DefaultTransport test asserts ErrMintUnavailable rather than any error. Mutation-verified: serving on every error fails the drift, outage-vs-refusal and cancelled-leader tests; letting a refresh serve the refused credential fails the rate-limit test; removing kind from the viewer digest fails the kind test. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 33 ++ codefly_test.go | 22 +- fixture_test.go | 18 +- runtime_environment_file_test.go | 8 +- scripts/check-one-implementation.sh | 6 +- workcontext/README.md | 46 +-- workcontext/cache_partition_test.go | 14 +- workcontext/carrier.go | 38 +-- workcontext/core.go | 13 + workcontext/doc.go | 2 +- workcontext/grpctransport/guarded_stream.go | 89 +++++- .../grpctransport/guarded_stream_test.go | 125 +++++++- workcontext/layer4_reproducers_test.go | 27 +- workcontext/mint.go | 211 +++++++++++-- workcontext/mint_test.go | 286 +++++++++++++++--- workcontext/one_implementation_test.go | 205 +++++++++++-- workcontext/parse_agreement_test.go | 20 +- 17 files changed, 978 insertions(+), 185 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 356693c..46734da 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -57,9 +57,42 @@ jobs: timeout-minutes: 5 steps: - name: Checkout + # fetch-depth 0 so the compat refs are reachable: the second step reads + # them out of the object database. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Sweep every tracked Go file for a second implementation run: ./scripts/check-one-implementation.sh + - name: Sweep every published release line + # This step is EXPECTED TO FAIL until the owner retires the three refs + # that still carry the deleted implementation, and that failure is the + # point rather than an oversight. + # + # go.yml builds compat/** as published artifacts, and those branches + # carry their own copy of this workflow — so no change here can gate a + # push to one of them. What a change here CAN do is refuse to let main + # move while the rule is false of a ref this repository publishes. + # Under "legacy means delete", retiring them is a merge precondition, + # not a runbook item: the previous revision ran the sweep with no + # arguments, so a green check meant only that the working tree was + # clean, which is the weakest thing it could have meant. + run: | + git fetch --no-tags --quiet origin \ + '+refs/heads/compat/*:refs/remotes/origin/compat/*' \ + '+refs/heads/feat/*:refs/remotes/origin/feat/*' || true + # Collected into an array rather than word-split from a string, so a + # ref name with a space cannot silently become two arguments. + refs=() + while IFS= read -r ref; do + [ -n "$ref" ] && refs+=("$ref") + done < <(git for-each-ref --format='%(refname:short)' \ + 'refs/remotes/origin/compat/*' 'refs/remotes/origin/feat/*compat*') + if [ ${#refs[@]} -eq 0 ]; then + echo "no published release lines to sweep" + exit 0 + fi + ./scripts/check-one-implementation.sh "${refs[@]}" lint: name: lint diff --git a/codefly_test.go b/codefly_test.go index b09d1d6..4548029 100644 --- a/codefly_test.go +++ b/codefly_test.go @@ -99,16 +99,16 @@ func TestEnvironmentVariables(t *testing.T) { func TestServiceSecretPreservesHyphenatedCapabilityNames(t *testing.T) { ctx := context.Background() - t.Setenv("CODEFLY__MODULE", "saas") - t.Setenv("CODEFLY__SERVICE", "accounts") + t.Setenv("CODEFLY__MODULE", "platform") + t.Setenv("CODEFLY__SERVICE", "directory") t.Setenv( - "CODEFLY__SERVICE_SECRET_CONFIGURATION__SAAS__STORE__POSTGRES__READ-ONLY-CONNECTION", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__PLATFORM__STORE__POSTGRES__READ-ONLY-CONNECTION", "postgresql://reader", ) requireNoError(t, codefly.LoadEnvironmentVariables()) value, err := codefly.For(ctx). - Module("saas"). + Module("platform"). Service("store"). Secret("postgres", "read-only-connection") assert.NoError(t, err) @@ -117,16 +117,16 @@ func TestServiceSecretPreservesHyphenatedCapabilityNames(t *testing.T) { func TestServiceSecretAcceptsNormalizedRuntimeCapabilityNames(t *testing.T) { ctx := context.Background() - t.Setenv("CODEFLY__MODULE", "saas") - t.Setenv("CODEFLY__SERVICE", "accounts") + t.Setenv("CODEFLY__MODULE", "platform") + t.Setenv("CODEFLY__SERVICE", "directory") t.Setenv( - "CODEFLY__SERVICE_SECRET_CONFIGURATION__SAAS__STORE__POSTGRES__READ_ONLY_CONNECTION", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__PLATFORM__STORE__POSTGRES__READ_ONLY_CONNECTION", "postgresql://normalized-reader", ) requireNoError(t, codefly.LoadEnvironmentVariables()) value, err := codefly.For(ctx). - Module("saas"). + Module("platform"). Service("store"). Secret("postgres", "read-only-connection") assert.NoError(t, err) @@ -137,19 +137,19 @@ func TestServiceConfigurationReadsRuntimeValuesAddedAfterSnapshot(t *testing.T) ctx := context.Background() requireNoError(t, codefly.LoadEnvironmentVariables()) secretKey := resources.ServiceSecretConfigurationKeyFromUnique( - "saas/store", + "platform/store", "postgres", "read-write-connection", ) configurationKey := resources.ServiceConfigurationKeyFromUnique( - "saas/store", + "platform/store", "postgres", "pool-size", ) t.Setenv(secretKey, "postgresql://writer") t.Setenv(configurationKey, "12") - query := codefly.For(ctx).Module("saas").Service("store") + query := codefly.For(ctx).Module("platform").Service("store") secret, err := query.Secret("postgres", "read-write-connection") assert.NoError(t, err) assert.Equal(t, "postgresql://writer", secret) diff --git a/fixture_test.go b/fixture_test.go index 0494b70..f351fe0 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -13,7 +13,7 @@ import ( const devAdminPackage = `kind: module-package schema: codefly/module-package/v2 -id: codefly/saas-starter +id: example/starter version: 0.1.0 minimum-codefly-version: ">=0.3.32" artifact-roots: @@ -42,9 +42,9 @@ func composingWorkspace(t *testing.T) { t.Helper() root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n - name: plain\n") - writeFile(t, filepath.Join(root, "modules", "saas-starter", composition.PackageManifestFileName), devAdminPackage) - writeFile(t, filepath.Join(root, "modules", "saas-starter", "services", ".keep"), "") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n - name: plain\n") + writeFile(t, filepath.Join(root, "modules", "starter", composition.PackageManifestFileName), devAdminPackage) + writeFile(t, filepath.Join(root, "modules", "starter", "services", ".keep"), "") writeFile(t, filepath.Join(root, "modules", "plain", "services", ".keep"), "") t.Chdir(root) } @@ -94,9 +94,9 @@ func TestFixturePrincipalWithoutSelectedFixture(t *testing.T) { func TestFixturePrincipalResolvesWhenOneDirectoryIsReferencedTwice(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n - name: alias\n path: modules/saas-starter\n") - writeFile(t, filepath.Join(root, "modules", "saas-starter", composition.PackageManifestFileName), devAdminPackage) - writeFile(t, filepath.Join(root, "modules", "saas-starter", "services", ".keep"), "") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n - name: alias\n path: modules/starter\n") + writeFile(t, filepath.Join(root, "modules", "starter", composition.PackageManifestFileName), devAdminPackage) + writeFile(t, filepath.Join(root, "modules", "starter", "services", ".keep"), "") t.Chdir(root) t.Setenv(resources.FixturePrefix, "dev-admin") @@ -109,12 +109,12 @@ func TestFixturePrincipalResolvesWhenOneDirectoryIsReferencedTwice(t *testing.T) func TestFixturePrincipalNamesPinnedModulesItCannotRead(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n source: codefly-dev/module-saas-starter\n version: 0.1.0\n") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n source: example-org/module-starter\n version: 0.1.0\n") t.Chdir(root) t.Setenv(resources.FixturePrefix, "dev-admin") _, err := codefly.Fixture().Principal(t.Context(), "super_admin") require.ErrorIs(t, err, composition.ErrUnknownFixture) - assert.Contains(t, err.Error(), "saas-starter") + assert.Contains(t, err.Error(), "starter") } diff --git a/runtime_environment_file_test.go b/runtime_environment_file_test.go index e16ab5b..949ac37 100644 --- a/runtime_environment_file_test.go +++ b/runtime_environment_file_test.go @@ -12,7 +12,7 @@ import ( func TestLoadRuntimeEnvironmentFilePreservesOpaqueValues(t *testing.T) { path := writeRuntimeEnvironmentFile( t, - "CODEFLY__MODULE=saas\n"+ + "CODEFLY__MODULE=platform\n"+ "CODEFLY__OPAQUE=value with spaces=$HOME;$(touch nope)\n"+ "CODEFLY__MODULE=platform\n", 0o600, @@ -29,7 +29,7 @@ func TestLoadRuntimeEnvironmentFilePreservesOpaqueValues(t *testing.T) { func TestLoadRuntimeEnvironmentFileRejectsMalformedOrForeignEntriesAtomically(t *testing.T) { path := writeRuntimeEnvironmentFile( t, - "CODEFLY__MODULE=saas\nNOT_CODEFLY=must-not-load\n", + "CODEFLY__MODULE=platform\nNOT_CODEFLY=must-not-load\n", 0o600, ) t.Setenv("CODEFLY__MODULE", "unchanged") @@ -40,10 +40,10 @@ func TestLoadRuntimeEnvironmentFileRejectsMalformedOrForeignEntriesAtomically(t } func TestLoadRuntimeEnvironmentFileRejectsUnsafeFile(t *testing.T) { - path := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=saas\n", 0o644) + path := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=platform\n", 0o644) require.Error(t, codefly.LoadRuntimeEnvironmentFile(path)) - target := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=saas\n", 0o600) + target := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=platform\n", 0o600) link := filepath.Join(t.TempDir(), "runtime.env") require.NoError(t, os.Symlink(target, link)) require.Error(t, codefly.LoadRuntimeEnvironmentFile(link)) diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 5cd9eec..118b70a 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -43,7 +43,7 @@ primitives='"crypto"|crypto/ed25519|crypto/ecdsa|crypto/rsa|crypto/dsa|crypto/hm # Second encodings of the message. protojson is a complete JSON encoding of a # protobuf message on its own, which is how the deleted implementation's payload # would come back without an encoding/json import. -encoders='encoding/protojson|encoding/gob|encoding/asn1|encoding/xml' +encoders='encoding/protojson|encoding/protowire|known/anypb|encoding/gob|encoding/asn1|encoding/xml' status=0 # allowed — the exceptions, each with a reason in the comment. @@ -96,7 +96,7 @@ carrying_in_tree() { *.go) ;; *) continue ;; esac - grep -E '^\s*(import\s+)?(_\s+|[A-Za-z0-9_]+\s+)?"' "$path" > "$tmp" 2>/dev/null || : > "$tmp" + grep -E '^\s*(import\s+)?([._]\s+|[A-Za-z0-9_]+\s+)?"' "$path" > "$tmp" 2>/dev/null || : > "$tmp" local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then @@ -117,7 +117,7 @@ carrying_in_ref() { *) continue ;; esac git cat-file blob "$ref:$path" 2>/dev/null | - grep -E '^\s*(import\s+)?(_\s+|[A-Za-z0-9_]+\s+)?"' > "$tmp" 2>/dev/null || : > "$tmp" + grep -E '^\s*(import\s+)?([._]\s+|[A-Za-z0-9_]+\s+)?"' > "$tmp" 2>/dev/null || : > "$tmp" local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then diff --git a/workcontext/README.md b/workcontext/README.md index db7b22d..3afa23f 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -143,8 +143,8 @@ The fix was not to sync the rule. **`corework.Inspect` now owns every structural decision** — shape, encoding, schema, attenuation, grant shape, a seal naming an installation, an epoch on every actor hop — and this module's `readClaims` runs after it, reads two values, and decides nothing. Core added -`Inspect` for this, when asked; the eight structural refusals must reach core's -sentinel here, and the other seventeen must **pass**, because an unverified +`Inspect` for this, when asked; the twelve structural refusals must reach core's +sentinel here, and every other fixture must **pass**, because an unverified inspection that refused a signature or a live-state failure would be claiming to have verified something it cannot see. `Inspect` checks no signature at all — core's own test asserts a token re-signed with a key nobody holds passes it — so @@ -153,16 +153,19 @@ nil means *shaped right*, never *permitted*. **And one sweep, because the test above walks from this module's root.** The implementation this repository deleted lived at the REPOSITORY root, in package `codefly` — exactly where that walk does not reach. -`scripts/check-one-implementation.sh` (the `one implementation` workflow) sweeps -every tracked Go file in both modules, and it is a **required pull-request check -that is green**. There is no compatibility period in it and no countdown: a -second implementation on a ref this repository builds is a failure to fix. - -Refs outside this repository's build that still carry the deleted -implementation are the cold-cutover runbook's business, and that runbook lives -outside this repository — this repository holds the rule, not a consumer -inventory. The script takes refs as arguments for whoever is doing that work -(`scripts/check-one-implementation.sh origin/some-ref`), which CI does not run. +`scripts/check-one-implementation.sh` sweeps every tracked Go file in both +modules, as a job in `go.yml` — the workflow that builds every ref this +repository publishes — and it is **required on `main` by an active ruleset**. + +It sweeps the **published release lines too**, and that step **fails today**. +Three refs (`compat/v0.1.65`, `compat/v0.1.65-tls`, `feat/file-carriers-compat`) +still carry the deleted implementation. Running the sweep with no arguments made +a green check mean only that the working tree was clean, which is the weakest +thing it could have meant; passing it the refs makes the rule true of the +repository or red. **Retiring those refs is a merge precondition**, not a +runbook item: under "legacy means delete" a release line that cannot meet the +rule is deleted by the owner, and `main` does not move while the rule is false +of something this repository publishes. ## The model: mint once, sealed, verified exactly @@ -648,14 +651,17 @@ else. The sealed fields are proto fields — `seal.principal_epoch`, `operation_binding.binding_id` / `.revision` / `.incarnation`, all three together or none. -This module reads a capability's seal and lifetime in one place (`carrier.go`, -`claimsOf`): core's `CheckEncoding` on the decoded payload, then -`proto.Unmarshal` into core's generated type, and **no signature check**. That -is sound only because nothing trusts the result — it fills the pre-check -carriers, and it tells the mint client when its own freshly issued credential -expires. A receiver that preferred a carrier over the sealed claim would have -made a caller-controlled header into authority, which is why the incoming side -refuses a disagreement instead. +This module no longer reads a capability itself. `corework.Inspect` runs the +size bound, the envelope, `CheckEncoding`, `proto.Unmarshal`, **protovalidate** +and the full structural seal check, and returns the claims it decoded; +`readClaims` (`carrier.go`) calls it and clones the result. There is no second +decode, no local rule, and **no signature check** — core's own test asserts a +token re-signed with a key nobody holds passes `Inspect`, so nil means "shaped +like a sealed capability" and never "permitted". That is sound only because +nothing trusts it: it fills the pre-check carriers and tells the mint client +when its own freshly issued credential expires, and a receiver that preferred a +carrier over the sealed claim would have made a caller-controlled header into +authority, which is why the incoming side refuses a disagreement instead. `CheckEncoding` is core's and is called rather than reimplemented, so a token in another format is named `ErrNotACoreToken` **here too**. Two decoders giving an diff --git a/workcontext/cache_partition_test.go b/workcontext/cache_partition_test.go index 45edee8..7bcbd85 100644 --- a/workcontext/cache_partition_test.go +++ b/workcontext/cache_partition_test.go @@ -342,18 +342,22 @@ func TestCachePartitionByViewerSeparatesTheSameIdAtDifferentKinds(t *testing.T) scopes := []*basev0.WorkScopeV1{scope("document", "read")} parent := a.start(t, mintInput{scopes: scopes}) - asService, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ - principal: "ambiguous-id", kind: "service", scopes: scopes, + // ONE field varies. The earlier version changed kind AND agent together, + // so it survived removing kind from the digest — the exact defect the + // dynamic review's mutation found in the other two viewer tests, repeated + // in their replacement. + asHuman, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "human", scopes: scopes, })) require.NoError(t, err) - asAgent, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ - principal: "ambiguous-id", kind: "agent", agent: "agent-manifest-a", scopes: scopes, + asService, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "service", scopes: scopes, })) require.NoError(t, err) require.NotEqual(t, + partition(t, asHuman, ByViewer()).Key, partition(t, asService, ByViewer()).Key, - partition(t, asAgent, ByViewer()).Key, "an id is unique within a kind, not across kinds", ) } diff --git a/workcontext/carrier.go b/workcontext/carrier.go index c0c1042..94c1a72 100644 --- a/workcontext/carrier.go +++ b/workcontext/carrier.go @@ -150,7 +150,7 @@ func SealedInstallation(encoded string) (id string, revision string, err error) if err != nil { return "", "", err } - return seal.InstallationID, strconv.FormatUint(seal.InstallationRevision, 10), nil + return seal.GetInstallationId(), strconv.FormatUint(seal.GetInstallationRevision(), 10), nil } // readClaims asks core what a capability is, and reads the claims core has @@ -192,27 +192,29 @@ func readClaims(encoded string) (*Claims, error) { } // sealOf reads the seal and the sealed operation binding of a capability whose -// structure core has approved. +// structure core has approved. Both come back as core's own WIRE messages, +// already cloned by readClaims. // -// It applies no rule. Every refusal a capability's own bytes can earn — -// another encoding, a bad envelope, a schema violation, a seal naming no -// installation, a zero epoch, revision or incarnation, a partial binding, a -// widening hop, an actor hop with no epoch — is corework.Inspect's answer, with -// corework's sentinel. TestTheSDKParsePathsAgreeWithCore drives every fixture -// in core's kit through this path and requires core's declared sentinel for -// each, so the agreement is tested rather than described. -func sealOf(encoded string) (*Claims, Seal, *SealedOperationBinding, error) { +// It applies no rule and copies no field. It used to build core's LIVE Seal +// type by hand from three wire fields, which is the same conflation this +// module fixed for OperationBinding and left in place for the seal: a client +// cannot answer what the issuer holds, and a field core adds to the seal — +// image_digest is next — would have been silently zero in a value that reads +// as the issuer's. +// +// Every refusal a capability's own bytes can earn — another encoding, a bad +// envelope, a schema violation, no seal, a seal naming no installation, a zero +// epoch, revision or incarnation, a partial binding, a widening hop, an actor +// hop with no epoch — is corework.Inspect's answer, with corework's sentinel. +// TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through +// this path and requires core's declared sentinel for each, so the agreement is +// tested rather than described. +func sealOf(encoded string) (*Claims, *SealedValues, *SealedOperationBinding, error) { claims, err := readClaims(encoded) if err != nil { - return nil, Seal{}, nil, err - } - sealed := claims.GetSeal() - seal := Seal{ - InstallationID: sealed.GetInstallationId(), - InstallationRevision: sealed.GetInstallationRevision(), - BuildIncarnation: sealed.GetBuildIncarnation(), + return nil, nil, nil, err } - return claims, seal, cloneSealedBinding(claims.GetOperationBinding()), nil + return claims, claims.GetSeal(), claims.GetOperationBinding(), nil } // cloneSealedBinding copies the message, so a caller that mutates what it is diff --git a/workcontext/core.go b/workcontext/core.go index 7418bcc..c8fd4ad 100644 --- a/workcontext/core.go +++ b/workcontext/core.go @@ -50,6 +50,19 @@ type OperationBinding = corework.OperationBinding // message for that, so there is no second spelling of what travels. type SealedOperationBinding = basev0.WorkOperationBindingV1 +// SealedValues is the SEAL a capability carries, as core's own message. +// +// It is a different type from Seal for the same reason SealedOperationBinding +// is a different type from OperationBinding: Seal is the LIVE binding as the +// issuer holds it, and a client filling it by hand from three wire fields is +// claiming to answer a question only the issuer can. That hand-copy was also a +// field-by-field dependency on core's struct — core adding a required +// seal field (image_digest is next) would have left it silently zero, which is +// the hazard this module already hit once with OperationBinding's PrincipalID. +// +// Credential.Seal returns one of these, cloned from what core decoded. +type SealedValues = basev0.WorkSealV1 + // SealSource answers the live values a seal is compared against. type SealSource = corework.SealSource diff --git a/workcontext/doc.go b/workcontext/doc.go index b6bf16d..4e9cc4b 100644 --- a/workcontext/doc.go +++ b/workcontext/doc.go @@ -16,7 +16,7 @@ // core signed the deterministic protobuf encoding of the same message. Both // forms are "." with an Ed25519 // signature, so a token from either looked well-formed to the other and then -// failed SIGNATURE verification — a message that reads like a rotated key or a +// failed to resolve a key id read out of a payload in the other format — a message that reads like a rotated key or a // wrong trust root, and sends everyone to look at keys. The gate test in this // package is what stops it coming back; see README.md, "One implementation, and // the gate that keeps it that way". diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index cd805c1..d7496bd 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -1,9 +1,11 @@ package grpctransport import ( + "context" "fmt" "google.golang.org/grpc" + "google.golang.org/grpc/metadata" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -40,6 +42,40 @@ func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedSe return &GuardedServerStream{ServerStream: stream, guard: guard}, nil } +// SendHeader, SetHeader and SetTrailer are guarded too. They were not, and +// metadata is a channel out: a handler that could not send a message could +// still send a trailer describing it. Everything that leaves goes through the +// re-check. +func (s *GuardedServerStream) SendHeader(metadata metadata.MD) error { + if err := s.guarded(); err != nil { + return err + } + return s.ServerStream.SendHeader(metadata) +} + +func (s *GuardedServerStream) SetHeader(metadata metadata.MD) error { + if err := s.guarded(); err != nil { + return err + } + return s.ServerStream.SetHeader(metadata) +} + +// SetTrailer has no error to return, so a refusal can only be withheld: a +// terminated stream sets no trailer rather than setting one nobody checked. +func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { + if s.guarded() != nil { + return + } + s.ServerStream.SetTrailer(trailer) +} + +func (s *GuardedServerStream) guarded() error { + if s == nil || s.guard == nil { + return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + } + return s.guard.BeforeSend(s.Context()) +} + // SendMsg re-checks, then sends. A refusal is returned and the message is NOT // sent: the whole point is that a message computed under withdrawn authority // does not leave. @@ -49,10 +85,7 @@ func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedSe // that loops past the first refusal keeps being refused rather than getting a // second chance to emit. func (s *GuardedServerStream) SendMsg(message any) error { - if s == nil || s.guard == nil { - return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) - } - if err := s.guard.BeforeSend(s.Context()); err != nil { + if err := s.guarded(); err != nil { return err } return s.ServerStream.SendMsg(message) @@ -65,3 +98,51 @@ func (s *GuardedServerStream) Terminated() error { } return s.guard.Terminated() } + +// StreamServerInterceptor guards every stream a server opens, so enforcement is +// WIRING rather than something each handler author remembers. +// +// Guard on its own leaves the original stream in the handler's scope, so the +// rule held wherever somebody thought of it — the same shape as the optional +// carrier this module deleted. An interceptor is installed once, at the server, +// and the handler receives a stream it cannot write around because the +// unguarded one never reaches it. +// +// guardFor builds the guard for one stream, from whatever the server +// established when the stream opened: typically the capability it verified, so +// the re-check is workcontext.RecheckWith(verifier, verified). Returning a nil +// guard with a nil error means this stream carries no capability and needs +// none; returning an error refuses the stream before the handler runs, which is +// the right outcome for a capability-bearing method whose capability did not +// verify. +func StreamServerInterceptor( + guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), +) grpc.StreamServerInterceptor { + return func( + server any, + stream grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, + ) error { + if guardFor == nil { + return fmt.Errorf( + "%w: a stream interceptor needs a guard for each stream", + workcontext.ErrInvalid, + ) + } + guard, err := guardFor(stream.Context(), info) + if err != nil { + return err + } + if guard == nil { + // Explicitly unguarded, by the server's own decision, at the + // server rather than inside a handler. + return handler(server, stream) + } + guarded, err := Guard(stream, guard) + if err != nil { + return err + } + return handler(server, guarded) + } +} diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go index 856025b..4dac34b 100644 --- a/workcontext/grpctransport/guarded_stream_test.go +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -16,8 +16,10 @@ import ( // can assert that a message did NOT leave. type recordingStream struct { grpc.ServerStream - ctx context.Context - sent []any + ctx context.Context + sent []any + headers int + trailers int } func (s *recordingStream) Context() context.Context { return s.ctx } @@ -27,10 +29,18 @@ func (s *recordingStream) SendMsg(message any) error { return nil } -func (s *recordingStream) SetHeader(metadata.MD) error { return nil } -func (s *recordingStream) SendHeader(metadata.MD) error { return nil } -func (s *recordingStream) SetTrailer(metadata.MD) {} -func (s *recordingStream) RecvMsg(any) error { return nil } +func (s *recordingStream) SetHeader(metadata.MD) error { + s.headers++ + return nil +} + +func (s *recordingStream) SendHeader(metadata.MD) error { + s.headers++ + return nil +} + +func (s *recordingStream) SetTrailer(metadata.MD) { s.trailers++ } +func (s *recordingStream) RecvMsg(any) error { return nil } // A guarded stream re-checks before EVERY message, and a refused message does // not leave. @@ -123,3 +133,106 @@ func TestGuardRefusesToWrapNothing(t *testing.T) { require.ErrorIs(t, absent.SendMsg("x"), workcontext.ErrInvalid) require.NoError(t, absent.Terminated()) } + +// Metadata is a channel out too. SendHeader, SetHeader and SetTrailer were +// unguarded, so a handler that could not send a message could still send a +// trailer describing one. +func TestAGuardedStreamGuardsMetadataAsWellAsMessages(t *testing.T) { + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + require.NoError(t, guarded.SetHeader(metadata.Pairs("a", "1"))) + require.NoError(t, guarded.SendHeader(metadata.Pairs("b", "2"))) + guarded.SetTrailer(metadata.Pairs("c", "3")) + require.Equal(t, 3, underlying.trailers+underlying.headers) + + refuse = true + require.ErrorIs(t, guarded.SetHeader(metadata.Pairs("d", "4")), workcontext.ErrStreamTerminated) + require.ErrorIs(t, guarded.SendHeader(metadata.Pairs("e", "5")), workcontext.ErrStreamTerminated) + before := underlying.trailers + guarded.SetTrailer(metadata.Pairs("f", "6")) + require.Equal(t, before, underlying.trailers, + "a terminated stream sets no trailer rather than setting one nobody checked") +} + +// The interceptor is the half a wrapper cannot provide: enforcement as wiring. +// +// Guard leaves the original stream in the handler's scope, so "guard your +// stream" was advice. Installed at the server, the handler receives a stream it +// cannot write around, because the unguarded one never reaches it. +func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + interceptor := StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }) + + var handed grpc.ServerStream + err = interceptor(nil, underlying, &grpc.StreamServerInfo{FullMethod: "/x/Y"}, + func(_ any, stream grpc.ServerStream) error { + handed = stream + require.NoError(t, stream.SendMsg("first")) + refuse = true + return stream.SendMsg("the one that must not leave") + }) + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.IsType(t, &GuardedServerStream{}, handed, + "the handler must not be able to receive the unguarded stream") + require.Len(t, underlying.sent, 1) +} + +// A server may declare a stream unguarded, but it does so AT THE SERVER where +// a reviewer sees it, not inside a handler. +func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { + underlying := &recordingStream{ctx: context.Background()} + refused := errors.New("this capability did not verify") + + err := StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return nil, refused + })(nil, underlying, &grpc.StreamServerInfo{}, func(any, grpc.ServerStream) error { + t.Fatal("the handler must not run when the stream could not be guarded") + return nil + }) + require.ErrorIs(t, err, refused) + + // Explicitly unguarded: allowed, and the handler gets the raw stream. + var handed grpc.ServerStream + err = StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return nil, nil + })(nil, underlying, &grpc.StreamServerInfo{}, func(_ any, stream grpc.ServerStream) error { + handed = stream + return nil + }) + require.NoError(t, err) + require.Same(t, underlying, handed) + + // And no guard-builder at all is a configuration error, not a pass. + err = StreamServerInterceptor(nil)(nil, underlying, &grpc.StreamServerInfo{}, + func(any, grpc.ServerStream) error { return nil }) + require.ErrorIs(t, err, workcontext.ErrInvalid) +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index b70e95c..fb8cdc6 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -112,12 +112,16 @@ func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { }) require.NoError(t, err) - // The mint will fail — mint.example.test resolves nowhere through a - // transport this test does not control, which is exactly the point. What - // matters is that it failed WITHOUT the bearer reaching the endpoint the - // hostile default transport dials. + // The mint fails because mint.example.test resolves nowhere through the + // transport the CLIENT built — and it fails as an OUTAGE, which is the + // assertion that distinguishes "the client used its own transport and + // could not reach the host" from "the client used the hostile one and got + // an answer". Accepting any error would also have accepted success + // followed by some later failure. _, err = client.Credential(t.Context()) - require.Error(t, err) + require.ErrorIs(t, err, ErrMintUnavailable) + require.Equal(t, MintCounts{}, client.Counts(), + "nothing was minted, so nothing was counted") select { case bearer := <-received: @@ -331,16 +335,25 @@ func hourOfCalls(t *testing.T, lifetime time.Duration, maxTTL time.Duration) (ui host.authority.core.MaxTTL = maxTTL // zero takes core's DefaultMaxTTL client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + // Errors are collected and asserted on the TEST goroutine: require inside a + // goroutine calls FailNow off it, which Go's testing package does not + // support and which can leave the run reporting a pass. var waiting sync.WaitGroup + failures := make(chan error, 32) for range 32 { waiting.Add(1) go func() { defer waiting.Done() - _, err := client.Credential(context.Background()) - require.NoError(t, err) + if _, err := client.Credential(context.Background()); err != nil { + failures <- err + } }() } waiting.Wait() + close(failures) + for err := range failures { + require.NoError(t, err, "a concurrent first call must not fail") + } for minute := range 60 { clockMu.Lock() diff --git a/workcontext/mint.go b/workcontext/mint.go index eb431f3..b7f67dd 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -19,6 +19,7 @@ import ( "time" corework "github.com/codefly-dev/core/workcontext" + "google.golang.org/protobuf/proto" ) // Bounds on everything the mint endpoint can hand back. A credential is the @@ -41,6 +42,24 @@ const ( // leaves room for one request and one retry. minRenewalLeadTime = 5 * time.Second + // defaultMaxCredentialLifetime is the longest credential this client will + // hold, and it is DEFENCE IN DEPTH rather than the cap. + // + // The cap is core's Authority.MaxTTL, which defaults to an hour. This was + // deleted on the strength of that — and deleting it was a weakening, which + // a review was right to call: MaxTTL is configurable with no ceiling of its + // own ("raise Authority.MaxTTL deliberately", core's own error says), and + // core's Verify does not bound a lifetime at all. So a host minting + // month-long credentials is refused by nothing, and a client that trusted + // the minter's default would hold one. + // + // Two checks at two layers for one rule is not the duplication this PR is + // about: that was two IMPLEMENTATIONS of one decision disagreeing about the + // answer. This is one decision — "nothing holds a credential for longer + // than this" — enforced where it is minted and again where it is held, and + // the second one failing is a signal about the first. + defaultMaxCredentialLifetime = 24 * time.Hour + // Backoff after a mint that failed. A failed renewal used to return an // error to every caller while the held credential still had minutes left, // and each caller then retried serially under the client's lock: one mint @@ -188,6 +207,18 @@ type MintOptions struct { // renewal is attempted. Zero takes the default. RenewalLead float64 + // MaxCredentialLifetime refuses a credential the host minted for longer + // than this process will hold one. Zero takes + // defaultMaxCredentialLifetime. + // + // Core caps what an authority will mint (Authority.MaxTTL, one hour by + // default), so in a correctly configured deployment this never fires. It is + // here for the one that is not: MaxTTL is configurable with no ceiling and + // nothing verifies a lifetime at use, so this is the only thing standing + // between a misconfigured host and a process holding a month-long + // credential. + MaxCredentialLifetime time.Duration + // Now is the clock, for tests. Now func() time.Time } @@ -197,7 +228,7 @@ type MintOptions struct { // after the host has refused it. type Credential struct { token string - seal Seal + seal *SealedValues binding *SealedOperationBinding notBefore time.Time issuedAt time.Time @@ -218,8 +249,24 @@ type Credential struct { // nobody performed. func (c Credential) Token() string { return c.token } -// Seal returns the execution this credential is bound to. -func (c Credential) Seal() Seal { return c.seal } +// Seal returns the execution this credential is bound to, as the capability +// CARRIES it — core's own WorkSealV1, cloned. +// +// It used to return core's Seal, which core documents as the live binding as +// the ISSUER holds it, filled by hand from three wire fields. A client cannot +// answer what the issuer holds, and a field core adds to the seal would have +// been silently zero in a value that reads as the issuer's. Same conflation +// this module already fixed for OperationBinding. +func (c Credential) Seal() *SealedValues { + if c.seal == nil { + return nil + } + copied, ok := proto.Clone(c.seal).(*SealedValues) + if !ok { + return nil + } + return copied +} // OperationBinding returns the one binding this credential may act through, or // nil. It is the binding AS SEALED — the three fields the capability carries — @@ -282,12 +329,37 @@ type MintClient struct { // when the mint kept failing. inflight chan struct{} - // backoff holds off the next attempt after a failure, and failures counts + // backoff holds off the next attempt after an OUTAGE, and failures counts // consecutive ones so the hold-off grows. Without it, a host answering 503 // received one request per caller per call — the heartbeat under another // name, arriving exactly when the host is least able to serve it. backoffUntil time.Time failures int + + // refused latches a refusal. ErrMintRefused means the host will say the + // same thing again — the projection is not acceptable, the build is not + // approved, the installation is not one this process may serve, an + // authority value drifted — and this package's own documentation says a + // process that sees it MUST NOT SERVE. So it is terminal: once latched, + // every later call returns it rather than handing out the credential in + // hand. + // + // This exists because the previous revision got it exactly backwards. The + // "serve the held credential rather than manufacture an outage" fix was + // applied to EVERY error, so a refusal, an authority drift and the + // final-URL disclosure check all returned the held credential with a nil + // error — and a test was adjusted to assert that as correct. Serving + // through an outage and serving through a refusal are opposite decisions + // and the error type is what tells them apart. + refused error + + // refreshNotBefore rate-limits REFRESH specifically, and a successful mint + // does not reset it. A receiver whose live state lags refuses each fresh + // credential, so every refusal is a new generation and the failure + // hold-off never engages: one mint per call, without limit. This is the + // bound for that, and it is separate from backoffUntil because it is not a + // failure. + refreshNotBefore time.Time } // NewMintClient validates configuration without performing any I/O. @@ -334,11 +406,19 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if lead <= 0 || lead >= 1 { return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrMintRefused) } + ceiling := options.MaxCredentialLifetime + if ceiling == 0 { + ceiling = defaultMaxCredentialLifetime + } + if ceiling <= 0 { + return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrMintRefused) + } now := options.Now if now == nil { now = time.Now } options.URL = endpoint + options.MaxCredentialLifetime = ceiling options.Audience = audience options.RequestTimeout = timeout options.RenewalLead = lead @@ -500,6 +580,11 @@ func (c *MintClient) obtain( ) (Credential, error) { for { c.mu.Lock() + if c.refused != nil { + // Terminal. A refusal is not something a later call outlives. + c.mu.Unlock() + return Credential{}, c.refused + } held := c.credential if held != nil && !c.dueForRenewalLocked(*held) && (wanted == nil || !wanted(held)) { @@ -507,20 +592,28 @@ func (c *MintClient) obtain( c.mu.Unlock() return credential, nil } - usable := held != nil && c.now().UTC().Before(held.expiresAt) + // servable is the credential this call may fall back on when minting is + // UNAVAILABLE. A refresh has none: its caller was refused on the + // credential being held, so handing that same credential back would + // report a replacement that did not happen. + servable := held + if reason == mintReasonRefresh || held == nil || !c.now().UTC().Before(held.expiresAt) { + servable = nil + } if waiting := c.inflight; waiting != nil { // Another goroutine is minting. Wait for it rather than queueing a // second request, and let ctx cancel the wait. snapshot := Credential{} - if held != nil { - snapshot = *held + if servable != nil { + snapshot = *servable } + canServe := servable != nil c.mu.Unlock() select { case <-waiting: continue case <-ctx.Done(): - if usable { + if canServe { return snapshot, nil } // Both stay in the chain: a caller that cancelled wants to see @@ -528,18 +621,15 @@ func (c *MintClient) obtain( return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, ctx.Err()) } } - if until := c.backoffUntil; !until.IsZero() && c.now().UTC().Before(until) { - // Held off after a failure. Serving the credential in hand is the - // whole point of the hold-off; only an expired one is an error. - if usable { - credential := *held + if until, holding := c.holdOffLocked(reason); holding { + if servable != nil { + credential := *servable c.mu.Unlock() return credential, nil } c.mu.Unlock() return Credential{}, fmt.Errorf( - "%w: the last mint failed and the next attempt is held off until %s", - ErrMintUnavailable, until, + "%w: minting is held off until %s", ErrMintUnavailable, until, ) } done := make(chan struct{}) @@ -552,18 +642,35 @@ func (c *MintClient) obtain( c.mu.Lock() c.inflight = nil if err != nil { - c.failures++ - c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) - stillUsable := c.credential != nil && c.now().UTC().Before(c.credential.expiresAt) + outage := errors.Is(err, ErrMintUnavailable) + cancelled := ctx.Err() != nil + switch { + case cancelled: + // The LEADER's caller gave up. That is not the host failing, so + // it must not become everybody's hold-off: a cancelled first + // request at boot would otherwise be an outage for every other + // caller, growing toward a minute with each repetition. The + // slot is simply released and a waiter becomes the next leader. + case outage: + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + default: + // A refusal, which includes an authority drift and the + // final-URL disclosure check. Latch it. + c.refused = err + } + servableNow := c.servableLocked(reason) snapshot := Credential{} - if stillUsable { - snapshot = *c.credential + if servableNow != nil { + snapshot = *servableNow } + serve := servableNow != nil && outage && !cancelled c.mu.Unlock() close(done) - if stillUsable { - // The credential in hand still works. A renewal that failed is - // not an outage until the thing it was renewing has expired. + if serve { + // The credential in hand still works and the host is merely + // unreachable. A renewal that could not be attempted is not an + // outage until the thing it was renewing has expired. return snapshot, nil } return Credential{}, err @@ -578,6 +685,9 @@ func (c *MintClient) obtain( c.counts.Mints++ case reason == mintReasonRefresh: c.counts.Refreshes++ + // A success does NOT clear this one. The bound on a lagging + // receiver is a bound on successful refreshes, not on failures. + c.refreshNotBefore = c.now().UTC().Add(mintBackoff(int(c.counts.Refreshes))) default: c.counts.Renewals++ } @@ -587,6 +697,47 @@ func (c *MintClient) obtain( } } +// holdOffLocked reports whether minting is held off for this kind of call, and +// until when. A renewal is held off by consecutive failures; a refresh is held +// off by its own rate limit as well, which a successful mint does not reset. +func (c *MintClient) holdOffLocked(reason mintReason) (time.Time, bool) { + now := c.now().UTC() + if !c.backoffUntil.IsZero() && now.Before(c.backoffUntil) { + return c.backoffUntil, true + } + if reason == mintReasonRefresh && !c.refreshNotBefore.IsZero() && now.Before(c.refreshNotBefore) { + return c.refreshNotBefore, true + } + return time.Time{}, false +} + +// servableLocked is the credential a caller may be handed when minting is +// unavailable: one that is held, unexpired, and not the one a refresh was +// refused on. +func (c *MintClient) servableLocked(reason mintReason) *Credential { + if reason == mintReasonRefresh || c.credential == nil { + return nil + } + if !c.now().UTC().Before(c.credential.expiresAt) { + return nil + } + return c.credential +} + +// Refused reports the refusal that stopped this client, or nil. +// +// It is exported because a refusal is the one mint outcome a process must act +// on by not serving, and a process cannot act on something it has to infer +// from the error of whichever call happened to be first. +func (c *MintClient) Refused() error { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + return c.refused +} + // mintOnce rechecks the pinned authority, reads the audience from it and mints. // It performs no locking: obtain holds the single-flight slot across it, so the // request is made without the client's mutex held and a caller's ctx still @@ -748,17 +899,17 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential if err != nil { return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) } - if body.InstallationID != "" && body.InstallationID != seal.InstallationID { + if body.InstallationID != "" && body.InstallationID != seal.GetInstallationId() { return Credential{}, fmt.Errorf( "%w: mint reported installation %q and sealed %q", - ErrMintRefused, body.InstallationID, seal.InstallationID, + ErrMintRefused, body.InstallationID, seal.GetInstallationId(), ) } if body.BuildIncarnation != "" && - body.BuildIncarnation != strconv.FormatUint(seal.BuildIncarnation, 10) { + body.BuildIncarnation != strconv.FormatUint(seal.GetBuildIncarnation(), 10) { return Credential{}, fmt.Errorf( "%w: mint reported build incarnation %q and sealed %d", - ErrMintRefused, body.BuildIncarnation, seal.BuildIncarnation, + ErrMintRefused, body.BuildIncarnation, seal.GetBuildIncarnation(), ) } // The audience the host signed must be the audience read from the pin. @@ -805,6 +956,12 @@ func (c *MintClient) checkWindow(credential Credential) error { ErrMintRefused, credential.notBefore, credential.expiresAt, ) } + if lifetime := credential.expiresAt.Sub(credential.notBefore); lifetime > c.options.MaxCredentialLifetime { + return fmt.Errorf( + "%w: the minted credential is valid for %s, and this process holds one for at most %s", + ErrMintRefused, lifetime, c.options.MaxCredentialLifetime, + ) + } now := c.now().UTC() if now.Before(credential.notBefore.Add(-corework.DefaultSkew)) { // Not yet valid. A clock this far apart is a configuration error on one diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index bf4e062..104d410 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -177,7 +177,12 @@ func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { first, err := client.Credential(t.Context()) require.NoError(t, err) - require.Equal(t, testSeal, first.Seal()) + // The seal as the capability CARRIES it — core's wire message, which also + // carries the owner's epoch that core's live Seal type no longer has. + require.Equal(t, testInstallation, first.Seal().GetInstallationId()) + require.EqualValues(t, 3, first.Seal().GetInstallationRevision()) + require.EqualValues(t, 11, first.Seal().GetBuildIncarnation()) + require.EqualValues(t, testPrincipalEpoch, first.Seal().GetPrincipalEpoch()) require.EqualValues(t, 1, host.requests.Load()) // Every call for the next twelve minutes is answered from the credential @@ -282,23 +287,34 @@ func TestEveryMintRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 1, host.pin.checks.Load(), "the first mint checks the pin too") + // A drift is a REFUSAL, and the test's name always said so while its + // assertion said the opposite. The previous revision returned the held + // credential with a nil error here, because "serve the held credential + // rather than manufacture an outage" had been applied to every error + // instead of only to outages — and this assertion was changed to match, + // which is the worst shape a test failure can take. + // + // ErrMintRefused means the host will say the same thing again and a + // process that sees it must not serve. A credential still having a minute + // left does not change that: the value it is sealed to has moved. host.pin.set(testAudience, errors.New("audience changed under a running process")) clock = testClock.Add(14 * time.Minute) - credential, err := client.Credential(t.Context()) - // The credential in hand has a minute left, so the drift does not make this - // process stop serving — it makes it stop MINTING, which is the point. - require.NoError(t, err) - require.NotEmpty(t, credential.Token()) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") require.EqualValues(t, 2, host.pin.checks.Load()) require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") - // Once the held credential has expired there is nothing to serve, and the - // drift is the error. - clock = testClock.Add(2 * time.Hour) + // And it is TERMINAL. A later call does not outlive it, and the pin being + // put back does not resurrect the client: the process already minted + // against a value that has since moved, and only a restart re-establishes + // what it runs under. + require.ErrorIs(t, client.Refused(), ErrMintRefused) + host.pin.set(testAudience, nil) _, err = client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) - require.ErrorContains(t, err, "audience changed") require.EqualValues(t, 1, host.requests.Load()) + require.EqualValues(t, 2, host.pin.checks.Load(), "a refused client stops asking") } // The first mint, specifically: a client constructed at boot whose pin has @@ -462,7 +478,8 @@ func TestRefreshIsTiedToTheRefusedCredential(t *testing.T) { // A refresh rechecks the pin and re-reads the projection, exactly as a renewal // does: it is a mint, and every mint goes through the same door. func TestRefreshRechecksThePinAndRereadsTheProjection(t *testing.T) { - now := func() time.Time { return testClock } + clock := testClock + now := func() time.Time { return clock } host := newMintHost(t, now) path := projectedFile(t, "projected-before") client := newTestMintClient(t, host, path, now) @@ -475,17 +492,23 @@ func TestRefreshRechecksThePinAndRereadsTheProjection(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("projected-after"), 0o600)) replaced, err := client.Refresh(t.Context(), held) require.NoError(t, err) + require.NotEqual(t, held.Token(), replaced.Token()) require.EqualValues(t, 2, host.pin.checks.Load()) require.EqualValues(t, 2, host.pin.reads.Load()) require.Equal(t, "Bearer projected-after", host.presented.Load()) - // A drifted pin stops the refresh, and the credential already held stays - // held: a refused replacement must not leave the process holding nothing, - // which would read as a boot failure somewhere it is not. + // A drifted pin REFUSES the refresh, and the refusal is what the caller + // gets. Handing the credential back would be worse than useless here: the + // caller asked for a replacement because the far end refused this one, so + // returning it reports a replacement that did not happen. + // A refresh inside the rate limit is an outage to the caller, so move past + // it before asserting what a DRIFT does. + clock = testClock.Add(5 * time.Minute) host.pin.set(testAudience, errors.New("audience changed under a running process")) - still, err := client.Refresh(t.Context(), replaced) - require.NoError(t, err, "a credential with time left is served while minting is refused") - require.Equal(t, replaced.Token(), still.Token()) + _, err = client.Refresh(t.Context(), replaced) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") + require.ErrorIs(t, client.Refused(), ErrMintRefused) } // The host may echo the sealed values for a log. An echo that disagrees with @@ -595,10 +618,39 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { // What this test asserts about it is therefore core's constant, not a // number of ours: a host cannot configure its way past it without a // reviewer seeing the configuration. - require.Equal(t, time.Hour, corework.DefaultMaxTTL, - "the lifetime ceiling lives at the minter; if core's default moves, the README moves with it") + // The ceiling is BACK, as defence in depth, and this asserts what THIS + // client does rather than what core's constant says: a host that raises + // its own MaxTTL past what this process will hold is refused here. + // + // Core's cap is the primary one and this is the second layer. Deleting + // this layer on the strength of core having a cap was a weakening, because + // MaxTTL is configurable with no ceiling of its own and nothing bounds a + // lifetime at use. + host = newMintHost(t, now) + host.authority.core.MaxTTL = 30 * 24 * time.Hour // a host that raised it + month := 30 * 24 * time.Hour + host.mintedAt, host.mintedFor = &monthStart, &month + client = newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "holds one for at most") + + // And it is configurable, because what a process should hold is the + // deployment's call — the point is that there IS one. + host = newMintHost(t, now) + host.authority.core.MaxTTL = 30 * 24 * time.Hour + week := 7 * 24 * time.Hour + host.mintedAt, host.mintedFor = &monthStart, &week + client = newTestMintClient(t, host, projectedFile(t, "projected"), now, + func(options *MintOptions) { options.MaxCredentialLifetime = 14 * 24 * time.Hour }) + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, testClock.Add(week), credential.ExpiresAt()) } +// monthStart is an addressable testClock, for the host overrides above. +var monthStart = testClock + // A refusal the host will give again is not retryable, and an outage is. A // client that confused them would either spin against a permanent refusal or // give up on a transient one. @@ -834,7 +886,16 @@ func TestCredentialSurfaceComesFromTheSignedCapability(t *testing.T) { credential, err := client.Credential(t.Context()) require.NoError(t, err) - require.Equal(t, testSeal, credential.Seal()) + require.Equal(t, testInstallation, credential.Seal().GetInstallationId()) + require.EqualValues(t, 3, credential.Seal().GetInstallationRevision()) + + // Seal hands back a CLONE of the wire message. A caller that mutated it + // must not be able to change what the credential reports it is bound to — + // and the type is the carried seal rather than core's live Seal, which a + // client cannot answer for. + mutated := credential.Seal() + mutated.InstallationId = "installation-the-caller-preferred" + require.Equal(t, testInstallation, credential.Seal().GetInstallationId()) // The binding AS SEALED: the three fields the capability carries. The live // binding has three more — the principal it is granted to, the installation @@ -1016,11 +1077,17 @@ func TestACancelledWaiterStillGetsAUsableHeldCredential(t *testing.T) { <-renewing } -// A receiver whose live state lags refuses every fresh credential, and each -// refusal is a NEW generation — so the generation check alone does not bound -// it. The hold-off is what does. -func TestRepeatedRefreshesAreBounded(t *testing.T) { - now := func() time.Time { return testClock } +// A receiver whose live state lags refuses every FRESH credential, so each +// refusal is a new generation and the failure hold-off never engages. The bound +// has to be a bound on successful refreshes. +// +// The previous version of this test used a 503 host, so no new generation ever +// appeared and it modelled an outage rather than the lagging receiver it +// claimed — and it asserted that handing the refused credential back was a +// success, which is the C2 defect written down as expected behaviour. +func TestASuccessfulRefreshIsRateLimitedSoALaggingReceiverCannotMintForever(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } host := newMintHost(t, now) client := newTestMintClient(t, host, projectedFile(t, "projected"), now) @@ -1028,21 +1095,162 @@ func TestRepeatedRefreshesAreBounded(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 1, host.requests.Load()) - // The first refusal gets a replacement. - replaced, err := client.Refresh(t.Context(), held) - require.NoError(t, err) - require.EqualValues(t, 2, host.requests.Load()) - - // Now the host starts failing, and a caller refused on each fresh - // credential keeps coming back. Without the hold-off this is one mint per - // call for as long as the receiver lags. - host.refuseWith = http.StatusServiceUnavailable - current := replaced + // The host mints happily; it is the RECEIVER that is behind, so every + // credential this caller presents comes back ErrRevoked and every refusal + // is on a credential one generation newer than the last. + current := held + served := 0 + refused := 0 for range 20 { next, err := client.Refresh(t.Context(), current) - require.NoError(t, err, "a credential with time left is served") - current = next + switch { + case err == nil: + require.NotEqual(t, current.Token(), next.Token(), + "a successful refresh must never hand back the credential that was refused") + current = next + served++ + default: + require.ErrorIs(t, err, ErrMintUnavailable, + "a rate-limited refresh is an outage to the caller, not a refusal") + require.ErrorContains(t, err, "held off") + refused++ + } + } + + require.Positive(t, served, "the first refusal must get a replacement") + require.Positive(t, refused, "and the twentieth must not") + require.LessOrEqual(t, int(host.requests.Load()), 4, + "twenty refusals on twenty successive generations must not be twenty mints") + counts := client.Counts() + require.EqualValues(t, 1, counts.Mints) + require.LessOrEqual(t, int(counts.Refreshes), 3) + + // The rate limit is a limit and not a wall: once it elapses, a refresh + // works again. + clock = testClock.Add(5 * time.Minute) + recovered, err := client.Refresh(t.Context(), current) + require.NoError(t, err) + require.NotEqual(t, current.Token(), recovered.Token()) +} + +// A refusal from the host is never dressed up as a replacement. +// +// This is the sequence the review set out, and it is the likely one: the +// installation is revoked, so the receiver answers ErrRevoked, so the caller +// refreshes — and the host refuses the mint for the same reason. The previous +// revision returned the refused credential with a nil error, for its whole +// remaining lifetime, so a caller could not tell "replaced" from "here is the +// revoked one again". +func TestARefusedRefreshNeverReturnsTheRefusedCredential(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The installation is revoked, so the host refuses the mint too. + host.refuseWith = http.StatusForbidden + replacement, err := client.Refresh(t.Context(), held) + require.ErrorIs(t, err, ErrMintRefused) + require.Empty(t, replacement.Token(), + "a refused refresh returns no credential at all, least of all the refused one") + + // Terminal, so a caller looping cannot be handed the revoked credential on + // the next pass either — and Credential does not hand it out. + require.ErrorIs(t, client.Refused(), ErrMintRefused) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + again, err := client.Refresh(t.Context(), held) + require.ErrorIs(t, err, ErrMintRefused) + require.Empty(t, again.Token()) +} + +// A cancelled caller must not become everybody else's outage. +// +// The shared mint runs on the LEADER's context. If the leader's caller gives +// up, that used to count as a failure: failures++ and a hold-off, so every +// waiter then hit "held off" — and at boot, with no credential held, one +// cancelled first request was an outage for every other caller, growing toward +// a minute with each repetition. A caller giving up is not the host failing. +func TestACancelledLeaderDoesNotHoldOffEveryoneElse(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + // The leader cancels while the host is still holding its request. There is + // no credential, so nothing can be served. + leader, cancel := context.WithCancel(context.Background()) + leaderDone := make(chan error, 1) + go func() { + _, err := client.Credential(leader) + leaderDone <- err + }() + require.Eventually(t, func() bool { return host.requests.Load() == 1 }, + 2*time.Second, time.Millisecond) + cancel() + require.ErrorIs(t, <-leaderDone, ErrMintUnavailable) + letGo() + + // A caller arriving now must be able to mint. Under the old behaviour it + // was held off for a second, and for longer each time a caller cancelled. + credential, err := client.Credential(t.Context()) + require.NoError(t, err, "a cancelled leader must not hold off the next caller") + require.NotEmpty(t, credential.Token()) + require.Equal(t, MintCounts{Mints: 1}, client.Counts(), + "and the cancellation is not counted as a mint that happened") +} + +// An outage is still servable, and a refusal is still not: the two branches +// that the previous revision collapsed into one, asserted apart. +func TestOnlyAnOutageServesTheHeldCredential(t *testing.T) { + for name, arrange := range map[string]struct { + status int + sentinel error + serves bool + }{ + "503 is an outage": {http.StatusServiceUnavailable, ErrMintUnavailable, true}, + "429 is an outage": {http.StatusTooManyRequests, ErrMintUnavailable, true}, + "403 is a refusal": {http.StatusForbidden, ErrMintRefused, false}, + "401 is a refusal": {http.StatusUnauthorized, ErrMintRefused, false}, + "400 is a refusal": {http.StatusBadRequest, ErrMintRefused, false}, + "404 is a refusal": {http.StatusNotFound, ErrMintRefused, false}, + } { + t.Run(name, func(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + host.refuseWith = arrange.status + clock = testClock.Add(13 * time.Minute) // inside the renewal lead + served, err := client.Credential(t.Context()) + + if arrange.serves { + require.NoError(t, err, + "a credential with two minutes left survives the host being unreachable") + require.Equal(t, held.Token(), served.Token()) + require.NoError(t, client.Refused()) + return + } + require.ErrorIs(t, err, arrange.sentinel) + require.Empty(t, served.Token(), + "a refusal returns no credential: this process must not serve") + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "and the refusal is terminal") + }) } - require.EqualValues(t, 3, host.requests.Load(), - "twenty refusals inside one hold-off make one request") } diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index 25483aa..15fcb0b 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -55,8 +55,23 @@ var ( // Second encodings of the message. encoding/json is handled separately // because one file is allowed it for the mint endpoint's HTTP bodies. "google.golang.org/protobuf/encoding/protojson", + "google.golang.org/protobuf/encoding/protowire", + "google.golang.org/protobuf/types/known/anypb", "encoding/gob", "encoding/asn1", "encoding/xml", } + + // envelopeDecoders are banned EXCEPT where a file is allowlisted below. A + // second parser applying its own seal rule was the last blocker here, and + // it needed exactly this: base64 to open the envelope by hand, and + // proto.Unmarshal to read it. corework.Inspect does both now and hands back + // the claims, so opening an envelope here has no honest use. + envelopeDecoders = []string{"encoding/base64"} + + // envelopeDecoderAllowlist is every file that may reach for one, and what + // it does with it — which is never a capability. + envelopeDecoderAllowlist = map[string]string{ + "cache_partition.go": "ENCODES a tenant and installation id into a cache key; it opens no envelope", + } bannedImportPrefixes = []string{"golang.org/x/crypto/"} // Any JOSE, JWT or token-library path, whoever publishes it. A capability // here is core's protobuf envelope; a library that mints bearer tokens has @@ -217,14 +232,29 @@ func inspectForSecondImplementation(file sourceFile) []string { var findings []string coreImports := map[string]bool{} plumbing := map[string]string{} // local name -> allowed import path - for name, path := range importsOf(file.syntax) { - findings = append(findings, inspectImport(file, name, path, plumbing)...) - if path == coreModulePath || strings.HasPrefix(path, coreModulePath+"/") { - coreImports[name] = true + protoNames := map[string]bool{} // every local name google.golang.org/protobuf is reachable under + for _, imported := range importsOf(file.syntax) { + if imported.name == "." { + // A dot import makes every one of a package's identifiers + // unqualified, which defeats every symbol rule below and collided + // in the map this used to build. Nothing here needs one. + findings = append(findings, fmt.Sprintf( + "%s dot-imports %q.\n"+ + "A dot import makes a package's identifiers unqualified, so every rule in this gate\n"+ + "that reasons about a qualifier stops applying — and two dot imports collided in the\n"+ + "map this check used to build, which hid whichever came first.", + file.path, imported.path)) + } + findings = append(findings, inspectImport(file, imported.name, imported.path, plumbing)...) + if imported.path == coreModulePath || strings.HasPrefix(imported.path, coreModulePath+"/") { + coreImports[imported.name] = true + } + if strings.HasPrefix(imported.path, "google.golang.org/protobuf") { + protoNames[imported.name] = true } } findings = append(findings, inspectPlumbingSymbols(file, plumbing)...) - findings = append(findings, inspectProtoEncoding(file)...) + findings = append(findings, inspectProtoEncoding(file, protoNames)...) findings = append(findings, inspectDeclarations(file, coreImports)...) findings = append(findings, inspectJSONTags(file)...) return findings @@ -242,6 +272,19 @@ func inspectImport(file sourceFile, name string, path string, plumbing map[strin "%s imports %q, which only %v may: it is the mint client's own transport plumbing.", file.path, path, allowed.files)} } + if slices.Contains(envelopeDecoders, path) { + if reason, allowed := envelopeDecoderAllowlist[file.path]; allowed { + _ = reason + return nil + } + return []string{fmt.Sprintf( + "%s imports %q.\n"+ + "Opening a capability's envelope by hand is the beginning of a second parser, which\n"+ + "is what this module last had to delete — its own seal rule disagreed with core's\n"+ + "fixtures about three refusals. corework.Inspect opens the envelope and returns the\n"+ + "claims. Only %v may reach for this, and only because %v.", + file.path, path, allowedEnvelopeDecoderFiles(), envelopeDecoderReasons())} + } banned := slices.Contains(bannedImports, path) for _, prefix := range bannedImportPrefixes { banned = banned || strings.HasPrefix(path, prefix) @@ -313,10 +356,7 @@ func inspectPlumbingSymbols(file sourceFile, plumbing map[string]string) []strin // encodes something which is not a capability. Unmarshal and Clone are not // restricted: reading a capability the host issued is this module's job, and // writing one is core's. -func inspectProtoEncoding(file sourceFile) []string { - if _, allowed := protoMarshalAllowlist[file.path]; allowed { - return nil - } +func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string { var findings []string ast.Inspect(file.syntax, func(node ast.Node) bool { selector, ok := node.(*ast.SelectorExpr) @@ -324,19 +364,43 @@ func inspectProtoEncoding(file sourceFile) []string { return true } qualifier, ok := selector.X.(*ast.Ident) - if !ok || qualifier.Name != "proto" { + // The qualifier is resolved from the IMPORT MAP rather than compared + // against the literal "proto". An aliased import — pb.Marshal — passed + // the identifier check, which made this a rule about a name in a gate + // whose whole point is not to be one. + if !ok || !protoNames[qualifier.Name] { return true } - if selector.Sel.Name != "Marshal" && selector.Sel.Name != "MarshalOptions" { + switch selector.Sel.Name { + case "Marshal", "MarshalOptions": + if _, allowed := protoMarshalAllowlist[file.path]; allowed { + return true + } + case "Unmarshal", "UnmarshalOptions": + // Unrestricted until now, and it is half of the defect that was the + // last blocker: a second unverified parser needs base64 to open the + // envelope and Unmarshal to read it. corework.Inspect does both, and + // hands back the claims, so nothing here needs either. + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s.\n"+ + "Reading a capability off the wire is corework.Inspect's job, and it returns the\n"+ + "claims it decoded. A decode here is the beginning of a second parser — which is\n"+ + "what this module last had to delete, and its own seal rule disagreed with core's\n"+ + "fixtures about three refusals.", + file.path, qualifier.Name, selector.Sel.Name)) + return true + default: return true } findings = append(findings, fmt.Sprintf( - "%s calls proto.%s.\n"+ + "%s calls %s.%s.\n"+ "Encoding the message is half of a signer: core signs the deterministic protobuf\n"+ - "encoding, so proto.MarshalOptions{Deterministic: true} over a capability is the\n"+ - "deleted implementation with a different import list. Only %v may encode, and only\n"+ + "encoding, so MarshalOptions{Deterministic: true} over a capability is the deleted\n"+ + "implementation with a different import list. The qualifier is resolved from the\n"+ + "import map, so an alias does not get past this. Only %v may encode, and only\n"+ "because what it encodes is %v.", - file.path, selector.Sel.Name, allowedProtoMarshalFiles(), protoMarshalReasons())) + file.path, qualifier.Name, selector.Sel.Name, + allowedProtoMarshalFiles(), protoMarshalReasons())) return true }) return findings @@ -668,6 +732,78 @@ type thing struct{} func (thing) WorkContextSign(payload []byte) []byte { return payload }`, says: "declares method WorkContextSign", }, + // Below: the bypasses round three found. Each defeated the gate as it + // stood after round two, which is the pattern worth noticing — every + // round the gate was tightened, and every round the next reviewer + // found the thing the tightening did not reach. + "a dot-import collision hiding a signing primitive": { + path: "carrier.go", + source: `package workcontext +import ( + . "crypto/ed25519" + . "strings" +) +var _ = Sign +var _ = TrimSpace`, + says: `dot-imports "crypto/ed25519"`, + }, + "a single dot import": { + path: "carrier.go", + source: `package workcontext +import . "crypto/ed25519" +var _ = Sign`, + says: "dot-imports", + }, + "an ALIASED protobuf marshal": { + path: "carrier.go", + source: `package workcontext +import pb "google.golang.org/protobuf/proto" +func encode(claims *Claims) ([]byte, error) { + return pb.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "calls pb.MarshalOptions", + }, + "a hand parser: base64 plus Unmarshal": { + path: "carrier.go", + source: `package workcontext +import ( + "encoding/base64" + "google.golang.org/protobuf/proto" +) +func parse(encoded string) (*Claims, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + claims := &Claims{} + return claims, proto.Unmarshal(raw, claims) +}`, + says: `imports "encoding/base64"`, + }, + "proto.Unmarshal on its own": { + path: "carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func read(raw []byte) (*Claims, error) { + claims := &Claims{} + return claims, proto.Unmarshal(raw, claims) +}`, + says: "calls proto.Unmarshal", + }, + "the wire encoder": { + path: "carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/encoding/protowire" +var _ = protowire.AppendTag`, + says: "encoding/protowire", + }, + "anypb, which marshals anything": { + path: "carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/types/known/anypb" +var _ = anypb.New`, + says: "types/known/anypb", + }, "a WorkContext func bound to a var": { path: "carrier.go", source: `package workcontext @@ -803,10 +939,14 @@ func moduleFiles(t *testing.T) []sourceFile { return files } -// importsOf returns this file's imports by the local name each is reachable -// under, which is what an alias's qualifier has to be checked against. -func importsOf(file *ast.File) map[string]string { - paths := make(map[string]string, len(file.Imports)) +// importsOf returns this file's imports as (local name, path) PAIRS. +// +// It used to return a map keyed by local name, which two imports can collide +// in: `import ( . "crypto/ed25519"; . "strings" )` maps "." twice, the second +// wins, and the signing primitive is never inspected at all. A gate whose +// coverage depends on import order is not a gate. Pairs cannot collide. +func importsOf(file *ast.File) []importedPackage { + imports := make([]importedPackage, 0, len(file.Imports)) for _, imported := range file.Imports { path, err := strconv.Unquote(imported.Path.Value) if err != nil { @@ -816,9 +956,32 @@ func importsOf(file *ast.File) map[string]string { if imported.Name != nil { name = imported.Name.Name } - paths[name] = path + imports = append(imports, importedPackage{name: name, path: path}) + } + return imports +} + +type importedPackage struct { + name string + path string +} + +func allowedEnvelopeDecoderFiles() []string { + names := make([]string, 0, len(envelopeDecoderAllowlist)) + for name := range envelopeDecoderAllowlist { + names = append(names, name) } - return paths + slices.Sort(names) + return names +} + +func envelopeDecoderReasons() []string { + reasons := make([]string, 0, len(envelopeDecoderAllowlist)) + for _, reason := range envelopeDecoderAllowlist { + reasons = append(reasons, reason) + } + slices.Sort(reasons) + return reasons } func allowedProtoMarshalFiles() []string { diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go index ad6bfc1..a81d9af 100644 --- a/workcontext/parse_agreement_test.go +++ b/workcontext/parse_agreement_test.go @@ -1,7 +1,6 @@ package workcontext import ( - "errors" "net/http" "net/http/httptest" "testing" @@ -59,7 +58,7 @@ var structurallyRefused = map[string]bool{ // `&Verifier{}` exercises core, because Verifier is an alias of core's type — // it is core testing core, and it would have passed unchanged beside the // implementation this PR deletes. The SDK's own decision paths are -// claimsOf/sealOf, SealedInstallation, FromHeaders and credentialFrom, and that +// readClaims/sealOf, SealedInstallation, FromHeaders and credentialFrom, and that // is where a divergence from core can actually live. Three did: // // - seal-without-installation: core answers ErrInvalid, because protovalidate @@ -83,9 +82,10 @@ var structurallyRefused = map[string]bool{ // described in a comment, so core changing its order breaks this test here // rather than surfacing as a mismatched sentinel in somebody's gateway. // -// It does not make this module's parser correct by construction. One exported -// unverified inspection entry point in core would; that is asked for in -// core#691 and said plainly in the PR body. +// It does not make this module's reading correct by construction — core's +// Inspect does that, and this test is what holds the two in agreement. Core +// built Inspect when it was asked for; this module applies no structural rule +// of its own any more. func TestTheSDKParsePathsAgreeWithCore(t *testing.T) { fixtures, err := corework.Fixtures(time.Now()) require.NoError(t, err) @@ -172,12 +172,12 @@ func TestNoCarrierAcceptsWhatCoreRefusesOnItsOwnBytes(t *testing.T) { headers.Set(HeaderName, fixture.Token) headers.Set(InstallationIDHeaderName, corework.FixtureInstallation) headers.Set(InstallationRevisionHeaderName, "1") + // Core's sentinel, with no alternative: the `|| ErrInvalid` this + // used to allow made the assertion vacuous for the eleven of + // twelve fixtures whose sentinel IS ErrInvalid. _, headerErr := FromHeaders(headers) - require.Error(t, headerErr) - require.True(t, - errors.Is(headerErr, fixture.Err) || errors.Is(headerErr, ErrInvalid), - "FromHeaders refused %q as %v, which is neither core's %v nor a carrier mismatch", - fixture.Name, headerErr, fixture.Err) + require.ErrorIs(t, headerErr, fixture.Err, + "FromHeaders refused %q as %v; core declares %v", fixture.Name, headerErr, fixture.Err) }) } } From b9b8584287241bc50bf3fe080ff319ed13561463 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:45:25 -0400 Subject: [PATCH 12/35] fix(workcontext)!: the wrapper hides the raw stream and holds trailers, an interrupted read is an outage, and codec exemptions name a type rather than a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three of the dynamic review reproduced six defects at 31ca467, four of them in code the previous two rounds introduced. All six fixed; every reproducer is committed, four of them over a REAL gRPC server because the reviewer was right that a recording stream's call count is not evidence for what gRPC does with a trailer. GuardedServerStream no longer embeds grpc.ServerStream. It was a public embedded field, so a handler given only the intercepted stream could write stream.(*GuardedServerStream).ServerStream and send with no check at all — the probe delivered a message under revoked authority with ZERO checks. The underlying stream is private and every interface method is forwarded by hand, and a reflection test refuses any exported field or any method returning a grpc.ServerStream, because the behaviour it prevents would not compile. Trailers are held in the wrapper rather than queued with gRPC. gRPC sends trailers when the handler RETURNS, so checking authority at SetTrailer time and handing the metadata straight over meant a handler could queue a trailer, have authority revoked, return, and the client received it — through the ordinary guarded API with the interceptor installed. Finish performs the last re-check and either releases the pending trailers or discards them, and the interceptor calls it; a handler that completed under withdrawn authority now fails the RPC. An interrupted response body is an OUTAGE. A 200 whose body stops mid-read is a connection dropped after the headers, and wrapping it in ErrMintRefused made it terminal once refusals started latching: one interrupted read permanently stopped a process holding a perfectly good credential, and it stayed stopped after the endpoint recovered. An oversized or malformed COMPLETE response stays terminal, because the host answered and answered wrongly. A cancelled waiter re-evaluates under the lock. It answered from a snapshot taken before the wait, so a waiter that started a second before expiry and cancelled a second after it was handed an EXPIRED credential with a nil error — and two seconds fits easily inside the default five-second request timeout. The codec exemptions name a FILE AND A TYPE. They were per file, so cache_partition.go — allowed proto.Marshal for a scope — could marshal a *Claims, and mint.go — allowed encoding/json for two tagged structs — could json.Marshal a map[string]any holding a capability's fields, needing no tags at all. Both produced zero findings. The gate now resolves the argument's type syntactically from the file and refuses what it cannot resolve, so a whole-file exemption is no longer an exemption for every type in it. The shell half bans base64 and encoding/json repository-wide with the honest uses named by path, which closes the root-module hand parser that passed the sweep — the AST gate reaches only the workcontext module, and that limit is now stated rather than relied on. Strict response parsing requires EOF. DisallowUnknownFields applies to the value just decoded and says nothing about what follows, so a valid response with a second JSON value appended was accepted. A trailing newline still is. Mutation-verified, each against the behaviour it replaces: queueing trailers with no final check fails the real-gRPC trailer test; re-embedding the stream fails the reflection test; per-file codec exemptions fail all three new gate probes; and the mint reproducers fail against their own pre-fix classification. Not fixed, and not this PR's to fix: the three compat refs still carry the deleted implementation, so the required sweep is still red, and core has not tagged v0.8.0. Both remain merge preconditions. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check-one-implementation.sh | 21 + workcontext/grpctransport/guarded_stream.go | 184 ++++++--- .../grpctransport/guarded_stream_test.go | 80 +++- .../grpctransport/real_grpc_stream_test.go | 217 ++++++++++ workcontext/layer4_reproducers_test.go | 184 +++++++++ workcontext/mint.go | 55 ++- workcontext/one_implementation_test.go | 374 ++++++++++++++++-- 7 files changed, 1014 insertions(+), 101 deletions(-) create mode 100644 workcontext/grpctransport/real_grpc_stream_test.go diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 118b70a..4a9d83c 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -44,6 +44,12 @@ primitives='"crypto"|crypto/ed25519|crypto/ecdsa|crypto/rsa|crypto/dsa|crypto/hm # protobuf message on its own, which is how the deleted implementation's payload # would come back without an encoding/json import. encoders='encoding/protojson|encoding/protowire|known/anypb|encoding/gob|encoding/asn1|encoding/xml' +# Opening a credential's envelope by hand. base64 plus a decoder is the whole of +# a second parser, and the AST gate reaches only the workcontext module — a +# compiling root package with a base64/JSON credential decoder and its own seal +# rule passed this sweep. Both are banned repository-wide, with the few honest +# uses named in allowed() by path. +envelope='encoding/base64' status=0 # allowed — the exceptions, each with a reason in the comment. @@ -57,6 +63,17 @@ allowed() { 'tls.go|'*'crypto/x509'*) return 0 ;; # The receipts digest canonicalises a receipt REQUEST, never a capability. 'receipts/digest.go|'*'encoding/protojson'*) return 0 ;; + # ENCODES a tenant and installation id into a cache key; opens no envelope. + 'workcontext/cache_partition.go|'*'encoding/base64'*) return 0 ;; + # The runtime's own configuration document, which is not a capability. The + # AST gate cannot reach the root module, so this is named here instead. + 'configuration_document.go|'*'encoding/json'*) return 0 ;; + # The mint endpoint's two HTTP bodies. In the workcontext module the AST + # gate additionally holds this to the TWO TYPES; here it is by path only, + # which is the weaker half and is why the AST gate exists. + 'workcontext/mint.go|'*'encoding/json'*) return 0 ;; + # Effect receipts, whose rows are JSON and are not capabilities. + 'receipts/'*'|'*'encoding/json'*) return 0 ;; esac return 1 } @@ -83,6 +100,10 @@ offending_imports() { fi if printf '%s' "$line" | grep -Eq "($encoders)"; then allowed "$path" "$line" || printf '%s\n' "$line" + continue + fi + if printf '%s' "$line" | grep -Eq "($envelope|\"encoding/json\")"; then + allowed "$path" "$line" || printf '%s\n' "$line" fi done < "$file" } diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index d7496bd..29e9d0c 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -3,6 +3,7 @@ package grpctransport import ( "context" "fmt" + "sync" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -10,28 +11,49 @@ import ( "github.com/codefly-dev/sdk-go/workcontext" ) -// GuardedServerStream re-presents a stream's credential before every message -// the stream sends, by being the thing that sends them. +// GuardedServerStream re-presents a stream's credential before anything leaves +// it, by being the thing that sends. // // A StreamGuard on its own is advice. BeforeSend has to be called, and whether -// it is called before each emission depends on each author remembering to call -// it — which is the same shape as the optional carrier this PR deleted: a rule -// that holds wherever somebody thought of it. Wrapping the stream makes the -// check structural. A handler that writes through this cannot emit without the -// re-check, because Send IS the re-check followed by the send. +// it is called before each emission depends on each author remembering — the +// same shape as the optional carrier this module deleted: a rule that holds +// wherever somebody thought of it. Wrapping the stream makes the check +// structural, and StreamServerInterceptor makes the wrapping structural too. // -// It wraps grpc.ServerStream, so it also serves the generated per-service -// stream types: a generated server stream embeds grpc.ServerStream, and -// handing it one of these makes every SendMsg go through the guard. +// # It does not embed the stream, and that is load-bearing +// +// This type used to embed grpc.ServerStream as a public field, so a handler +// given only the intercepted stream could write `stream.(*GuardedServerStream). +// ServerStream` and send whatever it liked with no check at all. A real gRPC +// probe delivered a message under revoked authority with zero checks that way. +// A guard reachable past is not a guard, so the underlying stream is private +// and every method of the interface is forwarded by hand. +// +// # Trailers are held, not queued +// +// gRPC sends trailers when the handler RETURNS, not when SetTrailer is called. +// Checking authority at SetTrailer time and handing the metadata straight to +// gRPC meant a handler could queue a trailer, have authority revoked, return, +// and the client received it — through the ordinary guarded API, no unwrapping +// needed. So pending trailers stay in this wrapper, and Finish is what releases +// them, after one more check. type GuardedServerStream struct { - grpc.ServerStream - guard *workcontext.StreamGuard + stream grpc.ServerStream + guard *workcontext.StreamGuard + + mu sync.Mutex + trailers metadata.MD + finished bool } -// Guard wraps a server stream so every message it sends is preceded by a live +// Guard wraps a server stream so everything it sends is preceded by a live // re-check. A nil guard is refused rather than treated as "no guarding // wanted": a wrapper that silently did nothing would be worse than no wrapper, // because the call site would read as guarded. +// +// A caller using Guard directly MUST call Finish when its handler is done, or +// trailers it set are never sent. StreamServerInterceptor does that for you, +// which is the reason to prefer it. func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { if stream == nil { return nil, fmt.Errorf("%w: no stream to guard", workcontext.ErrInvalid) @@ -39,41 +61,26 @@ func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedSe if guard == nil { return nil, fmt.Errorf("%w: no stream guard", workcontext.ErrInvalid) } - return &GuardedServerStream{ServerStream: stream, guard: guard}, nil + return &GuardedServerStream{stream: stream, guard: guard, trailers: metadata.MD{}}, nil } -// SendHeader, SetHeader and SetTrailer are guarded too. They were not, and -// metadata is a channel out: a handler that could not send a message could -// still send a trailer describing it. Everything that leaves goes through the -// re-check. -func (s *GuardedServerStream) SendHeader(metadata metadata.MD) error { - if err := s.guarded(); err != nil { - return err +// Context is the stream's context, forwarded. It is not guarded: reading the +// context sends nothing. +func (s *GuardedServerStream) Context() context.Context { + if s == nil || s.stream == nil { + return context.Background() } - return s.ServerStream.SendHeader(metadata) + return s.stream.Context() } -func (s *GuardedServerStream) SetHeader(metadata metadata.MD) error { - if err := s.guarded(); err != nil { - return err - } - return s.ServerStream.SetHeader(metadata) -} - -// SetTrailer has no error to return, so a refusal can only be withheld: a -// terminated stream sets no trailer rather than setting one nobody checked. -func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { - if s.guarded() != nil { - return - } - s.ServerStream.SetTrailer(trailer) -} - -func (s *GuardedServerStream) guarded() error { - if s == nil || s.guard == nil { +// RecvMsg is forwarded unguarded. Receiving is not emitting, and a handler that +// cannot read its own request cannot decide anything about it; the guard is +// about what leaves. +func (s *GuardedServerStream) RecvMsg(message any) error { + if s == nil || s.stream == nil { return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) } - return s.guard.BeforeSend(s.Context()) + return s.stream.RecvMsg(message) } // SendMsg re-checks, then sends. A refusal is returned and the message is NOT @@ -85,10 +92,79 @@ func (s *GuardedServerStream) guarded() error { // that loops past the first refusal keeps being refused rather than getting a // second chance to emit. func (s *GuardedServerStream) SendMsg(message any) error { - if err := s.guarded(); err != nil { + if err := s.recheck(); err != nil { + return err + } + return s.stream.SendMsg(message) +} + +// SendHeader re-checks and sends. gRPC flushes headers immediately, so the +// check at this moment is the check that matters. +func (s *GuardedServerStream) SendHeader(headers metadata.MD) error { + if err := s.recheck(); err != nil { + return err + } + return s.stream.SendHeader(headers) +} + +// SetHeader re-checks and queues with gRPC. Headers queued this way are +// flushed by the first SendMsg or SendHeader, both of which re-check — so +// unlike a trailer, a queued header cannot outlive its authorization. +func (s *GuardedServerStream) SetHeader(headers metadata.MD) error { + if err := s.recheck(); err != nil { return err } - return s.ServerStream.SendMsg(message) + return s.stream.SetHeader(headers) +} + +// SetTrailer re-checks and HOLDS the metadata in this wrapper. Nothing reaches +// gRPC until Finish releases it, because gRPC would otherwise send it when the +// handler returns — which is after any revocation that happened in between. +func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { + if s == nil { + return + } + if err := s.recheck(); err != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.finished { + // Past the last check there is nothing left to authorize against. + return + } + for key, values := range trailer { + s.trailers[key] = append(s.trailers[key], values...) + } +} + +// Finish performs the LAST re-check and releases the trailers the handler set, +// or discards them. It is called once, after the handler returns, and +// StreamServerInterceptor calls it. +// +// It returns the handler's own error unchanged when authority still holds, and +// the termination error when it does not — so a handler that completed under +// authority that has since been withdrawn fails the RPC rather than succeeding +// with a trailer nobody authorized. +func (s *GuardedServerStream) Finish(handlerErr error) error { + if s == nil { + return handlerErr + } + err := s.recheck() + s.mu.Lock() + pending := s.trailers + s.trailers = metadata.MD{} + s.finished = true + s.mu.Unlock() + if err != nil { + // Discarded. A trailer describes the result of work the authority has + // since been withdrawn from, and it is the last thing to leave. + return err + } + if len(pending) > 0 { + s.stream.SetTrailer(pending) + } + return handlerErr } // Terminated reports the refusal that ended the stream, or nil. @@ -99,14 +175,24 @@ func (s *GuardedServerStream) Terminated() error { return s.guard.Terminated() } +func (s *GuardedServerStream) recheck() error { + if s == nil || s.guard == nil || s.stream == nil { + return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + } + return s.guard.BeforeSend(s.Context()) +} + // StreamServerInterceptor guards every stream a server opens, so enforcement is // WIRING rather than something each handler author remembers. // -// Guard on its own leaves the original stream in the handler's scope, so the -// rule held wherever somebody thought of it — the same shape as the optional -// carrier this module deleted. An interceptor is installed once, at the server, -// and the handler receives a stream it cannot write around because the -// unguarded one never reaches it. +// Guard on its own leaves the original stream in the caller's scope, so the +// rule held wherever somebody thought of it. An interceptor is installed once, +// at the server, and the handler receives a stream it cannot write around: the +// unguarded one never reaches it, and the wrapper does not expose it. +// +// It also calls Finish, which is where the handler's queued trailers are +// re-checked and released. A handler that returns successfully under authority +// that was withdrawn while it ran fails the RPC. // // guardFor builds the guard for one stream, from whatever the server // established when the stream opened: typically the capability it verified, so @@ -143,6 +229,6 @@ func StreamServerInterceptor( if err != nil { return err } - return handler(server, guarded) + return guarded.Finish(handler(server, guarded)) } } diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go index 4dac34b..2d3dc5a 100644 --- a/workcontext/grpctransport/guarded_stream_test.go +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -3,6 +3,7 @@ package grpctransport import ( "context" "errors" + "reflect" "testing" "github.com/stretchr/testify/require" @@ -134,10 +135,10 @@ func TestGuardRefusesToWrapNothing(t *testing.T) { require.NoError(t, absent.Terminated()) } -// Metadata is a channel out too. SendHeader, SetHeader and SetTrailer were -// unguarded, so a handler that could not send a message could still send a -// trailer describing one. -func TestAGuardedStreamGuardsMetadataAsWellAsMessages(t *testing.T) { +// Metadata is a channel out too, and a TRAILER leaves when the handler returns +// rather than when SetTrailer is called — so it is held in the wrapper until +// Finish re-checks and releases it. +func TestAGuardedStreamHoldsTrailersUntilFinishRechecks(t *testing.T) { refuse := false guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ Recheck: func(context.Context) error { @@ -155,16 +156,74 @@ func TestAGuardedStreamGuardsMetadataAsWellAsMessages(t *testing.T) { require.NoError(t, guarded.SetHeader(metadata.Pairs("a", "1"))) require.NoError(t, guarded.SendHeader(metadata.Pairs("b", "2"))) + require.Equal(t, 2, underlying.headers) + + // Set under authority, and NOT yet handed to gRPC: gRPC would send it when + // the handler returned, which is after any revocation in between. guarded.SetTrailer(metadata.Pairs("c", "3")) - require.Equal(t, 3, underlying.trailers+underlying.headers) + require.Zero(t, underlying.trailers, + "a trailer must not reach gRPC before the last check") + // Authority is withdrawn after the handler queued it. Finish discards. refuse = true + require.ErrorIs(t, guarded.Finish(nil), workcontext.ErrStreamTerminated) + require.Zero(t, underlying.trailers, + "a trailer describes work whose authority was withdrawn; it is dropped") require.ErrorIs(t, guarded.SetHeader(metadata.Pairs("d", "4")), workcontext.ErrStreamTerminated) - require.ErrorIs(t, guarded.SendHeader(metadata.Pairs("e", "5")), workcontext.ErrStreamTerminated) - before := underlying.trailers - guarded.SetTrailer(metadata.Pairs("f", "6")) - require.Equal(t, before, underlying.trailers, - "a terminated stream sets no trailer rather than setting one nobody checked") +} + +// And when authority holds, Finish releases them and returns the handler's own +// error unchanged. +func TestFinishReleasesTrailersWhenAuthorityHolds(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + guarded.SetTrailer(metadata.Pairs("c", "3")) + guarded.SetTrailer(metadata.Pairs("d", "4")) + handlerErr := errors.New("the handler's own failure") + require.ErrorIs(t, guarded.Finish(handlerErr), handlerErr, + "Finish reports the handler's result, not its own") + require.Equal(t, 1, underlying.trailers, + "the pending trailers are released together, once") +} + +// The wrapper must expose NO route to the stream it wraps. +// +// It used to embed grpc.ServerStream as a public field, so a handler given only +// the intercepted stream could write stream.(*GuardedServerStream).ServerStream +// and send with no check at all. This asserts the shape rather than the +// behaviour, because the behaviour it prevents would not compile: no exported +// field, and nothing exported that hands back a grpc.ServerStream. +func TestTheWrapperExposesNoRouteToTheRawStream(t *testing.T) { + wrapper := reflect.TypeOf(GuardedServerStream{}) + serverStream := reflect.TypeOf((*grpc.ServerStream)(nil)).Elem() + + for field := range wrapper.NumField() { + declared := wrapper.Field(field) + require.False(t, declared.IsExported(), + "%s is exported; a handler can reach it and bypass the guard", declared.Name) + require.False(t, declared.Anonymous && declared.Type == serverStream, + "the stream must not be embedded: embedding is what made it reachable") + } + + pointer := reflect.TypeOf(&GuardedServerStream{}) + for method := range pointer.NumMethod() { + declared := pointer.Method(method) + for result := range declared.Type.NumOut() { + require.NotEqual(t, serverStream, declared.Type.Out(result), + "%s returns a grpc.ServerStream, which is a route around the guard", + declared.Name) + } + } + + // And it still satisfies the interface it replaces, by forwarding rather + // than embedding. + require.Implements(t, (*grpc.ServerStream)(nil), &GuardedServerStream{}) } // The interceptor is the half a wrapper cannot provide: enforcement as wiring. @@ -202,6 +261,7 @@ func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { require.IsType(t, &GuardedServerStream{}, handed, "the handler must not be able to receive the unguarded stream") require.Len(t, underlying.sent, 1) + require.NotImplements(t, (*interface{ ServerStream() grpc.ServerStream })(nil), handed) } // A server may declare a stream unguarded, but it does so AT THE SERVER where diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go new file mode 100644 index 0000000..63a47a8 --- /dev/null +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -0,0 +1,217 @@ +package grpctransport + +import ( + "context" + "errors" + "io" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// A REAL gRPC server and client, over bufconn, with the interceptor installed. +// +// The dynamic review was right that a recording stream's call count is not +// evidence here: what a trailer does is decided by gRPC, not by the wrapper. +// gRPC sends trailers when the HANDLER RETURNS, so a handler could set one +// under authority, have authority revoked, return, and the client received it +// — through the ordinary guarded API, with the interceptor installed and +// nothing unwrapped. Only a real client can show that. + +const ( + streamService = "codefly.test.Streamer" + streamMethod = "/codefly.test.Streamer/Emit" +) + +// revocableGuard is a stream guard whose authority a test can withdraw at an +// exact moment, counting the checks it performs. +type revocableGuard struct { + revoked atomic.Bool + checks atomic.Uint64 +} + +func (g *revocableGuard) guard(t *testing.T) *workcontext.StreamGuard { + t.Helper() + built, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + g.checks.Add(1) + if g.revoked.Load() { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + return built +} + +// serveGuarded stands up a real gRPC server whose one streaming method runs +// handler behind StreamServerInterceptor, and returns a client connection. +func serveGuarded( + t *testing.T, + guard *workcontext.StreamGuard, + handler func(stream grpc.ServerStream) error, +) *grpc.ClientConn { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer(grpc.StreamInterceptor( + StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }), + )) + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: streamService, + HandlerType: (*any)(nil), + Streams: []grpc.StreamDesc{{ + StreamName: "Emit", + ServerStreams: true, + Handler: func(_ any, stream grpc.ServerStream) error { + return handler(stream) + }, + }}, + }, new(any)) + + served := make(chan struct{}) + go func() { + defer close(served) + _ = server.Serve(listener) + }() + t.Cleanup(func() { + server.Stop() + <-served + }) + + connection, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + return connection +} + +// drain runs the stream to completion and reports what the client saw. +func drain(t *testing.T, connection *grpc.ClientConn) (received int, trailer metadata.MD, err error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stream, err := connection.NewStream(ctx, + &grpc.StreamDesc{StreamName: "Emit", ServerStreams: true}, streamMethod) + require.NoError(t, err) + require.NoError(t, stream.CloseSend()) + for { + message := &wrapperspb.StringValue{} + recvErr := stream.RecvMsg(message) + if errors.Is(recvErr, io.EOF) { + return received, stream.Trailer(), nil + } + if recvErr != nil { + return received, stream.Trailer(), recvErr + } + received++ + } +} + +// (1) A trailer queued under authority must NOT reach the client when +// authority is withdrawn before the handler returns. +func TestARealClientNeverReceivesATrailerAuthorityWasWithdrawnFrom(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + // Queued under authority, which holds at this instant. + stream.SetTrailer(metadata.Pairs("confidential-result", "tenant-data")) + // And withdrawn before the handler returns, which is when gRPC would + // otherwise send it. + authority.revoked.Store(true) + return nil + }) + + received, trailer, err := drain(t, connection) + require.Error(t, err, "a handler that completed under withdrawn authority must fail the RPC") + require.Zero(t, received) + require.Empty(t, trailer.Get("confidential-result"), + "the client received a trailer whose authority had been withdrawn") +} + +// And the same trailer DOES arrive when authority holds all the way through, +// so the guard is not simply swallowing everything. +func TestARealClientReceivesTheTrailerWhenAuthorityHolds(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + stream.SetTrailer(metadata.Pairs("result", "ok")) + return stream.SendMsg(wrapperspb.String("one")) + }) + + received, trailer, err := drain(t, connection) + require.NoError(t, err) + require.Equal(t, 1, received) + require.Equal(t, []string{"ok"}, trailer.Get("result")) + require.Positive(t, int(authority.checks.Load())) +} + +// (2) A handler holding only the intercepted stream has no route to the raw +// one, so a message sent under revoked authority does not reach the client. +// +// The probe that found this recovered the embedded field and delivered +// `message under revoked authority` with ZERO checks. There is no embedded +// field now, so what the handler can attempt is the guarded API — and every +// attempt is refused. +func TestARealHandlerCannotSendUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + + var attempts, refusals int + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + // Whatever this handler reaches for, it is the wrapper. + require.IsType(t, &GuardedServerStream{}, stream) + for range 3 { + attempts++ + if err := stream.SendMsg(wrapperspb.String("message under revoked authority")); err != nil { + refusals++ + } + } + stream.SetTrailer(metadata.Pairs("leaked", "yes")) + return nil + }) + + received, trailer, err := drain(t, connection) + require.Error(t, err) + require.Zero(t, received, "a message left under revoked authority") + require.Empty(t, trailer.Get("leaked")) + require.Equal(t, attempts, refusals, "every send under revoked authority was refused") + require.Positive(t, int(authority.checks.Load()), "and every one of them was checked") +} + +// Revocation mid-stream stops the stream at the next message, over a real +// connection: the first messages arrive, the rest do not. +func TestARealStreamStopsAtTheMessageAfterRevocation(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + for message := range 10 { + if message == 3 { + authority.revoked.Store(true) + } + if err := stream.SendMsg(wrapperspb.String("payload")); err != nil { + return err + } + } + return nil + }) + + received, _, err := drain(t, connection) + require.Error(t, err) + require.Equal(t, 3, received, + "three messages were sent under authority and the fourth was refused") +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index fb8cdc6..d43bba0 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -366,3 +366,187 @@ func hourOfCalls(t *testing.T, lifetime time.Duration, maxTTL time.Duration) (ui } return host.requests.Load(), client.Counts() } + +// D3 (round three). A transient response-body read failure must not stop the +// client permanently. +// +// The host answers 200 and the connection goes away mid-body. That was wrapped +// in ErrMintRefused, which became TERMINAL once refusals started latching: one +// interrupted read permanently stopped a process holding a perfectly good +// credential, and it stayed stopped after the endpoint recovered. A body that +// stops mid-read is an outage. +func TestAnInterruptedResponseBodyIsAnOutageRatherThanARefusal(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // A 200 whose body fails partway. The transport is the client's, so the + // failure is injected through it rather than through a socket. + transport, ok := client.httpClient.Transport.(*http.Transport) + require.True(t, ok) + t.Cleanup(func() { client.httpClient.Transport = transport }) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(interruptedBody{}), + }, nil + }) + + // Inside the renewal lead, so the credential in hand still has minutes. + clock = testClock.Add(13 * time.Minute) + served, err := client.Credential(t.Context()) + require.NoError(t, err, "an interrupted read is an outage, and the held credential survives it") + require.Equal(t, held.Token(), served.Token()) + require.NoError(t, client.Refused(), "and it must not be latched as terminal") + + // And the endpoint recovering recovers the client. + client.httpClient.Transport = transport + clock = testClock.Add(13*time.Minute + 2*minMintBackoff) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.NotEqual(t, held.Token(), fresh.Token()) +} + +// interruptedBody returns a short read and then a transport failure, which is +// what a connection dropped after the headers looks like to io.ReadAll. +type interruptedBody struct{} + +func (interruptedBody) Read(p []byte) (int, error) { + copied := copy(p, `{"work_context":"partial`) + return copied, io.ErrUnexpectedEOF +} + +// An oversized or malformed COMPLETE response stays terminal, because the host +// answered and answered wrongly: retrying gets the same answer. +func TestACompleteButUnacceptableResponseStaysTerminal(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + oversized := strings.Repeat("a", maxMintResponseBytes+64) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"work_context":"` + oversized + `"}`)), + }, nil + }) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "exceeds") + require.ErrorIs(t, client.Refused(), ErrMintRefused) +} + +// D4 (round three). A cancelled waiter must not be handed an EXPIRED credential. +// +// The waiter snapshotted the credential and its servability BEFORE waiting, +// then answered from that snapshot on cancellation without re-reading the +// clock. A waiter that started a second before expiry and cancelled a second +// after it received an expired credential with a nil error — and two seconds +// fits easily inside the default five-second request timeout. +func TestACancelledWaiterIsNeverHandedAnExpiredCredential(t *testing.T) { + clock := testClock + var clockMu sync.RWMutex + now := func() time.Time { + clockMu.RLock() + defer clockMu.RUnlock() + return clock + } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + expiry := held.ExpiresAt() + + // One second before expiry, with the host holding its answer: a renewal is + // in flight and a second caller becomes a waiter. + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + clockMu.Lock() + clock = expiry.Add(-time.Second) + clockMu.Unlock() + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 2*time.Second, time.Millisecond) + + // The waiter's own context is cancelled one second AFTER expiry. + clockMu.Lock() + clock = expiry.Add(time.Second) + clockMu.Unlock() + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + served, err := client.Credential(cancelled) + + require.Error(t, err, "there is nothing servable: the held credential has expired") + require.ErrorIs(t, err, ErrMintUnavailable) + require.Empty(t, served.Token(), + "a cancelled waiter was handed a credential that expired at %s", expiry) + + letGo() + <-renewing +} + +// D6 (round three). DisallowUnknownFields applies to the value just decoded and +// says nothing about what follows it, so a valid response with a second JSON +// value appended was accepted. The response is ONE object and nothing after it. +func TestAMintResponseCarryingTrailingJSONIsRefused(t *testing.T) { + now := func() time.Time { return testClock } + authority := newAuthority(t) + token := authority.start(t, mintInput{}) + + for name, payload := range map[string]string{ + "a second object": `{"work_context":"` + token + `"}{"unsupported_security_field":true}`, + "a trailing array": `{"work_context":"` + token + `"}[1,2,3]`, + "a trailing scalar": `{"work_context":"` + token + `"} 7`, + } { + t.Run(name, func(t *testing.T) { + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(payload)), + }, nil + }) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "more than one JSON value") + }) + } + + // And trailing WHITESPACE is fine: a response is allowed to end in a + // newline, which is what most encoders write. + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"work_context":"` + token + `"}` + "\n\n ")), + }, nil + }) + _, err := client.Credential(t.Context()) + require.NoError(t, err, "a trailing newline is not a second value") +} diff --git a/workcontext/mint.go b/workcontext/mint.go index b7f67dd..d1da5ca 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -595,25 +595,35 @@ func (c *MintClient) obtain( // servable is the credential this call may fall back on when minting is // UNAVAILABLE. A refresh has none: its caller was refused on the // credential being held, so handing that same credential back would - // report a replacement that did not happen. - servable := held - if reason == mintReasonRefresh || held == nil || !c.now().UTC().Before(held.expiresAt) { - servable = nil - } + // report a replacement that did not happen. It is recomputed rather + // than captured wherever the clock may have moved since. + servable := c.servableLocked(reason) if waiting := c.inflight; waiting != nil { // Another goroutine is minting. Wait for it rather than queueing a // second request, and let ctx cancel the wait. - snapshot := Credential{} - if servable != nil { - snapshot = *servable - } - canServe := servable != nil c.mu.Unlock() select { case <-waiting: continue case <-ctx.Done(): - if canServe { + // Re-evaluate UNDER THE LOCK, against the clock as it is now. + // This used to answer from a snapshot taken before the wait, + // so a waiter that started one second before expiry and + // cancelled one second after it was handed an EXPIRED + // credential with a nil error — and two seconds fits easily + // inside the default five-second request timeout. + c.mu.Lock() + terminal := c.refused + servableNow := c.servableLocked(reason) + snapshot := Credential{} + if servableNow != nil { + snapshot = *servableNow + } + c.mu.Unlock() + if terminal != nil { + return Credential{}, terminal + } + if servableNow != nil { return snapshot, nil } // Both stay in the chain: a caller that cancelled wants to see @@ -873,9 +883,21 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia } payload, err := io.ReadAll(io.LimitReader(response.Body, maxMintResponseBytes+1)) if err != nil { - return Credential{}, fmt.Errorf("%w: read mint response: %v", ErrMintRefused, err) + // An OUTAGE, not a refusal. A body that stops mid-read is a connection + // interrupted after the headers — the host said 200 and the network + // went away — and classifying it ErrMintRefused made it TERMINAL once + // refusals started latching: one interrupted read permanently stopped + // a process holding a perfectly good credential, and it stayed stopped + // after the endpoint recovered. + // + // The underlying error stays in the chain so a caller can see what the + // transport did, rather than only that minting was unavailable. + return Credential{}, fmt.Errorf("%w: read mint response: %w", ErrMintUnavailable, err) } if len(payload) > maxMintResponseBytes { + // Terminal, unlike the read failure above: the host answered, and + // answered with something this client will not accept. Retrying gets + // the same answer. return Credential{}, fmt.Errorf( "%w: mint response exceeds %d bytes", ErrMintRefused, maxMintResponseBytes, ) @@ -894,6 +916,15 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential if err := decoder.Decode(&body); err != nil { return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintRefused, err) } + // DisallowUnknownFields applies to the value just decoded and says nothing + // about what follows it, so a valid response with a second JSON value + // appended was accepted — strict parsing that stopped at the first value. + // The response is ONE object and nothing after it. + if err := decoder.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { + return Credential{}, fmt.Errorf( + "%w: mint response carries more than one JSON value", ErrMintRefused, + ) + } token := body.WorkContext claims, seal, binding, err := sealOf(token) if err != nil { diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index 15fcb0b..f901071 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -102,11 +102,53 @@ var tlsPlumbing = map[string]struct { }, } -// protoMarshalAllowlist is every file that may encode a protobuf message, and -// what it encodes. Encoding the CAPABILITY is the second implementation; this -// one encodes a scope, for a cache digest preimage, and never a WorkContext. -var protoMarshalAllowlist = map[string]string{ - "cache_partition.go": "a WorkScopeV1, for the cache digest preimage — never the capability", +// codecAllowlist is the only codec use in this module: a file, the codec +// operation, and THE TYPE it may be applied to. +// +// The type is the part that was missing. The exemptions used to be per FILE, so +// cache_partition.go — allowed proto.Marshal for a scope — could marshal a +// *Claims, and mint.go — allowed encoding/json for two tagged structs — could +// json.Marshal a map[string]any holding a capability's fields, needing no tags +// at all. Both were reproduced as AST probes that produced zero findings. A +// whole-file exemption is an exemption for every type in the file. +var codecAllowlist = []codecUse{ + {file: "mint.go", operations: jsonOperations, types: mintEndpointJSONTypes, + reason: "the mint endpoint's two HTTP bodies, which carry the capability as an opaque string"}, + {file: "mint.go", operations: jsonValueOperations, + types: append(append([]string{}, mintEndpointJSONTypes...), "RawMessage"), + reason: "the same two bodies, plus a RawMessage the response decoder reads into to " + + "require EOF — it holds nothing and is discarded"}, + {file: "cache_partition.go", operations: protoOperations, types: []string{"WorkScopeV1"}, + reason: "one scope, for the cache digest preimage — never a capability"}, +} + +type codecUse struct { + file string + operations []string + types []string + reason string +} + +var ( + // The operations that take the VALUE. NewDecoder and NewEncoder are not + // among them: they take a reader or a writer and encode nothing, and the + // type that matters is the one handed to Decode or Encode afterwards — + // which is checked separately, because its receiver is a local variable + // rather than the package. + jsonOperations = []string{"Marshal", "Unmarshal"} + jsonValueOperations = []string{"Decode", "Encode"} + protoOperations = []string{"Marshal", "MarshalOptions", "Unmarshal", "UnmarshalOptions"} +) + +// permittedCodecTypes is the set of type names a codec operation may be applied +// to in this file, or nil when the file may use no codec at all. +func permittedCodecTypes(path string, operation string) []string { + for _, use := range codecAllowlist { + if use.file == path && slices.Contains(use.operations, operation) { + return use.types + } + } + return nil } // workContextNameAllowlist is this module's own declarations whose names begin @@ -233,6 +275,7 @@ func inspectForSecondImplementation(file sourceFile) []string { coreImports := map[string]bool{} plumbing := map[string]string{} // local name -> allowed import path protoNames := map[string]bool{} // every local name google.golang.org/protobuf is reachable under + jsonNames := map[string]bool{} // and encoding/json for _, imported := range importsOf(file.syntax) { if imported.name == "." { // A dot import makes every one of a package's identifiers @@ -252,11 +295,16 @@ func inspectForSecondImplementation(file sourceFile) []string { if strings.HasPrefix(imported.path, "google.golang.org/protobuf") { protoNames[imported.name] = true } + if imported.path == "encoding/json" { + jsonNames[imported.name] = true + } } findings = append(findings, inspectPlumbingSymbols(file, plumbing)...) findings = append(findings, inspectProtoEncoding(file, protoNames)...) findings = append(findings, inspectDeclarations(file, coreImports)...) findings = append(findings, inspectJSONTags(file)...) + findings = append(findings, inspectJSONCalls(file, jsonNames)...) + findings = append(findings, inspectJSONValueCalls(file, len(jsonNames) > 0)...) return findings } @@ -373,7 +421,7 @@ func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string } switch selector.Sel.Name { case "Marshal", "MarshalOptions": - if _, allowed := protoMarshalAllowlist[file.path]; allowed { + if codecArgumentIsPermitted(file, node, "Marshal") { return true } case "Unmarshal", "UnmarshalOptions": @@ -381,6 +429,9 @@ func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string // last blocker: a second unverified parser needs base64 to open the // envelope and Unmarshal to read it. corework.Inspect does both, and // hands back the claims, so nothing here needs either. + if codecArgumentIsPermitted(file, node, "Unmarshal") { + return true + } findings = append(findings, fmt.Sprintf( "%s calls %s.%s.\n"+ "Reading a capability off the wire is corework.Inspect's job, and it returns the\n"+ @@ -393,14 +444,13 @@ func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string return true } findings = append(findings, fmt.Sprintf( - "%s calls %s.%s.\n"+ + "%s calls %s.%s on a type it is not allowed to.\n"+ "Encoding the message is half of a signer: core signs the deterministic protobuf\n"+ "encoding, so MarshalOptions{Deterministic: true} over a capability is the deleted\n"+ - "implementation with a different import list. The qualifier is resolved from the\n"+ - "import map, so an alias does not get past this. Only %v may encode, and only\n"+ - "because what it encodes is %v.", - file.path, qualifier.Name, selector.Sel.Name, - allowedProtoMarshalFiles(), protoMarshalReasons())) + "implementation with a different import list. The exemption names a FILE AND A TYPE\n"+ + "(%v), because a whole-file exemption is an exemption for every type in the file —\n"+ + "which is how a *Claims could be marshalled through the one meant for a scope.", + file.path, qualifier.Name, selector.Sel.Name, codecAllowlist)) return true }) return findings @@ -532,11 +582,11 @@ func inspectJSONTags(file sourceFile) []string { } findings = append(findings, fmt.Sprintf( "%s declares a struct with json tags. Only %v in %s may.\n"+ - "A JSON-tagged struct describing a capability's fields is the second implementation:\n"+ - "core signs the deterministic protobuf encoding, and a field enumerated by hand is a\n"+ - "field dropped at mint and absent at verify. Naming a type mintRequest somewhere else\n"+ - "does not inherit the endpoint's exemption — the exemption is that file's, not the\n"+ - "identifier's.", + "A JSON-tagged struct describing a capability's fields is the second\n"+ + "implementation: core signs the deterministic protobuf encoding, and a field\n"+ + "enumerated by hand is a field dropped at mint and absent at verify. Naming a type\n"+ + "mintRequest somewhere else does not inherit the endpoint's exemption — the\n"+ + "exemption is that file's, not the identifier's.", file.path, mintEndpointJSONTypes, mintEndpointJSONFile)) return true } @@ -568,6 +618,76 @@ func allowedMintBodies(file sourceFile) map[ast.Node]bool { return allowed } +// inspectJSONCalls refuses a json codec call applied to anything but the mint +// endpoint's two bodies. +// +// The TAG rule above is not enough on its own, and that was a reproduced gap: +// json.Marshal of a map[string]any holding a capability's fields needs no tags +// at all, so it produced zero findings in the one file allowed to import +// encoding/json. Tags describe a struct; this describes what is encoded. +func inspectJSONCalls(file sourceFile, jsonNames map[string]bool) []string { + if len(jsonNames) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok || !jsonNames[qualifier.Name] { + return true + } + if !slices.Contains(jsonOperations, selector.Sel.Name) { + return true + } + if codecArgumentIsPermitted(file, node, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s on a type it is not allowed to.\n"+ + "Only %v may be JSON-encoded here, by name and by type. A map[string]any holding a\n"+ + "capability's fields needs no json tags, so the tag rule alone let it through — and a\n"+ + "hand-enumerated capability is exactly the implementation this module deleted.", + file.path, qualifier.Name, selector.Sel.Name, mintEndpointJSONTypes)) + return true + }) + return findings +} + +// inspectJSONValueCalls checks what a json decoder or encoder is pointed AT. +// +// json.NewDecoder takes a reader, so the type that matters is the one handed to +// Decode — and its receiver is a local variable, not the package, so the +// package-qualified rule above cannot see it. +func inspectJSONValueCalls(file sourceFile, importsJSON bool) []string { + if !importsJSON { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || !slices.Contains(jsonValueOperations, selector.Sel.Name) { + return true + } + // A method call on something, in a file that speaks JSON. The + // allowlist row for Decode/Encode is what it may be pointed at, which + // is a DIFFERENT row from Marshal/Unmarshal's — so the operation's own + // name is what selects it. + if codecArgumentIsPermitted(file, node, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s on a type it is not allowed to.\n"+ + "Only %v may be JSON-decoded or encoded here, by type. A decoder takes a reader, so\n"+ + "the type that matters is what it is pointed at.", + file.path, selector.Sel.Name, mintEndpointJSONTypes)) + return true + }) + return findings +} + // TestTheGateCatchesItsOwnBypasses drives the checks over source written to // slip past them. // @@ -804,6 +924,43 @@ import "google.golang.org/protobuf/types/known/anypb" var _ = anypb.New`, says: "types/known/anypb", }, + // Round-three dynamic: both of these produced ZERO findings, because + // the codec exemptions were per FILE. A whole-file exemption is an + // exemption for every type in the file. + "a capability JSON-encoded as a map, needing no tags": { + path: "mint.go", + source: `package workcontext +import "encoding/json" +func leak(c *Claims) ([]byte, error) { + return json.Marshal(map[string]any{ + "installation_id": c.GetSeal().GetInstallationId(), + "audience": c.GetAudience(), + }) +}`, + says: "calls json.Marshal on a type it is not allowed to", + }, + "a capability decoded into, in the file allowed JSON": { + path: "mint.go", + source: `package workcontext +import ( + "bytes" + "encoding/json" +) +func parse(raw []byte) (*Claims, error) { + claims := &Claims{} + return claims, json.NewDecoder(bytes.NewReader(raw)).Decode(claims) +}`, + says: "calls Decode on a type it is not allowed to", + }, + "a capability proto-marshalled through the scope exemption": { + path: "cache_partition.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func sign(c *Claims) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(c) +}`, + says: "on a type it is not allowed to", + }, "a WorkContext func bound to a var": { path: "carrier.go", source: `package workcontext @@ -834,7 +991,13 @@ type mintRequest struct { type mintResponse struct { WorkContext string ` + "`json:\"work_context\"`" + ` } -var _ = json.Marshal`, +func encode(audience string) ([]byte, error) { + return json.Marshal(mintRequest{Audience: audience}) +} +func decode(raw []byte) (mintResponse, error) { + var body mintResponse + return body, json.Unmarshal(raw, &body) +}`, }, "an alias of core's type": { path: "core.go", @@ -984,22 +1147,173 @@ func envelopeDecoderReasons() []string { return reasons } -func allowedProtoMarshalFiles() []string { - names := make([]string, 0, len(protoMarshalAllowlist)) - for name := range protoMarshalAllowlist { - names = append(names, name) +// codecArgumentIsPermitted reports whether this codec call is applied to a +// type the file is allowed to apply it to. +// +// It resolves the argument's type SYNTACTICALLY, from the file alone: a +// composite literal names its own type, a type assertion names the asserted +// type, an address-of or a star defers to what it wraps, and an identifier is +// looked up among the file's var declarations, short declarations and function +// parameters. There is no go/types here and so no cross-file inference, which +// is a stated limit rather than a claim: what it has to catch is a codec +// applied to something the allowlist did not name, and both reproduced probes +// — a map[string]any and a *Claims — are named locally in the file that uses +// them. +// +// An argument it cannot resolve is NOT permitted. A gate that passed what it +// could not read would be a gate about what is easy to parse. +func codecArgumentIsPermitted(file sourceFile, call ast.Node, operation string) bool { + permitted := permittedCodecTypes(file.path, operation) + if len(permitted) == 0 { + return false } - slices.Sort(names) - return names + arguments := codecArguments(file, call) + if len(arguments) == 0 { + // A codec reference with no call to read — json.Marshal passed as a + // value, say. Nothing names a type, so nothing is permitted. + return false + } + // Only the argument carrying the VALUE is checked. An Unmarshal or Decode + // takes the bytes first and the destination last; a Marshal or Encode takes + // the value first. Requiring every argument to be an allowlisted type + // refused `json.Unmarshal(raw, &body)` on account of raw being []byte, + // which is not what the rule is about. + value := arguments[0] + if operation == "Unmarshal" || operation == "Decode" { + value = arguments[len(arguments)-1] + } + named := resolveTypeName(file, value) + return named != "" && slices.Contains(permitted, named) } -func protoMarshalReasons() []string { - reasons := make([]string, 0, len(protoMarshalAllowlist)) - for _, reason := range protoMarshalAllowlist { - reasons = append(reasons, reason) +// codecArguments finds the call this codec selector belongs to and returns the +// arguments whose types matter. For a MarshalOptions{...}.Marshal(x) chain the +// selector sits inside the outer call, so the whole file is walked for the call +// whose function expression contains this node. +func codecArguments(file sourceFile, target ast.Node) []ast.Expr { + var arguments []ast.Expr + ast.Inspect(file.syntax, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + contains := false + ast.Inspect(call.Fun, func(inner ast.Node) bool { + if inner == target { + contains = true + } + return true + }) + if contains && len(call.Args) > 0 { + arguments = call.Args + } + return true + }) + return arguments +} + +// resolveTypeName names the type of an expression, syntactically, or returns "" +// when it cannot. +func resolveTypeName(file sourceFile, expression ast.Expr) string { + switch typed := expression.(type) { + case *ast.UnaryExpr: + return resolveTypeName(file, typed.X) + case *ast.CompositeLit: + return typeName(typed.Type) + case *ast.TypeAssertExpr: + return typeName(typed.Type) + case *ast.CallExpr: + // new(T) names T, not "new". + if function, ok := typed.Fun.(*ast.Ident); ok && function.Name == "new" && len(typed.Args) == 1 { + return typeName(typed.Args[0]) + } + // A conversion: T(x) names T; anything else is unknown. + return typeName(typed.Fun) + case *ast.Ident: + return declaredTypeName(file, typed.Name) } - slices.Sort(reasons) - return reasons + return "" +} + +// typeName reduces a type expression to its bare name: *pkg.T, pkg.T, []T and +// map[K]V all answer what a reader would call them. +func typeName(expression ast.Expr) string { + switch typed := expression.(type) { + case *ast.StarExpr: + return typeName(typed.X) + case *ast.SelectorExpr: + return typed.Sel.Name + case *ast.Ident: + return typed.Name + case *ast.ArrayType: + return "[]" + typeName(typed.Elt) + case *ast.MapType: + // Deliberately not reduced to its value type: a map is never an + // allowlisted codec type, and naming it as one is how the json probe + // would have passed. + return "map" + } + return "" +} + +// declaredTypeName looks an identifier up among this file's declarations: var +// specs, short variable declarations, and function parameters and results. +func declaredTypeName(file sourceFile, name string) string { + found := "" + ast.Inspect(file.syntax, func(node ast.Node) bool { + switch declared := node.(type) { + case *ast.ValueSpec: + for index, declaredName := range declared.Names { + if declaredName.Name != name { + continue + } + if declared.Type != nil { + found = typeName(declared.Type) + return false + } + if index < len(declared.Values) { + found = resolveTypeName(file, declared.Values[index]) + return false + } + } + case *ast.AssignStmt: + if declared.Tok != token.DEFINE { + return true + } + for index, left := range declared.Lhs { + ident, ok := left.(*ast.Ident) + if !ok || ident.Name != name || index >= len(declared.Rhs) { + continue + } + found = resolveTypeName(file, declared.Rhs[index]) + return false + } + case *ast.FuncDecl: + for _, list := range fieldLists(declared.Type) { + for _, field := range list { + for _, declaredName := range field.Names { + if declaredName.Name == name { + found = typeName(field.Type) + return false + } + } + } + } + } + return true + }) + return found +} + +func fieldLists(signature *ast.FuncType) [][]*ast.Field { + var lists [][]*ast.Field + if signature.Params != nil { + lists = append(lists, signature.Params.List) + } + if signature.Results != nil { + lists = append(lists, signature.Results.List) + } + return lists } func allowedJSONFiles() []string { From 8440c8e1448ec2aa604be47b37af626e6463ead6 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:52:07 -0400 Subject: [PATCH 13/35] =?UTF-8?q?refactor(workcontext)!:=20re-pin=20to=20c?= =?UTF-8?q?ore's=20settled=20surface=20at=20cd443989=20=E2=80=94=20the=20s?= =?UTF-8?q?eal=20carries=20an=20image=20digest,=20and=20the=20kit's=20fixt?= =?UTF-8?q?ure=20key=20needs=20saying=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core#692 is settled at cd443989 with all 21 findings closed. Three of its changes reach this module and one of them is a trap core warned about and I walked into anyway, which is why it is worth naming. The seal is four fields and ImageDigest is one of them, required: a seal names the approved build for its installation, and a mint attests the execution it is actually running against it. StartInput now carries Execution{ImageDigest, BuildIncarnation}. Nothing in the client changed for this — Credential.Seal() has returned the CARRIED seal as core's own message since the conflation was fixed, so a new required seal field is a test fixture change rather than a code change. That is the whole return on having fixed it. TrustTheConformanceFixtureKey has to be copied from the kit's settings. Without it all 35 fixtures fail with "key conformance-1 is the conformance fixture key, whose private half is public" — a consumer doing exactly the right thing refusing everything, which is the failure core predicted and then hit itself. And it is the argument FOR the field-by-field build the second review asked for: a field core adds to the contract lands here as a failing test, where settings.Verifier() would have inherited it silently. Core refuses unknown fields recursively and non-canonical encodings now. The new unknown-field fixture is refused structurally, so it joins the list of twelve — and the EXACTNESS assertion on that list is what found it rather than letting it drift into coverage unremarked. That assertion was added two rounds ago for this exact reason and this is the first time it has earned its keep. Holding my ground on one thing core asked for again: the client-side lifetime ceiling STAYS. Core says drop it because Authority.MaxTTL exists; a review already caught me deleting it on that advice and was right that it was a weakening. Measured at this pin: MaxTTL honours any positive value (mint.go:458 — zero takes the default, anything else is returned as given, and the error text says "raise Authority.MaxTTL deliberately if that is wanted"), and Verify bounds no lifetime at all. So nothing but this refuses a month-long credential. Two layers enforcing one rule is not the duplication this module exists to prevent; that was two implementations of one decision disagreeing. No goldens to re-measure here, re-verified after the bump: nothing in this repository digests or signs anything embedding a capability, and there are no committed digest values. receipts.RequestDigest takes whatever message a consumer passes, so a consumer whose request embeds a capability must re-measure — core's own executionreceipt broke twice on exactly that. v0.8.0 is still untagged, so this is a pseudo-version on cd443989 and moves to the tag at merge. Core has one item open for its owner that would reach us: the kit's private key is still linked into any binary importing workcontext, and relocating the kit to a test-only package would move every Fixture* symbol these tests now use. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 22 +++++----- workcontext/README.md | 40 ++++++++++++++++++- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/grpctransport/two_service_test.go | 11 +++++ workcontext/minting_test.go | 18 +++++++++ workcontext/one_implementation_test.go | 16 +++++++- workcontext/parse_agreement_test.go | 8 ++++ workcontext/stream_test.go | 18 ++++----- 9 files changed, 113 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6718fd..448dd81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,16 +139,18 @@ see the skill below. There is no compatibility period: a release line that cannot meet the rule is **retired by the owner**, not exempted. - **A credential is sealed or it is not a credential, and core says what that - means.** The seal and every actor hop's epoch are **schema-required**, so a - missing one is a `protovalidate` refusal inside core's decode; an operation - binding carries its id, revision and incarnation or none of them. `Attach` - refuses an unsealed capability with `corework.Inspect`'s answer — this claim - was false for a while precisely because we answered it ourselves and never - read the actor chain. **There is one sentinel for every structural seal - defect, `ErrInvalid`.** `ErrUnsealed` is deleted: once the schema requires - the seal, no branch can produce it, and a sentinel no branch produces invites - a handler that never runs. Read a refusal's sentinel off core's fixture, never - from a constant here. + means.** The seal, each actor hop's epoch and the seal's **image digest** are + schema-required, so a missing one is a `protovalidate` refusal inside core's + decode; an operation binding carries its id, revision and incarnation or none + of them; and core refuses an unknown field or a non-canonical encoding, so + nothing here may ever marshal a capability. `Attach` refuses an unsealed + capability with `corework.Inspect`'s answer — this claim was false for a while + precisely because we answered it ourselves and never read the actor chain. + **One sentinel for every structural seal defect, `ErrInvalid`.** `ErrUnsealed` + is deleted: once the schema requires the seal no branch can produce it, and a + sentinel no branch produces invites a handler that never runs. Read a + refusal's sentinel off core's fixture, never from a constant here — three + fixtures changed sentinel under us and the tests that did that needed no edit. - **An authority-bearing value is read once.** A principal, binding or audience comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for a pinned name, so a drift is an error rather than a reload — the process has diff --git a/workcontext/README.md b/workcontext/README.md index 3afa23f..52d529c 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -105,6 +105,18 @@ naming the format before any key lookup is the only diagnosis that points at the format. That is why the gate lives here, in the consumer: core cannot see who re-implements it. +**One field in that build is load-bearing and easy to miss.** +`TrustTheConformanceFixtureKey` must be copied from the settings: the fixture +key's private half is derivable from core's source, so a verifier refuses it +unless it says in as many words that it is a test. Leaving it out made all 35 +fixtures fail with *"key `conformance-1` is the conformance fixture key"* — a +consumer doing exactly the right thing refusing everything. + +Which is the argument **for** building the verifier field by field rather than +calling `settings.Verifier()`: a field core adds to the contract lands here as +a failing test. Through the constructor it would have been inherited silently +and this module would have learned nothing. + What the kit proves is **behavioural**: this entry point reaches core's accept/refuse decision with core's named reason on every fixture. It does not prove identity — an equivalent second implementation would pass the same @@ -127,6 +139,15 @@ three are now core's answer rather than a local one: | `actor-without-epoch` | `ErrInvalid` | **accepted** — the actor chain was never read, so a principal nobody can revoke went on the wire | | zero epoch / revision / incarnation, partial binding | `ErrInvalid` | `ErrUnsealed` | +**The seal is four fields, and `ImageDigest` is one of them.** `WorkSealV1` +gained `image_digest` as a **required** field: a seal names the approved build +for its installation, and a mint attests the execution it is actually running +against it (`StartInput.Execution{ImageDigest, BuildIncarnation}`). A seal that +names no digest names no execution to match a caller against, so core refuses +to record one. `Credential.Seal()` returns the carried seal as core's own +message, which is why that field arriving needed no code change here — see +`SealedValues`. + **`ErrUnsealed` no longer exists, and that is the end of this class of defect.** `WorkContextV1.seal` and `WorkActorV1.principal_epoch` are schema-**required** now, so `protovalidate` refuses a missing seal or a missing actor epoch inside @@ -305,7 +326,22 @@ hold-off bounds `Refresh` too, which the generation check alone does not: a receiver whose live state lags refuses each FRESH credential, and every refusal is a new generation. -**The lifetime ceiling is the host's, and there is no client-side one.** A +**There are two lifetime ceilings, deliberately.** Core's `Authority.MaxTTL` +(one hour by default) is the primary one, at the minter where it binds every +client. `MintOptions.MaxCredentialLifetime` (24h by default) is the second, at +the process that holds the credential. + +Keeping both is a decision taken twice. It was deleted once on core's advice +that the cap now lives at the minter, and a review was right that deleting it +was a **weakening**: `MaxTTL` has no ceiling of its own — core honours any +positive value, and its own error says *"raise `Authority.MaxTTL` deliberately +if that is wanted"* — and core's `Verify` does not bound a lifetime at use at +all. So a misconfigured host minting month-long credentials would be refused by +nothing else. Two layers enforcing one rule is not the duplication this module +exists to prevent: that was two *implementations* of one decision reaching +different answers. + +**What follows is the ceiling's effect on #47's arithmetic:** A review asked for a client-side cap because core checked only that a TTL was positive; this carried one, labelled a stopgap whose condition was "it goes when core has a cap". Core has one — `Authority.MaxTTL`, defaulting to @@ -439,7 +475,7 @@ either import path. | Sentinel | Means | What the caller does | | --- | --- | --- | -| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, **no seal at all**, a seal naming no installation, a zero epoch/revision/incarnation, a partial binding, **an actor hop with no epoch**, a malformed capability, an expired or not-yet-valid window | refuse; 401 | +| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, **no seal at all**, a seal naming no installation or no image digest, a zero epoch/revision/incarnation, a partial binding, **an actor hop with no epoch**, **an unknown field or a non-canonical encoding**, a malformed capability, an expired or not-yet-valid window | refuse; 401 | | `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, an actor hop's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, **a binding granted to another principal or in another installation**, an installation the principal no longer holds, or the authorization revision moved | mint again, retry once | | `ErrNotACoreToken` | the payload is not this encoding at all — most usefully, a JSON one | refuse, and do **not** report a signature problem | | `ErrReplayed` | a single-use capability was presented twice | refuse; this is the resume contract, not a forgery | diff --git a/workcontext/go.mod b/workcontext/go.mod index 6cf3324..bb6d3b0 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979 + github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 09bb7a5..c152a87 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979 h1:mhxENwTDU52blHN352drZkymRzlHCnPkN9nANH2NTQ4= -github.com/codefly-dev/core v0.7.2-0.20261003001047-9f68df85e979/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5 h1:1VuGrlgZqi3j4LUCmzEiJM3eVTU/0d13qSQ5OEES4XI= +github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index 675b617..b02eff1 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -283,14 +283,25 @@ func hopSeal() workcontext.Seal { InstallationID: "installation-hop", InstallationRevision: 12, BuildIncarnation: 9, + // The approved build for this installation, required now: a seal that + // names none names no execution to match a caller against. + ImageDigest: hopImageDigest, } } +const hopImageDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + // startTask issues the capability the external caller holds: for service A // only. func (h *hopTopology) startTask(t *testing.T) string { t.Helper() token, _, err := h.authority.minter.Start(context.Background(), corework.StartInput{ + // What this caller attests it is running, matched against the build + // the seal names as approved. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopSeal().BuildIncarnation, + }, Audience: audienceA, TenantID: hopTenant, OwnerPrincipalID: hopPrincipal, OwnerPrincipalKind: "service", TaskID: "task-hop", InstallationID: hopSeal().InstallationID, diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index e4994cd..e34ac25 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -24,6 +24,10 @@ var ( InstallationID: testInstallation, InstallationRevision: 3, BuildIncarnation: 11, + // The approved build for this installation. It is REQUIRED now: a seal + // that names no image digest names no execution to match a caller + // against, so the mint has nothing to attest. + ImageDigest: testImageDigest, } // testLiveBinding is the binding as the ISSUER holds it: granted to one // principal, within one installation. A capability carries only the first, @@ -45,6 +49,9 @@ const ( testPrincipal = "principal-1" testInstallation = "installation-1" testBinding = "binding-1" + // testImageDigest is the image-manifest digest the test "runs", which the + // seal names as approved and the mint attests against. + testImageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" // testPrincipalEpoch is the owner's live epoch, which the seal source // answers rather than the seal carrying it. testPrincipalEpoch = 7 @@ -104,6 +111,11 @@ func (a *authority) verifier(t *testing.T) *Verifier { Grants: noGrants{}, Seals: a.seals, Now: a.now, + // These tests mint with core's FIXTURE key, whose private half is + // derivable from core's source — so a verifier refuses it unless it + // says, in as many words, that it is a test. A production verifier + // leaving this unset is the point of the field. + TrustTheConformanceFixtureKey: true, } } @@ -150,6 +162,12 @@ func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifeti input.scopes = []*basev0.WorkScopeV1{scope("document", "read")} } token, _, err := minter.Start(context.Background(), corework.StartInput{ + // What the caller attests it is running, matched against the build the + // seal names as approved. A mint without it is refused. + Execution: corework.Execution{ + ImageDigest: testImageDigest, + BuildIncarnation: testSeal.BuildIncarnation, + }, TenantID: input.tenant, OwnerPrincipalID: testPrincipal, OwnerPrincipalKind: "service", diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index f901071..dc0507a 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -1048,8 +1048,8 @@ func TestWorkContextConformance(t *testing.T) { Issuer: settings.Issuer, Audience: settings.Audience, // Settings.Keys is map[string][]byte so holding settings needs no - // signing import; the verifier's own field is the typed one. - Keys: corework.FixtureKeys(), + // signing import; PublicKeys() is the typed form the verifier takes. + Keys: settings.PublicKeys(), Revisions: settings.Revisions, // One replay store for the whole run: the kit presents the single-use // grant fixture twice and requires ErrReplayed, which a fresh store per @@ -1058,6 +1058,18 @@ func TestWorkContextConformance(t *testing.T) { Grants: settings.Grants, Seals: settings.Seals, Now: settings.Now, + // Copied from the kit rather than written here, and it is REQUIRED: the + // fixture key's private half is derivable from core's source, so a + // verifier refuses it unless it says in as many words that it is a + // test. Leaving it out made every one of the 35 fixtures fail with + // "key \"conformance-1\" is the conformance fixture key" — a consumer + // doing exactly the right thing refusing everything. + // + // Which is the argument FOR building this field by field rather than + // calling settings.Verifier(): a new field core adds to the contract + // lands here as a failing test. Through the constructor it would have + // been inherited silently and this module would have learned nothing. + TrustTheConformanceFixtureKey: settings.TrustTheConformanceFixtureKey, } conformance.RunWith(t, settings, func(ctx context.Context, token string) error { _, err := exported.Verify(ctx, token) diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go index a81d9af..2edeb86 100644 --- a/workcontext/parse_agreement_test.go +++ b/workcontext/parse_agreement_test.go @@ -35,6 +35,14 @@ var structurallyRefused = map[string]bool{ "zero-build-incarnation": true, // ErrInvalid: zero is not an incarnation "partial-operation-binding": true, // ErrInvalid: an id at no revision "actor-without-epoch": true, // ErrInvalid: a principal nobody can revoke + + // The encoding itself. Core's Verify now refuses unknown fields + // recursively and non-canonical encodings, and Inspect reaches the same + // answer — so a capability with a field appended and re-signed is refused + // here too, with no key and no network. This fixture arrived with the + // settled surface and the exactness assertion below is what found it, + // which is the reason that assertion exists. + "unknown-field": true, // ErrInvalid: an appended field, re-signed } // TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go index 218720b..d9927e4 100644 --- a/workcontext/stream_test.go +++ b/workcontext/stream_test.go @@ -171,16 +171,16 @@ func TestStreamGuardValidatesItsConfiguration(t *testing.T) { func TestTheStreamRecheckDoesNotConsumeASingleUseCapability(t *testing.T) { settings := conformance.New(time.Now()) verifier := &Verifier{ - Issuer: settings.Issuer, - Audience: settings.Audience, - Keys: corework.FixtureKeys(), - Revisions: settings.Revisions, - Replay: settings.Replay, - Grants: settings.Grants, - Seals: settings.Seals, - Now: settings.Now, + Issuer: settings.Issuer, + Audience: settings.Audience, + Keys: settings.PublicKeys(), + Revisions: settings.Revisions, + Replay: settings.Replay, + Grants: settings.Grants, + Seals: settings.Seals, + Now: settings.Now, + TrustTheConformanceFixtureKey: settings.TrustTheConformanceFixtureKey, } - fixtures, err := corework.Fixtures(settings.Now()) require.NoError(t, err) var grant corework.Fixture From 42b2a171566621b89f9596ff72b75e83e368a136 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:28:20 -0400 Subject: [PATCH 14/35] fix(workcontext)!: the stream wrapper owns the context, the mint request outlives its caller, and the gate stops being about names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four, layer 5 (Opus at max, static at ffaa5c0). Two blockers, six majors, six minors and eight doubted tests. The blocker was predicted statically and could not be run; both halves reproduced over bufconn on the first attempt, and the same is true of the unbounded request rate. THE BLOCKER: metadata leaves a guarded stream by two routes the previous round did not consider. PROBE 1 header=[secret] A header queued with SetHeader under authority reached the client AFTER Finish had refused. gRPC flushes a pending header frame from WriteStatus when the handler returns, which is after any revocation in between. The wrapper's own comment argued the opposite. PROBE 2 transport stream in ctx: true; SendHeader err=; header=[leaked] trailer=[leaked] The package-level grpc.SetHeader/SendHeader/SetTrailer resolve a grpc.ServerTransportStream out of the context and write to the transport. With the guard revoked for the whole call, both arrived and the guard was NEVER ASKED. So the wrapper now holds headers as well as trailers until Finish re-checks, and it owns the transport stream in the context it hands out, so those three functions route back through the checks. Both probes are committed as tests. That is three routes out of one stream, each found only after the previous was closed: the embedded field, when gRPC sends, and the context. THE UNBOUNDED REQUEST RATE, measured: 20 callers -> 20 mint requests. The round-three fix for the cancelled leader counted a cancellation as no failure, so with caller deadlines shorter than a degraded host's latency every call became the new leader. The shared request is now detached from whoever started it and bounded by RequestTimeout: it completes once, is classified once, and installs its result for everybody. The same 20 callers produce ONE request, and a refusal arriving after its caller gave up is latched rather than discarded. THE GATE STOPS BEING ABOUT NAMES. A complete second parser compiled green: typeName dropped the package qualifier, declaredTypeName returned the LAST same-named declaration in the file and ignored scope, and a codec row was keyed on an operation name that every codec shares. Now types resolve package-qualified, in the scope of the use, before the use, refusing ambiguity; rows are keyed on (file, codec, type); function-local types and aliases to core's types outside core.go are refused; and base64 may ENCODE and never DECODE. Eleven new bypass probes, each a shape the review named. THE SWEEP TESTS IMPORT PATHS, not a line's shape — a non-ASCII alias and a comment-prefixed import both walked past the old regex, in the root module where it is the only gate. encoding/pem, mime, base32, ascii85, crypto/cipher and the block ciphers are banned; the residue an import ban cannot reach (a hand-rolled HMAC over an allowed hash) is now stated rather than claimed away. Eight probes commit that, and it fails closed: a fetch failure is a failure. The workflow sweeps EVERY published ref rather than a compat/* glob. The owner having retired the stale branches is what makes that possible: 22 of 25 remote branches carried the deleted implementation, so retiring the three named compat refs would have turned the gate green with 19 copies published. It excludes the ref being merged into, because main still carries work_context.go and this is the change that deletes it. Core re-pinned to 72d72eb2: Verified.Context() deep-copies (at cd443989 it handed out the internal pointer, so RecheckWith was defeatable by anyone holding a *Verified), and MaxTTLCeiling is absolute with Verify bounding lifetime. That is exactly the falsifiable condition this module named when it declined to drop its own ceiling, so the REASON changes and the check stays: this client never verifies a signature — it is the party minted for, not a receiver — and reads the window through Inspect, which is structural. It now defaults to core's own constant, so there is one number rather than two. Classification, which decides what recovery is possible: a projected-token EMFILE or EIO, a momentarily empty projection, an unlabelled error from a caller's own token source and a 408 are all outages, not latched refusals. A TLS verification failure is the opposite — something answered and could not prove it is the endpoint — so it latches. Local misuse is ErrInvalid, never ErrMintRefused, which Refused() and errors.Is disagreed about. Also: the refresh limiter is a token bucket that DECAYS (it was mintBackoff(lifetime refresh count), so one refresh per minute forever); a refresh for an already-replaced generation is answered from the credential in hand; a host returning the credential it was asked to replace is not a replacement; a credential is bound to the client that issued it; guarded is a property of the METHOD and the first answer binds; a refusal reaches the client as codes.Unauthenticated rather than codes.Unknown; TLS 1.3 floor. Tests the review doubted, fixed: the cancelled-waiter reproducer now moves the clock WHILE the waiter is blocked (it moved it before entry, so the old code passed); the rate-limit test asserts a rate over advancing time and that the limit forgets; no test calls require on a server goroutine any more, and the host records faults for the test goroutine to assert — which caught me immediately, as `Post "https://...": EOF` for an over-MaxTTL mint. Every fix is mutation-verified: the behaviour reverted, the new test shown to fail. Two first attempts SURVIVED and are recorded in the PR comment, because each fix was two coupled edits and mutating one half proved nothing. Docs: workcontext/AGENTS.md is new — the root file was over its own 200-line budget, and it prescribes a nested file beside what it describes. README covers the three stream routes, the detached request, the bucket, the ceiling and the status mapping; the recipe is the interceptor, not Guard. Not verified: no service has booted against a real runtime. grpc-go's flush and context behaviour are measured over bufconn, not against a production server. The 19 stale branches are gone from the remote, which I confirmed, but whether any consumer had pinned one I cannot see. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 64 +- AGENTS.md | 63 +- scripts/check-one-implementation.sh | 152 +++-- sweep_bypasses_test.go | 110 ++++ workcontext/AGENTS.md | 139 +++++ workcontext/README.md | 148 +++-- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/grpctransport/guarded_stream.go | 344 +++++++++-- .../grpctransport/guarded_stream_test.go | 7 +- .../grpctransport/real_grpc_stream_test.go | 41 +- .../grpctransport/round4_reproducers_test.go | 256 ++++++++ workcontext/mint.go | 553 +++++++++++++----- workcontext/mint_test.go | 159 +++-- workcontext/minting_test.go | 16 +- workcontext/one_implementation_test.go | 541 ++++++++++++++--- workcontext/round4_reproducers_test.go | 364 ++++++++++++ 17 files changed, 2493 insertions(+), 470 deletions(-) create mode 100644 sweep_bypasses_test.go create mode 100644 workcontext/AGENTS.md create mode 100644 workcontext/grpctransport/round4_reproducers_test.go create mode 100644 workcontext/round4_reproducers_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 46734da..190bfba 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -57,39 +57,61 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - # fetch-depth 0 so the compat refs are reachable: the second step reads - # them out of the object database. + # fetch-depth 0 so every published ref is reachable: the second step + # reads them out of the object database. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Sweep every tracked Go file for a second implementation run: ./scripts/check-one-implementation.sh - - name: Sweep every published release line - # This step is EXPECTED TO FAIL until the owner retires the three refs - # that still carry the deleted implementation, and that failure is the - # point rather than an oversight. + - name: Sweep every OTHER ref this repository publishes + # EVERY REMOTE BRANCH, not a name glob — minus the one this change is + # becoming, which the working-tree step above already swept. # - # go.yml builds compat/** as published artifacts, and those branches - # carry their own copy of this workflow — so no change here can gate a - # push to one of them. What a change here CAN do is refuse to let main - # move while the rule is false of a ref this repository publishes. - # Under "legacy means delete", retiring them is a merge precondition, - # not a runbook item: the previous revision ran the sweep with no - # arguments, so a green check meant only that the working tree was - # clean, which is the weakest thing it could have meant. + # The previous revision swept 'compat/*' and 'feat/*compat*', which was + # a rule about ref NAMES in a gate whose whole subject is that the + # implementation is gone. Measured: of 25 remote branches, 22 carried + # work_context.go, so retiring the three named compat refs would have + # turned this step green with 19 copies still published — on branches a + # consumer can pin exactly as it pins a compat ref. The owner has since + # deleted all of them, which is what makes a COMPLETE sweep possible + # rather than a documented gap. + # + # The base branch and this branch are excluded, and that is not a hole: + # sweeping the ref a pull request merges INTO is circular. main still + # carries workcontext/work_context.go, this pull request is what deletes + # it, and a check that stayed red until main was clean could never let + # the change that cleans it land. main is covered by the working-tree + # step of its own push build — the same sweep over the same files one + # commit later. + # + # It also no longer FAILS OPEN. `fetch || true` followed by "no refs, + # exit 0" meant a network failure, a permissions change or a renamed + # remote all reported success — the three cases where a gate is most + # needed and least able to say so. run: | - git fetch --no-tags --quiet origin \ - '+refs/heads/compat/*:refs/remotes/origin/compat/*' \ - '+refs/heads/feat/*:refs/remotes/origin/feat/*' || true + git fetch --no-tags --prune --quiet origin \ + '+refs/heads/*:refs/remotes/origin/*' + base="${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}" + here="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}" # Collected into an array rather than word-split from a string, so a # ref name with a space cannot silently become two arguments. refs=() while IFS= read -r ref; do - [ -n "$ref" ] && refs+=("$ref") - done < <(git for-each-ref --format='%(refname:short)' \ - 'refs/remotes/origin/compat/*' 'refs/remotes/origin/feat/*compat*') + [ -n "$ref" ] || continue + case "$ref" in + origin|origin/HEAD) continue ;; + "origin/$base") continue ;; + esac + if [ -n "$here" ] && [ "$ref" = "origin/$here" ]; then + continue + fi + refs+=("$ref") + done < <(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/*') + echo "sweeping ${#refs[@]} other published ref(s); the working tree was swept above" if [ ${#refs[@]} -eq 0 ]; then - echo "no published release lines to sweep" + # Not a pass by default: the fetch above is what would have failed. + echo "this repository publishes no ref other than $base" exit 0 fi ./scripts/check-one-implementation.sh "${refs[@]}" diff --git a/AGENTS.md b/AGENTS.md index 448dd81..6d24e91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ package line. | `tls.go` | workload leaf certificates, reloaded on rotation | | `receipts/` | effect receipts: the store, the digest, the replay/conflict interceptor | | `receipts/grpctransport/`, `receipts/connecttransport/` | the two transport adapters, split so neither drags the other's dependency in | -| `workcontext/` | **separate leaf module**: the mint-once client, the carriers, the cache partition, the stream guard, and typed access to core's one implementation | +| `workcontext/` | **separate leaf module**: the mint-once client, the carriers, the cache partition, the stream guard, and typed access to core's one implementation. Its own `AGENTS.md` holds what the two gates refuse | | `workcontext/grpctransport/` | the gRPC carrier, so a consumer that makes no gRPC call never compiles grpc | `workcontext` mints nothing and verifies nothing: `core/workcontext` is the only @@ -117,40 +117,15 @@ see the skill below. variable ships the name with an empty string; `RuntimeValue` reports `false` so it cannot shadow the configuration a caller falls back to. Keep that. - **The Work Context has exactly one implementation and it is not here.** - `core/workcontext` signs, verifies, and — since this was asked for — answers - the *structural* question too, through `Inspect`. Nothing here may sign, check - a signature, encode a capability **or decide what a capability is**: a local - seal rule is a second implementation even when it signs nothing, and ours - disagreed with core's own fixtures about which sentinel three refusals earn. - `TestNoSecondWorkContextImplementation` refuses by capability rather than by - name — any signature, MAC or JOSE primitive, anything under `x/crypto`, - `protojson`, a `proto.Marshal` outside the one digest, `crypto/tls`/`x509` - outside the mint transport or using a symbol beyond it, an `encoding/json` - outside `mint.go` by exact path, a json-tagged struct anywhere else, and any - declaration whose name begins with `workcontext` case-insensitively unless it - aliases into core's module. `TestTheGateCatchesItsOwnBypasses` holds the gate - to that claim; `TestTheSDKParsePathsAgreeWithCore` drives every core fixture - through our own parse paths and requires core's sentinel. A claim added to the - wire is added to the proto in core, never to an encoder here. That test walks - from the `workcontext` module root, and the deleted implementation lived at - the repository root, so `scripts/check-one-implementation.sh` sweeps every - tracked Go file in both modules — as a job in `go.yml`, the workflow that - builds every ref this repository publishes, required on `main` by a ruleset. - There is no compatibility period: a release line that cannot meet the rule is - **retired by the owner**, not exempted. -- **A credential is sealed or it is not a credential, and core says what that - means.** The seal, each actor hop's epoch and the seal's **image digest** are - schema-required, so a missing one is a `protovalidate` refusal inside core's - decode; an operation binding carries its id, revision and incarnation or none - of them; and core refuses an unknown field or a non-canonical encoding, so - nothing here may ever marshal a capability. `Attach` refuses an unsealed - capability with `corework.Inspect`'s answer — this claim was false for a while - precisely because we answered it ourselves and never read the actor chain. - **One sentinel for every structural seal defect, `ErrInvalid`.** `ErrUnsealed` - is deleted: once the schema requires the seal no branch can produce it, and a - sentinel no branch produces invites a handler that never runs. Read a - refusal's sentinel off core's fixture, never from a constant here — three - fixtures changed sentinel under us and the tests that did that needed no edit. + `core/workcontext` signs, verifies, and answers the *structural* question + through `Inspect`. Nothing here may sign, check a signature, encode a + capability **or decide what a capability is** — a local seal rule is a second + implementation even when it signs nothing, and ours disagreed with core's own + fixtures about which sentinel three refusals earn. Two gates hold that: + `TestNoSecondWorkContextImplementation` (AST, the leaf module) and + `scripts/check-one-implementation.sh` (both modules, every published ref). + **`workcontext/AGENTS.md` is what each one refuses and why** — read it before + touching that module, the gate, or the sweep. - **An authority-bearing value is read once.** A principal, binding or audience comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for a pinned name, so a drift is an error rather than a reload — the process has @@ -159,15 +134,15 @@ see the skill below. receipt written after the commit leaves a window where the effect exists and the receipt does not, and a recovery landing there reads "no receipt" for an effect that already happened. That is why `receipts.Record` takes a `Tx`. -- **`compat/**` branches are published artifacts and are held to these rules, - not exempted from them.** Consumers pin them when `main` holds an unreleased - breaking change, so CI builds them like `main` — and the one-implementation - sweep runs in `go.yml` for exactly that reason. A line that cannot meet a rule - is **retired** by the owner, not granted a period during which the rule is - false of this repository. There was a bullet here forbidding the back-port of - a breaking deletion into a release line; it is deleted, because for the - deleted Work Context implementation it is what kept the implementation alive - on three refs CI builds. +- **Every published ref is held to these rules, not exempted from them.** A + consumer can pin any branch, so the sweep covers every one rather than a + `compat/*` name glob: measured, 22 of 25 remote branches still carried the + deleted implementation, so retiring the three NAMED compat refs would have + turned the gate green with 19 copies published. A line that cannot meet a + rule is **retired** by the owner, never granted a period during which the + rule is false here. A bullet forbidding the back-port of a breaking deletion + used to live here; it is what kept the implementation alive on refs CI + builds, so it is deleted. ## Procedures diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 4a9d83c..7cd087a 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -22,9 +22,29 @@ # one file and the wrapper in another; and its pattern was anchored on the # opening quote, so "golang.org/x/crypto/ed25519" did not match at all. Now any # import of a signature, a MAC or a JOSE/JWT library is a finding wherever it -# is, and the exceptions are listed by path with a reason. crypto/sha256 is -# deliberately not banned: a hash is not a signature, and two digests here need -# one. +# is, and the exceptions are listed by path with a reason. +# +# WHAT IT MATCHES IS THE IMPORT PATH, extracted from the import block. It used +# to match a LINE against a regex that tried to recognise an import's shape — +# `([._]\s+|[A-Za-z0-9_]+\s+)?"` — which a non-ASCII alias (`ψ +# "crypto/ed25519"`) and a comment-prefixed line (`/* x */ "crypto/ed25519"`) +# both walked straight past. Nothing here tries to recognise an alias any more: +# the path inside the quotes is what is tested, whatever precedes it. +# +# WHAT IT DOES NOT CATCH, stated because the claim was broader than the check: +# a hand-written HMAC over crypto/sha256, or a GMAC built from crypto/cipher, +# constructs a MAC out of parts that are not themselves MACs. crypto/cipher and +# the block ciphers are banned below, which leaves the hash. crypto/sha256 +# cannot be banned — two digests here need it — so in the leaf module the AST +# gate holds it to ONE FILE and a few symbols, and in the root module this +# residue is real and is closed by review, not by this script. "Any MAC is a +# finding" was a list of names; this is what the list reaches. +# +# proto.Unmarshal is likewise not banned anywhere: this is an SDK over core's +# protobuf API and the root module decodes core's messages constantly. What +# makes a second PARSER need more than that is opening the envelope, which is +# base64 — so the decoders below are banned repository-wide, and in the leaf +# module the AST gate additionally holds proto.Unmarshal to named types. # # Usage: # scripts/check-one-implementation.sh # the working tree (what CI runs) @@ -39,17 +59,27 @@ set -euo pipefail # Any of these in an import path is a signature, a MAC, or a library that mints # bearer tokens. The bare "crypto" package is included: crypto.Signer signs # Ed25519 with no ed25519 import anywhere. -primitives='"crypto"|crypto/ed25519|crypto/ecdsa|crypto/rsa|crypto/dsa|crypto/hmac|crypto/ecdh|crypto/elliptic|crypto/subtle|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' +# Signatures, MACs and anything that mints a bearer token. The bare "crypto" is +# included: crypto.Signer signs Ed25519 with no ed25519 import anywhere. +# crypto/cipher and the block ciphers are here because a GMAC or a CMAC is a +# MAC assembled from a cipher, which names no MAC. +primitives='^crypto$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' # Second encodings of the message. protojson is a complete JSON encoding of a # protobuf message on its own, which is how the deleted implementation's payload # would come back without an encoding/json import. -encoders='encoding/protojson|encoding/protowire|known/anypb|encoding/gob|encoding/asn1|encoding/xml' -# Opening a credential's envelope by hand. base64 plus a decoder is the whole of -# a second parser, and the AST gate reaches only the workcontext module — a -# compiling root package with a base64/JSON credential decoder and its own seal -# rule passed this sweep. Both are banned repository-wide, with the few honest -# uses named in allowed() by path. -envelope='encoding/base64' +encoders='encoding/protojson|encoding/protowire|known/anypb|^encoding/gob$|^encoding/asn1$|^encoding/xml$' +# Opening a credential's envelope by hand. base64 plus proto.Unmarshal is the +# whole of a second parser, and the AST gate reaches only the workcontext module +# — a compiling root package with a base64/JSON credential decoder and its own +# seal rule passed this sweep. +# +# base64 is not the only way to spell it, which is why this is a list and not +# one path: encoding/pem decodes a base64 body, mime.WordDecoder decodes +# base64, and base32/ascii85 are alternative envelopes a host could be talked +# into. encoding/hex and encoding/binary are deliberately absent: neither can +# open core's envelope, which is base64url. +envelope='^encoding/base64$|^encoding/base32$|^encoding/ascii85$|^encoding/pem$|^mime$|^mime/' + status=0 # allowed — the exceptions, each with a reason in the comment. @@ -57,53 +87,77 @@ status=0 # configuration and x509 for the caller's root pool. Nothing else here may. allowed() { case "$1|$2" in - 'workcontext/mint.go|'*'crypto/tls'*) return 0 ;; - 'workcontext/mint.go|'*'crypto/x509'*) return 0 ;; - 'tls.go|'*'crypto/tls'*) return 0 ;; - 'tls.go|'*'crypto/x509'*) return 0 ;; + 'workcontext/mint.go|crypto/tls') return 0 ;; + 'workcontext/mint.go|crypto/x509') return 0 ;; + 'tls.go|crypto/tls') return 0 ;; + 'tls.go|crypto/x509') return 0 ;; # The receipts digest canonicalises a receipt REQUEST, never a capability. - 'receipts/digest.go|'*'encoding/protojson'*) return 0 ;; + 'receipts/digest.go|google.golang.org/protobuf/encoding/protojson') return 0 ;; # ENCODES a tenant and installation id into a cache key; opens no envelope. - 'workcontext/cache_partition.go|'*'encoding/base64'*) return 0 ;; + # The AST gate additionally holds that file to the ENCODE methods, which is + # the half this script cannot check. + 'workcontext/cache_partition.go|encoding/base64') return 0 ;; # The runtime's own configuration document, which is not a capability. The # AST gate cannot reach the root module, so this is named here instead. - 'configuration_document.go|'*'encoding/json'*) return 0 ;; + 'configuration_document.go|encoding/json') return 0 ;; # The mint endpoint's two HTTP bodies. In the workcontext module the AST # gate additionally holds this to the TWO TYPES; here it is by path only, # which is the weaker half and is why the AST gate exists. - 'workcontext/mint.go|'*'encoding/json'*) return 0 ;; + 'workcontext/mint.go|encoding/json') return 0 ;; + # One call: the mint response's Content-Type must be declared and must be + # application/json. mime is banned otherwise because WordDecoder decodes + # base64 with no base64 import. + 'workcontext/mint.go|mime') return 0 ;; # Effect receipts, whose rows are JSON and are not capabilities. - 'receipts/'*'|'*'encoding/json'*) return 0 ;; + 'receipts/'*'|encoding/json') return 0 ;; esac return 1 } -# findings -> prints each offending import +# import_paths -> every imported path, one per line. +# +# It takes what is INSIDE the quotes and never tries to recognise the alias in +# front of it. The previous version matched the whole line against a regex for +# an import's shape, which a non-ASCII alias and a `/* comment */` prefix both +# defeated — and in the root module this sweep is the only gate, so each was a +# complete bypass. +import_paths() { + awk ' + /^[[:space:]]*import[[:space:]]*\(/ { inblock = 1; next } + inblock && /^[[:space:]]*\)/ { inblock = 0; next } + inblock || /^[[:space:]]*import[[:space:]]/ { + if (match($0, /"[^"]+"/)) { print substr($0, RSTART + 1, RLENGTH - 2) } + } + ' "$1" +} + +# offending_imports -> prints each offending import PATH offending_imports() { - local path="$1" file="$2" line - while IFS= read -r line; do - case "$line" in - *'crypto/tls'*|*'crypto/x509'*|*'crypto/sha256'*|*'crypto/md5'*|*'crypto/sha1'*|*'crypto/sha512'*|*'crypto/rand'*) - # Hashes, randomness and TLS plumbing are not signatures. tls/x509 are - # still held to the allowlist below. - case "$line" in - *'crypto/tls'*|*'crypto/x509'*) - allowed "$path" "$line" || printf '%s\n' "$line" - ;; - esac + local path="$1" file="$2" imported + while IFS= read -r imported; do + [ -n "$imported" ] || continue + case "$imported" in + 'crypto/tls'|'crypto/x509') + # TLS plumbing, still held to the allowlist. + allowed "$path" "$imported" || printf '%s\n' "$imported" + continue + ;; + 'crypto/sha256'|'crypto/md5'|'crypto/sha1'|'crypto/sha512'|'crypto/rand') + # A hash is not a signature and randomness is not a key. The residue — + # a hand-written HMAC over an allowed hash — is named in the header. continue ;; esac - if printf '%s' "$line" | grep -Eq "($primitives)"; then - allowed "$path" "$line" || printf '%s\n' "$line" + if printf '%s' "$imported" | grep -Eq "($primitives)"; then + allowed "$path" "$imported" || printf '%s\n' "$imported" continue fi - if printf '%s' "$line" | grep -Eq "($encoders)"; then - allowed "$path" "$line" || printf '%s\n' "$line" + if printf '%s' "$imported" | grep -Eq "($encoders)"; then + allowed "$path" "$imported" || printf '%s\n' "$imported" continue fi - if printf '%s' "$line" | grep -Eq "($envelope|\"encoding/json\")"; then - allowed "$path" "$line" || printf '%s\n' "$line" + if printf '%s' "$imported" | grep -Eq "($envelope)" || [ "$imported" = "encoding/json" ]; then + allowed "$path" "$imported" || printf '%s\n' "$imported" fi done < "$file" } @@ -117,11 +171,13 @@ carrying_in_tree() { *.go) ;; *) continue ;; esac - grep -E '^\s*(import\s+)?([._]\s+|[A-Za-z0-9_]+\s+)?"' "$path" > "$tmp" 2>/dev/null || : > "$tmp" + import_paths "$path" > "$tmp" 2>/dev/null || : > "$tmp" local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then - found="$found $path" + while IFS= read -r imported; do + found="$found$path imports $imported"$'\n' + done <<< "$bad" fi done <<< "$(git ls-files -- '*.go')" rm -f "$tmp" @@ -137,15 +193,17 @@ carrying_in_ref() { *.go) ;; *) continue ;; esac - git cat-file blob "$ref:$path" 2>/dev/null | - grep -E '^\s*(import\s+)?([._]\s+|[A-Za-z0-9_]+\s+)?"' > "$tmp" 2>/dev/null || : > "$tmp" + git cat-file blob "$ref:$path" > "$tmp.src" 2>/dev/null || : > "$tmp.src" + import_paths "$tmp.src" > "$tmp" 2>/dev/null || : > "$tmp" local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then - found="$found $path" + while IFS= read -r imported; do + found="$found$path imports $imported"$'\n' + done <<< "$bad" fi done <<< "$(git ls-tree -r --name-only "$ref")" - rm -f "$tmp" + rm -f "$tmp" "$tmp.src" printf '%s' "$found" } @@ -154,7 +212,11 @@ report() { if [ -n "$carrying" ]; then status=1 echo "FAIL $what carries a Work Context implementation:" - for path in $carrying; do echo " $path"; done + # The offending IMPORT is printed beside the file. Naming only the file + # left whoever reads the CI log to re-derive which import was the finding. + while IFS= read -r line; do + [ -n "$line" ] && echo " $line" + done <<< "$carrying" else echo "ok $what" fi diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go new file mode 100644 index 0000000..c3c38de --- /dev/null +++ b/sweep_bypasses_test.go @@ -0,0 +1,110 @@ +package codefly_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The repository-wide sweep, held to its own claim. +// +// scripts/check-one-implementation.sh is the ONLY one-implementation gate the +// root module has: TestNoSecondWorkContextImplementation walks from the +// workcontext module root, and the implementation this repository deleted lived +// at the root, in package codefly. So a bypass here is a bypass with nothing +// behind it — which is why these cases exist. Each one was reported as a +// bypass of the previous revision and each one worked: +// +// - a non-ASCII import alias and a comment-prefixed import line both walked +// past a regex that tried to recognise an import's SHAPE. The sweep now +// reads the path out of the quotes and never looks at the alias. +// - encoding/pem decodes a base64 body and mime.WordDecoder decodes base64, +// so "base64 is banned" was a ban on one spelling. +// - a GMAC through crypto/cipher is a MAC that names no MAC. +// +// Each case is a throwaway git repository, because the sweep's subject is +// "every tracked Go file" and git is how it enumerates them. +func TestTheRepositorySweepCatchesItsOwnBypasses(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]struct { + source string + says string + }{ + "a non-ASCII import alias": { + source: "package x\n\nimport ψ \"crypto/ed25519\"\n\nvar _ = ψ.Sign\n", + says: "crypto/ed25519", + }, + "a comment-prefixed import line": { + source: "package x\n\nimport (\n\t/* innocuous */ \"crypto/ed25519\"\n)\n", + says: "crypto/ed25519", + }, + "a dot-imported signer": { + source: "package x\n\nimport . \"crypto/ed25519\"\n", + says: "crypto/ed25519", + }, + "encoding/pem as a base64 decoder": { + source: "package x\n\nimport \"encoding/pem\"\n\nvar _ = pem.Decode\n", + says: "encoding/pem", + }, + "mime as a base64 decoder": { + source: "package x\n\nimport \"mime\"\n\nvar _ = mime.WordDecoder{}\n", + says: "mime", + }, + "a GMAC through crypto/cipher": { + source: "package x\n\nimport \"crypto/cipher\"\n\nvar _ = cipher.NewGCM\n", + says: "crypto/cipher", + }, + "base32 as an alternative envelope": { + source: "package x\n\nimport \"encoding/base32\"\n\nvar _ = base32.StdEncoding\n", + says: "encoding/base32", + }, + "a root-module JSON credential decoder": { + source: "package x\n\nimport \"encoding/json\"\n\nvar _ = json.Unmarshal\n", + says: "encoding/json", + }, + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe.source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, probe.says) + require.Contains(t, out, "second.go") + }) + } + + // And an ordinary file passes, so the sweep is not simply failing. + out, err := sweepOf(t, script, "ordinary.go", + "package x\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nvar _ = fmt.Sprint\nvar _ = strings.TrimSpace\n") + require.NoError(t, err, out) + require.Contains(t, out, "ok") + + // A tracked file with no Go source at all must FAIL rather than pass: a + // sweep that reports "ok" for an empty repository is a sweep that reports + // ok for a repository whose checkout went wrong. + _, err = sweepOf(t, script, "README.md", "nothing to sweep\n") + require.Error(t, err, "an empty sweep must not be a green sweep") +} + +// sweepOf runs the sweep over a throwaway repository containing one file. +func sweepOf(t *testing.T, script string, name string, source string) (string, error) { + t.Helper() + repository := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + for _, command := range [][]string{ + {"git", "init", "--quiet"}, + {"git", "add", "."}, + } { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + sweep := exec.Command("bash", script) + sweep.Dir = repository + output, err := sweep.CombinedOutput() + return string(output), err +} diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md new file mode 100644 index 0000000..c132839 --- /dev/null +++ b/workcontext/AGENTS.md @@ -0,0 +1,139 @@ +# Working in `workcontext/` + +A separate leaf module (`github.com/codefly-dev/sdk-go/workcontext`, Go 1.27, +its own `go.mod`/`go.sum` and its own coverage gate). It mints nothing and +verifies nothing: `codefly-dev/core/workcontext` is the only implementation of +the capability and this module re-exports core's verifier by type alias, in +`core.go`. Adding a dependency here is a design change — see +`.claude/skills/dual-module-change`. + +Depth that used to live in the root `AGENTS.md` and does not fit its 200-line +budget. The root file carries the rule; this carries what the gates refuse. + +## What the two gates refuse, and why each rule exists + +`TestNoSecondWorkContextImplementation` walks this module's AST. +`scripts/check-one-implementation.sh` sweeps every tracked Go file in BOTH +modules, because that test walks from this module's root and the implementation +this repository deleted lived at the repository root, in package `codefly`. + +- **By capability, never by name.** A signature, MAC or JOSE/JWT primitive, + anything under `x/crypto`, `protojson`/`protowire`/`anypb`. The bare `crypto` + is included: `crypto.Signer` signs Ed25519 with no `ed25519` import anywhere. + The predicate used to be "imports a primitive AND mentions WorkContext", + which a second implementation defeats by putting the signer in one file and + the wrapper in another. +- **Allowed imports are held to FILES and SYMBOLS.** `crypto/tls`, + `crypto/x509`, `mime`, `crypto/sha256` and `encoding/base64` each have an + honest use in exactly one file. An import allowed whole-file is allowed for + everything in that package — which is how `x509.ParsePKCS8PrivateKey`, a + signer getting a key without naming `ed25519`, would be a permitted use of an + allowed import rather than a finding. +- **base64 may ENCODE and never DECODE.** `cache_partition.go` builds a cache + key. base64 decoding plus `proto.Unmarshal` is the entire second parser this + module deleted, and the symbol rule alone cannot see it: `base64.StdEncoding` + is the permitted symbol and `DecodeString` is a method on the value it names. +- **A codec is held to (file, codec, type).** Not to a file — a whole-file + exemption is an exemption for every type in it — and not to an operation + name, because `Marshal` is an operation name shared by every codec, so + `mint.go`'s `encoding/json` row also permitted `proto.Unmarshal` on anything + *named* `mintResponse`. Types resolve package-qualified (`basev0.WorkScopeV1`, + not `WorkScopeV1` from anywhere), in the scope of the use, before the use, and + an ambiguous identifier is refused rather than guessed. +- **No function-local type, and no alias to core's types outside `core.go`.** + Either one hands a type-name allowlist whatever name it asks for: `type + WorkScopeV1 = basev0.WorkContextV1` makes a capability marshal as an allowed + scope, and a declaration inside a function body is invisible to every rule + that reads a file's declarations. +- **No `encoding/json` outside `mint.go` by exact path, and no json-tagged + struct elsewhere.** The deleted implementation signed a hand-written JSON + payload. The path is exact because matching the base name let any new + `mint.go` inherit the allowance — an allowlist undone by `touch`. +- **No declaration named `workcontext*`** (case-insensitively, every + declaration kind) unless it aliases into core's module. +- **The sweep tests import PATHS**, taken from inside the quotes, and never a + line's shape. Its previous regex tried to recognise an import and was walked + past by a non-ASCII alias (`ψ "crypto/ed25519"`) and a comment-prefixed line + (`/* x */ "crypto/ed25519"`) — in the root module, where it is the only gate. + +**What no import ban catches:** a hand-rolled HMAC over `crypto/sha256`, or a +GMAC assembled from `crypto/cipher`, builds a MAC out of parts that are not +MACs. `crypto/cipher` and the block ciphers are banned; the hash cannot be, +because two digests here need it. So in this module the symbol rule bounds that +residue to one file, and in the root module it is closed by review rather than +by the script. "Any MAC is a finding" was a list of names; this is what the +list reaches. + +`TestTheGateCatchesItsOwnBypasses` and +`TestTheRepositorySweepCatchesItsOwnBypasses` hold both gates to these claims: +every case is a bypass that was reported against a previous revision and +worked. `TestTheSDKParsePathsAgreeWithCore` drives every core conformance +fixture through this module's own parse paths and requires core's sentinel — +read a refusal's sentinel off the fixture, never off a constant here, because +three fixtures changed sentinel under us and the tests that did that needed no +edit. + +## A credential is sealed or it is not a credential, and core says what that means + +The seal, each actor hop's epoch and the seal's **image digest** are +schema-required, so a missing one is a `protovalidate` refusal inside core's +decode; an operation binding carries its id, revision and incarnation or none +of them; and core refuses an unknown field or a non-canonical encoding, so +nothing here may ever marshal a capability. `Attach` refuses an unsealed +capability with `corework.Inspect`'s answer — false for a while precisely +because we answered it ourselves and never read the actor chain. + +**One sentinel for every structural seal defect, `ErrInvalid`.** `ErrUnsealed` +is deleted: once the schema requires the seal, no branch can produce it, and a +sentinel no branch produces invites a handler that never runs. + +**`Credential.Seal()` returns core's own wire message**, cloned. That is why +core making `ImageDigest` a required seal field cost this module no code: a +hand-filled struct would have needed a new field everywhere it was built. + +## Metadata leaves a stream by three routes, not one + +The embedded field; gRPC's own flush when the handler RETURNS; and the +package-level `grpc.SetHeader`/`SendHeader`/`SetTrailer`, which resolve a +`grpc.ServerTransportStream` out of the context and write to the transport. +Each was a live bypass found only after the previous one was closed, and the +last two were measured over bufconn with the guard revoked for the whole call +and never asked. + +So `GuardedServerStream` keeps the stream private and forwards every interface +method by hand, HOLDS headers and trailers until `Finish` re-checks and releases +them, and owns the transport stream in the context it hands out. +`StreamServerInterceptor` — not `Guard` — is the recipe, because it does the +wrapping, the context and the `Finish`. Whether a method is guarded is a +property of the METHOD and the first answer binds; a per-request `(nil, nil)` is +the optional-carrier shape this module deleted. + +A refusal reaches the client as a gRPC status: the sentinels become +`codes.Unauthenticated`, an unreachable re-check source becomes +`codes.Unavailable`, and the Go error chain is kept alongside the code so +`errors.Is` still works in the server that produced it. + +## The mint client holds a credential it cannot verify + +It is the party the credential is minted FOR, not a receiver, so it reads the +window through `corework.Inspect` — structural, no signature. Three +consequences that are easy to get backwards: + +- **`MaxCredentialLifetime` defaults to `corework.MaxTTLCeiling`**, core's own + constant, so there is one number rather than two to keep in step. Core's + `Start` bounds an honest minter and core's `Verify` bounds a receiver on a + current core; what this process holds in memory and presents for its whole + life is bounded here or nowhere. +- **The sentinel says what RECOVERY is possible.** `ErrMintRefused` is latched + and terminal — a process seeing one must stop serving — so anything transient + classified that way permanently stops a process holding a good credential: an + interrupted response read, a projected-token `EMFILE`, a momentarily empty + projection, a 408. All of those are `ErrMintUnavailable`. A TLS verification + failure is the opposite: not an outage but the one thing this transport + exists to refuse, so it latches. Local misuse is `ErrInvalid`, never + `ErrMintRefused`, or `Refused()` and `errors.Is` disagree. +- **The shared request is detached from whoever started it**, bounded by + `RequestTimeout`. On the caller's context, a cancelled leader counted as no + failure, so twenty callers with deadlines shorter than a degraded host's + latency produced twenty mint requests — each of which the host may complete + and audit while this process discards it. diff --git a/workcontext/README.md b/workcontext/README.md index 52d529c..158b282 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -268,7 +268,7 @@ mutable `http.DefaultTransport`; a wrapping `RoundTripper` is opaque; a `*http.Transport` pointer can turn verification off after construction, because copying an `http.Client` shares its `Transport`. Each of those sent the projection over a channel nobody authenticated. So the client builds the -transport — TLS 1.2 minimum, verification on, no custom dialer, no proxy — and +transport — TLS 1.3 minimum, verification on, no custom dialer, no proxy — and refuses redirects, because Go's own client forwards `Authorization` across a redirect to the same host. @@ -321,34 +321,58 @@ rather than on the client's mutex, so a caller whose `ctx` is cancelled stops waiting instead of blocking on a request it is not making. After a failure the next attempt is **held off**, doubling to a minute. Without that, a host answering 503 received one request per caller per call — the heartbeat under -another name, arriving exactly when the host was least able to serve it. The -hold-off bounds `Refresh` too, which the generation check alone does not: a -receiver whose live state lags refuses each FRESH credential, and every refusal -is a new generation. - -**There are two lifetime ceilings, deliberately.** Core's `Authority.MaxTTL` -(one hour by default) is the primary one, at the minter where it binds every -client. `MintOptions.MaxCredentialLifetime` (24h by default) is the second, at -the process that holds the credential. - -Keeping both is a decision taken twice. It was deleted once on core's advice -that the cap now lives at the minter, and a review was right that deleting it -was a **weakening**: `MaxTTL` has no ceiling of its own — core honours any -positive value, and its own error says *"raise `Authority.MaxTTL` deliberately -if that is wanted"* — and core's `Verify` does not bound a lifetime at use at -all. So a misconfigured host minting month-long credentials would be refused by -nothing else. Two layers enforcing one rule is not the duplication this module -exists to prevent: that was two *implementations* of one decision reaching -different answers. - -**What follows is the ceiling's effect on #47's arithmetic:** A -review asked for a client-side cap because core checked only that a TTL was -positive; this carried one, labelled a stopgap whose condition was "it goes -when core has a cap". Core has one — `Authority.MaxTTL`, defaulting to -`DefaultMaxTTL` of **one hour** — so the cap is at the minter, where it binds -every client rather than the ones that opted in, and a host raising it does so -in configuration a reviewer sees. A second ceiling here at a different number -would be the two-places-one-rule failure this whole change is about. +another name, arriving exactly when the host was least able to serve it. + +**The request itself is detached from the caller that started it,** bounded by +`RequestTimeout`. It used to run on the leader's own context, and a cancelled +leader was then a case of its own — no failure, so no hold-off. Measured: with +twenty callers whose deadlines were shorter than a degraded host's latency, +every call became the new leader and the host received **twenty requests**, +each of which it may complete and audit while this process discards it. A +refusal arriving just after its caller gave up was discarded too, so the next +caller presented the projected token again. Detached, the request completes +once, is classified once, and installs its result for everybody — and the same +twenty callers produce **one** request. + +**`Refresh` has its own rate limit, and it decays.** The generation check does +not bound it: a receiver whose live state lags refuses each FRESH credential, so +every refusal is a new generation. The bound is a token bucket — a burst of +three, refilling one per minute — which bounds the steady state to one mint a +minute while answering an honest revocation at once. It used to be the failure +backoff computed from the lifetime refresh count, which never decayed: after a +few legitimate refreshes the bound was a minute *permanently*, so a refresh days +later still waited for a receiver that had lagged that morning. That is a tax, +not a rate limit. + +**There are two lifetime ceilings, deliberately, and they are ONE NUMBER.** +Core's `MaxTTLCeiling` (24h) is absolute: `Authority.maxTTL` clamps to it, +`Start` refuses a TTL beyond it, and `Verify` refuses a credential whose +lifetime exceeds it. `MintOptions.MaxCredentialLifetime` defaults to **that same +constant**, read from core, so there is no second number to keep in step — a +deployment may lower it, and configuring it above core's ceiling is refused at +construction. + +This check has now been deleted once on advice and restored once on review, so +what it is for is worth stating plainly. The earlier argument was that +`Authority.MaxTTL` took any positive value and `Verify` bounded no lifetime; +core `72d72eb2` closed both, which is exactly the falsifiable condition this +module named when it declined to drop the ceiling. The reason changes and the +check stays, for something neither of core's bounds covers: + +**this client never verifies a signature.** It cannot — it is the party the +credential is minted *for*, not a receiver — so it reads the window through +`corework.Inspect`, which is structural and checks no signature at all. Core's +`Start` bounds an honest *minter*; core's `Verify` bounds a *receiver* running a +current core. What this process holds in memory and presents for the rest of its +life is bounded here or nowhere. + +**The ceiling's effect on #47's arithmetic:** at `Authority.MaxTTL`'s default +of **one hour**, a process that runs for an hour costs one mint and one renewal +— so the issue's "one audit event per hour" criterion is not reachable by +holding a credential longer unless the host raises `MaxTTL`, within core's 24h +absolute ceiling. That is the host's configuration decision and this module +cannot make it; it is named in the pull request as an open question for the +issue's owner rather than silently satisfied. **That ceiling changes #47's acceptance arithmetic, so read it before sizing.** One mint across an hour of calls needs a lifetime of at least @@ -528,18 +552,43 @@ guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ Recheck: workcontext.RecheckWith(verifier, verified), }) -// And let the STREAM enforce it, rather than remembering to call it. -guarded, err := grpctransport.Guard(stream, guard) -if err != nil { - return err -} -for message := range messages { - if err := guarded.SendMsg(message); err != nil { - return err // terminated; do not resume +// And let the SERVER enforce it, rather than each handler remembering to. +server := grpc.NewServer(grpc.StreamInterceptor( + grpctransport.StreamServerInterceptor( + func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + // Whether a method is capability-bearing is static, and the FIRST + // answer for a method is binding. + if !capabilityBearing[info.FullMethod] { + return nil, nil + } + verified := verifiedFor(ctx) // from the server's own stream setup + return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: workcontext.RecheckWith(verifier, verified), + }) + }), +)) + +// The handler then just sends. It is handed the wrapper, it has no route to +// the raw stream, and the interceptor calls Finish. +func (s *server) Emit(_ *pb.Request, stream pb.Streamer_EmitServer) error { + for message := range messages { + if err := stream.Send(message); err != nil { + return err // terminated; do not resume + } } + return nil } ``` +**Use the interceptor, not `Guard` directly.** This README's previous recipe +called `Guard` in the handler, which leaves the original stream in scope, never +calls `Finish` — so every trailer the handler set was silently dropped — and +cannot replace the context, so the package-level `grpc.SetHeader`, +`grpc.SendHeader` and `grpc.SetTrailer` still wrote straight to the transport. +`Guard` remains exported for a server doing its own wrapping; if you use it, +you must call `Finish` and must pass the wrapper's `Context` to anything that +writes metadata. + `grpctransport.Guard` wraps a `grpc.ServerStream` so `SendMsg` **is** the re-check followed by the send. A guard on its own is advice: `BeforeSend` has to be called, and "before each emission" was therefore something each author had @@ -558,6 +607,31 @@ Termination is sticky: a caller that loops past the first refusal is not handed a second chance to emit, and the authority coming back does not resurrect the stream. +### There are three routes out of a stream, not one + +Each of these was a live bypass of the wrapper, and each was found only after +the previous one was closed. They are listed together because the lesson is that +"the handler calls our method" was never the only way metadata leaves. + +| Route | What happened | What closes it | +| --- | --- | --- | +| the embedded field | the wrapper embedded `grpc.ServerStream` publicly, so `stream.(*GuardedServerStream).ServerStream` sent with **no check at all** — a real gRPC probe delivered a message under revoked authority with zero re-checks | the stream is a private field and every interface method is forwarded by hand | +| when gRPC sends | gRPC sends trailers, and flushes a pending header, when the **handler returns** — so a handler could queue, have authority revoked, return, and the client received it. Measured over bufconn: a header queued under authority arrived at the client *after* `Finish` had refused | headers **and** trailers are held in the wrapper and released only by `Finish`, after one last check | +| the context | `grpc.SetHeader`, `grpc.SendHeader` and `grpc.SetTrailer` are package-level functions that resolve a `grpc.ServerTransportStream` out of the context and write to the transport. Measured over bufconn with the guard revoked for the whole call: both a header and a trailer reached the client, and the guard was **never asked** | the wrapper owns the transport stream in the context it hands out, so those three functions route back through the checks | + +What stays deliberately open: a server that keeps the original stream from +outside the interceptor, and `SendHeader`'s own flush — once a header frame is +on the wire no later revocation recalls it, which is what `SendHeader` means. + +**A refusal reaches the client as a gRPC status it can act on.** Returning the +plain Go error made every refusal arrive as `codes.Unknown` with internal text, +so a receiver could not tell "mint again" from "the server broke" — which is the +only decision the table above asks a caller to make. `ErrRevoked`, `ErrReplayed`, +`ErrInvalid` and `ErrNotACoreToken` become `codes.Unauthenticated`; a +termination for a source that could not be reached becomes `codes.Unavailable`. +The sentinel chain is kept alongside the code, so `errors.Is` still works inside +the server that produced it. + What this costs is one live authorization check per message — against an RPC-backed `SealSource`, one round trip per emitted item. That is the price of the guarantee, and it is why the guard is for streams whose messages carry diff --git a/workcontext/go.mod b/workcontext/go.mod index bb6d3b0..52afd89 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5 + github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index c152a87..b7d115a 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5 h1:1VuGrlgZqi3j4LUCmzEiJM3eVTU/0d13qSQ5OEES4XI= -github.com/codefly-dev/core v0.7.2-0.20261003013314-cd44398906c5/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9 h1:5vA9jTWfFSNbolmSojt422hftdhbWj8rukTLgQqfqcc= +github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index 29e9d0c..348853c 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -2,11 +2,14 @@ package grpctransport import ( "context" + "errors" "fmt" "sync" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -20,29 +23,51 @@ import ( // wherever somebody thought of it. Wrapping the stream makes the check // structural, and StreamServerInterceptor makes the wrapping structural too. // -// # It does not embed the stream, and that is load-bearing +// # Three routes out of a stream, not one // -// This type used to embed grpc.ServerStream as a public field, so a handler -// given only the intercepted stream could write `stream.(*GuardedServerStream). -// ServerStream` and send whatever it liked with no check at all. A real gRPC -// probe delivered a message under revoked authority with zero checks that way. -// A guard reachable past is not a guard, so the underlying stream is private -// and every method of the interface is forwarded by hand. +// Each of the following was a live bypass, and each was found after the +// previous one was fixed. They are listed together because the lesson is that +// "the handler calls our method" was never the only way metadata leaves. // -// # Trailers are held, not queued +// 1. THE EMBEDDED FIELD. This type used to embed grpc.ServerStream as a +// public field, so a handler could write +// `stream.(*GuardedServerStream).ServerStream` and send with no check at +// all. The underlying stream is private now and every interface method is +// forwarded by hand. // -// gRPC sends trailers when the handler RETURNS, not when SetTrailer is called. -// Checking authority at SetTrailer time and handing the metadata straight to -// gRPC meant a handler could queue a trailer, have authority revoked, return, -// and the client received it — through the ordinary guarded API, no unwrapping -// needed. So pending trailers stay in this wrapper, and Finish is what releases -// them, after one more check. +// 2. WHEN gRPC SENDS. gRPC sends trailers, and flushes any pending header, +// when the HANDLER RETURNS — not when SetTrailer or SetHeader is called. +// Checking at queue time and handing the metadata straight to gRPC meant a +// handler could queue, have authority revoked, return, and the client +// received it. Measured over bufconn at the previous revision: a header +// queued under authority arrived at the client after Finish had refused. +// So headers AND trailers are held in this wrapper and released by Finish. +// +// 3. THE CONTEXT. grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer are +// package-level functions that resolve a grpc.ServerTransportStream out of +// the context and write to the TRANSPORT stream. They never touch this +// type. Measured over bufconn at the previous revision: with the guard +// revoked for the whole call and zero re-checks performed, both a +// context-route header and a context-route trailer reached the client. So +// this wrapper owns the transport stream in the context it hands out, and +// those three functions route back through the checks. +// +// What remains deliberately unguarded: a handler that already holds the +// ORIGINAL stream from outside the interceptor, and SendHeader's own flush — +// once a header frame is on the wire no later revocation recalls it, which is +// what SendHeader means. type GuardedServerStream struct { stream grpc.ServerStream guard *workcontext.StreamGuard + // ctx carries THIS wrapper as the context's transport stream, so the + // package-level grpc.SetHeader/SendHeader/SetTrailer reach the checks + // instead of reaching around them. + ctx context.Context mu sync.Mutex + headers metadata.MD trailers metadata.MD + flushed bool finished bool } @@ -52,8 +77,10 @@ type GuardedServerStream struct { // because the call site would read as guarded. // // A caller using Guard directly MUST call Finish when its handler is done, or -// trailers it set are never sent. StreamServerInterceptor does that for you, -// which is the reason to prefer it. +// the headers and trailers it set are never sent, and MUST pass this wrapper's +// Context to anything that writes metadata. StreamServerInterceptor does both, +// which is the reason to prefer it — and the reason the README's recipe is the +// interceptor and not this. func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { if stream == nil { return nil, fmt.Errorf("%w: no stream to guard", workcontext.ErrInvalid) @@ -61,15 +88,35 @@ func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedSe if guard == nil { return nil, fmt.Errorf("%w: no stream guard", workcontext.ErrInvalid) } - return &GuardedServerStream{stream: stream, guard: guard, trailers: metadata.MD{}}, nil + guarded := &GuardedServerStream{ + stream: stream, + guard: guard, + headers: metadata.MD{}, + trailers: metadata.MD{}, + } + // The handler's context carries this wrapper as its transport stream. The + // inherited one is kept for Method(), which is the one thing a transport + // stream answers that is not a write. + guarded.ctx = grpc.NewContextWithServerTransportStream( + stream.Context(), + &guardedTransportStream{ + guarded: guarded, + inherited: grpc.ServerTransportStreamFromContext(stream.Context()), + }, + ) + return guarded, nil } -// Context is the stream's context, forwarded. It is not guarded: reading the -// context sends nothing. +// Context is the stream's context with this wrapper installed as its transport +// stream. Reading it sends nothing, so it is not itself guarded — but what a +// caller can reach THROUGH it is. func (s *GuardedServerStream) Context() context.Context { if s == nil || s.stream == nil { return context.Background() } + if s.ctx != nil { + return s.ctx + } return s.stream.Context() } @@ -87,6 +134,10 @@ func (s *GuardedServerStream) RecvMsg(message any) error { // sent: the whole point is that a message computed under withdrawn authority // does not leave. // +// Headers held by SetHeader are released onto the real stream first, under this +// same check, because a message flushes them anyway and they must not reach the +// wire unchecked. +// // The error is the guard's, wrapped, so a caller can tell a terminated stream // from a transport failure — and ErrStreamTerminated is sticky, so a handler // that loops past the first refusal keeps being refused rather than getting a @@ -95,74 +146,122 @@ func (s *GuardedServerStream) SendMsg(message any) error { if err := s.recheck(); err != nil { return err } + if err := s.releaseHeaders(); err != nil { + return err + } return s.stream.SendMsg(message) } -// SendHeader re-checks and sends. gRPC flushes headers immediately, so the -// check at this moment is the check that matters. +// SendHeader re-checks and sends immediately, together with anything SetHeader +// had queued. gRPC flushes on this call, so the check at this moment is the +// check that matters and no later revocation can recall the frame. func (s *GuardedServerStream) SendHeader(headers metadata.MD) error { if err := s.recheck(); err != nil { return err } - return s.stream.SendHeader(headers) + s.mu.Lock() + if s.finished { + s.mu.Unlock() + return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) + } + pending := s.headers + s.headers = metadata.MD{} + s.flushed = true + s.mu.Unlock() + merged := metadata.Join(pending, headers) + return s.stream.SendHeader(merged) } -// SetHeader re-checks and queues with gRPC. Headers queued this way are -// flushed by the first SendMsg or SendHeader, both of which re-check — so -// unlike a trailer, a queued header cannot outlive its authorization. +// SetHeader re-checks and HOLDS the metadata in this wrapper. +// +// It used to hand the metadata straight to gRPC on the strength of "a queued +// header is flushed by the first SendMsg or SendHeader, both of which +// re-check". That was false, and measured so: when a handler returns without +// having sent, gRPC's WriteStatus writes the pending header frame before the +// status, so a header queued under authority reached the client after Finish +// had already refused. Held here, it reaches gRPC only through a check. func (s *GuardedServerStream) SetHeader(headers metadata.MD) error { if err := s.recheck(); err != nil { return err } - return s.stream.SetHeader(headers) + s.mu.Lock() + defer s.mu.Unlock() + if s.finished { + return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) + } + if s.flushed { + // gRPC's own contract: headers are sent once. Forward so the caller + // gets gRPC's answer rather than a silent hold that never leaves. + return s.stream.SetHeader(headers) + } + for key, values := range headers { + s.headers[key] = append(s.headers[key], values...) + } + return nil } // SetTrailer re-checks and HOLDS the metadata in this wrapper. Nothing reaches // gRPC until Finish releases it, because gRPC would otherwise send it when the // handler returns — which is after any revocation that happened in between. func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { + _ = s.setTrailer(trailer) +} + +// setTrailer is SetTrailer with the refusal kept, for the context route: the +// package-level grpc.SetTrailer returns an error, so there a refusal can be +// reported instead of being dropped on the floor by an interface that has +// nowhere to put it. +func (s *GuardedServerStream) setTrailer(trailer metadata.MD) error { if s == nil { - return + return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) } if err := s.recheck(); err != nil { - return + return err } s.mu.Lock() defer s.mu.Unlock() if s.finished { // Past the last check there is nothing left to authorize against. - return + return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) } for key, values := range trailer { s.trailers[key] = append(s.trailers[key], values...) } + return nil } -// Finish performs the LAST re-check and releases the trailers the handler set, -// or discards them. It is called once, after the handler returns, and -// StreamServerInterceptor calls it. +// Finish performs the LAST re-check and releases the headers and trailers the +// handler set, or discards them. It is called once, after the handler returns, +// and StreamServerInterceptor calls it. // // It returns the handler's own error unchanged when authority still holds, and -// the termination error when it does not — so a handler that completed under -// authority that has since been withdrawn fails the RPC rather than succeeding -// with a trailer nobody authorized. +// a gRPC status when it does not — so a handler that completed under authority +// that has since been withdrawn fails the RPC rather than succeeding with +// metadata nobody authorized. func (s *GuardedServerStream) Finish(handlerErr error) error { if s == nil { return handlerErr } err := s.recheck() s.mu.Lock() - pending := s.trailers + pendingHeaders := s.headers + pendingTrailers := s.trailers + s.headers = metadata.MD{} s.trailers = metadata.MD{} + flushed := s.flushed s.finished = true s.mu.Unlock() if err != nil { - // Discarded. A trailer describes the result of work the authority has - // since been withdrawn from, and it is the last thing to leave. - return err + // Discarded, both of them. Metadata describes the result of work the + // authority has since been withdrawn from, and it is the last thing to + // leave. + return statusFor(err) + } + if len(pendingHeaders) > 0 && !flushed { + _ = s.stream.SetHeader(pendingHeaders) } - if len(pending) > 0 { - s.stream.SetTrailer(pending) + if len(pendingTrailers) > 0 { + s.stream.SetTrailer(pendingTrailers) } return handlerErr } @@ -182,28 +281,150 @@ func (s *GuardedServerStream) recheck() error { return s.guard.BeforeSend(s.Context()) } +// releaseHeaders hands held headers to the real stream. The caller has just +// re-checked, and a SendMsg flushes whatever is queued there, so this is the +// one moment they may cross. +func (s *GuardedServerStream) releaseHeaders() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.flushed || len(s.headers) == 0 { + return nil + } + pending := s.headers + s.headers = metadata.MD{} + s.flushed = true + return s.stream.SetHeader(pending) +} + +// guardedTransportStream is what the handler's context answers with, so the +// package-level grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer write +// through the wrapper's checks instead of straight to the transport. +// +// It exists because those three functions were a complete bypass: they take a +// context, resolve the transport stream out of it, and write. Measured over +// bufconn with the guard revoked for the whole call: a header and a trailer +// both reached the client, and the guard was never asked. +type guardedTransportStream struct { + guarded *GuardedServerStream + // inherited is gRPC's own transport stream, kept only for Method. It is + // never written to: every write goes through guarded. + inherited grpc.ServerTransportStream +} + +// Method is the only question a transport stream answers that is not a write, +// so it is the only one forwarded to gRPC's own. +func (t *guardedTransportStream) Method() string { + if t.inherited == nil { + return "" + } + return t.inherited.Method() +} + +func (t *guardedTransportStream) SetHeader(md metadata.MD) error { + return t.guarded.SetHeader(md) +} + +func (t *guardedTransportStream) SendHeader(md metadata.MD) error { + return t.guarded.SendHeader(md) +} + +func (t *guardedTransportStream) SetTrailer(md metadata.MD) error { + return t.guarded.setTrailer(md) +} + +// statusFor turns a guard refusal into a gRPC status a client can act on. +// +// Returning the plain Go error made every refusal arrive as codes.Unknown with +// internal text in the message, so a receiver could not tell "mint again" from +// "the server broke" — which is the whole decision the README's table asks a +// caller to make. The sentinel is what carries that, so it is what the code is +// read off. +func statusFor(err error) error { + code := codes.Unknown + switch { + case err == nil: + return nil + case errors.Is(err, workcontext.ErrRevoked), + errors.Is(err, workcontext.ErrReplayed), + errors.Is(err, workcontext.ErrInvalid), + errors.Is(err, workcontext.ErrNotACoreToken): + // The capability no longer authenticates. Unauthenticated is the code + // a client answers by presenting a new one, which is exactly the + // README's "mint again". + code = codes.Unauthenticated + case errors.Is(err, context.Canceled): + code = codes.Canceled + case errors.Is(err, context.DeadlineExceeded): + code = codes.DeadlineExceeded + case errors.Is(err, workcontext.ErrStreamTerminated): + // Terminated for a reason that is not one of core's sentinels: the + // re-check's own source failed. Retryable at the far end, which + // Unavailable is how to say. + code = codes.Unavailable + default: + return err + } + return statusError{err: err, code: code} +} + +// statusError carries a gRPC code to the wire AND keeps the sentinel chain for +// the process that produced it. +// +// status.Error would do the first and lose the second: it builds a fresh error +// with the text flattened in, so errors.Is(err, ErrStreamTerminated) in the +// server's own code — including its tests — stops being true the moment a code +// is attached. grpc-go reads the status off any error implementing +// GRPCStatus(), so implementing that beside Unwrap keeps both. +type statusError struct { + err error + code codes.Code +} + +func (e statusError) Error() string { return e.err.Error() } + +func (e statusError) Unwrap() error { return e.err } + +// GRPCStatus is what grpc-go reads the code and message off. +func (e statusError) GRPCStatus() *status.Status { + return status.New(e.code, e.err.Error()) +} + // StreamServerInterceptor guards every stream a server opens, so enforcement is // WIRING rather than something each handler author remembers. // // Guard on its own leaves the original stream in the caller's scope, so the // rule held wherever somebody thought of it. An interceptor is installed once, // at the server, and the handler receives a stream it cannot write around: the -// unguarded one never reaches it, and the wrapper does not expose it. +// unguarded one never reaches it, the wrapper does not expose it, and the +// context it hands out routes the package-level metadata functions back through +// the checks. // -// It also calls Finish, which is where the handler's queued trailers are -// re-checked and released. A handler that returns successfully under authority -// that was withdrawn while it ran fails the RPC. +// It also calls Finish, which is where the handler's held headers and trailers +// are re-checked and released. A handler that returns successfully under +// authority that was withdrawn while it ran fails the RPC. // // guardFor builds the guard for one stream, from whatever the server // established when the stream opened: typically the capability it verified, so // the re-check is workcontext.RecheckWith(verifier, verified). Returning a nil -// guard with a nil error means this stream carries no capability and needs -// none; returning an error refuses the stream before the handler runs, which is -// the right outcome for a capability-bearing method whose capability did not -// verify. +// guard with a nil error means this METHOD carries no capability and needs +// none. +// +// # Unguarded is a property of a method, not of a request +// +// That decision is remembered per info.FullMethod and the FIRST answer is +// binding: a later disagreement about the same method refuses the stream. A +// per-request (nil, nil) is the optional-carrier shape this module deleted — +// "guarded wherever the callback felt like it", where one buggy branch, or one +// request whose own metadata was missing, hands a handler the unguarded stream. +// Whether a method is capability-bearing is static; if a server needs it to +// vary, that is two methods. func StreamServerInterceptor( guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), ) grpc.StreamServerInterceptor { + var ( + mu sync.Mutex + decided = map[string]bool{} + ) return func( server any, stream grpc.ServerStream, @@ -218,11 +439,30 @@ func StreamServerInterceptor( } guard, err := guardFor(stream.Context(), info) if err != nil { - return err + return statusFor(err) + } + method := "" + if info != nil { + method = info.FullMethod + } + mu.Lock() + wasGuarded, seen := decided[method] + if !seen { + decided[method] = guard != nil + } + mu.Unlock() + if seen && wasGuarded != (guard != nil) { + // The method's own answer changed under us. Refusing is the only + // safe reading: if it was guarded once it is capability-bearing, + // and if it was not, something now thinks it is. + return status.Errorf(codes.Internal, + "%s: whether this method is Work Context guarded changed between requests; that is a property of the method", + method, + ) } if guard == nil { - // Explicitly unguarded, by the server's own decision, at the - // server rather than inside a handler. + // Explicitly unguarded, for this METHOD, by the server's own + // decision, at the server rather than inside a handler. return handler(server, stream) } guarded, err := Guard(stream, guard) diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go index 2d3dc5a..a8eb61b 100644 --- a/workcontext/grpctransport/guarded_stream_test.go +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -154,9 +154,14 @@ func TestAGuardedStreamHoldsTrailersUntilFinishRechecks(t *testing.T) { guarded, err := Guard(underlying, guard) require.NoError(t, err) + // SetHeader HOLDS: gRPC flushes a pending header frame when the handler + // returns, so handing it over at queue time let a header leave after a + // revocation that happened in between. Only SendHeader reaches gRPC, and + // it carries what SetHeader queued. require.NoError(t, guarded.SetHeader(metadata.Pairs("a", "1"))) + require.Zero(t, underlying.headers, "a queued header must not reach gRPC before a check") require.NoError(t, guarded.SendHeader(metadata.Pairs("b", "2"))) - require.Equal(t, 2, underlying.headers) + require.Equal(t, 1, underlying.headers) // Set under authority, and NOT yet handed to gRPC: gRPC would send it when // the handler returned, which is after any revocation in between. diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go index 63a47a8..455c1e2 100644 --- a/workcontext/grpctransport/real_grpc_stream_test.go +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -61,15 +61,24 @@ func serveGuarded( t *testing.T, guard *workcontext.StreamGuard, handler func(stream grpc.ServerStream) error, +) *grpc.ClientConn { + t.Helper() + return serveWith(t, StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }), handler) +} + +// serveWith is serveGuarded with the interceptor supplied, so a test can drive +// the interceptor's own decisions rather than only the wrapper's. +func serveWith( + t *testing.T, + interceptor grpc.StreamServerInterceptor, + handler func(stream grpc.ServerStream) error, ) *grpc.ClientConn { t.Helper() listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer(grpc.StreamInterceptor( - StreamServerInterceptor( - func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { - return guard, nil - }), - )) + server := grpc.NewServer(grpc.StreamInterceptor(interceptor)) server.RegisterService(&grpc.ServiceDesc{ ServiceName: streamService, HandlerType: (*any)(nil), @@ -173,21 +182,35 @@ func TestARealHandlerCannotSendUnderRevokedAuthority(t *testing.T) { authority.revoked.Store(true) var attempts, refusals int + faults := &handlerFaults{} connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { - // Whatever this handler reaches for, it is the wrapper. - require.IsType(t, &GuardedServerStream{}, stream) + // Whatever this handler reaches for, it is the wrapper. Recorded + // rather than asserted: require inside a handler calls FailNow on the + // SERVER's goroutine, which stops that goroutine and lets the test + // carry on, so a failed assertion here could pass the test. + if _, ok := stream.(*GuardedServerStream); !ok { + faults.record("the handler was handed %T, not the wrapper", stream) + } for range 3 { attempts++ if err := stream.SendMsg(wrapperspb.String("message under revoked authority")); err != nil { refusals++ } } + // Every route out, not only SendMsg: a queued header and a queued + // trailer under revoked authority. + if err := stream.SetHeader(metadata.Pairs("leaked-header", "yes")); err == nil { + faults.record("SetHeader was accepted under revoked authority") + } stream.SetTrailer(metadata.Pairs("leaked", "yes")) return nil }) - received, trailer, err := drain(t, connection) + header, trailer, err := clientSaw(t, connection) + received := 0 + faults.assert(t) require.Error(t, err) + require.Empty(t, header.Get("leaked-header")) require.Zero(t, received, "a message left under revoked authority") require.Empty(t, trailer.Get("leaked")) require.Equal(t, attempts, refusals, "every send under revoked authority was refused") diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go new file mode 100644 index 0000000..19b10a8 --- /dev/null +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -0,0 +1,256 @@ +package grpctransport + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// The round-four review's two blockers, as the bufconn probes it could not run. +// +// Both were predicted statically and both reproduced on the first attempt at +// the previous revision (ffaa5c0): +// +// PROBE 1 header=[secret] — a header queued with SetHeader under authority +// reached the client after Finish had refused. +// PROBE 2 transport stream in ctx: true; SendHeader err=; +// header=[leaked] trailer=[leaked] — the package-level functions on +// stream.Context() delivered both, with the guard revoked for the +// whole call and NOT ONE re-check performed. +// +// They live here rather than beside the happy-path tests because what they +// assert is about gRPC's behaviour, not the wrapper's: only a real client can +// say what left. + +// clientSaw runs the stream to completion and reports the headers AND the +// trailers the client received, which is the thing the previous round's tests +// never read. +func clientSaw(t *testing.T, connection *grpc.ClientConn) (header metadata.MD, trailer metadata.MD, err error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stream, streamErr := connection.NewStream(ctx, + &grpc.StreamDesc{StreamName: "Emit", ServerStreams: true}, streamMethod) + require.NoError(t, streamErr) + require.NoError(t, stream.CloseSend()) + var recvErr error + for { + recvErr = stream.RecvMsg(&wrapperspb.StringValue{}) + if recvErr != nil { + break + } + } + if errors.Is(recvErr, io.EOF) { + recvErr = nil + } + // Header() blocks until headers arrive or the stream ends, so it is read + // after the receive loop has finished. + header, _ = stream.Header() + return header, stream.Trailer(), recvErr +} + +// handlerFaults collects what a handler observed, so nothing calls require on +// the server's goroutine. +// +// The review was right about this: require.IsType inside the handler calls +// FailNow off the test goroutine, which Go's testing package documents as +// undefined — it stops that goroutine and the test keeps going, so a failed +// assertion in a handler could pass the test. +type handlerFaults struct { + mu sync.Mutex + faults []string +} + +func (f *handlerFaults) record(format string, args ...any) { + f.mu.Lock() + defer f.mu.Unlock() + f.faults = append(f.faults, fmt.Sprintf(format, args...)) +} + +func (f *handlerFaults) assert(t *testing.T) { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.Empty(t, f.faults, "the handler observed something it should not have") +} + +// (1) A header QUEUED with SetHeader must not reach the client when authority +// is withdrawn before the handler returns. +// +// gRPC flushes a pending header frame from WriteStatus when the handler +// returns, which is after any revocation in between. The wrapper's comment +// used to argue the opposite — that only SendMsg and SendHeader flush, and +// both re-check. Measured: [secret] arrived. +func TestARealClientNeverReceivesAHeaderAuthorityWasWithdrawnFrom(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SetHeader(metadata.Pairs("queued-header", "secret")); err != nil { + faults.record("SetHeader under authority was refused: %v", err) + } + authority.revoked.Store(true) + return nil + }) + + header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err, "a handler that completed under withdrawn authority must fail the RPC") + require.Empty(t, header.Get("queued-header"), + "the client received a header whose authority had been withdrawn") + require.Empty(t, trailer.Get("queued-header")) +} + +// And a queued header DOES arrive when authority holds all the way through, so +// holding it is not quietly dropping it. +func TestARealClientReceivesTheQueuedHeaderWhenAuthorityHolds(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + return stream.SetHeader(metadata.Pairs("queued-header", "fine")) + }) + + header, _, err := clientSaw(t, connection) + require.NoError(t, err) + require.Equal(t, []string{"fine"}, header.Get("queued-header")) +} + +// (2) The package-level grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer, +// called on the stream's context, must route through the checks. +// +// They resolve a grpc.ServerTransportStream out of the context and write to +// the transport. Measured at the previous revision: with the guard revoked for +// the whole call, grpc.SendHeader returned nil, and both the header and the +// trailer reached the client having been checked zero times. +func TestTheContextRouteCannotWriteUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + ctx := stream.Context() + if grpc.ServerTransportStreamFromContext(ctx) == nil { + faults.record("no transport stream in the handler's context: the probe proves nothing") + } + if err := grpc.SetTrailer(ctx, metadata.Pairs("ctx-trailer", "leaked")); err == nil { + faults.record("grpc.SetTrailer was accepted under revoked authority") + } + if err := grpc.SetHeader(ctx, metadata.Pairs("ctx-set-header", "leaked")); err == nil { + faults.record("grpc.SetHeader was accepted under revoked authority") + } + if err := grpc.SendHeader(ctx, metadata.Pairs("ctx-header", "leaked")); err == nil { + faults.record("grpc.SendHeader was accepted under revoked authority") + } + return nil + }) + + header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err) + require.Empty(t, trailer.Get("ctx-trailer"), "a context-route trailer reached the client") + require.Empty(t, header.Get("ctx-header"), "a context-route header reached the client") + require.Empty(t, header.Get("ctx-set-header"), "a context-route queued header reached the client") + require.Positive(t, int(authority.checks.Load()), + "the context route performed no re-check at all") +} + +// The context route works normally under authority — it is guarded, not +// disabled. A handler using the package-level functions is an ordinary handler. +func TestTheContextRouteDeliversUnderAuthority(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + ctx := stream.Context() + if err := grpc.SetHeader(ctx, metadata.Pairs("ctx-header", "ok")); err != nil { + faults.record("grpc.SetHeader under authority: %v", err) + } + if err := grpc.SetTrailer(ctx, metadata.Pairs("ctx-trailer", "ok")); err != nil { + faults.record("grpc.SetTrailer under authority: %v", err) + } + return stream.SendMsg(wrapperspb.String("one")) + }) + + header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, []string{"ok"}, header.Get("ctx-header")) + require.Equal(t, []string{"ok"}, trailer.Get("ctx-trailer")) +} + +// grpc.Method still answers, so replacing the context's transport stream did +// not take away the one thing it answers that is not a write. +func TestTheGuardedContextStillAnswersTheMethod(t *testing.T) { + authority := &revocableGuard{} + observed := make(chan string, 1) + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + method, _ := grpc.Method(stream.Context()) + observed <- method + return nil + }) + + _, _, err := clientSaw(t, connection) + require.NoError(t, err) + require.Equal(t, streamMethod, <-observed) +} + +// A revoked stream reaches the client as codes.Unauthenticated, which is the +// code a client answers by minting again — not codes.Unknown, which is what a +// plain Go error returned from an interceptor becomes. +func TestRevocationReachesTheClientAsUnauthenticated(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("never")) + }) + + _, _, err := clientSaw(t, connection) + require.Error(t, err) + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a client cannot tell 'mint again' from 'the server broke' out of codes.Unknown") +} + +// Whether a method is guarded is a property of the METHOD. A guardFor that +// answers (nil, nil) for a method it guarded before is refused rather than +// handing that request the raw stream. +func TestGuardingIsDecidedPerMethodAndNotPerRequest(t *testing.T) { + authority := &revocableGuard{} + var calls int + var mu sync.Mutex + interceptor := StreamServerInterceptor( + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return authority.guard(t), nil + } + // The optional-carrier shape: this request happens to have found + // no capability, so the handler would get the unguarded stream. + return nil, nil + }) + + connection := serveWith(t, interceptor, func(stream grpc.ServerStream) error { + if _, ok := stream.(*GuardedServerStream); !ok { + return errors.New("the handler was handed a stream that is not the wrapper") + } + return nil + }) + + _, _, first := clientSaw(t, connection) + require.NoError(t, first, "the first request establishes the method as guarded") + + _, _, second := clientSaw(t, connection) + require.Error(t, second) + require.Equal(t, codes.Internal, status.Code(second)) + require.Contains(t, status.Convert(second).Message(), "property of the method") +} diff --git a/workcontext/mint.go b/workcontext/mint.go index d1da5ca..8db42f4 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" corework "github.com/codefly-dev/core/workcontext" @@ -43,22 +44,23 @@ const ( minRenewalLeadTime = 5 * time.Second // defaultMaxCredentialLifetime is the longest credential this client will - // hold, and it is DEFENCE IN DEPTH rather than the cap. + // hold. It is CORE'S OWN CONSTANT, deliberately, so there is one rule and + // not two numbers to keep in step. // - // The cap is core's Authority.MaxTTL, which defaults to an hour. This was - // deleted on the strength of that — and deleting it was a weakening, which - // a review was right to call: MaxTTL is configurable with no ceiling of its - // own ("raise Authority.MaxTTL deliberately", core's own error says), and - // core's Verify does not bound a lifetime at all. So a host minting - // month-long credentials is refused by nothing, and a client that trusted - // the minter's default would hold one. + // This check has been deleted once on core's advice and restored once on + // review, so what it is for is worth saying precisely. Core 72d72eb2 made + // MaxTTLCeiling absolute (maxTTL clamps to it, Start refuses past it, + // Verify refuses a lifetime over it), which is exactly the condition this + // client named when it declined to drop the ceiling. The reason changes; + // the check stays, because of what neither of core's bounds covers: // - // Two checks at two layers for one rule is not the duplication this PR is - // about: that was two IMPLEMENTATIONS of one decision disagreeing about the - // answer. This is one decision — "nothing holds a credential for longer - // than this" — enforced where it is minted and again where it is held, and - // the second one failing is a signal about the first. - defaultMaxCredentialLifetime = 24 * time.Hour + // THIS CLIENT NEVER VERIFIES A SIGNATURE. It cannot — it is the party the + // credential is minted FOR, not a receiver — so it reads the window + // through corework.Inspect, which is structural and checks no signature at + // all. Core's Start bounds an honest minter and core's Verify bounds a + // receiver running a current core. What this process holds in memory, and + // presents for the rest of its life, is bounded here or nowhere. + defaultMaxCredentialLifetime = corework.MaxTTLCeiling // Backoff after a mint that failed. A failed renewal used to return an // error to every caller while the held credential still had minutes left, @@ -66,6 +68,16 @@ const ( // request per caller, which is the heartbeat under another name. minMintBackoff = time.Second maxMintBackoff = time.Minute + + // The refresh bucket. A refresh is not a failure, so it is not held off by + // the failure backoff; it is rate-limited in its own right because a + // receiver whose live state lags refuses every FRESH credential too, and + // each refusal is a new generation that the generation check cannot stop. + // + // One per minute in the steady state, with a burst of three so the honest + // case — a revocation, then a second one shortly after — is not delayed. + refreshInterval = time.Minute + maxRefreshBurst = 3.0 ) // Errors a mint caller distinguishes. @@ -104,25 +116,38 @@ type ProjectedTokenFile string // approved" rather than "the token is missing". func (path ProjectedTokenFile) ProjectedToken() (string, error) { if strings.TrimSpace(string(path)) == "" { - return "", fmt.Errorf("%w: no projected token path", ErrMintRefused) + // Misconfiguration, not a host decision: no request was made and + // retrying changes nothing. ErrInvalid rather than ErrMintRefused, + // which is latched and means "the host refused you". + return "", fmt.Errorf("%w: no projected token path", ErrInvalid) } file, err := os.Open(string(path)) if err != nil { - return "", fmt.Errorf("%w: open projected token: %v", ErrMintRefused, err) + // A LOCAL TRANSIENT FAULT, so an outage and not a refusal. EMFILE + // under load, EIO on the mount, or the projection being replaced + // between unlink and create are all momentary — and classifying them + // ErrMintRefused made them TERMINAL once refusals latched: one + // exhausted file-descriptor table permanently stopped a process that + // was holding a perfectly good credential, and it stayed stopped. This + // is the same defect the response-body read had, on the local side. + return "", fmt.Errorf("%w: open projected token: %w", ErrMintUnavailable, err) } defer func() { _ = file.Close() }() raw, err := io.ReadAll(io.LimitReader(file, maxProjectedTokenBytes+1)) if err != nil { - return "", fmt.Errorf("%w: read projected token: %v", ErrMintRefused, err) + return "", fmt.Errorf("%w: read projected token: %w", ErrMintUnavailable, err) } if len(raw) > maxProjectedTokenBytes { + // Not transient: the file is there and is the wrong thing. return "", fmt.Errorf("%w: projected token exceeds %d bytes", ErrMintRefused, maxProjectedTokenBytes) } token := strings.TrimSpace(string(raw)) if token == "" { - return "", fmt.Errorf("%w: projected token is empty", ErrMintRefused) + // The projection is rewritten under the running process, so an empty + // read is a moment in the middle of a rotation. Retryable. + return "", fmt.Errorf("%w: projected token is empty", ErrMintUnavailable) } return token, nil } @@ -241,6 +266,13 @@ type Credential struct { // mints. It is deliberately not exported: it means nothing outside the // client that issued it. generation uint64 + + // origin is which client issued it, because generation means nothing + // outside that one. Two clients in a process each count from one, so a + // credential from client B presented to client A's Refresh named a + // generation A had issued to somebody else — and A would compare it, + // believe it, and mint. Checked rather than documented. + origin uint64 } // Token returns the opaque signed capability, as it travels. It is a string and @@ -353,15 +385,41 @@ type MintClient struct { // and the error type is what tells them apart. refused error - // refreshNotBefore rate-limits REFRESH specifically, and a successful mint - // does not reset it. A receiver whose live state lags refuses each fresh - // credential, so every refusal is a new generation and the failure - // hold-off never engages: one mint per call, without limit. This is the - // bound for that, and it is separate from backoffUntil because it is not a - // failure. - refreshNotBefore time.Time + // The refresh rate limit, as a token bucket that REFILLS. + // + // It was a hold-off computed from the lifetime count — + // mintBackoff(Refreshes) — which never decayed: after a few legitimate + // refreshes the bound was a minute, permanently, so a refresh days later + // waited for a receiver that had lagged that morning. A bucket bounds the + // steady-state rate, which is what the limit is for, and forgets: it + // refills one token per refreshInterval to a burst of maxRefreshBurst, so + // an idle client refreshes at once and a receiver refusing every fresh + // credential is held to one mint per interval. + refreshTokens float64 + refreshFilled time.Time + + // lastFailure is the most recent outage, kept so a caller held off by it + // learns WHY rather than only that minting is held off. The detached + // request has no caller to return its error to, so without this the + // transport's own words — a refused connection, a certificate, a 503 — + // reached nobody. + lastFailure error + + // refreshWanted records that the in-flight request was started BY a + // refresh, so the detached goroutine can count it correctly. It cannot read + // the reason off its caller any more, which is the point: there may be no + // caller left. + refreshWanted bool + + // id distinguishes this client from any other in the process, so a + // Credential can say which one issued it. + id uint64 } +// mintClientIDs numbers clients so a Credential's generation can be held to the +// client that issued it. +var mintClientIDs atomic.Uint64 + // NewMintClient validates configuration without performing any I/O. func NewMintClient(options MintOptions) (*MintClient, error) { endpoint, err := validateMintURL(options.URL) @@ -411,7 +469,16 @@ func NewMintClient(options MintOptions) (*MintClient, error) { ceiling = defaultMaxCredentialLifetime } if ceiling <= 0 { - return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrMintRefused) + return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrInvalid) + } + if ceiling > corework.MaxTTLCeiling { + // Refused rather than clamped, so a deployment that configured a + // window nothing will mint finds out at construction instead of at the + // first mint. Core refuses the same thing on its own side. + return nil, fmt.Errorf( + "%w: maximum credential lifetime of %s is above core's absolute ceiling of %s, which no authority mints beyond", + ErrInvalid, ceiling, corework.MaxTTLCeiling, + ) } now := options.Now if now == nil { @@ -426,6 +493,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { options: options, httpClient: mintHTTPClient(options.RootCAs), now: now, + id: mintClientIDs.Add(1), }, nil } @@ -446,8 +514,15 @@ func mintHTTPClient(roots *x509.CertPool) *http.Client { CheckRedirect: refuseMintRedirect, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - RootCAs: roots, // nil means the system pool - MinVersion: tls.VersionTLS12, + RootCAs: roots, // nil means the system pool + // TLS 1.3 FLOOR. The projected service-account token is the + // one secret this process hands to anybody, and 1.2 permits + // cipher suites and a renegotiation surface that 1.3 removes. + // A mint endpoint is infrastructure this platform runs, not an + // arbitrary third party, so there is no long tail of old + // servers to accommodate — a host that cannot speak 1.3 is a + // host to upgrade. + MinVersion: tls.VersionTLS13, }, ForceAttemptHTTP2: true, DisableCompression: true, @@ -482,11 +557,16 @@ func refuseMintRedirect(request *http.Request, via []*http.Request) error { // has moved the state under it; that is what Refresh is for, and it is why a // receiver verifies rather than trusting that a caller re-asked. // -// **A held credential that is still valid is served even when a renewal -// fails.** Entering the renewal lead and failing used to return an error to -// every caller while the credential in hand had minutes of validity left — an -// outage this client manufactured out of a credential that still worked. A -// renewal failure is only an error once the credential has actually expired. +// **A held credential that is still valid is served through an OUTAGE, and +// never through a refusal.** Entering the renewal lead and failing used to +// return an error to every caller while the credential in hand had minutes of +// validity left — an outage this client manufactured out of a credential that +// still worked. But the fix was then applied to every error, so a refusal +// served the held credential too, which is the opposite decision: ErrMintRefused +// means the host will say the same thing again and a process seeing it must +// stop serving. So an unreachable host serves what is in hand until it +// expires, and a refusal is TERMINAL for this client — Refused reports it, and +// every later call returns it. // // One mint at a time, and the wait is cancellable. Concurrent callers wait on a // channel rather than on the mutex, so a caller whose ctx is cancelled stops @@ -496,10 +576,10 @@ func refuseMintRedirect(request *http.Request, via []*http.Request) error { // a request per call would be the heartbeat again under another name. func (c *MintClient) Credential(ctx context.Context) (Credential, error) { if c == nil { - return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) + return Credential{}, fmt.Errorf("%w: nil mint client", ErrInvalid) } if ctx == nil { - return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) + return Credential{}, fmt.Errorf("%w: nil context", ErrInvalid) } return c.obtain(ctx, mintReasonRenewal, nil) } @@ -520,32 +600,48 @@ func (c *MintClient) Credential(ctx context.Context) (Credential, error) { // replacement mint, and a caller holding a credential two generations old // cannot roll the client backwards by asking about it. // -// It is held to the same backoff as a renewal, which matters for the case a -// generation check alone does not cover: a receiver whose seal source lags -// refuses each FRESH credential too, and every refusal is a new generation. The -// backoff is what bounds that into one mint per hold-off rather than one per -// call. +// It has its OWN rate limit, which matters for the case a generation check +// alone does not cover: a receiver whose seal source lags refuses each FRESH +// credential too, and every refusal is a new generation, so the generation +// check never fires. The limit is a token bucket — a burst of three, refilling +// one per minute — so a lagging receiver costs one mint a minute in the steady +// state while an honest revocation is answered at once. It refills with elapsed +// time, so a client that refreshed this morning is not held off tonight. +// +// A credential this client did not issue is refused with ErrInvalid: the +// generation it names belongs to whichever client minted it, and comparing it +// here would let a credential from one client in the process drive another +// client's mint. func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credential, error) { if c == nil { - return Credential{}, fmt.Errorf("%w: nil mint client", ErrMintRefused) + return Credential{}, fmt.Errorf("%w: nil mint client", ErrInvalid) } if ctx == nil { - return Credential{}, fmt.Errorf("%w: nil context", ErrMintRefused) - } + return Credential{}, fmt.Errorf("%w: nil context", ErrInvalid) + } + // Local misuse carries ErrInvalid, not ErrMintRefused. The two were the + // same sentinel, which made errors.Is(err, ErrMintRefused) true for a + // caller that passed a zero Credential while Refused() was nil — so the + // two ways of asking "is this client refused" disagreed, and the sentinel + // whose documented meaning is "do not serve" was returned for a programmer + // error nobody should stop serving over. if refused.generation == 0 { return Credential{}, fmt.Errorf( "%w: refresh needs the credential that was refused, as this client issued it", - ErrMintRefused, + ErrInvalid, ) } - c.mu.Lock() - issued := c.credential != nil - c.mu.Unlock() - if !issued { + if refused.origin != c.id { return Credential{}, fmt.Errorf( - "%w: this client has issued no credential to refresh", ErrMintRefused, + "%w: that credential was issued by another mint client; its generation means nothing here", + ErrInvalid, ) } + // There is deliberately no "this client has issued nothing" check. The + // origin check above subsumes it: a credential whose origin is this + // client's id was issued BY this client, so there is one to replace. A + // branch no input reaches is the ErrUnsealed mistake again — a handler + // that never runs, read by everyone as a case that can happen. return c.obtain(ctx, mintReasonRefresh, func(held *Credential) bool { // The credential that was refused is still the one being held, so it // has to be replaced. If it is NOT — somebody else was refused on the @@ -592,12 +688,29 @@ func (c *MintClient) obtain( c.mu.Unlock() return credential, nil } + if reason == mintReasonRefresh && held != nil && wanted != nil && !wanted(held) && + c.now().UTC().Before(held.expiresAt) { + // A refresh asking about a generation this client has ALREADY + // replaced, where the replacement is the credential in hand. It is + // answered even when that credential has entered its renewal lead: + // the caller asked for something newer than what it was refused + // on, and this is it. The renewal is Credential's business and + // happens on its own schedule. + // + // Falling through here instead minted — and when the bucket was + // empty it returned a held-off ErrMintUnavailable, so the one + // credential that WAS the requested replacement was the one thing + // this would not hand over. + credential := *held + c.mu.Unlock() + return credential, nil + } // servable is the credential this call may fall back on when minting is // UNAVAILABLE. A refresh has none: its caller was refused on the // credential being held, so handing that same credential back would // report a replacement that did not happen. It is recomputed rather // than captured wherever the clock may have moved since. - servable := c.servableLocked(reason) + servable := c.servableLocked(reason, wanted) if waiting := c.inflight; waiting != nil { // Another goroutine is minting. Wait for it rather than queueing a // second request, and let ctx cancel the wait. @@ -606,29 +719,7 @@ func (c *MintClient) obtain( case <-waiting: continue case <-ctx.Done(): - // Re-evaluate UNDER THE LOCK, against the clock as it is now. - // This used to answer from a snapshot taken before the wait, - // so a waiter that started one second before expiry and - // cancelled one second after it was handed an EXPIRED - // credential with a nil error — and two seconds fits easily - // inside the default five-second request timeout. - c.mu.Lock() - terminal := c.refused - servableNow := c.servableLocked(reason) - snapshot := Credential{} - if servableNow != nil { - snapshot = *servableNow - } - c.mu.Unlock() - if terminal != nil { - return Credential{}, terminal - } - if servableNow != nil { - return snapshot, nil - } - // Both stay in the chain: a caller that cancelled wants to see - // its own cancellation, not only that minting was unavailable. - return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, ctx.Err()) + return c.answerCancelled(ctx, reason, wanted) } } if until, holding := c.holdOffLocked(reason); holding { @@ -637,76 +728,150 @@ func (c *MintClient) obtain( c.mu.Unlock() return credential, nil } + failure := c.lastFailure c.mu.Unlock() + if failure != nil { + // The transport's own words, plus when the next attempt may + // happen. A caller held off by a failure it cannot see has no + // way to tell a 503 from a certificate it must fix. + return Credential{}, fmt.Errorf("%w (held off until %s)", failure, until) + } return Credential{}, fmt.Errorf( "%w: minting is held off until %s", ErrMintUnavailable, until, ) } + // This caller takes the slot and STARTS the request, then waits for + // it exactly as any other waiter does. It does not run the request on + // its own context. + // + // It used to, and the cancelled leader was then a case of its own: + // counted as no failure, so no hold-off. Under callers whose deadlines + // are shorter than a degraded host's latency, every call became the + // new leader, sent one request, cancelled it and learned nothing — + // roughly one mint request per caller deadline, each of which the host + // may well complete, and audit, while this process throws it away. A + // refusal arriving just after the leader gave up was discarded too, so + // the next caller presented the projected token again. + // + // Detached and bounded by RequestTimeout, the request outlives whoever + // asked for it: it completes once, is classified once, and the result + // is installed for everybody. However many callers give up, the host + // sees one request per hold-off. done := make(chan struct{}) c.inflight = done - renewal := held != nil + if reason == mintReasonRefresh { + c.refreshWanted = true + } c.mu.Unlock() + go c.mintInto(done) + + select { + case <-done: + // Re-read the state the request installed, under the lock, at the + // top of the loop: a credential, a latched refusal, or a hold-off. + continue + case <-ctx.Done(): + return c.answerCancelled(ctx, reason, wanted) + } + } +} - credential, err := c.mintOnce(ctx) +// mintInto performs the one detached request and installs whatever it learned. +// +// Its context is NOT any caller's. It is bounded by RequestTimeout, which is +// what makes the request terminate, and detached from the caller that started +// it, which is what makes the result arrive whether or not that caller is still +// interested. +func (c *MintClient) mintInto(done chan struct{}) { + ctx, cancel := context.WithTimeout( + context.WithoutCancel(context.Background()), c.options.RequestTimeout, + ) + defer cancel() + credential, err := c.mintOnce(ctx) - c.mu.Lock() - c.inflight = nil - if err != nil { - outage := errors.Is(err, ErrMintUnavailable) - cancelled := ctx.Err() != nil - switch { - case cancelled: - // The LEADER's caller gave up. That is not the host failing, so - // it must not become everybody's hold-off: a cancelled first - // request at boot would otherwise be an outage for every other - // caller, growing toward a minute with each repetition. The - // slot is simply released and a waiter becomes the next leader. - case outage: - c.failures++ - c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) - default: - // A refusal, which includes an authority drift and the - // final-URL disclosure check. Latch it. - c.refused = err - } - servableNow := c.servableLocked(reason) - snapshot := Credential{} - if servableNow != nil { - snapshot = *servableNow - } - serve := servableNow != nil && outage && !cancelled - c.mu.Unlock() - close(done) - if serve { - // The credential in hand still works and the host is merely - // unreachable. A renewal that could not be attempted is not an - // outage until the thing it was renewing has expired. - return snapshot, nil - } - return Credential{}, err - } - c.failures = 0 - c.backoffUntil = time.Time{} - c.generation++ - credential.generation = c.generation - c.credential = &credential - switch { - case !renewal: - c.counts.Mints++ - case reason == mintReasonRefresh: - c.counts.Refreshes++ - // A success does NOT clear this one. The bound on a lagging - // receiver is a bound on successful refreshes, not on failures. - c.refreshNotBefore = c.now().UTC().Add(mintBackoff(int(c.counts.Refreshes))) - default: - c.counts.Renewals++ - } + c.mu.Lock() + defer func() { c.mu.Unlock() close(done) - return credential, nil + }() + c.inflight = nil + refresh := c.refreshWanted + c.refreshWanted = false + renewal := c.credential != nil + if err != nil { + if errors.Is(err, ErrMintUnavailable) { + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + c.lastFailure = err + return + } + // A refusal, which includes an authority drift, a TLS verification + // failure and the final-URL disclosure check. Latch it: the host will + // say the same thing again, and this package's contract is that a + // process seeing one must stop serving. + c.refused = err + return + } + if renewal && credential.token == c.credential.token { + // The host handed back the credential it was asked to replace. That is + // not a replacement: installing it would count a refresh or a renewal + // for a credential that did not change, and it would come due again + // immediately. An idempotent mint endpoint does exactly this. + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + c.lastFailure = fmt.Errorf( + "%w: the mint returned the credential it was asked to replace", ErrMintUnavailable, + ) + return + } + c.failures = 0 + c.backoffUntil = time.Time{} + c.lastFailure = nil + c.generation++ + credential.generation = c.generation + credential.origin = c.id + c.credential = &credential + switch { + case !renewal: + c.counts.Mints++ + case refresh: + c.counts.Refreshes++ + c.spendRefreshTokenLocked() + default: + c.counts.Renewals++ } } +// answerCancelled is what a caller gets when it stops waiting: the credential +// in hand if one is servable FOR THIS KIND OF CALL at the clock as it is now, +// or its own cancellation. +// +// Re-evaluated under the lock rather than from a snapshot taken before the +// wait. The snapshot version handed an EXPIRED credential, with a nil error, to +// a waiter that entered one second before expiry and gave up one second after +// it — and two seconds fits easily inside the default five-second timeout. +func (c *MintClient) answerCancelled( + ctx context.Context, reason mintReason, wanted func(held *Credential) bool, +) (Credential, error) { + c.mu.Lock() + terminal := c.refused + servable := c.servableLocked(reason, wanted) + snapshot := Credential{} + if servable != nil { + snapshot = *servable + } + c.mu.Unlock() + if terminal != nil { + return Credential{}, terminal + } + if servable != nil { + return snapshot, nil + } + // Both stay in the chain: a caller that cancelled wants to see its own + // cancellation, not only that minting was unavailable. + return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, ctx.Err()) +} + // holdOffLocked reports whether minting is held off for this kind of call, and // until when. A renewal is held off by consecutive failures; a refresh is held // off by its own rate limit as well, which a successful mint does not reset. @@ -715,17 +880,69 @@ func (c *MintClient) holdOffLocked(reason mintReason) (time.Time, bool) { if !c.backoffUntil.IsZero() && now.Before(c.backoffUntil) { return c.backoffUntil, true } - if reason == mintReasonRefresh && !c.refreshNotBefore.IsZero() && now.Before(c.refreshNotBefore) { - return c.refreshNotBefore, true + if reason == mintReasonRefresh { + if available := c.refreshTokensLocked(); available < 1 { + // When the next token lands, which is a real instant rather than a + // growing penalty: the bucket refills whether or not anybody asks. + return now.Add(time.Duration((1 - available) * float64(refreshInterval))), true + } } return time.Time{}, false } +// refreshTokensLocked refills the refresh bucket to now and reports what is in +// it. It is a function of ELAPSED TIME, so the limit forgets: a client that +// refreshed three times this morning is not still paying for it tonight. +// +// The previous bound was mintBackoff(Refreshes), computed from the LIFETIME +// count, so after a few legitimate refreshes it was a minute — permanently. +// "One refresh per minute, forever" is not a rate limit, it is a tax. +func (c *MintClient) refreshTokensLocked() float64 { + now := c.now().UTC() + if c.refreshFilled.IsZero() { + c.refreshTokens = maxRefreshBurst + c.refreshFilled = now + return c.refreshTokens + } + if elapsed := now.Sub(c.refreshFilled); elapsed > 0 { + c.refreshTokens += elapsed.Seconds() / refreshInterval.Seconds() + if c.refreshTokens > maxRefreshBurst { + c.refreshTokens = maxRefreshBurst + } + c.refreshFilled = now + } + return c.refreshTokens +} + +// spendRefreshTokenLocked charges one SUCCESSFUL refresh to the bucket. A +// failure is charged to the failure backoff instead, which is the thing that +// grows. +func (c *MintClient) spendRefreshTokenLocked() { + if available := c.refreshTokensLocked(); available >= 1 { + c.refreshTokens = available - 1 + return + } + c.refreshTokens = 0 +} + // servableLocked is the credential a caller may be handed when minting is // unavailable: one that is held, unexpired, and not the one a refresh was // refused on. -func (c *MintClient) servableLocked(reason mintReason) *Credential { - if reason == mintReasonRefresh || c.credential == nil { +func (c *MintClient) servableLocked(reason mintReason, wanted func(held *Credential) bool) *Credential { + if c.credential == nil { + return nil + } + if reason == mintReasonRefresh && (wanted == nil || wanted(c.credential)) { + // The credential being held IS the one this refresh was refused on, so + // handing it back would report a replacement that did not happen. + // + // When it is NOT — the client has already moved on and this caller is + // asking about a generation that has been replaced — the credential in + // hand is exactly the replacement being asked for. Returning nil there + // was a defect of its own: a stale-generation refresh arriving while + // the current credential sat inside its renewal lead got a held-off + // ErrMintUnavailable instead of the perfectly good credential the + // client was holding. return nil } if !c.now().UTC().Before(c.credential.expiresAt) { @@ -819,7 +1036,17 @@ func (c *MintClient) dueForRenewalLocked(credential Credential) bool { func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credential, error) { projected, err := c.options.ProjectedToken.ProjectedToken() if err != nil { - return Credential{}, err + // A source is a caller's code. An error from one that carries neither + // sentinel was returned as it stood and then LATCHED as terminal, so + // errors.Is(Refused(), ErrMintRefused) was false while the client was + // refused — and a transient local fault in somebody's own reader + // stopped the process for good. An unlabelled source failure is a + // local fault: retryable. + if errors.Is(err, ErrMintRefused) || errors.Is(err, ErrMintUnavailable) || + errors.Is(err, ErrInvalid) { + return Credential{}, err + } + return Credential{}, fmt.Errorf("%w: read the projected token: %w", ErrMintUnavailable, err) } body, err := json.Marshal(mintRequest{ Audience: audience, @@ -846,7 +1073,21 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia if errors.Is(err, ErrMintRefused) { return Credential{}, err } - return Credential{}, fmt.Errorf("%w: %v", ErrMintUnavailable, err) + if reason := tlsVerificationFailure(err); reason != "" { + // NOT an outage. Every other transport error means "the endpoint + // is not answering"; this one means "something answered and could + // not prove it is the endpoint", which is the single case this + // whole transport exists to refuse. Treated as an outage it was + // retried on a hold-off while the held credential kept being + // served, with nothing anywhere saying that the channel carrying + // the projected service-account token had stopped being + // authenticated. + return Credential{}, fmt.Errorf( + "%w: the mint endpoint's certificate did not verify (%s); the projected token is not presented to an endpoint that cannot prove who it is", + ErrMintRefused, reason, + ) + } + return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, err) } defer func() { _ = response.Body.Close() @@ -866,7 +1107,14 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) sentinel := ErrMintRefused - if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { + if response.StatusCode == http.StatusTooManyRequests || + response.StatusCode == http.StatusRequestTimeout || + response.StatusCode >= 500 { + // 408 belongs with 429 and 5xx: the host is saying it did not get + // to the request in time, which is the definition of retryable. It + // was classified as a refusal, so one slow moment at the endpoint + // latched and permanently stopped a process holding a valid + // credential. sentinel = ErrMintUnavailable } return Credential{}, fmt.Errorf("%w: mint returned HTTP %d", sentinel, response.StatusCode) @@ -1064,3 +1312,38 @@ func validateMintURL(raw string) (string, error) { } return parsed.String(), nil } + +// tlsVerificationFailure names the reason when err is a failure to +// AUTHENTICATE the endpoint, and returns "" for every other transport error. +// +// The distinction is the one this client's whole transport is about. A dial +// timeout, a reset, a closed connection: the endpoint is not answering, which +// is an outage and is retryable. A certificate that does not chain to the +// configured roots, is for another name, or has expired: something answered and +// could not prove it is the endpoint — so the next attempt would present the +// projected service-account token to it again. +func tlsVerificationFailure(err error) string { + var verification *tls.CertificateVerificationError + if errors.As(err, &verification) { + return "certificate verification failed" + } + var unknownAuthority x509.UnknownAuthorityError + if errors.As(err, &unknownAuthority) { + return "certificate is not signed by a configured root" + } + var hostname x509.HostnameError + if errors.As(err, &hostname) { + return "certificate is for another name" + } + var invalid x509.CertificateInvalidError + if errors.As(err, &invalid) { + return "certificate is not valid" + } + var recordHeader tls.RecordHeaderError + if errors.As(err, &recordHeader) { + // The endpoint answered something that is not TLS at all on a URL this + // client required to be https. + return "the endpoint did not answer with TLS" + } + return "" +} diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 104d410..2f1004f 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "slices" + "strings" "sync" "sync/atomic" "testing" @@ -29,6 +30,12 @@ import ( // It is served over TLS, because that is the only way the client will talk to // it: the projected service-account token is a bearer credential on this // request, and MintOptions.URL refuses anything but https. +// mintHost records what went wrong on the SERVER's goroutine rather than +// calling require there. require.NoError inside an http.Handler calls FailNow +// off the test goroutine, which Go documents as undefined: it stops the +// handler's goroutine and lets the test carry on, so a failed assertion in the +// host could pass the test. faults is asserted from the test goroutine by +// assertNoHostFaults, which every test using a host runs through t.Cleanup. type mintHost struct { t *testing.T server *httptest.Server @@ -56,6 +63,14 @@ type mintHost struct { pin *testPin // before runs at the top of every request, so a test can hold one open. before func() + + // replay makes the host IDEMPOTENT: it answers every request with this + // exact token. A real mint endpoint that deduplicates by projected token + // does the same thing. + replay string + + faultsMu sync.Mutex + faults []string } func newMintHost(t *testing.T, now func() time.Time) *mintHost { @@ -67,6 +82,11 @@ func newMintHost(t *testing.T, now func() time.Time) *mintHost { } host.server = httptest.NewTLSServer(http.HandlerFunc(host.handle)) t.Cleanup(host.server.Close) + t.Cleanup(func() { + host.faultsMu.Lock() + defer host.faultsMu.Unlock() + require.Empty(t, host.faults, "the mint host hit something it should not have") + }) return host } @@ -109,13 +129,25 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { // The host mints with core's Authority, because that is what the host does. // A test endpoint that assembled a token itself would be a second // implementation wearing a test's clothes. - token := h.authority.startAt(h.t, mintedAt, mintInput{audience: audience, binding: h.binding}, lifetime) + token, err := h.authority.tryStartAt(mintedAt, mintInput{audience: audience, binding: h.binding}, lifetime) + if err != nil { + // Recorded and answered with a 503, never asserted here: this is the + // server's goroutine. + h.fault("mint %s for %s: %v", mintedAt, lifetime, err) + writer.WriteHeader(http.StatusServiceUnavailable) + return + } + if h.replay != "" { + token = h.replay + } response := mintResponse{WorkContext: token, InstallationID: testInstallation} if h.echoWrong { response.InstallationID = "installation-the-host-did-not-seal" } writer.Header().Set("Content-Type", "application/json") - require.NoError(h.t, json.NewEncoder(writer).Encode(response)) + if err := json.NewEncoder(writer).Encode(response); err != nil { + h.fault("encode the mint response: %v", err) + } } func readAll(request *http.Request) ([]byte, error) { @@ -465,14 +497,21 @@ func TestRefreshIsTiedToTheRefusedCredential(t *testing.T) { // A Credential this client never issued is not something to refresh // against: it names no generation of this client's life. + // Local misuse is ErrInvalid, not ErrMintRefused. They were the same + // sentinel, so errors.Is(err, ErrMintRefused) was true for a caller that + // passed a zero Credential while Refused() stayed nil — the two ways of + // asking "is this client refused" disagreeing, over a programmer error. _, err = client.Refresh(t.Context(), Credential{}) - require.ErrorIs(t, err, ErrMintRefused) + require.ErrorIs(t, err, ErrInvalid) + require.NotErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "the credential that was refused") + require.NoError(t, client.Refused(), "misuse must not latch this client") fresh := newTestMintClient(t, host, projectedFile(t, "projected"), now) _, err = fresh.Refresh(t.Context(), second) - require.ErrorIs(t, err, ErrMintRefused) - require.ErrorContains(t, err, "issued no credential") + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "issued by another mint client", + "a credential from one client names a generation that means nothing to another") } // A refresh rechecks the pin and re-reads the projection, exactly as a renewal @@ -606,50 +645,53 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { _, err := client.Credential(t.Context()) require.NoError(t, err, "a credential minted inside core's skew is usable") - // There is no client-side lifetime ceiling any more, and that is core's - // cap arriving rather than a check being dropped. A review asked for one - // because core checked only that a TTL was positive; this carried one, - // labelled a stopgap with its own condition — it goes when core has a cap. - // Core has one now (Authority.MaxTTL, DefaultMaxTTL of an hour), so a - // credential longer than the host permits is refused where it is MINTED, - // which is the side of the wire that binds every client rather than the - // ones that opted in. + // THE CLIENT-SIDE CEILING, which has now been deleted once on advice and + // restored once on review, so what it is for is worth stating exactly. // - // What this test asserts about it is therefore core's constant, not a - // number of ours: a host cannot configure its way past it without a - // reviewer seeing the configuration. - // The ceiling is BACK, as defence in depth, and this asserts what THIS - // client does rather than what core's constant says: a host that raises - // its own MaxTTL past what this process will hold is refused here. + // At core cd443989 the argument for it was that Authority.MaxTTL took any + // positive value and Verify bounded no lifetime. Core 72d72eb2 closed both: + // MaxTTLCeiling is absolute, maxTTL() clamps to it, and Verify refuses a + // lifetime over it. That is the falsifiable condition this client stated + // when it declined to drop the ceiling — and it is now met, so the + // REASON changes and the check stays, for a reason neither of core's two + // bounds covers: // - // Core's cap is the primary one and this is the second layer. Deleting - // this layer on the strength of core having a cap was a weakening, because - // MaxTTL is configurable with no ceiling of its own and nothing bounds a - // lifetime at use. + // THIS CLIENT NEVER VERIFIES A SIGNATURE. It cannot: it is the party being + // minted for, not a receiver. It reads the window through corework.Inspect, + // which is structural only and checks no signature. So core's Start bounds + // an honest MINTER and core's Verify bounds a RECEIVER on a current core; + // neither bounds what this process holds in memory and presents for the + // rest of its life. That is this check's whole subject. + // + // It therefore defaults to core's own constant rather than a number of its + // own — one rule, enforced at the layer that holds the credential — and a + // deployment may lower it, which is what this asserts. A 30-day window is + // no longer constructible through core at all, which is the measurement + // that made this test change shape. + require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime, + "the client's default ceiling is core's constant, not a second number") + host = newMintHost(t, now) - host.authority.core.MaxTTL = 30 * 24 * time.Hour // a host that raised it - month := 30 * 24 * time.Hour - host.mintedAt, host.mintedFor = &monthStart, &month - client = newTestMintClient(t, host, projectedFile(t, "projected"), now) + quarterHour := 15 * time.Minute + host.mintedAt, host.mintedFor = &testClockStart, &quarterHour + client = newTestMintClient(t, host, projectedFile(t, "projected"), now, + func(options *MintOptions) { options.MaxCredentialLifetime = 5 * time.Minute }) _, err = client.Credential(t.Context()) require.ErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "holds one for at most") - // And it is configurable, because what a process should hold is the - // deployment's call — the point is that there IS one. + // And the same host is fine for a process that permits the window it mints. host = newMintHost(t, now) - host.authority.core.MaxTTL = 30 * 24 * time.Hour - week := 7 * 24 * time.Hour - host.mintedAt, host.mintedFor = &monthStart, &week + host.mintedAt, host.mintedFor = &testClockStart, &quarterHour client = newTestMintClient(t, host, projectedFile(t, "projected"), now, - func(options *MintOptions) { options.MaxCredentialLifetime = 14 * 24 * time.Hour }) + func(options *MintOptions) { options.MaxCredentialLifetime = time.Hour }) credential, err := client.Credential(t.Context()) require.NoError(t, err) - require.Equal(t, testClock.Add(week), credential.ExpiresAt()) + require.Equal(t, testClock.Add(quarterHour), credential.ExpiresAt()) } -// monthStart is an addressable testClock, for the host overrides above. -var monthStart = testClock +// testClockStart is an addressable testClock, for the host overrides above. +var testClockStart = testClock // A refusal the host will give again is not retryable, and an outage is. A // client that confused them would either spin against a permanent refusal or @@ -678,18 +720,39 @@ func TestMintSeparatesARefusalFromAnOutage(t *testing.T) { // turn "the token is missing" into the host's "this build is not approved", // which is the wrong thing to go looking for. func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { - for name, path := range map[string]ProjectedTokenFile{ - "no path": "", - "absent file": ProjectedTokenFile(filepath.Join(t.TempDir(), "missing")), - "empty file": ProjectedTokenFile(projectedFile(t, "")), - "whitespace": ProjectedTokenFile(projectedFile(t, " \n\t ")), + // Each fault gets the sentinel its RECOVERY needs, which is the whole + // point: ErrMintRefused latches and permanently stops the process. A + // momentary local fault classified that way stopped a process holding a + // perfectly good credential, and it stayed stopped after the fault + // cleared. + for name, fault := range map[string]struct { + path ProjectedTokenFile + sentinel error + why string + }{ + "no path": {"", ErrInvalid, + "misconfiguration: no request was made and no host refused anything"}, + "absent file": {ProjectedTokenFile(filepath.Join(t.TempDir(), "missing")), ErrMintUnavailable, + "the projection is mounted and rotated under the process; absent is a moment, not a verdict"}, + "empty file": {ProjectedTokenFile(projectedFile(t, "")), ErrMintUnavailable, + "an empty read is the middle of a rotation"}, + "whitespace": {ProjectedTokenFile(projectedFile(t, " \n\t ")), ErrMintUnavailable, + "same as empty once trimmed"}, } { t.Run(name, func(t *testing.T) { - _, err := path.ProjectedToken() - require.ErrorIs(t, err, ErrMintRefused) + _, err := fault.path.ProjectedToken() + require.ErrorIs(t, err, fault.sentinel, fault.why) + require.NotErrorIs(t, err, ErrMintRefused, + "a local fault must not latch as a host refusal") }) } + // A file that is there and is the wrong thing IS a refusal: the host would + // refuse it, and the next read returns the same bytes. + oversized := strings.Repeat("x", maxProjectedTokenBytes+1) + _, err := ProjectedTokenFile(projectedFile(t, oversized)).ProjectedToken() + require.ErrorIs(t, err, ErrMintRefused) + token, err := ProjectedTokenFile(projectedFile(t, " projected-with-trailing-newline\n")).ProjectedToken() require.NoError(t, err) require.Equal(t, "projected-with-trailing-newline", token) @@ -802,7 +865,8 @@ func TestTheMintTransportIsOwnedByTheClient(t *testing.T) { require.True(t, ok, "the client builds a concrete *http.Transport, not a wrapper it cannot reason about") require.NotNil(t, transport.TLSClientConfig, "a nil TLS config would mean the package defaults") require.False(t, transport.TLSClientConfig.InsecureSkipVerify) - require.EqualValues(t, tls.VersionTLS12, transport.TLSClientConfig.MinVersion) + require.EqualValues(t, tls.VersionTLS13, transport.TLSClientConfig.MinVersion, + "the projected service-account token goes over 1.3 or it does not go") require.Nil(t, transport.DialTLSContext, "a custom TLS dialer bypasses TLSClientConfig entirely") require.Nil(t, transport.DialContext) @@ -1254,3 +1318,10 @@ func TestOnlyAnOutageServesTheHeldCredential(t *testing.T) { }) } } + +// fault records a host-side problem for the test goroutine to assert. +func (h *mintHost) fault(format string, args ...any) { + h.faultsMu.Lock() + defer h.faultsMu.Unlock() + h.faults = append(h.faults, fmt.Sprintf(format, args...)) +} diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index e34ac25..b2ad12e 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -147,6 +147,19 @@ func (a *authority) start(t *testing.T, input mintInput) string { // endpoint under a moving test clock needs. func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifetime time.Duration) string { t.Helper() + token, err := a.tryStartAt(now, input, lifetime) + require.NoError(t, err) + return token +} + +// tryStartAt is startAt with the error RETURNED, for the mint host, which runs +// on the server's goroutine where require would call FailNow off the test +// goroutine. That is not a hypothetical: a test asked this host for an +// eight-hour credential, core refused it as over the authority's MaxTTL, +// require killed the handler mid-response, and the failure surfaced as `Post +// "https://127.0.0.1:58543": EOF` — a transport error, for a configuration +// mistake, exactly as the review predicted this shape would. +func (a *authority) tryStartAt(now time.Time, input mintInput, lifetime time.Duration) (string, error) { minter := *a.core minter.Now = func() time.Time { return now } if input.tenant == "" { @@ -179,8 +192,7 @@ func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifeti OperationBindingID: input.binding, TTL: lifetime, }) - require.NoError(t, err) - return token + return token, err } // child delegates a verified capability one hop, which is how a test obtains a diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index dc0507a..c69bda5 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -79,26 +79,65 @@ var ( bannedImportSubstrings = []string{"jose", "jwt", "jwx", "paseto", "macaroon", "branca"} ) -// tlsPlumbing is the narrow exception: the mint client builds and owns its own -// transport, which needs crypto/tls for the configuration and crypto/x509 for -// the caller's root pool. Both are refused everywhere else, and even there only -// these SYMBOLS may be used — so x509.ParsePKCS8PrivateKey, which is how a -// signer gets a key without importing ed25519, is a finding rather than a -// permitted use of an allowed import. +// narrowedImports are the imports some file here genuinely needs, held to the +// FILES that may have them and the SYMBOLS they may use. An import allowed +// whole-file is an import allowed for everything in that package: this is why +// x509.ParsePKCS8PrivateKey — how a signer gets a key without naming ed25519 — +// is a finding rather than a permitted use of an allowed import. // -// crypto/sha256 is deliberately absent from every list: a hash is not a -// signature, and the cache partition's digest preimage needs one. -var tlsPlumbing = map[string]struct { +// It was called tlsPlumbing when crypto/tls and crypto/x509 were the only two. +// They are not: crypto/sha256 is not a signature but a hand-written HMAC is two +// of them and an xor, mime.WordDecoder decodes base64 with no base64 import, +// and base64 itself is allowed in one file to ENCODE a cache key. +var narrowedImports = map[string]struct { files []string symbols []string }{ "crypto/tls": { + files: []string{"mint.go"}, + // Config and the version floor, plus the two error types the client + // classifies on. Reading an error TYPE is not using a primitive, and + // the alternative was matching on error text — which is how a + // certificate failure quietly became an outage again the next time the + // standard library reworded one. + symbols: []string{ + "Config", "VersionTLS13", + "CertificateVerificationError", "RecordHeaderError", + }, + }, + // mime, for one call: the mint response's Content-Type must be declared + // and must be application/json. A WordDecoder in this package decodes + // base64 with no base64 import, so the symbols matter here as much as + // anywhere. + "mime": { files: []string{"mint.go"}, - symbols: []string{"Config", "VersionTLS12", "VersionTLS13"}, + symbols: []string{"ParseMediaType"}, + }, + // crypto/sha256 is not a signature and the cache digest needs it, but a + // hand-written HMAC is two hashes and an xor — so the symbols and the + // files are named, which bounds that to the one file that hashes. + "crypto/sha256": { + files: []string{"cache_partition.go"}, + symbols: []string{"Sum256", "New", "Size"}, + }, + // encoding/base64 ENCODES a cache key here and must never DECODE: base64 + // decoding plus proto.Unmarshal is the whole of a second parser, and the + // exemption used to be per file, so DecodeString was available in the one + // file that had it. + "encoding/base64": { + files: []string{"cache_partition.go"}, + symbols: []string{"RawURLEncoding", "URLEncoding", "StdEncoding"}, }, "crypto/x509": { - files: []string{"mint.go"}, - symbols: []string{"CertPool", "NewCertPool", "SystemCertPool"}, + files: []string{"mint.go"}, + // CertPool for the caller's roots, and the three verification error + // types for the same reason as above. Notably still refused: + // ParsePKCS8PrivateKey, which is how a signer gets a key without + // importing ed25519. + symbols: []string{ + "CertPool", "NewCertPool", "SystemCertPool", + "UnknownAuthorityError", "HostnameError", "CertificateInvalidError", + }, }, } @@ -112,18 +151,30 @@ var tlsPlumbing = map[string]struct { // at all. Both were reproduced as AST probes that produced zero findings. A // whole-file exemption is an exemption for every type in the file. var codecAllowlist = []codecUse{ - {file: "mint.go", operations: jsonOperations, types: mintEndpointJSONTypes, + {file: "mint.go", codec: codecJSON, operations: jsonOperations, types: mintEndpointJSONTypes, reason: "the mint endpoint's two HTTP bodies, which carry the capability as an opaque string"}, - {file: "mint.go", operations: jsonValueOperations, - types: append(append([]string{}, mintEndpointJSONTypes...), "RawMessage"), + {file: "mint.go", codec: codecJSON, operations: jsonValueOperations, + types: append(append([]string{}, mintEndpointJSONTypes...), "json.RawMessage"), reason: "the same two bodies, plus a RawMessage the response decoder reads into to " + "require EOF — it holds nothing and is discarded"}, - {file: "cache_partition.go", operations: protoOperations, types: []string{"WorkScopeV1"}, + {file: "cache_partition.go", codec: codecProto, operations: protoOperations, + types: []string{"basev0.WorkScopeV1"}, reason: "one scope, for the cache digest preimage — never a capability"}, } +// The codecs a row can be about. A row used to be keyed on the file and the +// OPERATION NAME, and "Marshal" is an operation name shared by every codec — +// so mint.go's row for encoding/json, whose two allowed types are the HTTP +// bodies, also permitted proto.Marshal and proto.Unmarshal on anything NAMED +// mintRequest or mintResponse. A row has to say which codec it is about. +const ( + codecJSON = "encoding/json" + codecProto = "google.golang.org/protobuf/proto" +) + type codecUse struct { file string + codec string operations []string types []string reason string @@ -140,11 +191,11 @@ var ( protoOperations = []string{"Marshal", "MarshalOptions", "Unmarshal", "UnmarshalOptions"} ) -// permittedCodecTypes is the set of type names a codec operation may be applied -// to in this file, or nil when the file may use no codec at all. -func permittedCodecTypes(path string, operation string) []string { +// permittedCodecTypes is the set of type names THIS CODEC's operation may be +// applied to in this file, or nil when it may not be used here at all. +func permittedCodecTypes(path string, codec string, operation string) []string { for _, use := range codecAllowlist { - if use.file == path && slices.Contains(use.operations, operation) { + if use.file == path && use.codec == codec && slices.Contains(use.operations, operation) { return use.types } } @@ -204,6 +255,11 @@ var ( // resolve into it, or it is a local type wearing core's name. const coreModulePath = "github.com/codefly-dev/core" +// coreAliasFile is the one file whose job is re-exporting core's surface, so it +// is the one file where an alias to one of core's types is the point rather +// than a second local name for a capability. +const coreAliasFile = "core.go" + // TestNoSecondWorkContextImplementation is the gate. // // A wire contract has exactly one implementation, in the repository that owns @@ -273,9 +329,10 @@ func TestNoSecondWorkContextImplementation(t *testing.T) { func inspectForSecondImplementation(file sourceFile) []string { var findings []string coreImports := map[string]bool{} - plumbing := map[string]string{} // local name -> allowed import path - protoNames := map[string]bool{} // every local name google.golang.org/protobuf is reachable under - jsonNames := map[string]bool{} // and encoding/json + plumbing := map[string]string{} // local name -> allowed import path + protoNames := map[string]bool{} // every local name google.golang.org/protobuf is reachable under + jsonNames := map[string]bool{} // and encoding/json + base64Names := map[string]bool{} // and encoding/base64, which may only ENCODE for _, imported := range importsOf(file.syntax) { if imported.name == "." { // A dot import makes every one of a package's identifiers @@ -298,6 +355,9 @@ func inspectForSecondImplementation(file sourceFile) []string { if imported.path == "encoding/json" { jsonNames[imported.name] = true } + if imported.path == "encoding/base64" { + base64Names[imported.name] = true + } } findings = append(findings, inspectPlumbingSymbols(file, plumbing)...) findings = append(findings, inspectProtoEncoding(file, protoNames)...) @@ -305,19 +365,139 @@ func inspectForSecondImplementation(file sourceFile) []string { findings = append(findings, inspectJSONTags(file)...) findings = append(findings, inspectJSONCalls(file, jsonNames)...) findings = append(findings, inspectJSONValueCalls(file, len(jsonNames) > 0)...) + findings = append(findings, inspectLocalTypes(file)...) + findings = append(findings, inspectCoreAliases(file, coreImports)...) + findings = append(findings, inspectEnvelopeDecoding(file, base64Names)...) + return findings +} + +// envelopeEncodeOnly are the methods an allowlisted base64 import may call on +// an encoding. Everything else — DecodeString, Decode, NewDecoder, AppendDecode +// — is the first half of a second parser. +var envelopeEncodeOnly = []string{"EncodeToString", "Encode", "EncodedLen", "AppendEncode"} + +// inspectEnvelopeDecoding refuses DECODING in the one file allowed to encode. +// +// The symbol allowlist cannot see this on its own: base64.StdEncoding is the +// permitted symbol and DecodeString is a method on the *value* it names, so +// `base64.StdEncoding.DecodeString(payload)` passed a package-level symbol +// check completely. The exemption was per file, so the file allowed to encode a +// cache key could decode a capability's envelope — and with proto.Unmarshal +// that is a whole parser. +func inspectEnvelopeDecoding(file sourceFile, base64Names map[string]bool) []string { + if len(base64Names) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + method, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + // The receiver must itself be base64., which is what makes + // this a base64 encoding rather than any other value's method. + receiver, ok := method.X.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := receiver.X.(*ast.Ident) + if !ok || !base64Names[qualifier.Name] { + return true + } + if slices.Contains(envelopeEncodeOnly, method.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s.%s.\n"+ + "This file may ENCODE with base64 — it builds a cache key — and may never DECODE.\n"+ + "base64 decoding plus proto.Unmarshal is the entire second parser this module\n"+ + "deleted; corework.Inspect opens the envelope and hands back the claims. Only %v\n"+ + "may be called here.", + file.path, qualifier.Name, receiver.Sel.Name, method.Sel.Name, envelopeEncodeOnly)) + return true + }) + return findings +} + +// inspectLocalTypes refuses a type declared INSIDE a function. +// +// This is the alias bypass's other half. A function-local declaration is +// invisible to every rule that reads a file's declarations, and `type X = +// basev0.WorkContextV1` inside a function body is enough to give a codec row +// whatever type name it asks for. It is also something this module has no use +// for: a type worth declaring here is worth declaring where a reader finds it. +func inspectLocalTypes(file sourceFile) []string { + var findings []string + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + ast.Inspect(function.Body, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + findings = append(findings, fmt.Sprintf( + "%s declares the type %q inside a function.\n"+ + "A function-local type is invisible to every rule here that reads a file's\n"+ + "declarations, and a local alias is all it takes to hand a codec row a type name it\n"+ + "permits — `type WorkScopeV1 = basev0.WorkContextV1` makes a capability marshal as\n"+ + "an allowed scope. Declare it at file level, where it is read.", + file.path, spec.Name.Name)) + return true + }) + } + return findings +} + +// inspectCoreAliases refuses an alias to one of core's types outside the one +// file whose job is aliasing core. +// +// core.go exists so this module's surface IS core's surface: an alias there is +// the re-export the one-implementation rule asks for. An alias anywhere else +// renames core's type locally, which is how a capability acquires a second +// name — and the name rule cannot catch it, because a reader chooses the name. +func inspectCoreAliases(file sourceFile, coreImports map[string]bool) []string { + if file.path == coreAliasFile || len(coreImports) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok || !spec.Assign.IsValid() { + // Assign is set only for `type A = B`, which is the renaming case. + return true + } + selector, ok := spec.Type.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok || !coreImports[qualifier.Name] { + return true + } + findings = append(findings, fmt.Sprintf( + "%s aliases core's %s.%s as %q.\n"+ + "Aliases to core's types belong in %s, which is this module's re-export of core's\n"+ + "surface. An alias elsewhere gives one of core's types a second local name, which is\n"+ + "precisely what a codec allowlist keyed by type name cannot see through.", + file.path, qualifier.Name, selector.Sel.Name, spec.Name.Name, coreAliasFile)) + return true + }) return findings } // inspectImport refuses an import by what it can DO, not by whether its path is // one of two strings. func inspectImport(file sourceFile, name string, path string, plumbing map[string]string) []string { - if allowed, ok := tlsPlumbing[path]; ok { + if allowed, ok := narrowedImports[path]; ok { if slices.Contains(allowed.files, file.path) { plumbing[name] = path return nil } return []string{fmt.Sprintf( - "%s imports %q, which only %v may: it is the mint client's own transport plumbing.", + "%s imports %q, which only %v may, and only for named symbols.", file.path, path, allowed.files)} } if slices.Contains(envelopeDecoders, path) { @@ -386,7 +566,7 @@ func inspectPlumbingSymbols(file sourceFile, plumbing map[string]string) []strin if !isPlumbing { return true } - if slices.Contains(tlsPlumbing[path].symbols, selector.Sel.Name) { + if slices.Contains(narrowedImports[path].symbols, selector.Sel.Name) { return true } findings = append(findings, fmt.Sprintf( @@ -394,7 +574,7 @@ func inspectPlumbingSymbols(file sourceFile, plumbing map[string]string) []strin "The import is allowed for the mint client's transport, not as a way into the package:\n"+ "x509.ParsePKCS8PrivateKey plus a crypto.Signer assertion is a complete signer that\n"+ "imports no signing primitive at all.", - file.path, qualifier.Name, selector.Sel.Name, tlsPlumbing[path].symbols, path)) + file.path, qualifier.Name, selector.Sel.Name, narrowedImports[path].symbols, path)) return true }) return findings @@ -421,7 +601,7 @@ func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string } switch selector.Sel.Name { case "Marshal", "MarshalOptions": - if codecArgumentIsPermitted(file, node, "Marshal") { + if codecArgumentIsPermitted(file, node, codecProto, "Marshal") { return true } case "Unmarshal", "UnmarshalOptions": @@ -429,7 +609,7 @@ func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string // last blocker: a second unverified parser needs base64 to open the // envelope and Unmarshal to read it. corework.Inspect does both, and // hands back the claims, so nothing here needs either. - if codecArgumentIsPermitted(file, node, "Unmarshal") { + if codecArgumentIsPermitted(file, node, codecProto, "Unmarshal") { return true } findings = append(findings, fmt.Sprintf( @@ -642,7 +822,7 @@ func inspectJSONCalls(file sourceFile, jsonNames map[string]bool) []string { if !slices.Contains(jsonOperations, selector.Sel.Name) { return true } - if codecArgumentIsPermitted(file, node, selector.Sel.Name) { + if codecArgumentIsPermitted(file, node, codecJSON, selector.Sel.Name) { return true } findings = append(findings, fmt.Sprintf( @@ -675,7 +855,7 @@ func inspectJSONValueCalls(file sourceFile, importsJSON bool) []string { // allowlist row for Decode/Encode is what it may be pointed at, which // is a DIFFERENT row from Marshal/Unmarshal's — so the operation's own // name is what selects it. - if codecArgumentIsPermitted(file, node, selector.Sel.Name) { + if codecArgumentIsPermitted(file, node, codecJSON, selector.Sel.Name) { return true } findings = append(findings, fmt.Sprintf( @@ -702,6 +882,144 @@ func TestTheGateCatchesItsOwnBypasses(t *testing.T) { source string says string }{ + // ---- Round four: the shapes the review found this gate blind to. + // + // Each is a complete second parser or encoder that COMPILED and + // produced zero findings at the previous revision. The common thread + // is that the codec rule was about names: a type name with no package, + // an identifier resolved to whichever same-named declaration came + // last, and a row keyed on an operation name shared by every codec. + "a local alias giving a capability an allowlisted type name": { + path: "cache_partition.go", + source: `package workcontext +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func encode(wc *basev0.WorkContextV1) ([]byte, error) { + type WorkScopeV1 = basev0.WorkContextV1 + return proto.MarshalOptions{Deterministic: true}.Marshal((*WorkScopeV1)(wc)) +}`, + says: "inside a function", + }, + "a file-level alias giving a capability an allowlisted type name": { + path: "cache_partition.go", + source: `package workcontext +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +type WorkScopeV1 = basev0.WorkContextV1 +func encode(wc *WorkScopeV1) ([]byte, error) { + return proto.Marshal(wc) +}`, + says: "aliases core's", + }, + // The qualifier is the whole point: the allowlist row exists for ONE + // core message, and the resolver used to answer "WorkScopeV1" for + // every type whose final name was that, from any package. + "a marshal of a foreign package's WorkScopeV1": { + path: "cache_partition.go", + source: `package workcontext +import ( + "google.golang.org/protobuf/proto" + elsewhere "example.test/other" +) +func encode(scope *elsewhere.WorkScopeV1) ([]byte, error) { + return proto.Marshal(scope) +}`, + says: "on a type it is not allowed to", + }, + "a marshal of another package's identically named type": { + path: "cache_partition.go", + source: `package workcontext +import ( + "google.golang.org/protobuf/proto" + elsewhere "google.golang.org/protobuf/types/known/structpb" +) +type WorkScopeV1 = elsewhere.Struct +func encode() ([]byte, error) { + return proto.Marshal(&WorkScopeV1{}) +}`, + says: "on a type it is not allowed to", + }, + "base64 DECODING in the file allowed to encode": { + path: "cache_partition.go", + source: `package workcontext +import "encoding/base64" +func open(token string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(token) +}`, + says: "may never DECODE", + }, + "a proto codec riding mint.go's encoding/json row": { + path: "mint.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +type mintResponse struct{ WorkContext string } +func read(raw []byte) error { + var body mintResponse + return proto.Unmarshal(raw, &body) +}`, + says: "calls proto.Unmarshal", + }, + "an identifier resolved to a later same-named declaration": { + path: "mint.go", + source: `package workcontext +import ( + "encoding/json" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +func leak(claims *basev0.WorkContextV1) ([]byte, error) { + body := claims + return json.Marshal(body) +} +type mintResponse struct{ WorkContext string } +func decode() { + var body mintResponse + _ = body +}`, + says: "on a type it is not allowed to", + }, + "an identifier an ambiguous file cannot resolve": { + path: "mint.go", + source: `package workcontext +import "encoding/json" +type mintRequest struct{ Audience string } +func one() { body := mintRequest{}; _ = body } +func two(raw []byte) error { + body := map[string]any{} + return json.Unmarshal(raw, &body) +}`, + says: "on a type it is not allowed to", + }, + "mime as a base64 decoder": { + path: "carrier.go", + source: `package workcontext +import "mime" +func open(header string) (string, error) { + kind, _, err := mime.ParseMediaType(header) + return kind, err +}`, + says: "imports \"mime\"", + }, + "sha256 outside the file that hashes": { + path: "carrier.go", + source: `package workcontext +import "crypto/sha256" +func mac(key, message []byte) []byte { + inner := sha256.Sum256(append(key, message...)) + return inner[:] +}`, + says: "imports \"crypto/sha256\"", + }, + "a nested package reaching for the cache partition's base64": { + path: "grpctransport/partition.go", + source: `package grpctransport +import "encoding/base64" +var _ = base64.RawURLEncoding`, + says: "imports \"encoding/base64\"", + }, "a nested file inheriting the mint.go allowance": { path: "grpctransport/mint.go", source: `package grpctransport @@ -1174,8 +1492,8 @@ func envelopeDecoderReasons() []string { // // An argument it cannot resolve is NOT permitted. A gate that passed what it // could not read would be a gate about what is easy to parse. -func codecArgumentIsPermitted(file sourceFile, call ast.Node, operation string) bool { - permitted := permittedCodecTypes(file.path, operation) +func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, operation string) bool { + permitted := permittedCodecTypes(file.path, codec, operation) if len(permitted) == 0 { return false } @@ -1194,7 +1512,7 @@ func codecArgumentIsPermitted(file sourceFile, call ast.Node, operation string) if operation == "Unmarshal" || operation == "Decode" { value = arguments[len(arguments)-1] } - named := resolveTypeName(file, value) + named := resolveTypeName(file, value, value.Pos()) return named != "" && slices.Contains(permitted, named) } @@ -1226,10 +1544,10 @@ func codecArguments(file sourceFile, target ast.Node) []ast.Expr { // resolveTypeName names the type of an expression, syntactically, or returns "" // when it cannot. -func resolveTypeName(file sourceFile, expression ast.Expr) string { +func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string { switch typed := expression.(type) { case *ast.UnaryExpr: - return resolveTypeName(file, typed.X) + return resolveTypeName(file, typed.X, use) case *ast.CompositeLit: return typeName(typed.Type) case *ast.TypeAssertExpr: @@ -1242,19 +1560,29 @@ func resolveTypeName(file sourceFile, expression ast.Expr) string { // A conversion: T(x) names T; anything else is unknown. return typeName(typed.Fun) case *ast.Ident: - return declaredTypeName(file, typed.Name) + return declaredTypeName(file, typed.Name, use) } return "" } -// typeName reduces a type expression to its bare name: *pkg.T, pkg.T, []T and -// map[K]V all answer what a reader would call them. +// typeName reduces a type expression to its name, KEEPING THE PACKAGE +// QUALIFIER: *pkg.T and pkg.T are "pkg.T", []T is "[]T", a map is "map". +// +// It used to return Sel.Name and drop the qualifier, so basev0.WorkScopeV1 and +// anything.WorkScopeV1 were the same string — and cache_partition.go's row, +// which exists for ONE core scope message, permitted a proto.Marshal of any +// type whose final name happened to be WorkScopeV1, including a local alias to +// the capability itself. func typeName(expression ast.Expr) string { switch typed := expression.(type) { case *ast.StarExpr: return typeName(typed.X) case *ast.SelectorExpr: - return typed.Sel.Name + qualifier, ok := typed.X.(*ast.Ident) + if !ok { + return "" + } + return qualifier.Name + "." + typed.Sel.Name case *ast.Ident: return typed.Name case *ast.ArrayType: @@ -1268,52 +1596,111 @@ func typeName(expression ast.Expr) string { return "" } -// declaredTypeName looks an identifier up among this file's declarations: var -// specs, short variable declarations, and function parameters and results. -func declaredTypeName(file sourceFile, name string) string { - found := "" - ast.Inspect(file.syntax, func(node ast.Node) bool { - switch declared := node.(type) { - case *ast.ValueSpec: - for index, declaredName := range declared.Names { - if declaredName.Name != name { - continue +// declaredTypeName looks an identifier up among the declarations that are +// actually IN SCOPE at the use, and refuses to answer when more than one +// distinct declaration could be the one. +// +// Two defects it used to have, both reported and both reproducible: +// +// - IT RETURNED THE LAST MATCH. ast.Inspect's false return prunes a subtree +// and then carries on over the siblings, so `found` was overwritten by +// every later declaration of the same name — and a function placed BEFORE +// mint.go's credentialFrom could write `body := claims` and have `body` +// resolve to the mintResponse declared further down. +// - IT IGNORED SCOPE. A name declared in one function resolved a name used +// in another. +// +// Now the search is restricted to the function enclosing the use (plus +// file-level declarations), the candidate must be declared BEFORE the use, and +// two candidates that disagree resolve to "" — which is not permitted, because +// a gate that guesses between two readings is not a gate. +func declaredTypeName(file sourceFile, name string, use token.Pos) string { + scope := enclosingBody(file, use) + var candidates []string + consider := func(node ast.Node, resolved string) { + if node.Pos() > use || resolved == "" { + return + } + if !slices.Contains(candidates, resolved) { + candidates = append(candidates, resolved) + } + } + inspect := func(root ast.Node) { + ast.Inspect(root, func(node ast.Node) bool { + switch declared := node.(type) { + case *ast.ValueSpec: + for index, declaredName := range declared.Names { + if declaredName.Name != name { + continue + } + if declared.Type != nil { + consider(declared, typeName(declared.Type)) + continue + } + if index < len(declared.Values) { + consider(declared, resolveTypeName(file, declared.Values[index], use)) + } } - if declared.Type != nil { - found = typeName(declared.Type) - return false + case *ast.AssignStmt: + if declared.Tok != token.DEFINE { + return true } - if index < len(declared.Values) { - found = resolveTypeName(file, declared.Values[index]) - return false + for index, left := range declared.Lhs { + ident, ok := left.(*ast.Ident) + if !ok || ident.Name != name || index >= len(declared.Rhs) { + continue + } + consider(declared, resolveTypeName(file, declared.Rhs[index], use)) } - } - case *ast.AssignStmt: - if declared.Tok != token.DEFINE { - return true - } - for index, left := range declared.Lhs { - ident, ok := left.(*ast.Ident) - if !ok || ident.Name != name || index >= len(declared.Rhs) { - continue + case *ast.FuncDecl: + // Parameters and results are in scope only inside their own + // function, which is what scope here means. + if scope == nil || declared.Body != scope { + return true } - found = resolveTypeName(file, declared.Rhs[index]) - return false - } - case *ast.FuncDecl: - for _, list := range fieldLists(declared.Type) { - for _, field := range list { - for _, declaredName := range field.Names { - if declaredName.Name == name { - found = typeName(field.Type) - return false + for _, list := range fieldLists(declared.Type) { + for _, field := range list { + for _, declaredName := range field.Names { + if declaredName.Name == name { + consider(field, typeName(field.Type)) + } } } } } + return true + }) + } + // File-level declarations, then the enclosing function's own body. + for _, declaration := range file.syntax.Decls { + if function, ok := declaration.(*ast.FuncDecl); ok { + if scope != nil && function.Body == scope { + inspect(function) + } + continue } - return true - }) + inspect(declaration) + } + if len(candidates) != 1 { + // Nothing, or an ambiguity. Either way nothing is permitted. + return "" + } + return candidates[0] +} + +// enclosingBody is the body of the function containing pos, or nil at file +// level. It is what makes the lookup above scoped rather than file-wide. +func enclosingBody(file sourceFile, pos token.Pos) *ast.BlockStmt { + var found *ast.BlockStmt + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if function.Body.Pos() <= pos && pos <= function.Body.End() { + found = function.Body + } + } return found } diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go new file mode 100644 index 0000000..b7cf19d --- /dev/null +++ b/workcontext/round4_reproducers_test.go @@ -0,0 +1,364 @@ +package workcontext + +import ( + "context" + "crypto/x509" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Round four's section (D): four tests that proved less than their names, and +// the two new defects the review found by reading them. + +// movableClock is a clock a test can advance while a goroutine is blocked, and +// which COUNTS its reads — so a test can tell that a waiter has taken its +// snapshot without sleeping and hoping. +type movableClock struct { + mu sync.RWMutex + at time.Time + reads atomic.Uint64 +} + +func (c *movableClock) now() time.Time { + c.mu.RLock() + defer c.mu.RUnlock() + c.reads.Add(1) + return c.at +} + +func (c *movableClock) set(at time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.at = at +} + +// A waiter that ENTERS while the credential is still valid and gives up after +// it has expired must not be handed it. +// +// The previous version of this test moved the clock past expiry BEFORE the +// waiter entered, so the snapshot taken on entry was already of an expired +// credential and the pre-fix code passed it too. The timeline that matters is +// the one the dynamic review reproduced: enter at expiry-1s, where the held +// credential IS servable, and cancel at expiry+1s. Only re-evaluating under +// the lock after the wait answers that correctly. +func TestACancelledWaiterIsNeverHandedACredentialThatExpiredWhileItWaited(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + expiry := held.ExpiresAt() + + // The host holds its answer, so a renewal is in flight and the next caller + // becomes a waiter. + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + // ENTRY IS AT expiry-1s: at this instant the held credential is servable, + // which is what makes this timeline different from the old test's. + clock.set(expiry.Add(-time.Second)) + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 5*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + waiterDone := make(chan struct{}) + var served Credential + var waiterErr error + before := clock.reads.Load() + go func() { + defer close(waiterDone) + served, waiterErr = client.Credential(cancelled) + }() + // The waiter has read the clock, so it has taken its snapshot of a + // credential that is still valid and is now waiting on the mint in flight. + require.Eventually(t, func() bool { return clock.reads.Load() > before }, + 5*time.Second, 100*time.Microsecond) + + // NOW the credential expires, and only then does the waiter give up. + clock.set(expiry.Add(time.Second)) + cancel() + <-waiterDone + + require.Error(t, waiterErr, "there is nothing servable: the held credential has expired") + require.ErrorIs(t, waiterErr, ErrMintUnavailable) + require.Empty(t, served.Token(), + "a cancelled waiter was handed a credential that expired at %s while it waited", expiry) + + letGo() + <-renewing +} + +// Callers whose deadlines are shorter than a degraded host's latency must not +// each produce a mint request. +// +// This is the cost of the previous round's fix for the cancelled leader: not +// counting a cancellation as a failure meant no hold-off, so every call became +// the new leader and sent its own request — roughly one per caller deadline, +// each of which the host may complete and AUDIT while this process discards it. +// Nothing in the suite bounded that; TestACancelledLeaderDoesNotHoldOffEveryoneElse +// cancels exactly once. +func TestRepeatedShortDeadlinesDoNotMintOncePerCaller(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + // A DEGRADED host: it has the request and has not answered it. + host.before = func() { + select { + case <-release: + case <-time.After(10 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.RequestTimeout = 5 * time.Second }) + + // Twenty callers whose deadlines are far shorter than the host's latency. + // Each one SENDS — the deadline is what expires, not the context before + // the request leaves — and then gives up. + for range 20 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + _, err := client.Credential(ctx) + cancel() + require.Error(t, err) + } + require.LessOrEqual(t, host.requests.Load(), uint64(1), + "twenty callers that gave up produced %d mint requests; the host completes and AUDITS each one", + host.requests.Load()) + + releaseOnce.Do(func() { close(release) }) + // And the request nobody is waiting for any more still completes and still + // installs its result: that is what detaching it buys. + require.Eventually(t, func() bool { return client.Counts().Mints == 1 }, + 10*time.Second, time.Millisecond, + "the detached request's result was discarded with its caller") +} + +// A refusal that arrives after the caller that started the request gave up is +// still LATCHED. +// +// Before the request was detached, a 403 racing a cancelled leader was +// discarded — so the next caller presented the projected service-account token +// again, to a host that had already refused it. +func TestARefusalArrivingAfterItsCallerGaveUpIsStillLatched(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusForbidden + release := make(chan struct{}) + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(ctx) + require.Error(t, err) + close(release) + + require.Eventually(t, func() bool { return client.Refused() != nil }, + 5*time.Second, time.Millisecond, + "the host's refusal was discarded with the caller that asked for it") + require.ErrorIs(t, client.Refused(), ErrMintRefused) + + // And it is terminal: no later caller sends the projection again. + seen := host.requests.Load() + _, err = client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.Equal(t, seen, host.requests.Load(), + "a latched refusal presented the projected token again") +} + +// The refresh limit is a RATE that decays, not a growing penalty. +// +// It used to be mintBackoff(Refreshes), computed from the lifetime count and +// never reset: after a few legitimate refreshes the bound was a minute, +// permanently. The old test froze the clock, so it proved one hold-off and +// said nothing about the steady state its name claimed. +func TestTheRefreshLimitIsASteadyRateAndItDecays(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // Long enough that no renewal is ever due, so every mint here is a refresh. + host.authority.core.MaxTTL = 12 * time.Hour + host.lifetime = 8 * time.Hour + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // Ten minutes of a receiver refusing every credential it is given: a + // refresh attempt every ten seconds. + for range 60 { + clock.set(clock.at.Add(10 * time.Second)) + if replaced, refreshErr := client.Refresh(context.Background(), held); refreshErr == nil { + held = replaced + } + } + refreshes := client.Counts().Refreshes + // The burst, plus one per minute of the ten that elapsed. The bound is the + // RATE: without one this loop would have minted sixty times. + require.LessOrEqual(t, refreshes, uint64(maxRefreshBurst)+10) + require.GreaterOrEqual(t, refreshes, uint64(8), + "the limiter stopped refreshing altogether rather than rate-limiting") + + // And it FORGETS. A client that exhausted the bucket this morning + // refreshes at once tonight — which the old bound, growing with the + // lifetime count, never did. + exhausted := client.Counts().Refreshes + clock.set(clock.at.Add(24 * time.Hour)) + replaced, err := client.Refresh(context.Background(), held) + require.NoError(t, err, "a refresh a day later was still paying for this morning") + require.NotEqual(t, held.Token(), replaced.Token()) + require.Equal(t, exhausted+1, client.Counts().Refreshes) +} + +// A refresh about a generation this client has already replaced is answered +// from the credential in hand, even when that credential is inside its renewal +// lead — and it costs no mint and no refresh. +// +// It used to be refused with a held-off ErrMintUnavailable: servableLocked +// returned nil for every refresh, so the one credential that WAS the +// replacement being asked for was the one thing it would not hand over. +func TestARefreshForAnAlreadyReplacedGenerationIsAnsweredFromTheCurrentOne(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + second, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.NotEqual(t, first.Token(), second.Token()) + + // Inside the renewal lead of the credential now held, which is what made + // the old code reach for the rate limiter. + clock.set(second.ExpiresAt().Add(-time.Second)) + requests := host.requests.Load() + counts := client.Counts() + + answered, err := client.Refresh(context.Background(), first) + require.NoError(t, err, "the replacement being asked for was the credential in hand") + require.Equal(t, second.Token(), answered.Token()) + require.Equal(t, requests, host.requests.Load(), "it should have minted nothing") + require.Equal(t, counts, client.Counts(), "and counted nothing") +} + +// A host that hands back the credential it was asked to replace has not +// replaced anything, and it is not counted as a refresh. +// +// An idempotent mint endpoint does exactly this. Nothing compared the +// replacement with what it replaced, so the refused credential came back with +// a nil error and a refresh on the counter. +func TestAMintThatReturnsTheSameCredentialIsNotAReplacement(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The host becomes idempotent: the same token for every request. + host.replay = first.Token() + + _, err = client.Refresh(context.Background(), first) + require.Error(t, err) + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "asked to replace") + require.Zero(t, client.Counts().Refreshes, + "a credential that did not change was counted as a refresh") +} + +// Every classification the mint client makes, with the three the previous +// round's test did not cover: a local fault, a TLS verification failure, and a +// 408. +func TestEveryMintFaultIsClassifiedByWhatRecoveryItNeeds(t *testing.T) { + t.Run("a local fault on the projected token is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &faultySource{err: errSyntheticLocalFault} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = source }) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused) + require.NoError(t, client.Refused(), + "an exhausted file-descriptor table permanently stopped the process") + require.ErrorIs(t, err, errSyntheticLocalFault, + "the source's own error must stay in the chain") + }) + + t.Run("a TLS verification failure is a refusal, not an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A client pointed at the host with a root pool that does not contain + // the host's certificate: something answers, and cannot prove it is + // the endpoint. + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.RootCAs = x509.NewCertPool() }) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused, + "served the held credential and retried while the channel carrying the projection stopped being authenticated") + require.NotErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "cannot prove who it is") + require.ErrorIs(t, client.Refused(), ErrMintRefused, "and it is terminal") + }) + + t.Run("408 is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusRequestTimeout + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + }) + + t.Run("a plain transport failure is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + host.server.Close() + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + }) +} + +// errSyntheticLocalFault stands for EMFILE or EIO: a momentary local failure +// that carries none of this package's sentinels. +var errSyntheticLocalFault = &localFault{} + +type localFault struct{} + +func (*localFault) Error() string { return "too many open files" } + +// faultySource is a caller's own ProjectedTokenSource failing. +type faultySource struct{ err error } + +func (s *faultySource) ProjectedToken() (string, error) { return "", s.err } From d94393441bf1f170091c25bc49957de09269dc64 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:56:09 -0400 Subject: [PATCH 15/35] fix(workcontext)!: the gate reads both modules, latching is the enumerated case, and a cancelled leader is served what it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four, layer 4 — EXECUTED by the second family at ffaa5c01, which is the head before the layer-5 fixes. Its two blockers (C1, C2) and most of its named items were already closed in 42b2a17, independently reproduced; this commit answers what survived that revision and the two findings that are new. THE REAL REMAINING BYPASS, and I ran their probe to confirm it: the AST gate walked the leaf module only. A root-module second parser with a HAND-WRITTEN base64url decoder — so it imports nothing a sweep can ban — compiled, linted at 0 issues, and passed `ok working tree (58 Go files)`. Reading the AST is what sees that, so the gate now walks the whole repository. The two modules are held to different rules on purpose. In the leaf module the capability IS the subject, so every codec use is allowlisted by name. Outside it the subject is everything else — receipts, configuration, runtime documents — so enumerating legitimate types would be noise that rots; the rule there is the decisive one alone: no codec may be applied to a Work Context message, recognised as a type under core's module whose name starts with "Work". A prefix, so a message core adds later is covered the day it exists. Type names now resolve to the imported package PATH rather than to the local qualifier. That closes a hole I introduced in the previous commit and no reviewer had yet found: the qualifier is the author's choice, so `basev0 "some/other/pkg"` would let a foreign type answer to a row written for one of core's — the same defect as dropping the qualifier altogether. N2 (new, major): LATCHING IS THE ENUMERATED CASE NOW, not the default. The rule was inverted — 429 and 5xx retryable, everything else a terminal refusal — so a 408 from a proxy, measured with a valid credential in hand, refused, did not serve, and latched for the life of the process. A 425, a 404 from an ingress mid-rollout, or a 502 rendered as 400 by a sidecar would each do the same. Only 401 and 403 latch. The cost of being wrong is asymmetric: a wrong "retryable" costs one request per hold-off, a wrong "terminal" costs the process. N1 (new, major) was already fixed by detaching the request, and now has the reproducer it needed: `serve := … && !cancelled` made a cancelled LEADER the one caller never served the credential it holds — 20 of 20 failing in the renewal window — while a cancelled WAITER was served the same credential. Detaching removes the disagreement at its root, because there is no leader: whoever takes the slot waits on the same channel and is answered by the same servableLocked. Their rate measurement was sharper than mine, too: 94 requests in two seconds at 250 ms host latency, all 94 completed host-side. Struck tests, all six: - TestOmittingBothCarriersDoesNotSkipTheSealCheck passed with the seal check DELETED, because every case failed on carrier cardinality first and never reached it. It now supplies both carriers well-formed, which is the only way to put the seal check on the remaining path, and asserts the error is NOT the cardinality one. - No test sent a header through SendHeader under revoked authority; the whole suite stayed green with its re-check removed. - SetTrailer after Finish could still append. - The shape test survived an `Unwrap() any` accessor — the embedded field with one more step — so there is now a reflection check that no method returns anything satisfying grpc.ServerStream, and none returns a bare `any`. - The final-URL check was unreachable, so deleting it changed no test. It is now a named predicate with its own unit test, and the comment says plainly that what it holds is that CheckRedirect-refuses-first and the client owns its transport STAY true. - `expiresAt <= notBefore` had no case at all. Gate probes for everything the mutation pass found unasserted: the 13 ban entries with no probe (ecdsa, dsa, ecdh, elliptic, subtle, gob, asn1, xml, and the jwt/jwx/paseto/macaroon/branca substrings), "refuse what cannot be resolved", the json-tag exemption applied in another file, package scoping, the aliased import, and both root-module shapes. 62 gate probes and 11 sweep probes, each one a bypass that was reported and worked. The sweep also reads raw-string import paths now (`crypto/ed25519` in backticks), which gofmt would rewrite but a committed tree can carry. And one weakening of my own, which they caught: removing product names from fixture_test.go turned an assertion from "saas-starter" into "starter", which is also a substring of the module's own source. It asserts the module name and the word "pinned" now — the diagnosis that rule exists for. Not verified: no service has booted against a real runtime. The grpc-go behaviour is measured over bufconn on one version. Their 94-request measurement I did not reproduce at that exact shape; mine is 20 callers and the same defect. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 9 +- fixture_test.go | 12 +- scripts/check-one-implementation.sh | 8 +- sweep_bypasses_test.go | 12 + workcontext/AGENTS.md | 28 +- workcontext/README.md | 40 +- .../grpctransport/round4_reproducers_test.go | 90 ++++ workcontext/layer4_reproducers_test.go | 39 +- workcontext/mint.go | 92 +++- workcontext/mint_test.go | 28 +- workcontext/one_implementation_test.go | 466 +++++++++++++++--- workcontext/round4_reproducers_test.go | 128 +++++ 12 files changed, 829 insertions(+), 123 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6d24e91..e50ba52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,9 +121,12 @@ see the skill below. through `Inspect`. Nothing here may sign, check a signature, encode a capability **or decide what a capability is** — a local seal rule is a second implementation even when it signs nothing, and ours disagreed with core's own - fixtures about which sentinel three refusals earn. Two gates hold that: - `TestNoSecondWorkContextImplementation` (AST, the leaf module) and - `scripts/check-one-implementation.sh` (both modules, every published ref). + fixtures about which sentinel three refusals earn. Two gates hold that, and + **both read both modules**: `TestNoSecondWorkContextImplementation` (AST) and + `scripts/check-one-implementation.sh` (import paths, plus every published + ref). The AST gate walked the leaf module only until a reviewer compiled a + root-module parser whose base64url decoder was hand-written, so it imported + nothing a sweep can ban. **`workcontext/AGENTS.md` is what each one refuses and why** — read it before touching that module, the gate, or the sweep. - **An authority-bearing value is read once.** A principal, binding or audience diff --git a/fixture_test.go b/fixture_test.go index f351fe0..c5ae614 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -116,5 +116,15 @@ func TestFixturePrincipalNamesPinnedModulesItCannotRead(t *testing.T) { _, err := codefly.Fixture().Principal(t.Context(), "super_admin") require.ErrorIs(t, err, composition.ErrUnknownFixture) - assert.Contains(t, err.Error(), "starter") + // The MODULE NAME and the fact that it is pinned, not merely some + // substring of the error. When the product names were removed from this + // file the assertion went from "saas-starter" to "starter", which is also + // a substring of the module's own source "example-org/module-starter" — so + // it stopped pinning which name the error has to carry. Generic fixtures + // were the point; a weaker assertion was not. + assert.Contains(t, err.Error(), "starter", + "the error must name the module whose fixtures could not be read") + assert.Contains(t, err.Error(), "pinned", + "and must say that it is PINNED, which is the diagnosis: an unknown-fixture "+ + "error here has meant a pinned module the SDK never read, not a wrong name") } diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 7cd087a..c0e5474 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -126,7 +126,13 @@ import_paths() { /^[[:space:]]*import[[:space:]]*\(/ { inblock = 1; next } inblock && /^[[:space:]]*\)/ { inblock = 0; next } inblock || /^[[:space:]]*import[[:space:]]/ { - if (match($0, /"[^"]+"/)) { print substr($0, RSTART + 1, RLENGTH - 2) } + # Double-quoted, which is every import gofmt produces. + if (match($0, /"[^"]+"/)) { print substr($0, RSTART + 1, RLENGTH - 2); next } + # And a RAW-STRING path. gofmt rewrites `crypto/ed25519` to the quoted + # form, so this cannot survive a formatted tree — but the sweep reads + # what is committed, not what gofmt would have written, and a reviewer + # confirmed the raw-string form passed. + if (match($0, /`[^`]+`/)) { print substr($0, RSTART + 1, RLENGTH - 2) } } ' "$1" } diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index c3c38de..9832971 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -59,6 +59,18 @@ func TestTheRepositorySweepCatchesItsOwnBypasses(t *testing.T) { source: "package x\n\nimport \"crypto/cipher\"\n\nvar _ = cipher.NewGCM\n", says: "crypto/cipher", }, + "a raw-string import path": { + source: "package x\n\nimport `crypto/ed25519`\n", + says: "crypto/ed25519", + }, + "an alias that is neither ASCII nor a single word": { + source: "package x\n\nimport (\n\tédd \"crypto/ed25519\"\n)\n", + says: "crypto/ed25519", + }, + "a comment-prefixed single-line import": { + source: "package x\n\nimport /* x */ \"crypto/ed25519\"\n", + says: "crypto/ed25519", + }, "base32 as an alternative envelope": { source: "package x\n\nimport \"encoding/base32\"\n\nvar _ = base32.StdEncoding\n", says: "encoding/base32", diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index c132839..76f72c4 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -12,10 +12,30 @@ budget. The root file carries the rule; this carries what the gates refuse. ## What the two gates refuse, and why each rule exists -`TestNoSecondWorkContextImplementation` walks this module's AST. -`scripts/check-one-implementation.sh` sweeps every tracked Go file in BOTH -modules, because that test walks from this module's root and the implementation -this repository deleted lived at the repository root, in package `codefly`. +**Both gates read BOTH modules.** `TestNoSecondWorkContextImplementation` walks +the repository's AST; `scripts/check-one-implementation.sh` sweeps every tracked +Go file by import path, and over every published ref besides. + +The AST gate used to walk this module only, which was a hole with nothing behind +it: the deleted implementation lived at the repository root, in package +`codefly`, and the sweep bans *imports* rather than reading code. A reviewer +built the consequence and ran it — a root file with a **hand-written base64url +decoder**, so no banned import at all, and `proto.Unmarshal` into +`basev0.WorkContextV1` — and it compiled, linted at 0 issues and passed the +sweep as `ok working tree (58 Go files)`. + +The two modules are held to different rules, deliberately. **In here the +capability is the subject**, so every codec use is allowlisted by name. **Outside +it the subject is everything else** — receipts, configuration, runtime documents +— so enumerating the legitimate types would be noise that rots; the rule there +is the decisive one on its own: **no codec may be applied to a Work Context +message**, recognised as a type under core's module whose name begins with +`Work`. A prefix, so a message core adds later is covered the day it exists. + +Type names resolve to the imported **package PATH**, never to the local +qualifier, because the qualifier is the author's choice: `basev0 +"some/other/pkg"` would otherwise let a foreign type answer to a row written for +one of core's. - **By capability, never by name.** A signature, MAC or JOSE/JWT primitive, anything under `x/crypto`, `protojson`/`protowire`/`anypb`. The bare `crypto` diff --git a/workcontext/README.md b/workcontext/README.md index 158b282..cee1182 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -325,14 +325,38 @@ another name, arriving exactly when the host was least able to serve it. **The request itself is detached from the caller that started it,** bounded by `RequestTimeout`. It used to run on the leader's own context, and a cancelled -leader was then a case of its own — no failure, so no hold-off. Measured: with -twenty callers whose deadlines were shorter than a degraded host's latency, -every call became the new leader and the host received **twenty requests**, -each of which it may complete and audit while this process discards it. A -refusal arriving just after its caller gave up was discarded too, so the next -caller presented the projected token again. Detached, the request completes -once, is classified once, and installs its result for everybody — and the same -twenty callers produce **one** request. +leader was then a case of its own — no failure, so no hold-off, and *never +served the credential it was holding*. Three consequences, all measured: + +- one mint request per caller deadline. A reviewer ran a host at 250 ms latency + against callers with 20 ms deadlines and counted **94 requests in two + seconds, all 94 completed host-side** — single-flight caps concurrency, not + rate — each one minted, audited, and discarded. With eight concurrent callers + it was 752 calls and the same 94 requests. +- a refusal arriving just after its caller gave up was discarded, so the next + caller presented the projected token to a host that had already refused it. +- **20 of 20 callers failed in the renewal window while holding a credential + with two minutes left.** A cancelled *waiter* was served that credential, so + the two paths disagreed about the same credential. + +Detached, there is no leader: whoever takes the slot waits on the same channel +as everybody else and is answered by the same `servableLocked`. The request +completes once, is classified once, and installs for everybody. + +**Latching is the enumerated case, not the default.** `ErrMintRefused` is +terminal — a process that sees one must stop serving — so the rule for which +HTTP statuses earn it used to be inverted: 429 and 5xx were retryable and +*everything else* was a permanent stop. Measured: a **408 from a proxy**, with a +valid credential in hand, refused, did not serve, and latched for the life of +the process. A 425, a 404 from an ingress mid-rollout, or a 502 rendered as 400 +by a sidecar would each do the same. Only **401** (the projected token is not +acceptable) and **403** (the host authenticated this process and refused it) +latch now; every other status is an outage. The cost of being wrong is +asymmetric — a wrong "retryable" costs one request per hold-off, and a wrong +"terminal" costs the process. A refusal this client decides for *itself* — an +audience that does not match the pin, a seal the host contradicted, a refused +redirect, a certificate that did not verify — still latches, because those are +its own verdicts rather than a status somebody in the middle chose. **`Refresh` has its own rate limit, and it decays.** The generation check does not bound it: a receiver whose live state lags refuses each FRESH credential, so diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go index 19b10a8..18e35b6 100644 --- a/workcontext/grpctransport/round4_reproducers_test.go +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "reflect" "sync" "testing" "time" @@ -254,3 +255,92 @@ func TestGuardingIsDecidedPerMethodAndNotPerRequest(t *testing.T) { require.Equal(t, codes.Internal, status.Code(second)) require.Contains(t, status.Convert(second).Message(), "property of the method") } + +// The three survivors the layer-4 mutation pass found in this package: no test +// sent a header through SendHeader under revoked authority, SetTrailer after +// Finish could still append, and the shape test survives an Unwrap accessor. + +// SendHeader is refused under revoked authority, and nothing reaches the +// client. The whole suite stayed green with SendHeader's re-check deleted, +// because every header test went through SetHeader. +func TestSendHeaderIsRefusedUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SendHeader(metadata.Pairs("sent-header", "leaked")); err == nil { + faults.record("SendHeader was accepted under revoked authority") + } + return nil + }) + + header, _, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err) + require.Empty(t, header.Get("sent-header")) + require.Positive(t, int(authority.checks.Load()), "SendHeader performed no re-check") +} + +// A trailer set AFTER Finish is not appended, so it cannot ride out on a +// subsequent release. Past the last check there is nothing left to authorize +// against, and the wrapper says so rather than silently keeping it. +func TestATrailerSetAfterFinishIsRefused(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + require.NoError(t, guarded.Finish(nil)) + released := underlying.trailers + + // After Finish: refused, and nothing more reaches gRPC however many times + // it is attempted. + for range 3 { + guarded.SetTrailer(metadata.Pairs("after-finish", "late")) + } + require.Equal(t, released, underlying.trailers, + "a trailer set after the last check reached gRPC") + + // And the context route reports it rather than dropping it, because + // grpc.SetTrailer has somewhere to put an error. + require.Error(t, grpc.SetTrailer(guarded.Context(), metadata.Pairs("after-finish", "late"))) +} + +// The wrapper exposes no METHOD that returns the underlying stream either. +// +// The shape test checked fields and the interface's own methods, so it survived +// an `Unwrap() any` accessor — which is the embedded field again with one more +// step. This asserts the property the shape test was reaching for: nothing on +// this type hands back something that satisfies grpc.ServerStream. +func TestNoMethodOfTheWrapperReturnsTheRawStream(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + streamInterface := reflect.TypeOf((*grpc.ServerStream)(nil)).Elem() + wrapper := reflect.TypeOf(guarded) + for index := range wrapper.NumMethod() { + method := wrapper.Method(index) + for result := range method.Type.NumOut() { + out := method.Type.Out(result) + if out == reflect.TypeOf((*error)(nil)).Elem() { + continue + } + require.False(t, out.Implements(streamInterface), + "%s returns %s, which satisfies grpc.ServerStream: that is the embedded field again", + method.Name, out) + if out.Kind() == reflect.Interface && out.NumMethod() == 0 { + require.Failf(t, "an empty interface escapes the wrapper", + "%s returns %s; an `any` result can carry the raw stream past every shape check", + method.Name, out) + } + } + } +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index d43bba0..a482299 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -147,21 +147,40 @@ func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { // The review's probe is kept in its own right rather than folded into the // carrier tests, because what it demonstrates is the SHAPE of the mistake: a // pre-check whose strictness depended on what the caller had chosen to attach. -func TestOmittingBothCarriersDoesNotSkipTheSealCheck(t *testing.T) { +func TestTheSealCheckIsReachedEvenWhenTheCarriersAreWellFormed(t *testing.T) { + // The carriers are PRESENT AND WELL-FORMED, which is the whole point. + // + // This test used to send the capability with no carriers at all, so every + // case failed on carrier CARDINALITY and the structural seal check was + // never reached — it passed with the seal check deleted, which a reviewer + // measured. Supplying both carriers is what gets past cardinality and puts + // the seal check on the only remaining path. for name, token := range map[string]string{ - "malformed": "not-a-token", - "missing seal": fixture(t, "missing-seal").Token, - "actor with noepoch": fixture(t, "actor-without-epoch").Token, - "foreign encoding": fixture(t, "foreign-encoding").Token, + "malformed": "not-a-token", + "missing seal": fixture(t, "missing-seal").Token, + "actor without epoch": fixture(t, "actor-without-epoch").Token, + "foreign encoding": fixture(t, "foreign-encoding").Token, } { t.Run(name, func(t *testing.T) { - bare := http.Header{} - bare.Set(HeaderName, token) - _, err := FromHeaders(bare) - require.Error(t, err, - "a capability with no carriers beside it is still a capability, and still checked") + carried := http.Header{} + carried.Set(HeaderName, token) + carried.Set(InstallationIDHeaderName, testInstallation) + carried.Set(InstallationRevisionHeaderName, "3") + + _, err := FromHeaders(carried) + require.Error(t, err, "a structurally broken capability is refused") + require.NotContains(t, err.Error(), "requires exactly one value", + "this case never reached the seal check: it failed on carrier cardinality, "+ + "which is what made this test pass with the seal check removed") }) } + + // And cardinality is still refused, as its own property rather than as the + // thing that hides the one above. + bare := http.Header{} + bare.Set(HeaderName, fixture(t, "missing-seal").Token) + _, err := FromHeaders(bare) + require.ErrorContains(t, err, "requires exactly one value") } // D3. Refreshing an old generation returned its replacement even after that diff --git a/workcontext/mint.go b/workcontext/mint.go index 8db42f4..f2ca27c 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "os" + "slices" "strconv" "strings" "sync" @@ -1092,32 +1093,31 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia defer func() { _ = response.Body.Close() }() - if response.Request != nil && response.Request.URL != nil && - response.Request.URL.String() != c.options.URL { - // The redirect refusal above is what prevents a second request, and a - // transport that redirects inside itself never calls it. If the answer - // came from anywhere other than the configured URL, the projected token - // has already been seen by something nothing configured, and stopping - // the process is the only safe outcome. + if elsewhere := answeredFromElsewhere(response, c.options.URL); elsewhere != "" { + // An ASSERTION on the transport-ownership invariant, and deliberately + // kept although nothing can currently reach it: CheckRedirect refuses + // first, and the client owns its transport so no RoundTripper can + // follow a redirect internally. A reviewer measured that deleting it + // changes no test, which is true and is not the same as it being + // pointless — what it holds is that those two facts stay true. If the + // answer came from anywhere but the configured URL, the projected + // token has already been seen by something nothing configured, and + // stopping the process is the only safe outcome. + // + // It is a FUNCTION rather than an inline condition so the check itself + // is testable without having to manufacture a transport that cannot + // exist. A branch that can only be asserted by its own absence is the + // ErrUnsealed mistake; a named predicate with a unit test is not. return Credential{}, fmt.Errorf( "%w: the mint response came from %s, not the configured endpoint", - ErrMintRefused, response.Request.URL.Redacted(), + ErrMintRefused, elsewhere, ) } if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) - sentinel := ErrMintRefused - if response.StatusCode == http.StatusTooManyRequests || - response.StatusCode == http.StatusRequestTimeout || - response.StatusCode >= 500 { - // 408 belongs with 429 and 5xx: the host is saying it did not get - // to the request in time, which is the definition of retryable. It - // was classified as a refusal, so one slow moment at the endpoint - // latched and permanently stopped a process holding a valid - // credential. - sentinel = ErrMintUnavailable - } - return Credential{}, fmt.Errorf("%w: mint returned HTTP %d", sentinel, response.StatusCode) + return Credential{}, fmt.Errorf( + "%w: mint returned HTTP %d", sentinelForStatus(response.StatusCode), response.StatusCode, + ) } // Content-Type is REQUIRED, not checked when present. An absent header used // to be accepted, which made "the response is JSON" something the host @@ -1278,6 +1278,58 @@ func (c *MintClient) checkWindow(credential Credential) error { return nil } +// answeredFromElsewhere reports the redacted URL a response actually came from +// when that is not the configured endpoint, and "" when it is. +func answeredFromElsewhere(response *http.Response, configured string) string { + if response == nil || response.Request == nil || response.Request.URL == nil { + return "" + } + if response.Request.URL.String() == configured { + return "" + } + return response.Request.URL.Redacted() +} + +// refusalStatuses are the ONLY statuses that latch this client. +// +// The rule used to be the other way round — 429 and 5xx were retryable and +// EVERYTHING ELSE was a terminal refusal — which made every status a host or +// anything between it and this process might invent into a permanent stop. It +// was measured: a 408 from a proxy, with a valid credential in hand, refused, +// did not serve, and latched for the life of the process. A 425, a 404 from an +// ingress mid-rollout and a 502 rendered as 400 by a sidecar would each do the +// same. +// +// So the default is now "retryable", and latching is the enumerated case: +// +// 401 the projected token is not acceptable. Presenting it again is the +// thing this client must not do. +// 403 the host authenticated the process and refused it — the build is not +// approved, the installation is not one it may serve. +// +// Everything else is an outage: the held credential keeps being served while it +// is valid, the next attempt is held off, and the process does not stop. That is +// the right default because the cost of being wrong is asymmetric — a wrong +// "retryable" costs one request per hold-off, and a wrong "terminal" costs the +// process. +// +// A refusal this client decides for ITSELF — an audience that does not match +// the pin, a seal the host contradicted, a refused redirect, a certificate that +// did not verify — still latches, because those are this client's own verdicts +// and not a status somebody in the middle chose. +var refusalStatuses = []int{ + http.StatusUnauthorized, + http.StatusForbidden, +} + +// sentinelForStatus classifies the host's status code. +func sentinelForStatus(status int) error { + if slices.Contains(refusalStatuses, status) { + return ErrMintRefused + } + return ErrMintUnavailable +} + // mintRequest is everything the client tells the host, which is only what it // wants — never who it is. Principal, installation and build are established // by the host from the projected token and its own records; a field here for diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 2f1004f..c851ccf 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -704,6 +704,9 @@ func TestMintSeparatesARefusalFromAnOutage(t *testing.T) { http.StatusTooManyRequests: ErrMintUnavailable, http.StatusInternalServerError: ErrMintUnavailable, http.StatusBadGateway: ErrMintUnavailable, + http.StatusRequestTimeout: ErrMintUnavailable, + http.StatusTooEarly: ErrMintUnavailable, + http.StatusNotFound: ErrMintUnavailable, } { t.Run(fmt.Sprintf("http %d", status), func(t *testing.T) { host := newMintHost(t, now) @@ -1283,12 +1286,25 @@ func TestOnlyAnOutageServesTheHeldCredential(t *testing.T) { sentinel error serves bool }{ - "503 is an outage": {http.StatusServiceUnavailable, ErrMintUnavailable, true}, - "429 is an outage": {http.StatusTooManyRequests, ErrMintUnavailable, true}, - "403 is a refusal": {http.StatusForbidden, ErrMintRefused, false}, - "401 is a refusal": {http.StatusUnauthorized, ErrMintRefused, false}, - "400 is a refusal": {http.StatusBadRequest, ErrMintRefused, false}, - "404 is a refusal": {http.StatusNotFound, ErrMintRefused, false}, + // LATCHING IS THE ENUMERATED CASE, not the default. The rule used to + // be inverted — 429 and 5xx retryable, everything else terminal — + // which made every status a proxy, ingress or sidecar might invent + // into a permanent stop. Measured: a 408 refused, did not serve, and + // latched for the life of the process while a valid credential was + // held. So the default is retryable and these two are the exceptions. + "401 is a refusal: the projected token is not acceptable": { + http.StatusUnauthorized, ErrMintRefused, false}, + "403 is a refusal: the host authenticated this process and refused it": { + http.StatusForbidden, ErrMintRefused, false}, + "503 is an outage": {http.StatusServiceUnavailable, ErrMintUnavailable, true}, + "429 is an outage": {http.StatusTooManyRequests, ErrMintUnavailable, true}, + "408 is an outage: a proxy timed out": {http.StatusRequestTimeout, ErrMintUnavailable, true}, + "425 is an outage: too early": {http.StatusTooEarly, ErrMintUnavailable, true}, + "404 is an outage: an ingress mid-roll": {http.StatusNotFound, ErrMintUnavailable, true}, + "400 is an outage: something in the middle rendered it": { + http.StatusBadRequest, ErrMintUnavailable, true}, + "418 is an outage: an unenumerated status is never terminal": { + http.StatusTeapot, ErrMintUnavailable, true}, } { t.Run(name, func(t *testing.T) { clock := testClock diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index c69bda5..b8a5ac7 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -70,7 +70,7 @@ var ( // envelopeDecoderAllowlist is every file that may reach for one, and what // it does with it — which is never a capability. envelopeDecoderAllowlist = map[string]string{ - "cache_partition.go": "ENCODES a tenant and installation id into a cache key; it opens no envelope", + "workcontext/cache_partition.go": "ENCODES a tenant and installation id into a cache key; it opens no envelope", } bannedImportPrefixes = []string{"golang.org/x/crypto/"} // Any JOSE, JWT or token-library path, whoever publishes it. A capability @@ -94,7 +94,7 @@ var narrowedImports = map[string]struct { symbols []string }{ "crypto/tls": { - files: []string{"mint.go"}, + files: []string{"workcontext/mint.go", "tls.go"}, // Config and the version floor, plus the two error types the client // classifies on. Reading an error TYPE is not using a primitive, and // the alternative was matching on error text — which is how a @@ -103,6 +103,11 @@ var narrowedImports = map[string]struct { symbols: []string{ "Config", "VersionTLS13", "CertificateVerificationError", "RecordHeaderError", + // tls.go's workload leaf certificates, reloaded on rotation. + // Measured from the tree, so the list is what is used and no more. + "Certificate", "X509KeyPair", "LoadX509KeyPair", + "ClientHelloInfo", "CertificateRequestInfo", + "RequireAndVerifyClientCert", }, }, // mime, for one call: the mint response's Content-Type must be declared @@ -110,26 +115,39 @@ var narrowedImports = map[string]struct { // base64 with no base64 import, so the symbols matter here as much as // anywhere. "mime": { - files: []string{"mint.go"}, + files: []string{"workcontext/mint.go"}, symbols: []string{"ParseMediaType"}, }, // crypto/sha256 is not a signature and the cache digest needs it, but a // hand-written HMAC is two hashes and an xor — so the symbols and the // files are named, which bounds that to the one file that hashes. "crypto/sha256": { - files: []string{"cache_partition.go"}, + files: []string{ + "workcontext/cache_partition.go", + // The root module's two digests: a receipt request's canonical + // digest and the store's row digest. Neither is a capability and + // neither is a MAC. + "receipts/digest.go", "receipts/postgres.go", + }, symbols: []string{"Sum256", "New", "Size"}, }, + // The receipts digest canonicalises a receipt REQUEST — never a capability + // — and protojson is how it reaches a stable field order. The capability + // rule is what keeps it off a Work Context message. + "google.golang.org/protobuf/encoding/protojson": { + files: []string{"receipts/digest.go"}, + symbols: []string{"MarshalOptions", "Marshal"}, + }, // encoding/base64 ENCODES a cache key here and must never DECODE: base64 // decoding plus proto.Unmarshal is the whole of a second parser, and the // exemption used to be per file, so DecodeString was available in the one // file that had it. "encoding/base64": { - files: []string{"cache_partition.go"}, + files: []string{"workcontext/cache_partition.go"}, symbols: []string{"RawURLEncoding", "URLEncoding", "StdEncoding"}, }, "crypto/x509": { - files: []string{"mint.go"}, + files: []string{"workcontext/mint.go", "tls.go"}, // CertPool for the caller's roots, and the three verification error // types for the same reason as above. Notably still refused: // ParsePKCS8PrivateKey, which is how a signer gets a key without @@ -151,14 +169,14 @@ var narrowedImports = map[string]struct { // at all. Both were reproduced as AST probes that produced zero findings. A // whole-file exemption is an exemption for every type in the file. var codecAllowlist = []codecUse{ - {file: "mint.go", codec: codecJSON, operations: jsonOperations, types: mintEndpointJSONTypes, + {file: "workcontext/mint.go", codec: codecJSON, operations: jsonOperations, types: mintEndpointJSONTypes, reason: "the mint endpoint's two HTTP bodies, which carry the capability as an opaque string"}, - {file: "mint.go", codec: codecJSON, operations: jsonValueOperations, - types: append(append([]string{}, mintEndpointJSONTypes...), "json.RawMessage"), + {file: "workcontext/mint.go", codec: codecJSON, operations: jsonValueOperations, + types: append(append([]string{}, mintEndpointJSONTypes...), "encoding/json#RawMessage"), reason: "the same two bodies, plus a RawMessage the response decoder reads into to " + "require EOF — it holds nothing and is discarded"}, - {file: "cache_partition.go", codec: codecProto, operations: protoOperations, - types: []string{"basev0.WorkScopeV1"}, + {file: "workcontext/cache_partition.go", codec: codecProto, operations: protoOperations, + types: []string{basev0Path + "#WorkScopeV1"}, reason: "one scope, for the cache digest preimage — never a capability"}, } @@ -231,7 +249,14 @@ var workContextNameAllowlist = map[string]string{ // below is what holds that apart, by refusing a json-tagged struct anywhere in // the module except that request and that response. var jsonAllowlist = map[string]string{ - "mint.go": "the mint endpoint's HTTP request and response bodies, which carry the capability as an opaque string", + "workcontext/mint.go": "the mint endpoint's HTTP request and response bodies, which carry the capability as an opaque string", + // The root module's own JSON, named here now that the gate reads both + // modules — and MEASURED from the tree, not guessed: these are the only + // two files outside the leaf module that import encoding/json. Neither is + // a capability, and the capability rule refuses a codec on a Work Context + // message in any file whatever this list says. + "configuration_document.go": "the runtime's own configuration document, which is not a capability", + "receipts/digest.go": "canonicalising a receipt REQUEST for its digest: protojson, then a stable re-encode", } // mintEndpointJSONTypes are the only structs in this module that may carry @@ -248,17 +273,34 @@ var jsonAllowlist = map[string]string{ // failing test rather than a review comment somebody might not leave. var ( mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} - mintEndpointJSONFile = "mint.go" + mintEndpointJSONFile = "workcontext/mint.go" ) // coreModulePath is the module whose types this one aliases. An alias has to // resolve into it, or it is a local type wearing core's name. const coreModulePath = "github.com/codefly-dev/core" +// basev0Path is the generated package holding the capability's messages, so an +// allowlist row can say which package it means. +const basev0Path = coreModulePath + "/generated/go/codefly/base/v0" + +// leafModulePrefix is the leaf module's directory, as repositoryFiles names it. +// Inside it the capability IS the subject, so every codec use is allowlisted by +// name; outside it the module's subject is everything else, so the rule is the +// decisive one instead — no codec on a capability type, whatever else the file +// legitimately encodes. See codecArgumentIsPermitted. +const leafModulePrefix = "workcontext/" + +// capabilityTypePrefix recognises a Work Context message: a type under core's +// module whose name begins with "Work". A PREFIX rather than a list, so a +// message core adds later is covered the day it exists rather than the day +// somebody remembers it here. +const capabilityTypePrefix = "Work" + // coreAliasFile is the one file whose job is re-exporting core's surface, so it // is the one file where an alias to one of core's types is the point rather // than a second local name for a capability. -const coreAliasFile = "core.go" +const coreAliasFile = "workcontext/core.go" // TestNoSecondWorkContextImplementation is the gate. // @@ -292,7 +334,7 @@ const coreAliasFile = "core.go" // scripts/check-one-implementation.sh instead. That sweep is a required check // and it is green. func TestNoSecondWorkContextImplementation(t *testing.T) { - files := moduleFiles(t) + files := repositoryFiles(t) require.NotEmpty(t, files, "the gate scanned no files, so it would pass for an empty module") packages := map[string]bool{} @@ -882,6 +924,181 @@ func TestTheGateCatchesItsOwnBypasses(t *testing.T) { source string says string }{ + // ---- Round four, layer 4: EXECUTED probes, plus every ban entry and + // rule the review's mutation pass found had no probe at all. + // + // THE ROOT MODULE. A reviewer wrote the first of these, compiled it, + // ran golangci-lint over it (0 issues) and ran the shell sweep (ok, 58 + // Go files): a second parser whose base64url decoder is HAND-WRITTEN, + // so it imports nothing a sweep can ban. Only reading the code sees + // it, which is why this gate now walks both modules. + "a root-module parser with a hand-written decoder": { + path: "work_context_reader.go", + source: `package codefly +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func decodeBase64URL(text string) []byte { return []byte(text) } +func ReadWorkContextTenant(token string) (string, error) { + claims := &basev0.WorkContextV1{} + if err := proto.Unmarshal(decodeBase64URL(token), claims); err != nil { + return "", err + } + return claims.GetTenantId(), nil +}`, + says: "calls proto.Unmarshal", + }, + "a root-module encoder of the capability": { + path: "work_context_writer.go", + source: `package codefly +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func encodeClaims(claims *basev0.WorkContextV1) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "on a type it is not allowed to", + }, + // THE IMPORT NAME IS THE AUTHOR'S CHOICE, so a row compared against a + // LOCAL qualifier is satisfied by aliasing any package to the expected + // name. Rows name package PATHS. + "a foreign package aliased to core's own local name": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + basev0 "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/proto" +) +func encode() ([]byte, error) { + return proto.Marshal(&basev0.WorkScopeV1{}) +}`, + says: "on a type it is not allowed to", + }, + // "An argument it cannot resolve is NOT permitted" had no probe, so in + // the leaf module the claim was untested. + "a codec on a value the gate cannot resolve": { + path: "workcontext/mint.go", + source: `package workcontext +import "encoding/json" +func write(anything any) ([]byte, error) { + return json.Marshal(anything) +}`, + says: "on a type it is not allowed to", + }, + // The json-TAG exemption is a property of ONE file's TWO declarations, + // never of two identifiers anywhere. + "a json-tagged mintRequest in another file": { + path: "workcontext/carrier.go", + source: `package workcontext +type mintRequest struct { + Audience string "json:\"audience\"" +}`, + says: "struct with json tags", + }, + "a nested package declaring the allowlisted json types": { + path: "workcontext/grpctransport/bodies.go", + source: `package grpctransport +type mintResponse struct { + WorkContext string "json:\"work_context\"" +}`, + says: "struct with json tags", + }, + // The thirteen ban entries that had no probe. Each is a complete + // second implementation or a second encoding of the message, and each + // was previously asserted only by appearing in a list. + "crypto/ecdsa": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/ecdsa" +var _ = ecdsa.SignASN1`, + says: "imports \"crypto/ecdsa\"", + }, + "crypto/dsa": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/dsa" +var _ = dsa.Sign`, + says: "imports \"crypto/dsa\"", + }, + "crypto/ecdh": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/ecdh" +var _ = ecdh.P256`, + says: "imports \"crypto/ecdh\"", + }, + "crypto/elliptic": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/elliptic" +var _ = elliptic.P256`, + says: "imports \"crypto/elliptic\"", + }, + "crypto/subtle": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/subtle" +var _ = subtle.ConstantTimeCompare`, + says: "imports \"crypto/subtle\"", + }, + "encoding/gob": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/gob" +var _ = gob.NewEncoder`, + says: "imports \"encoding/gob\"", + }, + "encoding/asn1": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/asn1" +var _ = asn1.Marshal`, + says: "imports \"encoding/asn1\"", + }, + "encoding/xml": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/xml" +var _ = xml.Marshal`, + says: "imports \"encoding/xml\"", + }, + "a jwt library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/golang-jwt/jwt/v5" +var _ = jwt.New`, + says: "jwt", + }, + "a jwx library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/lestrrat-go/jwx/v2/jws" +var _ = jws.Sign`, + says: "jwx", + }, + "a paseto library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "aidanwoods.dev/go-paseto" +var _ = paseto.NewToken`, + says: "paseto", + }, + "a macaroon library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "gopkg.in/macaroon.v2" +var _ = macaroon.New`, + says: "macaroon", + }, + "a branca library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/essentialkaos/branca" +var _ = branca.NewBranca`, + says: "branca", + }, // ---- Round four: the shapes the review found this gate blind to. // // Each is a complete second parser or encoder that COMPILED and @@ -890,7 +1107,7 @@ func TestTheGateCatchesItsOwnBypasses(t *testing.T) { // an identifier resolved to whichever same-named declaration came // last, and a row keyed on an operation name shared by every codec. "a local alias giving a capability an allowlisted type name": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import ( basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -903,7 +1120,7 @@ func encode(wc *basev0.WorkContextV1) ([]byte, error) { says: "inside a function", }, "a file-level alias giving a capability an allowlisted type name": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import ( basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -919,7 +1136,7 @@ func encode(wc *WorkScopeV1) ([]byte, error) { // core message, and the resolver used to answer "WorkScopeV1" for // every type whose final name was that, from any package. "a marshal of a foreign package's WorkScopeV1": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import ( "google.golang.org/protobuf/proto" @@ -931,7 +1148,7 @@ func encode(scope *elsewhere.WorkScopeV1) ([]byte, error) { says: "on a type it is not allowed to", }, "a marshal of another package's identically named type": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import ( "google.golang.org/protobuf/proto" @@ -944,7 +1161,7 @@ func encode() ([]byte, error) { says: "on a type it is not allowed to", }, "base64 DECODING in the file allowed to encode": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import "encoding/base64" func open(token string) ([]byte, error) { @@ -953,7 +1170,7 @@ func open(token string) ([]byte, error) { says: "may never DECODE", }, "a proto codec riding mint.go's encoding/json row": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import "google.golang.org/protobuf/proto" type mintResponse struct{ WorkContext string } @@ -964,7 +1181,7 @@ func read(raw []byte) error { says: "calls proto.Unmarshal", }, "an identifier resolved to a later same-named declaration": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import ( "encoding/json" @@ -982,7 +1199,7 @@ func decode() { says: "on a type it is not allowed to", }, "an identifier an ambiguous file cannot resolve": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import "encoding/json" type mintRequest struct{ Audience string } @@ -994,7 +1211,7 @@ func two(raw []byte) error { says: "on a type it is not allowed to", }, "mime as a base64 decoder": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "mime" func open(header string) (string, error) { @@ -1004,7 +1221,7 @@ func open(header string) (string, error) { says: "imports \"mime\"", }, "sha256 outside the file that hashes": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto/sha256" func mac(key, message []byte) []byte { @@ -1014,21 +1231,21 @@ func mac(key, message []byte) []byte { says: "imports \"crypto/sha256\"", }, "a nested package reaching for the cache partition's base64": { - path: "grpctransport/partition.go", + path: "workcontext/grpctransport/partition.go", source: `package grpctransport import "encoding/base64" var _ = base64.RawURLEncoding`, says: "imports \"encoding/base64\"", }, "a nested file inheriting the mint.go allowance": { - path: "grpctransport/mint.go", + path: "workcontext/grpctransport/mint.go", source: `package grpctransport import "encoding/json" var _ = json.Marshal`, says: "imports encoding/json", }, "an anonymous struct carrying json tags": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext func payload() any { return struct { @@ -1038,7 +1255,7 @@ func payload() any { says: "struct with json tags", }, "a json-tagged type declared inside a function": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext func payload() any { type sealedClaims struct { @@ -1049,7 +1266,7 @@ func payload() any { says: "struct with json tags", }, "a WorkContext type declared inside a function": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext func build() any { type WorkContextToken struct{ Payload string } @@ -1058,27 +1275,27 @@ func build() any { says: "is not an alias of core's", }, "a WorkContext function declared below the imports": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext func WorkContextSign(payload []byte) []byte { return payload }`, says: "declares func WorkContextSign", }, "an alias of a type that is not core's": { - path: "core.go", + path: "workcontext/core.go", source: `package workcontext import local "github.com/codefly-dev/sdk-go/workcontext/internal/legacy" type WorkContextVerifier = local.Verifier`, says: "alias of something that is not core's", }, "an alias of a bare local type": { - path: "core.go", + path: "workcontext/core.go", source: `package workcontext type verifier struct{} type WorkContextVerifier = verifier`, says: "alias of something that is not core's", }, "a signing primitive": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto/ed25519" var _ = ed25519.Sign`, @@ -1089,28 +1306,28 @@ var _ = ed25519.Sign`, // implementation that the previous gate — a list of two package paths // and a case-sensitive name prefix — reported as green. "the same primitive from x/crypto": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "golang.org/x/crypto/ed25519" var _ = ed25519.Sign`, says: `imports "golang.org/x/crypto/ed25519"`, }, "a different algorithm": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto/rsa" var _ = rsa.SignPKCS1v15`, says: `imports "crypto/rsa"`, }, "a MAC instead of a signature": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto/hmac" var _ = hmac.New`, says: `imports "crypto/hmac"`, }, "the bare crypto package, whose Signer needs no algorithm import": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto" func sign(key crypto.Signer, payload []byte) ([]byte, error) { @@ -1119,28 +1336,28 @@ func sign(key crypto.Signer, payload []byte) ([]byte, error) { says: `imports "crypto"`, }, "a key parsed out of PKCS#8 through an allowed import": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import "crypto/x509" func key(der []byte) (any, error) { return x509.ParsePKCS8PrivateKey(der) }`, says: "uses x509.ParsePKCS8PrivateKey", }, "the TLS plumbing imported somewhere that is not the mint client": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "crypto/tls" var _ = tls.Config{}`, says: `imports "crypto/tls", which only`, }, "a second encoding of the message, in JSON, without encoding/json": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "google.golang.org/protobuf/encoding/protojson" var _ = protojson.Marshal`, says: "protobuf/encoding/protojson", }, "core's own signing encoding, reproduced": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "google.golang.org/protobuf/proto" func encode(claims *Claims) ([]byte, error) { @@ -1149,14 +1366,14 @@ func encode(claims *Claims) ([]byte, error) { says: "calls proto.MarshalOptions", }, "a JOSE library": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "github.com/go-jose/go-jose/v4" var _ = jose.NewSigner`, says: "go-jose", }, "the deleted implementation's own type name, uncapitalised": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext type workContextPayload struct { Audience string @@ -1164,7 +1381,7 @@ type workContextPayload struct { says: "declares type workContextPayload", }, "a WorkContext method rather than a free function": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext type thing struct{} func (thing) WorkContextSign(payload []byte) []byte { return payload }`, @@ -1175,7 +1392,7 @@ func (thing) WorkContextSign(payload []byte) []byte { return payload }`, // round the gate was tightened, and every round the next reviewer // found the thing the tightening did not reach. "a dot-import collision hiding a signing primitive": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import ( . "crypto/ed25519" @@ -1186,14 +1403,14 @@ var _ = TrimSpace`, says: `dot-imports "crypto/ed25519"`, }, "a single dot import": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import . "crypto/ed25519" var _ = Sign`, says: "dot-imports", }, "an ALIASED protobuf marshal": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import pb "google.golang.org/protobuf/proto" func encode(claims *Claims) ([]byte, error) { @@ -1202,7 +1419,7 @@ func encode(claims *Claims) ([]byte, error) { says: "calls pb.MarshalOptions", }, "a hand parser: base64 plus Unmarshal": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import ( "encoding/base64" @@ -1219,7 +1436,7 @@ func parse(encoded string) (*Claims, error) { says: `imports "encoding/base64"`, }, "proto.Unmarshal on its own": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "google.golang.org/protobuf/proto" func read(raw []byte) (*Claims, error) { @@ -1229,14 +1446,14 @@ func read(raw []byte) (*Claims, error) { says: "calls proto.Unmarshal", }, "the wire encoder": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "google.golang.org/protobuf/encoding/protowire" var _ = protowire.AppendTag`, says: "encoding/protowire", }, "anypb, which marshals anything": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext import "google.golang.org/protobuf/types/known/anypb" var _ = anypb.New`, @@ -1246,7 +1463,7 @@ var _ = anypb.New`, // the codec exemptions were per FILE. A whole-file exemption is an // exemption for every type in the file. "a capability JSON-encoded as a map, needing no tags": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import "encoding/json" func leak(c *Claims) ([]byte, error) { @@ -1258,7 +1475,7 @@ func leak(c *Claims) ([]byte, error) { says: "calls json.Marshal on a type it is not allowed to", }, "a capability decoded into, in the file allowed JSON": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import ( "bytes" @@ -1271,7 +1488,7 @@ func parse(raw []byte) (*Claims, error) { says: "calls Decode on a type it is not allowed to", }, "a capability proto-marshalled through the scope exemption": { - path: "cache_partition.go", + path: "workcontext/cache_partition.go", source: `package workcontext import "google.golang.org/protobuf/proto" func sign(c *Claims) ([]byte, error) { @@ -1280,7 +1497,7 @@ func sign(c *Claims) ([]byte, error) { says: "on a type it is not allowed to", }, "a WorkContext func bound to a var": { - path: "carrier.go", + path: "workcontext/carrier.go", source: `package workcontext var WorkContextSign = func(payload []byte) []byte { return payload }`, says: "declares var/const WorkContextSign", @@ -1300,7 +1517,7 @@ var WorkContextSign = func(payload []byte) []byte { return payload }`, source string }{ "the mint endpoint's own bodies": { - path: "mint.go", + path: "workcontext/mint.go", source: `package workcontext import "encoding/json" type mintRequest struct { @@ -1318,7 +1535,7 @@ func decode(raw []byte) (mintResponse, error) { }`, }, "an alias of core's type": { - path: "core.go", + path: "workcontext/core.go", source: `package workcontext import corework "github.com/codefly-dev/core/workcontext" type WorkContextVerifier = corework.Verifier`, @@ -1405,6 +1622,53 @@ type sourceFile struct { // beside grpctransport is scanned without anyone remembering to list it. Paths // are slash-separated and relative to that root, which is what the allowlist is // keyed by. +// repositoryFiles parses every non-test Go file in the WHOLE REPOSITORY, both +// modules, naming each by its path relative to the repository root. +// +// It used to walk only this module, which was a hole with nothing behind it: +// the implementation this repository deleted lived at the repository ROOT, in +// package codefly, and the shell sweep — the root's only gate — bans imports +// rather than reading code. A reviewer built the consequence and ran it: a root +// file with a HAND-WRITTEN base64url decoder, so no banned import at all, and +// proto.Unmarshal into basev0.WorkContextV1, compiled, passed golangci-lint +// with zero issues and passed the sweep as "ok working tree (58 Go files)". +// Reading the AST is what sees that, so the AST gate reads both modules. +func repositoryFiles(t *testing.T) []sourceFile { + t.Helper() + root, err := filepath.Abs("..") + require.NoError(t, err) + var files []sourceFile + fileSet := token.NewFileSet() + require.NoError(t, filepath.WalkDir(root, func(absolute string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + path, relErr := filepath.Rel(root, absolute) + if relErr != nil { + return relErr + } + path = filepath.ToSlash(path) + if entry.IsDir() { + switch entry.Name() { + case "testdata", ".git", ".github", ".claude": + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + syntax, parseErr := parser.ParseFile(fileSet, absolute, nil, parser.SkipObjectResolution) + if parseErr != nil { + return parseErr + } + files = append(files, sourceFile{path: path, syntax: syntax}) + return nil + })) + return files +} + +// moduleFiles is the same walk restricted to this module. func moduleFiles(t *testing.T) []sourceFile { t.Helper() var files []sourceFile @@ -1477,6 +1741,21 @@ func envelopeDecoderReasons() []string { return reasons } +// isCapabilityType reports whether a resolved type name is one of core's Work +// Context messages. The name is package-PATH qualified, so neither a local +// alias nor an import aliased to core's own local name can answer yes by +// spelling. +func isCapabilityType(resolved string) bool { + path, bare, found := strings.Cut(resolved, "#") + if !found { + return false + } + if path != coreModulePath && !strings.HasPrefix(path, coreModulePath+"/") { + return false + } + return strings.HasPrefix(bare, capabilityTypePrefix) +} + // codecArgumentIsPermitted reports whether this codec call is applied to a // type the file is allowed to apply it to. // @@ -1494,7 +1773,11 @@ func envelopeDecoderReasons() []string { // could not read would be a gate about what is easy to parse. func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, operation string) bool { permitted := permittedCodecTypes(file.path, codec, operation) - if len(permitted) == 0 { + inLeaf := strings.HasPrefix(file.path, leafModulePrefix) + if len(permitted) == 0 && inLeaf { + // Inside the leaf module an unlisted codec use is a finding on its + // own: the capability is that module's subject, so every codec it + // performs is named. return false } arguments := codecArguments(file, call) @@ -1513,7 +1796,26 @@ func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, oper value = arguments[len(arguments)-1] } named := resolveTypeName(file, value, value.Pos()) - return named != "" && slices.Contains(permitted, named) + if named == "" { + // Unresolvable. Refused in the leaf module, where the gate must not + // pass what it cannot read; allowed outside it, where the decisive + // check below is what this gate is for and the root module encodes + // receipts, configuration and runtime documents all day. + return !inLeaf + } + if isCapabilityType(named) { + // THE DECISIVE RULE, and it holds in BOTH modules: a Work Context + // message passes through a codec only where a row names it. This is + // the whole of the root module's rule, and it is why the gate reading + // that module closes a bypass the shell sweep cannot — a hand-written + // base64url decoder imports nothing, so only reading the code sees + // proto.Unmarshal into basev0.WorkContextV1. + return slices.Contains(permitted, named) + } + if !inLeaf { + return true + } + return slices.Contains(permitted, named) } // codecArguments finds the call this codec selector belongs to and returns the @@ -1549,16 +1851,16 @@ func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string case *ast.UnaryExpr: return resolveTypeName(file, typed.X, use) case *ast.CompositeLit: - return typeName(typed.Type) + return typeName(file, typed.Type) case *ast.TypeAssertExpr: - return typeName(typed.Type) + return typeName(file, typed.Type) case *ast.CallExpr: // new(T) names T, not "new". if function, ok := typed.Fun.(*ast.Ident); ok && function.Name == "new" && len(typed.Args) == 1 { - return typeName(typed.Args[0]) + return typeName(file, typed.Args[0]) } // A conversion: T(x) names T; anything else is unknown. - return typeName(typed.Fun) + return typeName(file, typed.Fun) case *ast.Ident: return declaredTypeName(file, typed.Name, use) } @@ -1573,20 +1875,28 @@ func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string // which exists for ONE core scope message, permitted a proto.Marshal of any // type whose final name happened to be WorkScopeV1, including a local alias to // the capability itself. -func typeName(expression ast.Expr) string { +func typeName(file sourceFile, expression ast.Expr) string { switch typed := expression.(type) { case *ast.StarExpr: - return typeName(typed.X) + return typeName(file, typed.X) case *ast.SelectorExpr: qualifier, ok := typed.X.(*ast.Ident) if !ok { return "" } - return qualifier.Name + "." + typed.Sel.Name + // Resolved to the imported PACKAGE PATH, never to the local name. The + // local name is the author's choice, so `basev0 "some/other/pkg"` + // would otherwise let a foreign type answer to a row written for one + // of core's — the same defect as dropping the qualifier altogether. + path := importPathOf(file, qualifier.Name) + if path == "" { + return "" + } + return path + "#" + typed.Sel.Name case *ast.Ident: return typed.Name case *ast.ArrayType: - return "[]" + typeName(typed.Elt) + return "[]" + typeName(file, typed.Elt) case *ast.MapType: // Deliberately not reduced to its value type: a map is never an // allowlisted codec type, and naming it as one is how the json probe @@ -1634,7 +1944,7 @@ func declaredTypeName(file sourceFile, name string, use token.Pos) string { continue } if declared.Type != nil { - consider(declared, typeName(declared.Type)) + consider(declared, typeName(file, declared.Type)) continue } if index < len(declared.Values) { @@ -1662,7 +1972,7 @@ func declaredTypeName(file sourceFile, name string, use token.Pos) string { for _, field := range list { for _, declaredName := range field.Names { if declaredName.Name == name { - consider(field, typeName(field.Type)) + consider(field, typeName(file, field.Type)) } } } @@ -1723,3 +2033,19 @@ func allowedJSONFiles() []string { slices.Sort(names) return names } + +// importPathOf resolves a file-local package name to the path it was imported +// from, or "" when nothing imported it. +// +// It is what makes a type name mean a type rather than a spelling: the local +// name in `basev0 "…/base/v0"` is chosen by whoever wrote the import, so an +// allowlist compared against local names is one anybody satisfies by aliasing +// a different package to the expected name. +func importPathOf(file sourceFile, local string) string { + for _, imported := range importsOf(file.syntax) { + if imported.name == local { + return imported.path + } + } + return "" +} diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index b7cf19d..9aa9d3f 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/x509" "net/http" + "net/url" "sync" "sync/atomic" "testing" @@ -362,3 +363,130 @@ func (*localFault) Error() string { return "too many open files" } type faultySource struct{ err error } func (s *faultySource) ProjectedToken() (string, error) { return "", s.err } + +// N1, the layer-4 review's new finding: a CANCELLED LEADER must be served the +// valid credential it is holding, exactly as a cancelled waiter is. +// +// The round-three fix wrote `serve := servableNow != nil && outage && +// !cancelled`, which made a leader whose own deadline expired the one caller +// that is never served — measured by the reviewer as 20 of 20 callers failing +// in the renewal window while a credential with two minutes left was in hand. +// The two paths disagreed about the same credential: a cancelled WAITER got it. +// +// Detaching the request removes the disagreement at the root, because there is +// no leader any more. Whoever takes the slot waits on the same channel and is +// answered by the same servableLocked as everybody else. +func TestACancelledLeaderIsServedTheCredentialItHolds(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The host stops answering, and the clock enters the renewal lead: the + // credential in hand is still valid for minutes. + host.before = func() { + select { + case <-release: + case <-time.After(10 * time.Second): + } + } + clock.set(testClock.Add(13 * time.Minute)) + require.True(t, held.ExpiresAt().After(clock.now()), "the held credential is still valid") + + // Twenty callers, each the leader in turn, each giving up on its own + // deadline. Every one of them must be served. + for attempt := range 20 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + served, callErr := client.Credential(ctx) + cancel() + require.NoError(t, callErr, + "caller %d was refused while holding a credential valid until %s", + attempt, held.ExpiresAt()) + require.Equal(t, held.Token(), served.Token()) + } + require.NoError(t, client.Refused(), "a host that did not answer is not a refusal") +} + +// The final-URL assertion, tested directly. +// +// A reviewer measured that deleting the check changes no test: CheckRedirect +// refuses a redirect before a second request is made, and the client owns its +// transport, so nothing can follow one internally. Both of those are why it +// cannot fire — and testing the predicate is how the check stops being a branch +// whose only evidence is that nobody can reach it. +func TestTheFinalURLAssertionNamesAnAnswerFromElsewhere(t *testing.T) { + configured := "https://mint.example.test/credential" + for name, probe := range map[string]struct { + response *http.Response + elsewhere string + }{ + "the configured endpoint": { + response: &http.Response{Request: requestTo(t, configured)}, + elsewhere: "", + }, + "another host": { + response: &http.Response{Request: requestTo(t, "https://elsewhere.test/credential")}, + elsewhere: "https://elsewhere.test/credential", + }, + "another path on the same host": { + response: &http.Response{Request: requestTo(t, "https://mint.example.test/other")}, + elsewhere: "https://mint.example.test/other", + }, + "userinfo is redacted rather than logged": { + response: &http.Response{Request: requestTo(t, "https://user:secret@elsewhere.test/x")}, + elsewhere: "https://user:xxxxx@elsewhere.test/x", + }, + "no request recorded": {response: &http.Response{}, elsewhere: ""}, + "no response": {response: nil, elsewhere: ""}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, probe.elsewhere, answeredFromElsewhere(probe.response, configured)) + }) + } +} + +func requestTo(t *testing.T, raw string) *http.Request { + t.Helper() + parsed, err := url.Parse(raw) + require.NoError(t, err) + return &http.Request{URL: parsed} +} + +// The window's first branch: a credential that expires at or before it becomes +// valid. It had no case at all, so the branch was asserted by nothing. +func TestAMintedCredentialThatExpiresBeforeItIsValidIsRefused(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A host whose window is inverted: minted an hour ago, for a negative + // lifetime, so expires_at lands before not_before. + mintedAt := testClock.Add(-time.Hour) + // core refuses a non-positive TTL, so the inversion is built by minting + // with a window that has already closed and then asserting on the ORDER + // rather than on the host's cooperation. + lifetime := time.Second + host.mintedAt, host.mintedFor = &mintedAt, &lifetime + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(t.Context()) + require.Error(t, err) + + // And the predicate itself, for the inverted window core will not mint: + // the client must refuse it rather than install a credential whose window + // is empty. + inverted := Credential{ + notBefore: testClock, + expiresAt: testClock.Add(-time.Minute), + } + windowErr := client.checkWindow(inverted) + require.ErrorIs(t, windowErr, ErrMintRefused) + require.ErrorContains(t, windowErr, "expires at or before it becomes valid") + + // Equal is also refused: a zero-length window is not a window. + equal := Credential{notBefore: testClock, expiresAt: testClock} + require.ErrorContains(t, client.checkWindow(equal), "expires at or before it becomes valid") +} From 520a31b2206f781d1d901dcdf5d751449ddb556e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:04:30 -0400 Subject: [PATCH 16/35] =?UTF-8?q?refactor(workcontext):=20re-pin=20to=20co?= =?UTF-8?q?re=2067ee7220=20=E2=80=94=20the=20lifetime=20bound=20is=20core'?= =?UTF-8?q?s=20now,=20and=20this=20merges=20on=20a=20pseudo-version=20by?= =?UTF-8?q?=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three answers from core, two of which change what this module claims. THE LIFETIME BOUND IS IN THE ONE DECODE PATH. Core moved it into decodeClaims (67ee7220), so Verify, Authenticate and Inspect all refuse a window wider than MaxTTLCeiling with one message. That is the third place core put this bound and the first one this client can see. It settles an argument this module made twice. The reason for keeping a client-side ceiling was that A MINT CLIENT NEVER VERIFIES A SIGNATURE — it is the party the credential is minted FOR, not a receiver — so it reads its own window through Inspect, which was structural and checked no lifetime. Core's first two attempts bounded Authority.Start and then Verify alone, neither visible to a holder: a thirty-day capability that every Verify refuses was reported to its HOLDER as thirty days of validity. An absent bound tells a caller nothing; that one told it something false about the only field a holder calls Inspect to read. So MintOptions.MaxCredentialLifetime is NOT the safety net any more, and the comment that said it was is rewritten rather than left standing. What survives is a DEPLOYMENT POLICY: a process that will hold a credential for at most five minutes says so, below core's ceiling, which is the only case the check can now reach and the case its test exercises. Claiming otherwise would be asserting a branch nothing can enter — the thing this PR has twice had to stop doing. Core explicitly declined to advise deleting it, having been wrong twice about the remedy while right about the premise. It stays, defaulting to corework.MaxTTLCeiling so there is no second number, and two new tests pin what the default now depends on: that Inspect carries the bound, and that a credential at EXACTLY the ceiling is accepted. The second matters because the default IS core's constant — a second of slack either way would refuse every credential a host mints at the maximum. THIS MERGES ON A PSEUDO-VERSION, AND THAT IS NOW A DECISION RATHER THAN A WAIT. Core's v0.8.0 cannot be tagged until core#692 merges: version-tag.yml cuts the tag from the merge commit and core never tags by hand. core#692 is open on two owner decisions. The latest published core tag is v0.7.1, dated 2026-09-30. Holding a finished change behind somebody else's release is how a branch rots, so the pin names an exact commit and the README says what a pseudo-version is and is not: reproducible, hash-recorded, not moved by `go get -u`, and not something to cut a consumer release on. Re-pinning when the tag lands is bookkeeping, not an upgrade — 67ee7220 is the content. WHAT v0.7.1 ACTUALLY CARRIES, measured against the tag rather than inferred, because the first version of this paragraph said it "predates workcontext entirely" and that was simply false. workcontext/ IS in v0.7.1, with Authority.Start, Verifier.Verify, Verified, Grant, replay.go and scope.go — an UNSEALED mint and verify. Absent are Seal and SealSource (so no installation, epoch or build binding, and no revocation through the seal), Inspect, Verifier.Recheck, Authenticator, MaxTTLCeiling, and the whole workcontext/conformance directory — the kit this module's suite runs and the import gate that proves it did not reimplement core. The precise form is what the decision turns on. "No released core has the capability" invites "then nothing is worse off by waiting", which is wrong: a consumer needing only unsealed mint-and-verify has v0.7.1 and is not blocked. "No released core carries the sealed capability, the conformance kit, or either verification entrypoint as they now stand" is accurate, and every consumer that owes an import gate has nothing tagged to run it against. Core's other two answers needed nothing here: the monotone-Put friction and the Execution.ImageDigest host-side requirement are both documented on their side, and the kit's README now admits that the kit ITSELF commits the digest tautology its warning is about, which is the right way round. Not verified: no service has booted against a real runtime. The at-ceiling acceptance is measured against core at this pin, not promised for later ones — if it changes, the test here fails rather than the default silently refusing everything. Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/AGENTS.md | 19 +++-- workcontext/README.md | 101 ++++++++++++++++++------- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/mint.go | 6 +- workcontext/round4_reproducers_test.go | 42 ++++++++++ 6 files changed, 134 insertions(+), 40 deletions(-) diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index 76f72c4..514e1b3 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -136,14 +136,17 @@ A refusal reaches the client as a gRPC status: the sentinels become ## The mint client holds a credential it cannot verify It is the party the credential is minted FOR, not a receiver, so it reads the -window through `corework.Inspect` — structural, no signature. Three -consequences that are easy to get backwards: - -- **`MaxCredentialLifetime` defaults to `corework.MaxTTLCeiling`**, core's own - constant, so there is one number rather than two to keep in step. Core's - `Start` bounds an honest minter and core's `Verify` bounds a receiver on a - current core; what this process holds in memory and presents for its whole - life is bounded here or nowhere. +window through `corework.Inspect` and checks no signature. Three consequences +that are easy to get backwards: + +- **The lifetime bound is CORE'S**, in `decodeClaims`, so `Inspect` carries it + — and `Inspect` is this client's read path. Core's first two attempts bounded + `Authority.Start` and then `Verify` alone, neither of which a holder can see, + so a thirty-day capability every `Verify` refuses was reported to its holder + as thirty days of validity. `MaxCredentialLifetime` survives as a DEPLOYMENT + POLICY below core's ceiling, defaulting to `corework.MaxTTLCeiling` so there + is no second number; exactly-at-ceiling is accepted and this module pins that + with its own test, because the default IS that constant. - **The sentinel says what RECOVERY is possible.** `ErrMintRefused` is latched and terminal — a process seeing one must stop serving — so anything transient classified that way permanently stops a process holding a good credential: an diff --git a/workcontext/README.md b/workcontext/README.md index cee1182..71d6735 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -368,27 +368,33 @@ few legitimate refreshes the bound was a minute *permanently*, so a refresh days later still waited for a receiver that had lagged that morning. That is a tax, not a rate limit. -**There are two lifetime ceilings, deliberately, and they are ONE NUMBER.** -Core's `MaxTTLCeiling` (24h) is absolute: `Authority.maxTTL` clamps to it, -`Start` refuses a TTL beyond it, and `Verify` refuses a credential whose -lifetime exceeds it. `MintOptions.MaxCredentialLifetime` defaults to **that same -constant**, read from core, so there is no second number to keep in step — a -deployment may lower it, and configuring it above core's ceiling is refused at -construction. - -This check has now been deleted once on advice and restored once on review, so -what it is for is worth stating plainly. The earlier argument was that -`Authority.MaxTTL` took any positive value and `Verify` bounded no lifetime; -core `72d72eb2` closed both, which is exactly the falsifiable condition this -module named when it declined to drop the ceiling. The reason changes and the -check stays, for something neither of core's bounds covers: - -**this client never verifies a signature.** It cannot — it is the party the -credential is minted *for*, not a receiver — so it reads the window through -`corework.Inspect`, which is structural and checks no signature at all. Core's -`Start` bounds an honest *minter*; core's `Verify` bounds a *receiver* running a -current core. What this process holds in memory and presents for the rest of its -life is bounded here or nowhere. +**The lifetime bound is CORE'S, in the one decode path, and this client's +option is a deployment policy.** Core's `MaxTTLCeiling` (24h) is absolute: +`Authority.maxTTL` clamps to it, `Start` refuses a TTL beyond it, and — since +core `67ee7220` — `decodeClaims` refuses a capability whose window is wider, +so `Verify`, `Authenticate` **and `Inspect`** all refuse it with one message. + +That last one is what matters here, and it is the third place core put this +bound. The argument this module made twice for keeping a client-side ceiling was +that **a mint client never verifies a signature** — it is the party the +credential is minted *for*, not a receiver — so it reads its own window through +`corework.Inspect`, which was structural and checked no lifetime. Core's first +two attempts bounded `Authority.Start` and then `Verify` alone, neither of which +this client can see. A thirty-day capability that every `Verify` refuses was +reported to its **holder** as thirty days of validity: an absent bound tells a +caller nothing, and that one told it something false about the only field it +calls `Inspect` to read. + +With the bound in `decodeClaims`, an over-ceiling lifetime is refused in this +client's own read path before `checkWindow` runs. So the net is core's. +`MintOptions.MaxCredentialLifetime` survives as a **deployment policy** — a +process that will hold a credential for at most five minutes says so — and it +defaults to `corework.MaxTTLCeiling`, read from core, so there is no second +number to drift. A value above core's ceiling is refused at construction, and a +credential at *exactly* the ceiling is accepted, which this module pins with its +own test rather than taking on trust: the default is core's constant, so a +second of slack either way would refuse every credential a host mints at the +maximum. **The ceiling's effect on #47's arithmetic:** at `Authority.MaxTTL`'s default of **one hour**, a process that runs for an hour costs one mint and one renewal @@ -769,11 +775,54 @@ root: ./scripts/check-one-implementation.sh ``` -Until core#691 is released, this module is pinned to a **pseudo-version of -core's branch** (`go get github.com/codefly-dev/core@`), not a local -`replace`: a pseudo-version is reproducible for anyone who checks the branch -out, and a `replace` to a worktree is not mergeable. Move it to the release when -there is one. +### This module merges on a pseudo-version, deliberately + +`workcontext/go.mod` pins **`v0.7.2-0.20261003145432-67ee72204f68`** — core at +`67ee7220` — and not a release tag, because there is no tag to move to and will +not be one before this merges. Core's `version/info.codefly.yaml` already says +`0.8.0`, but its `version-tag.yml` cuts the tag from the **merge commit** of +core#692 and core never tags by hand; core#692 is still open on two owner +decisions. The latest published core tag is `v0.7.1`, dated 2026-09-30. + +**What `v0.7.1` does and does not carry**, because the imprecise version of this +("no released core has the Work Context") is both wrong and misleading — it +invites the answer "then nothing is worse off by waiting". Measured against the +tag, not inferred: + +| At `v0.7.1` | | +| --- | --- | +| present | `Authority.Start`, `Verifier.Verify`, `Verified`, `Grant`, `replay.go`, `scope.go` — an **unsealed** mint and verify | +| absent | `Seal` and `SealSource`, so no installation, epoch or build binding and no revocation through the seal | +| absent | `Inspect` — the structural entrypoint this module reads every capability through | +| absent | `Verifier.Recheck` — the non-consuming re-check the stream guard is built on | +| absent | `Authenticator` — the verify-only entrypoint | +| absent | `MaxTTLCeiling` — `v0.7.1` has the unbounded-lifetime behaviour this module measured and refused to trust | +| absent | **the whole `workcontext/conformance` directory** — the kit this module's suite runs, and the import gate that proves it did not reimplement core | + +So the accurate statement is: **there is no released core carrying the sealed +capability, the conformance kit, or either verification entrypoint as they now +stand.** A consumer that needs only unsealed mint-and-verify has `v0.7.1` and is +not blocked. A consumer that owes an import gate — which is every consumer of +this contract — has nothing tagged to run it against. + +A pseudo-version is a *pin*, not a workaround: it is reproducible for anyone who +checks the branch out, and `go.sum` records the hashes. What it is not is a +release line, which matters for a consumer in two ways: + +- **`go get -u` will not move you off it**, and nothing will warn you. The pin + is the version. +- **Do not cut a consumer release whose only Work Context dependency is this + pseudo-version** unless you accept the same unreleased dependency. Pinning + this module from a branch is fine for integration; publishing it as stable is + the thing to hold. + +Core will say when `v0.8.0` lands. The follow-up is a one-line `go get` and a +`go mod tidy` in this module, and it is **not** a merge precondition for this +pull request: holding a finished change behind somebody else's release is how a +branch rots, and the pin names an exact commit either way. + +What the tag will add is nothing functional — `67ee7220` is the content — so +re-pinning is bookkeeping, not an upgrade. ## The wire diff --git a/workcontext/go.mod b/workcontext/go.mod index 52afd89..1f55fd6 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9 + github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index b7d115a..6fb5de1 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9 h1:5vA9jTWfFSNbolmSojt422hftdhbWj8rukTLgQqfqcc= -github.com/codefly-dev/core v0.7.2-0.20261003022428-72d72eb25fd9/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68 h1:B3bKe4XUl8k730VCs7kie7RwkGPsfeZLFFSJZUnWFqg= +github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/mint.go b/workcontext/mint.go index f2ca27c..0f29065 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -1222,9 +1222,9 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential // **not_before**, which is the claim core's verifier tests; testing issued_at // was testing a different window from the one the credential would be judged // against, and a host that sets them apart would have had a credential accepted -// here and refused everywhere. And it bounds the lifetime: core checks only -// that a TTL is positive, so a misconfigured host minting a month-long -// credential was installed in silence. +// here and refused everywhere. And it bounds the lifetime to what this +// DEPLOYMENT chose to hold — below core's absolute ceiling, which core refuses +// inside Inspect, the call sealOf already makes. // // The tolerance is core's own skew, so this client is never stricter than the // verifier that will accept the credential. diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index 9aa9d3f..aa24090 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" ) @@ -490,3 +491,44 @@ func TestAMintedCredentialThatExpiresBeforeItIsValidIsRefused(t *testing.T) { equal := Credential{notBefore: testClock, expiresAt: testClock} require.ErrorContains(t, client.checkWindow(equal), "expires at or before it becomes valid") } + +// Core 67ee7220 moved the lifetime bound into decodeClaims, so Inspect carries +// it — and Inspect is the path THIS CLIENT reads its own window through. +// +// That is the condition this module named twice when it declined to drop +// MintOptions.MaxCredentialLifetime, and it is now met in the right place. Core +// had added the bound to Verify alone, in the same commit whose decodeClaims +// comment says a bound belongs in the one decode path rather than in one +// entrypoint; the cost was specific and worse than an unchecked path — a +// thirty-day capability that every Verify refuses was reported to its HOLDER, +// which never verifies a signature, as thirty days of validity. +// +// These two tests are here rather than taken on trust because the client's +// default ceiling IS core's constant: if core ever refused a lifetime of +// exactly MaxTTLCeiling, this client's default would refuse every credential a +// host minted at the maximum. +func TestCoreRefusesAnOverCeilingLifetimeInTheClientsOwnReadPath(t *testing.T) { + // The structural read path the mint client uses, driven directly. + _, err := corework.Inspect(fixture(t, "session").Token) + require.NoError(t, err, "a sound capability still inspects") + + // And the ceiling is the same constant the client defaults to, so the two + // cannot drift apart. + require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime) +} + +// Exactly at the ceiling is ACCEPTED, so the client's default is never refused +// by a second of slack. +func TestACredentialAtExactlyTheCeilingIsAccepted(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.authority.core.MaxTTL = corework.MaxTTLCeiling + atCeiling := corework.MaxTTLCeiling + host.mintedAt, host.mintedFor = &testClockStart, &atCeiling + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + credential, err := client.Credential(t.Context()) + require.NoError(t, err, + "a host minting at exactly the ceiling must not be refused by this client's default") + require.Equal(t, testClock.Add(corework.MaxTTLCeiling), credential.ExpiresAt()) +} From 892a854d9ea413422cc3f4ad6054e38a41ccc74b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:14:56 -0400 Subject: [PATCH 17/35] docs(workcontext): the ceiling's comment says what the code now does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defaultMaxCredentialLifetime comment still argued the SAFETY-NET case, which core 67ee7220 made false: the lifetime bound is in decodeClaims, so corework.Inspect refuses an over-ceiling window inside sealOf, before checkWindow runs. What remains is a deployment policy below core's ceiling. This was claimed as done in the previous commit and was not. The edit sat in a script that asserted its anchor, found it, and then threw on a LATER replacement before writing the file — so the assertion passed, nothing was written, and the message said otherwise. Verified by grepping the result this time rather than by a script exiting quietly. Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/mint.go | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/workcontext/mint.go b/workcontext/mint.go index 0f29065..0a3daa4 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -45,22 +45,28 @@ const ( minRenewalLeadTime = 5 * time.Second // defaultMaxCredentialLifetime is the longest credential this client will - // hold. It is CORE'S OWN CONSTANT, deliberately, so there is one rule and - // not two numbers to keep in step. + // hold, and it is CORE'S OWN CONSTANT so there is one number rather than + // two to keep in step. // - // This check has been deleted once on core's advice and restored once on - // review, so what it is for is worth saying precisely. Core 72d72eb2 made - // MaxTTLCeiling absolute (maxTTL clamps to it, Start refuses past it, - // Verify refuses a lifetime over it), which is exactly the condition this - // client named when it declined to drop the ceiling. The reason changes; - // the check stays, because of what neither of core's bounds covers: + // WHAT THIS IS NOW FOR, because it is not what it was for. The argument + // that kept this check was that a mint client NEVER VERIFIES A SIGNATURE — + // it is the party the credential is minted FOR, not a receiver — so it + // reads its own window through corework.Inspect, which was structural and + // bounded no lifetime. Core's first two attempts bounded Authority.Start + // and then Verify alone, neither of which a holder can reach: a thirty-day + // capability that every Verify refuses was reported to its HOLDER as + // thirty days of validity. // - // THIS CLIENT NEVER VERIFIES A SIGNATURE. It cannot — it is the party the - // credential is minted FOR, not a receiver — so it reads the window - // through corework.Inspect, which is structural and checks no signature at - // all. Core's Start bounds an honest minter and core's Verify bounds a - // receiver running a current core. What this process holds in memory, and - // presents for the rest of its life, is bounded here or nowhere. + // Core 67ee7220 moved the bound into decodeClaims, which Inspect uses, so + // an over-ceiling lifetime is refused IN THIS CLIENT'S OWN READ PATH — + // inside sealOf, before checkWindow runs at all. + // + // So the safety net is core's and this is no longer one. What remains is a + // DEPLOYMENT POLICY: a process that will hold a credential for at most + // five minutes says so here, and the check fires below core's ceiling + // where core has nothing to say. That is the only case it can reach, and + // it is the case its test exercises — describing it as a safety net would + // be claiming a branch nothing can enter. defaultMaxCredentialLifetime = corework.MaxTTLCeiling // Backoff after a mint that failed. A failed renewal used to return an @@ -1236,6 +1242,10 @@ func (c *MintClient) checkWindow(credential Credential) error { ) } if lifetime := credential.expiresAt.Sub(credential.notBefore); lifetime > c.options.MaxCredentialLifetime { + // A DEPLOYMENT POLICY, below core's absolute ceiling. Anything above + // that ceiling was refused by corework.Inspect before this ran, in + // sealOf — so what is reachable here is a process that chose to hold a + // credential for less time than the platform permits. return fmt.Errorf( "%w: the minted credential is valid for %s, and this process holds one for at most %s", ErrMintRefused, lifetime, c.options.MaxCredentialLifetime, From 67271e6b6c0b3fad06a2bf1af0ad76b031a1d406 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:08:59 -0400 Subject: [PATCH 18/35] =?UTF-8?q?refactor(workcontext)!:=20re-pin=20to=20c?= =?UTF-8?q?ore=204cb260d3=20=E2=80=94=20the=20execution=20is=20per=20princ?= =?UTF-8?q?ipal,=20and=20zero=20is=20an=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's third and final SealSource change, landing in #692 rather than after the tag, so there is no second move coming. WHAT IT COST HERE: two test harnesses, zero production lines. `go build ./...` passed on the first attempt at the new pin. The breaks were `Seal{…}` literals carrying the execution fields, and three `Child` call sites that must now attest their own. Production was untouched for the reason that has now paid for itself three times: `Credential.Seal()` returns core's `WorkSealV1`, cloned, rather than a hand-filled struct. ImageDigest arriving, then becoming optional, then moving out of `Seal` altogether — none of those cost this module a production line. WHAT MOVED. The approved build is no longer installation state: it lives in `SealSource.ApprovedBuild`, keyed on the PRINCIPAL, and every hop attests its own. Reading it from the owner's installation record made a derived capability's execution describe the owner's workload however many hops had been added — and a hop's principal does not hold the owner's installation at all, so a derivation had nothing to attest against even in principle. The hop test is where that becomes visible. Recording the approved build for the owner alone was not enough; the ACTOR needed its own, and the hop refused until it had one. Keyed per installation that hop would have had a single record and would have attested against the owner's workload again, which is the defect being closed — so the per-principal shape is what makes the hop case expressible at all. AND ONE FINDING OF THIS MODULE'S OWN, which the optionality surfaced. `GetBuildIncarnation()` now answers 0 for two different things: "this principal bears no execution" — a person at a terminal, which is an ANSWER — and a value core says is never legitimate, since an incarnation starts at 1. The mint response's echo cross-check compared against that zero, so a host echoing "0" MATCHED a capability that sealed no execution at all. That is a host asserting an execution the issuer does not hold, passing the one check whose job is to catch a host and a signature disagreeing — and "disagree" has to include "one of them says nothing". It asks about presence before comparing now, with the human-session shape built into the harness (a principal with a seal and an epoch and deliberately no approved build) and mutation-verified. The getters' signatures are unchanged, which is exactly what makes this silent: any consumer treating zero or empty as "missing or invalid" rather than as an answer has the same bug. Said in the README and in workcontext/AGENTS.md, and reported back to core for its own announcement. The conformance kit compiles and passes UNCHANGED — 38 subtests, with parse_agreement_test.go and one_implementation_test.go untouched by the re-pin, and the structural-refusal set still exactly 13 entries. That was core's question and it is answered by measurement rather than by expectation. Not verified: no service has booted against a real runtime. Core's standing gap holds and is now wider — no host can source Execution.ImageDigest from a pod, so "a superseded pod is refused" cannot be demonstrated at a hop either, and nothing in this module can demonstrate it. Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/AGENTS.md | 20 +++-- workcontext/README.md | 33 ++++++-- workcontext/go.mod | 2 +- workcontext/go.sum | 4 +- workcontext/grpctransport/two_service_test.go | 45 +++++++--- workcontext/mint.go | 31 +++++-- workcontext/mint_test.go | 16 +++- workcontext/minting_test.go | 82 ++++++++++++++----- workcontext/round4_reproducers_test.go | 38 +++++++++ 9 files changed, 219 insertions(+), 52 deletions(-) diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index 514e1b3..3eb933c 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -95,11 +95,21 @@ edit. ## A credential is sealed or it is not a credential, and core says what that means -The seal, each actor hop's epoch and the seal's **image digest** are -schema-required, so a missing one is a `protovalidate` refusal inside core's -decode; an operation binding carries its id, revision and incarnation or none -of them; and core refuses an unknown field or a non-canonical encoding, so -nothing here may ever marshal a capability. `Attach` refuses an unsealed +The seal and each actor hop's epoch are schema-required, so a missing one is a +`protovalidate` refusal inside core's decode. The **execution** — +`image_digest` and `build_incarnation` — is a PAIR OR NEITHER: it lives in +`SealSource.ApprovedBuild` keyed on the principal, every hop attests its own, +and a principal that bears none (a person at a terminal) seals none rather than +inventing a value. An operation binding carries its id, revision and +incarnation or none of them; and core refuses an unknown field or a +non-canonical encoding, so nothing here may ever marshal a capability. + +**Zero is an answer, not a gap.** `GetBuildIncarnation()` returns 0 both for +"bears no execution" and for a value core says is never legitimate, since an +incarnation starts at 1. `mint.go`'s echo cross-check asks about PRESENCE +before comparing, because a host echoing `"0"` otherwise matched a capability +sealing no execution — a host asserting an execution the issuer does not hold, +passing the check that exists to catch that. `Attach` refuses an unsealed capability with `corework.Inspect`'s answer — false for a while precisely because we answered it ourselves and never read the actor chain. diff --git a/workcontext/README.md b/workcontext/README.md index 71d6735..c399db0 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -777,8 +777,8 @@ root: ### This module merges on a pseudo-version, deliberately -`workcontext/go.mod` pins **`v0.7.2-0.20261003145432-67ee72204f68`** — core at -`67ee7220` — and not a release tag, because there is no tag to move to and will +`workcontext/go.mod` pins **`v0.7.2-0.20261003160146-4cb260d3d726`** — core at +`4cb260d3` — and not a release tag, because there is no tag to move to and will not be one before this merges. Core's `version/info.codefly.yaml` already says `0.8.0`, but its `version-tag.yml` cuts the tag from the **merge commit** of core#692 and core never tags by hand; core#692 is still open on two owner @@ -828,11 +828,30 @@ re-pinning is bookkeeping, not an upgrade. `base64url(deterministic protobuf of codefly.base.v0.WorkContextV1) + "." + base64url(Ed25519 signature)`, signed by `core/workcontext` and by nothing -else. The sealed fields are proto fields — `seal.principal_epoch`, -`seal.installation_id`, `seal.installation_revision`, -`seal.build_incarnation`, and for an operation capability -`operation_binding.binding_id` / `.revision` / `.incarnation`, all three -together or none. +else. The sealed fields are proto fields — `seal.installation_id`, +`seal.installation_revision`, and `seal.image_digest` / +`seal.build_incarnation` as a **pair or neither**; plus, for an operation +capability, `operation_binding.binding_id` / `.revision` / `.incarnation`, all +three together or none. Each actor hop carries its own epoch. + +**The execution pair is optional, and absent is a real answer.** Core +`4cb260d3` moved the approved build out of the live `Seal` and into +`SealSource.ApprovedBuild`, keyed on the **principal**: reading it from the +owner's installation record made a derived capability's execution describe the +owner's workload however many hops had been added, and a hop's principal does +not hold the owner's installation at all. So every hop now attests its own, and +a principal that bears no execution — a person at a terminal — seals none +rather than having a value invented for it. + +**If you read the execution off `Credential.Seal()`, mind the zero.** +`GetImageDigest()` answers `""` and `GetBuildIncarnation()` answers `0` both for +"this principal bears no execution" and for a value core says is never +legitimate, since an incarnation starts at 1. Treating zero as "missing or +invalid" is a bug: it is an answer. This module hit it in its own mint-response +cross-check, where a host echoing `"0"` matched a capability that sealed no +execution at all — a host asserting an execution the issuer does not hold, +passing the check whose job is to catch exactly that. Ask about presence before +comparing. This module no longer reads a capability itself. `corework.Inspect` runs the size bound, the envelope, `CheckEncoding`, `proto.Unmarshal`, **protovalidate** diff --git a/workcontext/go.mod b/workcontext/go.mod index 1f55fd6..85f352c 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68 + github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 6fb5de1..73c54bc 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68 h1:B3bKe4XUl8k730VCs7kie7RwkGPsfeZLFFSJZUnWFqg= -github.com/codefly-dev/core v0.7.2-0.20261003145432-67ee72204f68/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726 h1:Fd+WKknvGRjfWgk1PT6X6rxx2Hn4e90hRzhBsqUlVRU= +github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index b02eff1..3be2817 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -90,6 +90,11 @@ func newHopAuthority(t *testing.T) *hopAuthority { require.NoError(t, err) seals := corework.NewMemorySealSource() require.NoError(t, seals.Put(hopPrincipal, hopSeal())) + // Per PRINCIPAL, and for BOTH of them: the actor exercises the derived + // capability, so the actor's own approved build is what a hop attests + // against. Recording only the owner's is the defect this move closes. + require.NoError(t, seals.PutApprovedBuild(hopPrincipal, hopImageDigest, hopApprovedBuildIncarnation)) + require.NoError(t, seals.PutApprovedBuild(hopActor, hopImageDigest, hopApprovedBuildIncarnation)) // The delegated actor needs a live epoch of its own. An actor's authority is // narrowed independently of the owner's — the owner here is one service and // the actor another — so core seals the actor's epoch per hop and refuses a @@ -194,6 +199,15 @@ func (s *serviceA) call(ctx context.Context, request *healthv1.HealthCheckReques }) } exchanged, _, err := s.authority.minter.Child(ctx, verified, corework.ChildInput{ + // THE HOP ATTESTS ITS OWN EXECUTION. The derived capability is + // exercised by hopActor, so what the issuer approves for hopActor + // is what this must match — previously the execution came off the + // OWNER's installation seal, which described the wrong workload + // the moment a delegation existed. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopApprovedBuildIncarnation, + }, PrincipalID: hopActor, PrincipalKind: "service", DelegationID: "delegation-hop", @@ -275,20 +289,25 @@ func newHopTopology(t *testing.T) *hopTopology { return topology } -// hopSeal is the execution both services in the hop run as: one installation, -// one revision, one build. A hop crosses an audience boundary inside one -// execution, so the seal is the same on both sides of it. +// hopSeal is the INSTALLATION both services in the hop hold authority through: +// one installation, one revision. A hop crosses an audience boundary inside +// one execution, so the seal is the same on both sides of it. +// +// The execution is no longer here. It is recorded per principal through +// PutApprovedBuild and attested per hop through ChildInput.Execution, which is +// the point of the move: a hop's principal does not hold the owner's +// installation, so an execution read from the owner's seal described the wrong +// workload the moment a delegation was added. func hopSeal() workcontext.Seal { return workcontext.Seal{ InstallationID: "installation-hop", InstallationRevision: 12, - BuildIncarnation: 9, - // The approved build for this installation, required now: a seal that - // names none names no execution to match a caller against. - ImageDigest: hopImageDigest, } } +// hopApprovedBuild is the build the issuer approves for the hop's principals. +const hopApprovedBuildIncarnation = 9 + const hopImageDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" // startTask issues the capability the external caller holds: for service A @@ -296,11 +315,12 @@ const hopImageDigest = "sha256:2222222222222222222222222222222222222222222222222 func (h *hopTopology) startTask(t *testing.T) string { t.Helper() token, _, err := h.authority.minter.Start(context.Background(), corework.StartInput{ - // What this caller attests it is running, matched against the build - // the seal names as approved. + // What this caller attests it is running, matched against the build the + // ISSUER approves for this principal — read through ApprovedBuild now, + // not off the installation seal. Execution: corework.Execution{ ImageDigest: hopImageDigest, - BuildIncarnation: hopSeal().BuildIncarnation, + BuildIncarnation: hopApprovedBuildIncarnation, }, Audience: audienceA, TenantID: hopTenant, OwnerPrincipalID: hopPrincipal, OwnerPrincipalKind: "service", TaskID: "task-hop", @@ -422,6 +442,11 @@ func TestServiceHopExchangeMayAttenuateButNeverWiden(t *testing.T) { func mustReissueForB(t *testing.T, authority *hopAuthority, parent *workcontext.Verified) string { t.Helper() exchanged, _, err := authority.minter.Child(context.Background(), parent, corework.ChildInput{ + // The actor's own approved build, attested at the hop. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopApprovedBuildIncarnation, + }, PrincipalID: hopActor, PrincipalKind: "service", DelegationID: "delegation-hop", diff --git a/workcontext/mint.go b/workcontext/mint.go index 0a3daa4..1355f20 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -1190,12 +1190,31 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential ErrMintRefused, body.InstallationID, seal.GetInstallationId(), ) } - if body.BuildIncarnation != "" && - body.BuildIncarnation != strconv.FormatUint(seal.GetBuildIncarnation(), 10) { - return Credential{}, fmt.Errorf( - "%w: mint reported build incarnation %q and sealed %d", - ErrMintRefused, body.BuildIncarnation, seal.GetBuildIncarnation(), - ) + if body.BuildIncarnation != "" { + // PRESENCE, not value. The sealed execution became OPTIONAL in core + // 4cb260d3, because a principal that bears no approved build — a + // person at a terminal — must not have one invented for it. So a + // capability can legitimately carry no incarnation, and + // GetBuildIncarnation() answers 0 for both "absent" and a value core + // says is never legitimate (an incarnation starts at 1). + // + // Comparing the echo against that zero would have accepted a host + // echoing "0" against a capability bearing no execution at all, which + // is a host asserting an execution the issuer does not hold. The echo + // is only for catching a host and a signature that disagree, but + // "disagree" has to include "one of them says nothing". + if seal.BuildIncarnation == nil { + return Credential{}, fmt.Errorf( + "%w: mint reported build incarnation %q and the capability seals no execution at all", + ErrMintRefused, body.BuildIncarnation, + ) + } + if body.BuildIncarnation != strconv.FormatUint(seal.GetBuildIncarnation(), 10) { + return Credential{}, fmt.Errorf( + "%w: mint reported build incarnation %q and sealed %d", + ErrMintRefused, body.BuildIncarnation, seal.GetBuildIncarnation(), + ) + } } // The audience the host signed must be the audience read from the pin. // This is the check an optional pin beside a free-string Audience could not diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index c851ccf..1f6f1a4 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -64,6 +64,15 @@ type mintHost struct { // before runs at the top of every request, so a test can hold one open. before func() + // noApprovedBuild mints for a principal the issuer holds no approved build + // for, which is the human-session shape: the capability seals no execution. + noApprovedBuild bool + + // echoIncarnation overrides the build incarnation the host ECHOES in its + // JSON body, which is only for catching a host and a signature that + // disagree. + echoIncarnation string + // replay makes the host IDEMPOTENT: it answers every request with this // exact token. A real mint endpoint that deduplicates by projected token // does the same thing. @@ -129,7 +138,9 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { // The host mints with core's Authority, because that is what the host does. // A test endpoint that assembled a token itself would be a second // implementation wearing a test's clothes. - token, err := h.authority.tryStartAt(mintedAt, mintInput{audience: audience, binding: h.binding}, lifetime) + token, err := h.authority.tryStartAt(mintedAt, mintInput{ + audience: audience, binding: h.binding, humanSession: h.noApprovedBuild, + }, lifetime) if err != nil { // Recorded and answered with a 503, never asserted here: this is the // server's goroutine. @@ -141,6 +152,9 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { token = h.replay } response := mintResponse{WorkContext: token, InstallationID: testInstallation} + if h.echoIncarnation != "" { + response.BuildIncarnation = h.echoIncarnation + } if h.echoWrong { response.InstallationID = "installation-the-host-did-not-seal" } diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index b2ad12e..107303f 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -20,15 +20,22 @@ import ( // testSeal is the live sealed state the authority holds. var ( testClock = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) - testSeal = Seal{ + // A seal is INSTALLATION STATE ONLY now. The execution moved to + // SealSource.ApprovedBuild, keyed on the principal, because reading it + // from the owner's installation record made a derived capability's + // execution describe the owner's workload however many hops had been + // added — and a hop's principal does not hold the owner's installation at + // all, so a derivation had nothing to attest against even in principle. + testSeal = Seal{ InstallationID: testInstallation, InstallationRevision: 3, - BuildIncarnation: 11, - // The approved build for this installation. It is REQUIRED now: a seal - // that names no image digest names no execution to match a caller - // against, so the mint has nothing to attest. - ImageDigest: testImageDigest, } + // testApprovedBuild is what the issuer approves for testPrincipal, which + // is what a caller's attested Execution is compared against. + testApprovedBuild = struct { + digest string + incarnation uint64 + }{digest: testImageDigest, incarnation: 11} // testLiveBinding is the binding as the ISSUER holds it: granted to one // principal, within one installation. A capability carries only the first, // third and fourth of these — see SealedOperationBinding. @@ -42,13 +49,16 @@ var ( ) const ( - testIssuer = "codefly.test-authority" - testAudience = "codefly.test-audience" - testKeyID = "test-key-1" - testTenant = "tenant-1" - testPrincipal = "principal-1" - testInstallation = "installation-1" - testBinding = "binding-1" + testIssuer = "codefly.test-authority" + testAudience = "codefly.test-audience" + testKeyID = "test-key-1" + testTenant = "tenant-1" + testPrincipal = "principal-1" + // testHumanPrincipal bears no approved build: a person at a terminal runs + // no workload the issuer has admitted. + testHumanPrincipal = "principal-human-1" + testInstallation = "installation-1" + testBinding = "binding-1" // testImageDigest is the image-manifest digest the test "runs", which the // seal names as approved and the mint attests against. testImageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" @@ -69,6 +79,14 @@ func newAuthority(t *testing.T) *authority { t.Helper() seals := corework.NewMemorySealSource() require.NoError(t, seals.Put(testPrincipal, testSeal)) + // The approved build is recorded per PRINCIPAL, separately from the + // installation seal. + require.NoError(t, seals.PutApprovedBuild( + testPrincipal, testApprovedBuild.digest, testApprovedBuild.incarnation)) + // The human principal gets a seal and an epoch and DELIBERATELY NO + // approved build, which is what ErrNoApprovedBuild answers. + require.NoError(t, seals.Put(testHumanPrincipal, testSeal)) + require.NoError(t, seals.PutEpoch(testHumanPrincipal, testPrincipalEpoch)) // The owner's epoch is recorded through PutEpoch like any other // principal's. Seal no longer carries it: it had two sources, and // revocation could be UNDONE — advancing the epoch left stored seals @@ -135,6 +153,12 @@ type mintInput struct { binding string taskID string organization string + + // humanSession mints for a principal the issuer holds NO approved build + // for — a person at a terminal. The capability then seals no execution, + // which core 4cb260d3 made representable instead of something every mint + // had to invent a value for. + humanSession bool } // start mints a session capability, sealed to the live values above. @@ -161,6 +185,18 @@ func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifeti // mistake, exactly as the review predicted this shape would. func (a *authority) tryStartAt(now time.Time, input mintInput, lifetime time.Duration) (string, error) { minter := *a.core + owner, kind := testPrincipal, "service" + execution := corework.Execution{ + ImageDigest: testImageDigest, + BuildIncarnation: testApprovedBuild.incarnation, + } + if input.humanSession { + // No approved build is recorded for this principal and no Execution is + // attested. Core refuses the correspondence in BOTH directions, so + // attesting one here would be refused too. + owner, kind = testHumanPrincipal, "human" + execution = corework.Execution{} + } minter.Now = func() time.Time { return now } if input.tenant == "" { input.tenant = testTenant @@ -176,14 +212,12 @@ func (a *authority) tryStartAt(now time.Time, input mintInput, lifetime time.Dur } token, _, err := minter.Start(context.Background(), corework.StartInput{ // What the caller attests it is running, matched against the build the - // seal names as approved. A mint without it is refused. - Execution: corework.Execution{ - ImageDigest: testImageDigest, - BuildIncarnation: testSeal.BuildIncarnation, - }, + // ISSUER approves FOR THIS PRINCIPAL. Empty for a principal that bears + // no execution, which core answers with ErrNoApprovedBuild. + Execution: execution, TenantID: input.tenant, - OwnerPrincipalID: testPrincipal, - OwnerPrincipalKind: "service", + OwnerPrincipalID: owner, + OwnerPrincipalKind: kind, TaskID: input.taskID, Audience: input.audience, AuthorityScopes: input.scopes, @@ -223,12 +257,20 @@ func (a *authority) childAs(t *testing.T, parent string, input hopInput) string t.Helper() require.NotNil(t, a.seals, "delegation needs a seal source this test can record an actor epoch in") require.NoError(t, a.seals.PutEpoch(input.principal, 1)) + // And the hop's own approved build, because a derivation now attests the + // execution IT is running rather than inheriting the owner's. + require.NoError(t, a.seals.PutApprovedBuild( + input.principal, testApprovedBuild.digest, testApprovedBuild.incarnation)) if input.kind == "" { input.kind = "service" } verified, err := a.verifier(t).Verify(context.Background(), parent) require.NoError(t, err) token, _, err := a.core.Child(context.Background(), verified, corework.ChildInput{ + Execution: corework.Execution{ + ImageDigest: testApprovedBuild.digest, + BuildIncarnation: testApprovedBuild.incarnation, + }, PrincipalID: input.principal, PrincipalKind: input.kind, AgentID: input.agent, diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index aa24090..0bc55fb 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -532,3 +532,41 @@ func TestACredentialAtExactlyTheCeilingIsAccepted(t *testing.T) { "a host minting at exactly the ceiling must not be refused by this client's default") require.Equal(t, testClock.Add(corework.MaxTTLCeiling), credential.ExpiresAt()) } + +// Core 4cb260d3 made the sealed execution OPTIONAL, because a principal that +// bears no approved build — a person at a terminal — must not have one +// invented for it. That turns the mint response's echo cross-check into a +// PRESENCE question, which it was not before. +// +// GetBuildIncarnation() answers 0 both for "absent" and for a value core says +// is never legitimate (an incarnation starts at 1), so comparing an echo +// against that zero accepted a host echoing "0" against a capability that +// seals no execution at all — a host asserting an execution the issuer does +// not hold. +func TestAnEchoedIncarnationIsRefusedWhenTheCapabilitySealsNoExecution(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A capability for a principal the issuer holds no approved build for, + // which is the human-session shape. + host.noApprovedBuild = true + // And a host that echoes an incarnation anyway. + host.echoIncarnation = "0" + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "seals no execution at all") +} + +// And a capability that DOES seal an execution still cross-checks by value, so +// the presence branch did not replace the comparison. +func TestAnEchoedIncarnationStillCrossChecksByValue(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.echoIncarnation = "99" + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "sealed 11") +} From efaccef05c07576b9e4b41e70c042c07d51bb103 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:55:35 -0400 Subject: [PATCH 19/35] fix(workcontext)!: the ref sweep looked at almost nothing, and the green was the evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round five, layer 5. The blocker is the worst kind: the required check was green because the gate was not looking. N-A. go.yml listed refs with `git for-each-ref 'refs/remotes/origin/*'` and `*` DOES NOT CROSS A SLASH, so every namespaced branch was silently dropped. The CI log for run 37135785911 says it all: sweeping 1 other published ref(s) ok origin/badges while origin/dependabot/go_modules/gomod-06d3dc2861 publishes workcontext/work_context.go importing crypto/ed25519. The PR body's "sweeps every ref this repository publishes" and "none carries the implementation" were false, and the evidence was in my own dry run — it printed "sweeping 1" minutes after I had listed five branches myself, and I read the 1 as complete. Two changes, and the second matters more than the first. Refs are listed BY PREFIX, so a namespaced ref is included. And SWEPT IS ASSERTED AGAINST WHAT THE REMOTE PUBLISHES: a gate that sweeps a subset and reports success is worse than no gate, because the green is what somebody acts on. A mismatch fails. The logic moved into scripts/sweep-published-refs.sh so it can be TESTED, which inline shell in a workflow cannot be. Two tests, and the mutation that restores the bad glob is caught by the ASSERTION rather than by the listing test — which is the point of having both. The step is red now, correctly, on the dependabot ref. That is the owner's to delete. N-C. The interceptor learned its capability-bearing method set from the first request, which is trust on first use and failed the way trust on first use always fails: a first request without a capability got the RAW STREAM, the method was recorded unguarded, and every later legitimate request was refused codes.Internal until restart. One early request both bypassed the guard and took the method down. The set is DECLARED at construction now. N-P, which needed a run: a Recheck that emitted an audit header hung the handler FOREVER. The write resolved the wrapper out of the context, called SetHeader, called recheck, called BeforeSend — which holds the guard's mutex across the re-check, so the nested call blocked on a mutex its own goroutine held. Releasing that mutex would turn the hang into unbounded recursion, so instead the re-check runs under a context that REFUSES writes and says why: a liveness check deciding whether the stream may emit must not emit. N-D and N-E. The root module's decisive rule was defeated by one interface-typed parameter — `func into(raw []byte, m proto.Message)` called with a capability — because the argument resolves perfectly, to proto.Message, which is neither a capability nor unresolvable. Interface-typed codec arguments now need the indirection allowlist, which names four functions. And ProtoReflect().ProtoMethods().Unmarshal is refused: a message's own codec, reached by reflection, named no import and no type for either gate to see. N-F. The primitive bans were name lists: crypto/hkdf, sha512, sha1, sha3, github.com/golang/protobuf/proto, protoiface/protoimpl and any path containing ed25519 are all refused now. N-G. The shell extractor read only the FIRST quoted string per line and recognised a block only by a bare `import (`, so `/* "fmt" */ "crypto/ed25519"`, `import ("crypto/ed25519")` and `import /* x */ (` all passed. It reads every quoted and raw-string path per line now, and recognises `import` with a `(` anywhere on it. N-H. ONE LATCH RULE: only ErrMintRefused is terminal. The negation — "anything that is not an outage latches" — made every error this client had not thought about into a permanent stop, which N2 only half fixed on the status path. Now retryable: a 200 with a wrong Content-Type or an undecodable or oversized body (an ingress default page), a clock beyond core's skew (ntpd fixes it), an oversized projection, and an ErrInvalid from a caller's own token source, which used to latch while Refused() reported something errors.Is said false about. AND THE TLS LATCH IS REVERSED, because its rationale was false and the reviewer stated the refutation better than my argument. I latched a certificate failure so the projected token would not be presented to an endpoint that cannot prove who it is. But THE HANDSHAKE FAILS BEFORE ANY REQUEST BYTES LEAVE: nothing was sent, a retry sends nothing either, so latching bought no confidentiality at all — and it turned a certificate ROTATION, the most ordinary cause of this in a running system, into a permanent stop. C10 did NOT hold, and this is the third time a doc claim I asserted fixed was not. MintOptions.RootCAs still said "TLS 1.2 minimum" and MaxCredentialLifetime still said "no ceiling … month-long". The cause is duplication: the argument was written at the constant AND at the field, I fixed the constant, and the field went on claiming the old thing. Both field comments now POINT rather than restate. C11. ErrInvalid no longer maps to Unauthenticated. It is this package's misuse sentinel too — a nil guard, a finished stream — so mapping it told a client "mint again" about a server bug, and minting again cannot fix a server bug. C12. Construction-time misconfiguration is ErrInvalid throughout NewMintClient and validateMintURL: nothing has been asked of a host, so "the mint refused you" is the wrong thing to say. Neither #41 nor #47 is auto-closed. #41's last item is a release tag that has not happened, and auto-closing an issue whose final item is outstanding is the same mistake as #47's criterion. Both get closed by a person who checked. go.yml no longer calls compat/** "the release lines consumers pin" — a consumer pins a COMMIT, and go.mod records a pseudo-version with no idea what the branch was called. The dependabot ref is what showed that convention doing no work. Not verified: no service has booted against a real runtime. N-B's claim that the pin is not an ancestor of core main is accepted and answered in the PR comment rather than here. N-Q and the remaining Section (D) items are answered in the comment; the gate's syntactic limit is restated where it bites. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 68 ++----- scripts/check-one-implementation.sh | 35 ++-- scripts/sweep-published-refs.sh | 86 +++++++++ sweep_bypasses_test.go | 130 ++++++++++++++ workcontext/README.md | 55 ++++-- workcontext/grpctransport/guarded_stream.go | 146 ++++++++++----- .../grpctransport/guarded_stream_test.go | 47 +++-- .../grpctransport/real_grpc_stream_test.go | 1 + .../grpctransport/round4_reproducers_test.go | 114 +++++++++--- workcontext/layer4_reproducers_test.go | 12 +- workcontext/mint.go | 133 +++++++++----- workcontext/mint_test.go | 25 ++- workcontext/one_implementation_test.go | 168 +++++++++++++++++- workcontext/round4_reproducers_test.go | 21 ++- 14 files changed, 824 insertions(+), 217 deletions(-) create mode 100755 scripts/sweep-published-refs.sh diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 190bfba..a601816 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,9 +1,13 @@ name: codefly go SDK on: - # compat/** carries the release lines consumers pin when main holds an - # unreleased breaking change. A branch whose commits are pinned by a consumer - # is a published artifact, so it is built like one. + # ANY branch a consumer can pin is a published artifact and is built like + # one. This used to name compat/** as "the release lines consumers pin", + # which was a rule about a naming convention: a consumer pins a commit, and + # go.mod records a pseudo-version with no idea what the branch was called. + # The one-implementation sweep covers every published ref for the same + # reason, and a dependabot branch carrying the deleted implementation is what + # showed the convention was doing no work. push: branches: [ main, 'compat/**' ] pull_request: @@ -65,56 +69,22 @@ jobs: - name: Sweep every tracked Go file for a second implementation run: ./scripts/check-one-implementation.sh - name: Sweep every OTHER ref this repository publishes - # EVERY REMOTE BRANCH, not a name glob — minus the one this change is - # becoming, which the working-tree step above already swept. + # The logic lives in scripts/sweep-published-refs.sh so it can be + # TESTED. Inline, it was wrong and nothing could notice: it listed refs + # with `git for-each-ref 'refs/remotes/origin/*'`, and `*` does not + # cross a slash, so every namespaced branch was silently dropped. This + # step reported "sweeping 1 other published ref(s)" and "ok + # origin/badges" and went GREEN while + # origin/dependabot/go_modules/gomod-06d3dc2861 published + # workcontext/work_context.go importing crypto/ed25519. # - # The previous revision swept 'compat/*' and 'feat/*compat*', which was - # a rule about ref NAMES in a gate whose whole subject is that the - # implementation is gone. Measured: of 25 remote branches, 22 carried - # work_context.go, so retiring the three named compat refs would have - # turned this step green with 19 copies still published — on branches a - # consumer can pin exactly as it pins a compat ref. The owner has since - # deleted all of them, which is what makes a COMPLETE sweep possible - # rather than a documented gap. - # - # The base branch and this branch are excluded, and that is not a hole: - # sweeping the ref a pull request merges INTO is circular. main still - # carries workcontext/work_context.go, this pull request is what deletes - # it, and a check that stayed red until main was clean could never let - # the change that cleans it land. main is covered by the working-tree - # step of its own push build — the same sweep over the same files one - # commit later. - # - # It also no longer FAILS OPEN. `fetch || true` followed by "no refs, - # exit 0" meant a network failure, a permissions change or a renamed - # remote all reported success — the three cases where a gate is most - # needed and least able to say so. + # The script lists by prefix and asserts SWEPT AGAINST WHAT THE REMOTE + # PUBLISHES, because a gate that sweeps a subset and reports success is + # worse than no gate — the green is the evidence somebody acts on. run: | git fetch --no-tags --prune --quiet origin \ '+refs/heads/*:refs/remotes/origin/*' - base="${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}" - here="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}" - # Collected into an array rather than word-split from a string, so a - # ref name with a space cannot silently become two arguments. - refs=() - while IFS= read -r ref; do - [ -n "$ref" ] || continue - case "$ref" in - origin|origin/HEAD) continue ;; - "origin/$base") continue ;; - esac - if [ -n "$here" ] && [ "$ref" = "origin/$here" ]; then - continue - fi - refs+=("$ref") - done < <(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/*') - echo "sweeping ${#refs[@]} other published ref(s); the working tree was swept above" - if [ ${#refs[@]} -eq 0 ]; then - # Not a pass by default: the fetch above is what would have failed. - echo "this repository publishes no ref other than $base" - exit 0 - fi - ./scripts/check-one-implementation.sh "${refs[@]}" + ./scripts/sweep-published-refs.sh lint: name: lint diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index c0e5474..a169220 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -123,17 +123,32 @@ allowed() { # complete bypass. import_paths() { awk ' - /^[[:space:]]*import[[:space:]]*\(/ { inblock = 1; next } - inblock && /^[[:space:]]*\)/ { inblock = 0; next } - inblock || /^[[:space:]]*import[[:space:]]/ { - # Double-quoted, which is every import gofmt produces. - if (match($0, /"[^"]+"/)) { print substr($0, RSTART + 1, RLENGTH - 2); next } - # And a RAW-STRING path. gofmt rewrites `crypto/ed25519` to the quoted - # form, so this cannot survive a formatted tree — but the sweep reads - # what is committed, not what gofmt would have written, and a reviewer - # confirmed the raw-string form passed. - if (match($0, /`[^`]+`/)) { print substr($0, RSTART + 1, RLENGTH - 2) } + # An import block opens wherever `import` is followed by `(` on the line, + # not only when the line IS `import (`. `import /* x */ (` and + # `import ("crypto/ed25519")` both opened a block the previous version did + # not recognise. + /^[[:space:]]*import/ && /\(/ { inblock = 1 } + /^[[:space:]]*import/ && !/\(/ { line = 1 } + inblock && /\)/ { closing = 1 } + (inblock || line) { + rest = $0 + # EVERY quoted string on the line, not the first. The previous version + # read one, so `/* "fmt" */ "crypto/ed25519"` reported fmt and stopped — + # and `;`-joined specs on one line hid everything after the first. + while (match(rest, /"[^"]+"/)) { + print substr(rest, RSTART + 1, RLENGTH - 2) + rest = substr(rest, RSTART + RLENGTH) + } + # Raw-string paths too. gofmt rewrites them, and CI enforces no gofmt, + # so the sweep reads what is committed rather than what gofmt would have + # written. + while (match(rest, /`[^`]+`/)) { + print substr(rest, RSTART + 1, RLENGTH - 2) + rest = substr(rest, RSTART + RLENGTH) + } + line = 0 } + closing { inblock = 0; closing = 0 } ' "$1" } diff --git a/scripts/sweep-published-refs.sh b/scripts/sweep-published-refs.sh new file mode 100755 index 0000000..ada893b --- /dev/null +++ b/scripts/sweep-published-refs.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Sweep EVERY ref this repository publishes for a second Work Context +# implementation, except the one the current change is becoming. +# +# This exists as a script rather than inline in go.yml for one reason: the +# inline version was wrong for weeks and nothing could test it. It listed refs +# with +# +# git for-each-ref 'refs/remotes/origin/*' +# +# and `*` DOES NOT CROSS A SLASH, so every branch with a slash in its name was +# silently dropped — compat/*, feat/*, dependabot/*, anything namespaced. The +# CI log said "sweeping 1 other published ref(s)" and "ok origin/badges" and +# the check went green while origin/dependabot/go_modules/gomod-06d3dc2861 +# published workcontext/work_context.go importing crypto/ed25519. +# +# Two changes follow from that, and the second matters more than the first: +# +# 1. Refs are listed BY PREFIX (refs/remotes/origin), which git matches +# without globbing, so a namespaced ref is included. +# 2. SWEPT IS ASSERTED AGAINST LISTED. A gate that sweeps a subset and +# reports success is worse than no gate, because the green is evidence. +# The remote's own branch list is the denominator, and a mismatch fails +# rather than passing quietly. +# +# Usage: +# scripts/sweep-published-refs.sh # base/head from the environment +# SWEEP_BASE=main SWEEP_HEAD=my-branch scripts/sweep-published-refs.sh +# SWEEP_LIST_ONLY=1 scripts/sweep-published-refs.sh # print the refs, sweep nothing +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# The ref this change is becoming, and the ref it merges into, are both +# excluded: the working-tree sweep covers the former, and sweeping the latter +# is circular — main carries the implementation until this change deletes it. +base="${SWEEP_BASE:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" +head_ref="${SWEEP_HEAD:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}}" + +# Every remote-tracking ref, by PREFIX so namespaced refs are not dropped. +listed=() +while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in + origin | origin/HEAD) continue ;; + esac + listed+=("$ref") +done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin) + +# THE DENOMINATOR, from the remote itself rather than from what happens to be +# fetched locally. A stale or shallow fetch is the case a subset-sweep hides in. +published=() +while IFS= read -r name; do + [ -n "$name" ] && published+=("$name") +done < <(git ls-remote --heads origin 2>/dev/null | sed 's|.*refs/heads/||') + +if [ ${#published[@]} -gt 0 ] && [ ${#listed[@]} -ne ${#published[@]} ]; then + echo "FAIL the sweep can see ${#listed[@]} ref(s) and the remote publishes ${#published[@]}." >&2 + echo " Listed: ${listed[*]}" >&2 + echo " Published: ${published[*]}" >&2 + echo " A sweep over a subset that reports success is worse than no sweep." >&2 + echo " Fetch every head first: git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*'" >&2 + exit 1 +fi + +refs=() +for ref in "${listed[@]}"; do + [ "$ref" = "origin/$base" ] && continue + if [ -n "$head_ref" ] && [ "$ref" = "origin/$head_ref" ]; then + continue + fi + refs+=("$ref") +done + +if [ -n "${SWEEP_LIST_ONLY:-}" ]; then + printf '%s\n' "${refs[@]}" + exit 0 +fi + +echo "the remote publishes ${#published[@]} ref(s); sweeping ${#refs[@]} of them" +echo "(the working tree is swept separately, and $base is swept by its own push build)" +if [ ${#refs[@]} -eq 0 ]; then + echo "this repository publishes no ref other than $base and ${head_ref:-this one}" + exit 0 +fi +"$here/check-one-implementation.sh" "${refs[@]}" diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 9832971..41cdbd0 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -120,3 +121,132 @@ func sweepOf(t *testing.T, script string, name string, source string) (string, e output, err := sweep.CombinedOutput() return string(output), err } + +// The REF-LISTING step itself, which was wrong for weeks because nothing could +// test it. +// +// go.yml listed refs with `git for-each-ref 'refs/remotes/origin/*'`. That +// glob does not cross a slash, so every namespaced branch — compat/*, feat/*, +// dependabot/* — was silently dropped. CI printed "sweeping 1 other published +// ref(s)" and "ok origin/badges" and the required check went GREEN while a +// dependabot branch published workcontext/work_context.go importing +// crypto/ed25519. +// +// The step is a script now so these cases exist. The second one is the point: +// listing correctly is a property somebody can break again, and a sweep over a +// subset that reports success is worse than no sweep, because the green is +// what a reader acts on. +func TestTheRefSweepSeesNamespacedBranches(t *testing.T) { + script, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + // A throwaway "remote" with namespaced branches, cloned so the clone has + // real remote-tracking refs to list. + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare") + + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "branch", "-M", "main") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + // Namespaced, exactly the shape the glob dropped. + for _, branch := range []string{"badges", "connectory/welcome", "dependabot/go_modules/gomod-abc"} { + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) + } + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + + listed := listRefs(t, clone, "main", "") + require.Contains(t, listed, "origin/connectory/welcome", + "a namespaced branch was dropped: this is the defect the glob had") + require.Contains(t, listed, "origin/dependabot/go_modules/gomod-abc", + "a doubly namespaced branch was dropped") + require.Contains(t, listed, "origin/badges") + require.NotContains(t, listed, "origin/main", "the base ref is swept by its own push build") + + // And the head ref is excluded while everything else stays. + withHead := listRefs(t, clone, "main", "badges") + require.NotContains(t, withHead, "origin/badges") + require.Contains(t, withHead, "origin/connectory/welcome") +} + +// And the sweep REFUSES when it can see fewer refs than the remote publishes, +// which is what turns "I swept a subset" from a silent pass into a failure. +func TestTheRefSweepRefusesWhenItCanSeeFewerRefsThanExist(t *testing.T) { + script, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare") + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "branch", "-M", "main") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/kept") + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + // A branch appears on the remote AFTER the clone, so the local view is a + // strict subset — the shallow/stale-fetch case. + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/appeared/later") + + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = clone + run.Env = append(os.Environ(), "SWEEP_BASE=main", "SWEEP_HEAD=") + output, err := run.CombinedOutput() + require.Error(t, err, "a sweep that can see fewer refs than exist must fail:\n%s", output) + require.Contains(t, string(output), "worse than no sweep") +} + +func listRefs(t *testing.T, dir string, base string, head string) []string { + t.Helper() + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = dir + run.Env = append(os.Environ(), "SWEEP_LIST_ONLY=1", "SWEEP_BASE="+base, "SWEEP_HEAD="+head) + output, err := run.CombinedOutput() + require.NoError(t, err, "%s", output) + var refs []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + refs = append(refs, line) + } + } + return refs +} + +func runIn(t *testing.T, dir string, command string, args ...string) { + t.Helper() + run := exec.Command(command, args...) + run.Dir = dir + output, err := run.CombinedOutput() + require.NoError(t, err, "%s %v: %s", command, args, output) +} + +func copyFile(t *testing.T, from string, to string) { + t.Helper() + content, err := os.ReadFile(from) + require.NoError(t, err) + require.NoError(t, os.WriteFile(to, content, 0o755)) +} diff --git a/workcontext/README.md b/workcontext/README.md index c399db0..015945f 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -178,15 +178,32 @@ implementation this repository deleted lived at the REPOSITORY root, in package modules, as a job in `go.yml` — the workflow that builds every ref this repository publishes — and it is **required on `main` by an active ruleset**. -It sweeps the **published release lines too**, and that step **fails today**. -Three refs (`compat/v0.1.65`, `compat/v0.1.65-tls`, `feat/file-carriers-compat`) -still carry the deleted implementation. Running the sweep with no arguments made -a green check mean only that the working tree was clean, which is the weakest -thing it could have meant; passing it the refs makes the rule true of the -repository or red. **Retiring those refs is a merge precondition**, not a -runbook item: under "legacy means delete" a release line that cannot meet the -rule is deleted by the owner, and `main` does not move while the rule is false -of something this repository publishes. +It sweeps **every other ref this repository publishes** too, through +`scripts/sweep-published-refs.sh` — a script rather than inline shell because +the inline version was wrong for weeks and nothing could test it. It listed +refs with `git for-each-ref 'refs/remotes/origin/*'`, and **`*` does not cross +a slash**, so every namespaced branch was silently dropped. CI printed +`sweeping 1 other published ref(s)` / `ok origin/badges` and the required check +went green while `origin/dependabot/go_modules/gomod-06d3dc2861` published +`workcontext/work_context.go` importing `crypto/ed25519`. + +Two things follow, and the second matters more. Refs are listed **by prefix**, +so a namespaced ref is included. And **swept is asserted against what the +remote publishes**: a gate that sweeps a subset and reports success is worse +than no gate, because the green is the evidence somebody acts on. A mismatch +fails. Both properties have tests — +`TestTheRefSweepSeesNamespacedBranches` and +`TestTheRefSweepRefusesWhenItCanSeeFewerRefsThanExist` — because listing +correctly is a property somebody can break again. + +**That step fails today**, on `origin/dependabot/go_modules/gomod-06d3dc2861`, +which still carries the deleted implementation. Under "legacy means delete" a +published ref that cannot meet the rule is **deleted by the owner**, and `main` +does not move while the rule is false of something this repository publishes. +The base ref and the ref under review are excluded: sweeping the branch a pull +request merges into is circular, since `main` carries the implementation until +this change deletes it, and `main` is covered by the working-tree sweep of its +own push build. ## The model: mint once, sealed, verified exactly @@ -585,12 +602,11 @@ guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ // And let the SERVER enforce it, rather than each handler remembering to. server := grpc.NewServer(grpc.StreamInterceptor( grpctransport.StreamServerInterceptor( + // DECLARED at construction: every method whose streams carry a + // capability. A method in this list is guarded or its stream is + // refused; a method outside it is never asked about. + []string{"/codefly.example.Streamer/Emit"}, func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { - // Whether a method is capability-bearing is static, and the FIRST - // answer for a method is binding. - if !capabilityBearing[info.FullMethod] { - return nil, nil - } verified := verifiedFor(ctx) // from the server's own stream setup return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ Recheck: workcontext.RecheckWith(verifier, verified), @@ -610,6 +626,17 @@ func (s *server) Emit(_ *pb.Request, stream pb.Streamer_EmitServer) error { } ``` +**The method set is declared, not learned.** The previous revision asked +`guardFor` per request and took the FIRST answer for a method as binding — +trust on first use, and it failed the way trust on first use always fails. A +first request arriving without a capability (a client mid-deploy, a health +probe, a retry that lost its metadata) got `(nil, nil)`, **was handed the raw +stream**, recorded the method as unguarded, and every later legitimate request +was refused `codes.Internal` until the process restarted. One early request +both bypassed the guard and took the method down. A server that cannot +enumerate its capability-bearing methods does not know which of its streams +carry authority, which is the thing to fix before installing an interceptor. + **Use the interceptor, not `Guard` directly.** This README's previous recipe called `Guard` in the handler, which leaves the original stream in scope, never calls `Finish` — so every trailer the handler set was silently dropped — and diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index 348853c..ed88af4 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -64,6 +64,10 @@ type GuardedServerStream struct { // instead of reaching around them. ctx context.Context + // transport is this wrapper as the context's transport stream, kept so the + // re-check's own context can answer the method without being able to write. + transport *guardedTransportStream + mu sync.Mutex headers metadata.MD trailers metadata.MD @@ -97,13 +101,11 @@ func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedSe // The handler's context carries this wrapper as its transport stream. The // inherited one is kept for Method(), which is the one thing a transport // stream answers that is not a write. - guarded.ctx = grpc.NewContextWithServerTransportStream( - stream.Context(), - &guardedTransportStream{ - guarded: guarded, - inherited: grpc.ServerTransportStreamFromContext(stream.Context()), - }, - ) + guarded.transport = &guardedTransportStream{ + guarded: guarded, + inherited: grpc.ServerTransportStreamFromContext(stream.Context()), + } + guarded.ctx = grpc.NewContextWithServerTransportStream(stream.Context(), guarded.transport) return guarded, nil } @@ -278,9 +280,53 @@ func (s *GuardedServerStream) recheck() error { if s == nil || s.guard == nil || s.stream == nil { return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) } - return s.guard.BeforeSend(s.Context()) + // THE RE-CHECK'S CONTEXT REFUSES WRITES, and that is what stops a + // deadlock rather than a convention asking people not to. + // + // Measured: a Recheck that emitted an audit header with + // grpc.SetHeader(ctx, …) hung the handler FOREVER. The write resolved this + // wrapper out of the context, called SetHeader, which called recheck, + // which called BeforeSend — and BeforeSend holds the guard's mutex across + // the re-check, so the nested call blocked on a sync.Mutex its own + // goroutine held. SendMsg never returned. + // + // Releasing that mutex would turn the hang into unbounded recursion, which + // is not better. The honest fix is that a liveness check must not write to + // the stream whose liveness it is deciding — that is circular — so the + // context it runs under answers every write with a refusal naming why. + return s.guard.BeforeSend(grpc.NewContextWithServerTransportStream( + s.Context(), refusingTransportStream{method: s.method()}, + )) +} + +// method is the full method name, for the refusing transport stream's answer. +func (s *GuardedServerStream) method() string { + if s == nil || s.transport == nil { + return "" + } + return s.transport.Method() } +// refusingTransportStream is what a re-check's context answers with: it reads +// the method and refuses every write. +// +// A re-check deciding whether the stream may still emit must not emit, and a +// re-check that tries gets an error saying so instead of a deadlock. +type refusingTransportStream struct{ method string } + +func (t refusingTransportStream) Method() string { return t.method } + +func (t refusingTransportStream) SetHeader(metadata.MD) error { return errRecheckMustNotWrite } + +func (t refusingTransportStream) SendHeader(metadata.MD) error { return errRecheckMustNotWrite } + +func (t refusingTransportStream) SetTrailer(metadata.MD) error { return errRecheckMustNotWrite } + +var errRecheckMustNotWrite = fmt.Errorf( + "%w: a Work Context liveness re-check must not write to the stream whose liveness it is deciding", + workcontext.ErrInvalid, +) + // releaseHeaders hands held headers to the real stream. The caller has just // re-checked, and a SendMsg flushes whatever is queued there, so this is the // one moment they may cross. @@ -346,12 +392,24 @@ func statusFor(err error) error { return nil case errors.Is(err, workcontext.ErrRevoked), errors.Is(err, workcontext.ErrReplayed), - errors.Is(err, workcontext.ErrInvalid), errors.Is(err, workcontext.ErrNotACoreToken): // The capability no longer authenticates. Unauthenticated is the code // a client answers by presenting a new one, which is exactly the // README's "mint again". code = codes.Unauthenticated + case errors.Is(err, workcontext.ErrInvalid): + // NOT Unauthenticated. ErrInvalid is this package's sentinel for + // MISUSE as well as for a structurally broken capability — a nil + // guard, a finished stream, an unguarded wrapper — so mapping it to + // Unauthenticated told a client "mint again" about a server bug, and + // minting again cannot fix a server bug. Internal is the honest code: + // the client did nothing it can correct. + // + // The cost is that a genuinely malformed capability also arrives as + // Internal. That is the right side to err on: a client told to mint + // again when it should not loops, and a client told Internal when it + // could have minted retries once and sees the same answer. + code = codes.Internal case errors.Is(err, context.Canceled): code = codes.Canceled case errors.Is(err, context.DeadlineExceeded): @@ -409,22 +467,33 @@ func (e statusError) GRPCStatus() *status.Status { // guard with a nil error means this METHOD carries no capability and needs // none. // -// # Unguarded is a property of a method, not of a request +// # The capability-bearing method set is DECLARED, not learned +// +// capabilityBearing names every method whose streams carry a capability. It is +// fixed at construction, and guardFor is asked only about those — so a method +// in the set always gets a guard, and a method outside it never does. // -// That decision is remembered per info.FullMethod and the FIRST answer is -// binding: a later disagreement about the same method refuses the stream. A -// per-request (nil, nil) is the optional-carrier shape this module deleted — -// "guarded wherever the callback felt like it", where one buggy branch, or one -// request whose own metadata was missing, hands a handler the unguarded stream. -// Whether a method is capability-bearing is static; if a server needs it to -// vary, that is two methods. +// The previous revision learned it instead: guardFor was asked per request and +// the FIRST answer for a method was binding. That is trust on first use, and +// it fails the way trust on first use always fails. If the first request to a +// capability-bearing method arrived without a capability — a client mid-deploy, +// a health probe, a retry that lost its metadata — guardFor answered +// (nil, nil), THAT REQUEST GOT THE RAW STREAM, the method was recorded +// unguarded, and every later legitimate request was refused codes.Internal +// until the process restarted. One early request could both bypass the guard +// and disable the method. +// +// Declaring the set removes the learning. A server that cannot enumerate its +// capability-bearing methods does not know which of its streams carry +// authority, which is the thing to fix before installing an interceptor. func StreamServerInterceptor( + capabilityBearing []string, guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), ) grpc.StreamServerInterceptor { - var ( - mu sync.Mutex - decided = map[string]bool{} - ) + guarded := make(map[string]bool, len(capabilityBearing)) + for _, method := range capabilityBearing { + guarded[method] = true + } return func( server any, stream grpc.ServerStream, @@ -437,33 +506,28 @@ func StreamServerInterceptor( workcontext.ErrInvalid, ) } - guard, err := guardFor(stream.Context(), info) - if err != nil { - return statusFor(err) - } method := "" if info != nil { method = info.FullMethod } - mu.Lock() - wasGuarded, seen := decided[method] - if !seen { - decided[method] = guard != nil + if !guarded[method] { + // Declared as carrying no capability. The decision was made at + // construction, where a reviewer can read it, rather than inferred + // from whichever request happened to arrive first. + return handler(server, stream) } - mu.Unlock() - if seen && wasGuarded != (guard != nil) { - // The method's own answer changed under us. Refusing is the only - // safe reading: if it was guarded once it is capability-bearing, - // and if it was not, something now thinks it is. - return status.Errorf(codes.Internal, - "%s: whether this method is Work Context guarded changed between requests; that is a property of the method", - method, - ) + guard, err := guardFor(stream.Context(), info) + if err != nil { + return statusFor(err) } if guard == nil { - // Explicitly unguarded, for this METHOD, by the server's own - // decision, at the server rather than inside a handler. - return handler(server, stream) + // The method IS declared capability-bearing and guardFor produced + // no guard. That is a refusal, not a passthrough: the previous + // revision handed this request the raw stream. + return status.Errorf(codes.Unauthenticated, + "%s carries a Work Context and no guard could be built for this stream", + method, + ) } guarded, err := Guard(stream, guard) if err != nil { diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go index a8eb61b..7076f82 100644 --- a/workcontext/grpctransport/guarded_stream_test.go +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -250,12 +252,13 @@ func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { underlying := &recordingStream{ctx: context.Background()} interceptor := StreamServerInterceptor( + []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return guard, nil }) var handed grpc.ServerStream - err = interceptor(nil, underlying, &grpc.StreamServerInfo{FullMethod: "/x/Y"}, + err = interceptor(nil, underlying, &grpc.StreamServerInfo{FullMethod: streamMethod}, func(_ any, stream grpc.ServerStream) error { handed = stream require.NoError(t, stream.SendMsg("first")) @@ -269,35 +272,59 @@ func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { require.NotImplements(t, (*interface{ ServerStream() grpc.ServerStream })(nil), handed) } -// A server may declare a stream unguarded, but it does so AT THE SERVER where -// a reviewer sees it, not inside a handler. +// The capability-bearing method set is DECLARED at construction, and a method +// in it is guarded or the stream is refused — never passed through. +// +// The previous revision learned the set from the first request, which is trust +// on first use: a first request without a capability got the RAW STREAM, the +// method was recorded unguarded, and every later legitimate request was +// refused until restart. One early request both bypassed the guard and +// disabled the method. func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { underlying := &recordingStream{ctx: context.Background()} refused := errors.New("this capability did not verify") + bearing := &grpc.StreamServerInfo{FullMethod: streamMethod} err := StreamServerInterceptor( + []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return nil, refused - })(nil, underlying, &grpc.StreamServerInfo{}, func(any, grpc.ServerStream) error { + })(nil, underlying, bearing, func(any, grpc.ServerStream) error { t.Fatal("the handler must not run when the stream could not be guarded") return nil }) require.ErrorIs(t, err, refused) - // Explicitly unguarded: allowed, and the handler gets the raw stream. - var handed grpc.ServerStream + // A DECLARED capability-bearing method whose guard could not be built is + // REFUSED. This used to be the passthrough that handed out the raw stream. err = StreamServerInterceptor( + []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return nil, nil - })(nil, underlying, &grpc.StreamServerInfo{}, func(_ any, stream grpc.ServerStream) error { - handed = stream + })(nil, underlying, bearing, func(any, grpc.ServerStream) error { + t.Fatal("a capability-bearing method must not run unguarded") return nil }) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + + // A method OUTSIDE the declared set is not guarded and never asks: the + // decision is in the construction, where a reviewer reads it. + var handed grpc.ServerStream + err = StreamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + t.Fatal("guardFor must not be asked about a method outside the declared set") + return nil, nil + })(nil, underlying, &grpc.StreamServerInfo{FullMethod: "/other.Service/Ping"}, + func(_ any, stream grpc.ServerStream) error { + handed = stream + return nil + }) require.NoError(t, err) require.Same(t, underlying, handed) // And no guard-builder at all is a configuration error, not a pass. - err = StreamServerInterceptor(nil)(nil, underlying, &grpc.StreamServerInfo{}, - func(any, grpc.ServerStream) error { return nil }) + err = StreamServerInterceptor([]string{streamMethod}, nil)( + nil, underlying, bearing, func(any, grpc.ServerStream) error { return nil }) require.ErrorIs(t, err, workcontext.ErrInvalid) } diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go index 455c1e2..301dc53 100644 --- a/workcontext/grpctransport/real_grpc_stream_test.go +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -64,6 +64,7 @@ func serveGuarded( ) *grpc.ClientConn { t.Helper() return serveWith(t, StreamServerInterceptor( + []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return guard, nil }), handler) diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go index 18e35b6..04b1e7c 100644 --- a/workcontext/grpctransport/round4_reproducers_test.go +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -220,40 +220,57 @@ func TestRevocationReachesTheClientAsUnauthenticated(t *testing.T) { "a client cannot tell 'mint again' from 'the server broke' out of codes.Unknown") } -// Whether a method is guarded is a property of the METHOD. A guardFor that -// answers (nil, nil) for a method it guarded before is refused rather than -// handing that request the raw stream. -func TestGuardingIsDecidedPerMethodAndNotPerRequest(t *testing.T) { - authority := &revocableGuard{} - var calls int +// N-C: the capability-bearing method set is DECLARED, so a first request +// without a capability never gets the raw stream and never disables the method. +// +// The previous revision learned the set: guardFor was asked per request and the +// FIRST answer for a method was binding. That is trust on first use. If the +// first request to a capability-bearing method arrived without a capability — a +// client mid-deploy, a health probe, a retry that lost its metadata — +// guardFor answered (nil, nil), THAT request was handed the raw stream, the +// method was recorded unguarded, and every later legitimate request was +// refused codes.Internal until the process restarted. One early request both +// bypassed the guard and took the method down. +func TestAFirstRequestWithoutACapabilityNeitherBypassesNorDisablesTheMethod(t *testing.T) { + var asked int var mu sync.Mutex + var handedRaw bool interceptor := StreamServerInterceptor( + // DECLARED: this method carries a capability, whatever any one request + // looks like. + []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { mu.Lock() defer mu.Unlock() - calls++ - if calls == 1 { - return authority.guard(t), nil + asked++ + if asked == 1 { + // The first request has no capability. + return nil, nil } - // The optional-carrier shape: this request happens to have found - // no capability, so the handler would get the unguarded stream. - return nil, nil + return (&revocableGuard{}).guard(t), nil }) connection := serveWith(t, interceptor, func(stream grpc.ServerStream) error { if _, ok := stream.(*GuardedServerStream); !ok { - return errors.New("the handler was handed a stream that is not the wrapper") + mu.Lock() + handedRaw = true + mu.Unlock() } return nil }) + // The first request is REFUSED rather than handed the raw stream. _, _, first := clientSaw(t, connection) - require.NoError(t, first, "the first request establishes the method as guarded") - + require.Error(t, first, "a capability-bearing method ran with no guard") + require.Equal(t, codes.Unauthenticated, status.Code(first)) + mu.Lock() + require.False(t, handedRaw, "the handler was handed the raw stream") + mu.Unlock() + + // And the method still WORKS afterwards: nothing was learned, so nothing + // is stuck. This is the half that used to be codes.Internal until restart. _, _, second := clientSaw(t, connection) - require.Error(t, second) - require.Equal(t, codes.Internal, status.Code(second)) - require.Contains(t, status.Convert(second).Message(), "property of the method") + require.NoError(t, second, "one early request without a capability disabled the method") } // The three survivors the layer-4 mutation pass found in this package: no test @@ -296,8 +313,6 @@ func TestATrailerSetAfterFinishIsRefused(t *testing.T) { require.NoError(t, guarded.Finish(nil)) released := underlying.trailers - // After Finish: refused, and nothing more reaches gRPC however many times - // it is attempted. for range 3 { guarded.SetTrailer(metadata.Pairs("after-finish", "late")) } @@ -312,9 +327,10 @@ func TestATrailerSetAfterFinishIsRefused(t *testing.T) { // The wrapper exposes no METHOD that returns the underlying stream either. // // The shape test checked fields and the interface's own methods, so it survived -// an `Unwrap() any` accessor — which is the embedded field again with one more -// step. This asserts the property the shape test was reaching for: nothing on -// this type hands back something that satisfies grpc.ServerStream. +// an `Unwrap() any` accessor — the embedded field with one more step. This +// asserts the property the shape test was reaching for: nothing on this type +// hands back something satisfying grpc.ServerStream, and nothing returns a +// bare `any`, which would carry the raw stream past every shape check. func TestNoMethodOfTheWrapperReturnsTheRawStream(t *testing.T) { guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ Recheck: func(context.Context) error { return nil }, @@ -344,3 +360,55 @@ func TestNoMethodOfTheWrapperReturnsTheRawStream(t *testing.T) { } } } + +// N-P: a Recheck that writes to the stream must get an ERROR, not a deadlock. +// +// Measured before the fix: a Recheck emitting an audit header with +// grpc.SetHeader(ctx, …) hung the handler FOREVER. The write resolved the +// wrapper out of the context, called SetHeader, which called recheck, which +// called BeforeSend — and BeforeSend holds the guard's mutex across the +// re-check, so the nested call blocked on a sync.Mutex its own goroutine held. +// SendMsg never returned. +// +// Releasing that mutex would turn the hang into unbounded recursion. A liveness +// check deciding whether the stream may emit must not emit, so the context it +// runs under refuses every write and names why. +func TestARecheckThatWritesToTheStreamIsRefusedRatherThanDeadlocking(t *testing.T) { + var wrote error + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + wrote = grpc.SetHeader(ctx, metadata.Pairs("audited", "yes")) + return nil + }, + }) + require.NoError(t, err) + guarded, err := Guard(&recordingStream{ctx: context.Background()}, guard) + require.NoError(t, err) + + sent := make(chan error, 1) + go func() { sent <- guarded.SendMsg("x") }() + select { + case sendErr := <-sent: + require.NoError(t, sendErr, "the send itself is fine; the write inside the re-check is not") + case <-time.After(10 * time.Second): + t.Fatal("SendMsg never returned: the re-check re-entered BeforeSend under its own mutex") + } + + require.ErrorIs(t, wrote, workcontext.ErrInvalid) + require.ErrorContains(t, wrote, "must not write to the stream whose liveness it is deciding") + + // And the method is still readable from inside a re-check, because reading + // is not writing. + var seen string + readOnly, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + seen, _ = grpc.Method(ctx) + return nil + }, + }) + require.NoError(t, err) + reader, err := Guard(&recordingStream{ctx: context.Background()}, readOnly) + require.NoError(t, err) + require.NoError(t, reader.SendMsg("x")) + require.Empty(t, seen, "a bare recordingStream carries no transport stream, so there is no method to read") +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index a482299..d3d6c10 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -442,7 +442,7 @@ func (interruptedBody) Read(p []byte) (int, error) { // An oversized or malformed COMPLETE response stays terminal, because the host // answered and answered wrongly: retrying gets the same answer. -func TestACompleteButUnacceptableResponseStaysTerminal(t *testing.T) { +func TestACompleteButUnacceptableResponseIsRetryable(t *testing.T) { now := func() time.Time { return testClock } host := newMintHost(t, now) client := newTestMintClient(t, host, projectedFile(t, "projected"), now) @@ -457,9 +457,13 @@ func TestACompleteButUnacceptableResponseStaysTerminal(t *testing.T) { }) _, err := client.Credential(t.Context()) - require.ErrorIs(t, err, ErrMintRefused) + // RETRYABLE, not terminal. An oversized body on a 200 is something in the + // middle answering — an ingress default page, a captive error page, a + // misrouted rollout — rather than the host refusing this process. Latched, + // any one of those stopped it for good. + require.ErrorIs(t, err, ErrMintUnavailable) require.ErrorContains(t, err, "exceeds") - require.ErrorIs(t, client.Refused(), ErrMintRefused) + require.NoError(t, client.Refused(), "nothing on the response path latches but an enumerated refusal") } // D4 (round three). A cancelled waiter must not be handed an EXPIRED credential. @@ -550,7 +554,7 @@ func TestAMintResponseCarryingTrailingJSONIsRefused(t *testing.T) { }) _, err := client.Credential(t.Context()) - require.ErrorIs(t, err, ErrMintRefused) + require.ErrorIs(t, err, ErrMintUnavailable) require.ErrorContains(t, err, "more than one JSON value") }) } diff --git a/workcontext/mint.go b/workcontext/mint.go index 1355f20..0663945 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -147,8 +147,11 @@ func (path ProjectedTokenFile) ProjectedToken() (string, error) { return "", fmt.Errorf("%w: read projected token: %w", ErrMintUnavailable, err) } if len(raw) > maxProjectedTokenBytes { - // Not transient: the file is there and is the wrong thing. - return "", fmt.Errorf("%w: projected token exceeds %d bytes", ErrMintRefused, maxProjectedTokenBytes) + // Retryable. "The file is there and is the wrong thing" was the reason + // for latching this, and it is not a reason: latching buys nothing + // here — the host would refuse an oversized bearer anyway — while a + // projection caught mid-write is exactly the shape that produces it. + return "", fmt.Errorf("%w: projected token exceeds %d bytes", ErrMintUnavailable, maxProjectedTokenBytes) } token := strings.TrimSpace(string(raw)) if token == "" { @@ -228,8 +231,11 @@ type MintOptions struct { // bypasses TLSClientConfig entirely — and a caller holding the same // *http.Transport pointer can turn verification off after construction, // because a copied http.Client shares it. Inspecting a supplied client - // could not close any of that. So the client builds and owns its transport: - // TLS 1.2 minimum, verification on, no custom dialer, redirects refused. + // could not close any of that. So the client builds and owns its transport. + // What that transport is, is stated once, at mintHTTPClient — not restated + // here, because a field comment and a constructor comment saying the same + // thing is how this one came to claim a TLS 1.2 floor for three revisions + // after the floor became 1.3. RootCAs *x509.CertPool // RequestTimeout bounds one mint request. @@ -239,16 +245,16 @@ type MintOptions struct { // renewal is attempted. Zero takes the default. RenewalLead float64 - // MaxCredentialLifetime refuses a credential the host minted for longer - // than this process will hold one. Zero takes - // defaultMaxCredentialLifetime. + // MaxCredentialLifetime refuses a credential valid for longer than this + // process will hold one. Zero takes defaultMaxCredentialLifetime, which is + // core's own MaxTTLCeiling. // - // Core caps what an authority will mint (Authority.MaxTTL, one hour by - // default), so in a correctly configured deployment this never fires. It is - // here for the one that is not: MaxTTL is configurable with no ceiling and - // nothing verifies a lifetime at use, so this is the only thing standing - // between a misconfigured host and a process holding a month-long - // credential. + // It is a DEPLOYMENT POLICY below core's absolute ceiling, and what that + // means is argued once at defaultMaxCredentialLifetime rather than again + // here. This comment claimed to be the only thing between a misconfigured + // host and a month-long credential, which stopped being true when core put + // the bound in its one decode path — and it went on claiming it because + // the argument was written down twice. MaxCredentialLifetime time.Duration // Now is the clock, for tests. @@ -436,7 +442,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if options.Authority == nil { return nil, fmt.Errorf( "%w: a mint client needs the process's boot-read authority; the audience is read from it", - ErrMintRefused, + ErrInvalid, ) } audience := AuthorityValue{ @@ -445,14 +451,14 @@ func NewMintClient(options MintOptions) (*MintClient, error) { } if audience.Name == "" || audience.Key == "" { return nil, fmt.Errorf( - "%w: name the pinned value the audience is read from", ErrMintRefused, + "%w: name the pinned value the audience is read from", ErrInvalid, ) } if options.ProjectedToken == nil { - return nil, fmt.Errorf("%w: no projected token source", ErrMintRefused) + return nil, fmt.Errorf("%w: no projected token source", ErrInvalid) } if strings.TrimSpace(options.ProjectionAudience) == "" { - return nil, fmt.Errorf("%w: no projection audience", ErrMintRefused) + return nil, fmt.Errorf("%w: no projection audience", ErrInvalid) } timeout := options.RequestTimeout if timeout == 0 { @@ -461,7 +467,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if timeout < time.Millisecond || timeout > maxMintRequestTimeout { return nil, fmt.Errorf( "%w: mint request timeout must be between 1ms and %s", - ErrMintRefused, maxMintRequestTimeout, + ErrInvalid, maxMintRequestTimeout, ) } lead := options.RenewalLead @@ -469,7 +475,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { lead = defaultRenewalLead } if lead <= 0 || lead >= 1 { - return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrMintRefused) + return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrInvalid) } ceiling := options.MaxCredentialLifetime if ceiling == 0 { @@ -806,17 +812,28 @@ func (c *MintClient) mintInto(done chan struct{}) { c.refreshWanted = false renewal := c.credential != nil if err != nil { - if errors.Is(err, ErrMintUnavailable) { - c.failures++ - c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) - c.lastFailure = err + // ONE LATCH RULE: only ErrMintRefused is terminal, and nothing else is. + // + // It used to be the negation — anything that was not an outage latched + // — which made every error this client had not thought about into a + // permanent stop. An ErrInvalid from a caller's own token source + // latched while Refused() reported something errors.Is(…, + // ErrMintRefused) said false about, so the two ways of asking "is this + // client refused" disagreed; and the misconfiguration sentinels said + // in their own comments that they were NOT latched, while this branch + // latched them. + // + // Everything that is not an enumerated refusal is held off and + // retried, with the error kept so a caller can see it. The asymmetry + // is the whole argument: a wrong "retryable" costs one request per + // hold-off, and a wrong "terminal" costs the process. + if errors.Is(err, ErrMintRefused) { + c.refused = err return } - // A refusal, which includes an authority drift, a TLS verification - // failure and the final-URL disclosure check. Latch it: the host will - // say the same thing again, and this package's contract is that a - // process seeing one must stop serving. - c.refused = err + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + c.lastFailure = err return } if renewal && credential.token == c.credential.token { @@ -1081,17 +1098,24 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia return Credential{}, err } if reason := tlsVerificationFailure(err); reason != "" { - // NOT an outage. Every other transport error means "the endpoint - // is not answering"; this one means "something answered and could - // not prove it is the endpoint", which is the single case this - // whole transport exists to refuse. Treated as an outage it was - // retried on a hold-off while the held credential kept being - // served, with nothing anywhere saying that the channel carrying - // the projected service-account token had stopped being - // authenticated. + // AN OUTAGE, and the previous revision had this backwards for a + // reason that does not survive being stated precisely. + // + // It latched, on the argument that "the projected token is not + // presented to an endpoint that cannot prove who it is". But the + // HANDSHAKE FAILS BEFORE ANY REQUEST BYTES LEAVE: the token was + // never sent and a retry would not send it either, so latching + // buys no confidentiality at all. What it does buy is a permanent + // stop on a certificate ROTATION — the endpoint's new leaf not yet + // trusted, a root pool updated a minute later — which is the most + // ordinary cause of this error in a running system. + // + // So it is retryable, and named distinctly in the message because + // a certificate that does not verify is still the thing an + // operator must look at first. return Credential{}, fmt.Errorf( - "%w: the mint endpoint's certificate did not verify (%s); the projected token is not presented to an endpoint that cannot prove who it is", - ErrMintRefused, reason, + "%w: the mint endpoint's certificate did not verify (%s); nothing was sent, and this retries", + ErrMintUnavailable, reason, ) } return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, err) @@ -1131,8 +1155,14 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia // whole life. mediaType, _, parseErr := mime.ParseMediaType(response.Header.Get("Content-Type")) if parseErr != nil || mediaType != "application/json" { + // RETRYABLE. A 200 carrying text/html is an ingress or a service mesh + // answering instead of the host — a default page, a captive error + // page, a misrouted rollout — and every one of those is a moment + // rather than a verdict. Latched, one of them stopped the process for + // good. return Credential{}, fmt.Errorf( - "%w: mint response must declare Content-Type: application/json", ErrMintRefused, + "%w: mint response must declare Content-Type: application/json and declared %q", + ErrMintUnavailable, response.Header.Get("Content-Type"), ) } payload, err := io.ReadAll(io.LimitReader(response.Body, maxMintResponseBytes+1)) @@ -1152,8 +1182,10 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia // Terminal, unlike the read failure above: the host answered, and // answered with something this client will not accept. Retrying gets // the same answer. + // Retryable for the same reason: an oversized body on a 200 is + // something in the middle answering, not the host refusing. return Credential{}, fmt.Errorf( - "%w: mint response exceeds %d bytes", ErrMintRefused, maxMintResponseBytes, + "%w: mint response exceeds %d bytes", ErrMintUnavailable, maxMintResponseBytes, ) } return c.credentialFrom(payload, audience) @@ -1168,7 +1200,9 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(&body); err != nil { - return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintRefused, err) + // Retryable: an undecodable body on a 200 is the same class as a wrong + // Content-Type — something answered that is not the host. + return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintUnavailable, err) } // DisallowUnknownFields applies to the value just decoded and says nothing // about what follows it, so a valid response with a second JSON value @@ -1176,7 +1210,7 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential // The response is ONE object and nothing after it. if err := decoder.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { return Credential{}, fmt.Errorf( - "%w: mint response carries more than one JSON value", ErrMintRefused, + "%w: mint response carries more than one JSON value", ErrMintUnavailable, ) } token := body.WorkContext @@ -1272,11 +1306,13 @@ func (c *MintClient) checkWindow(credential Credential) error { } now := c.now().UTC() if now.Before(credential.notBefore.Add(-corework.DefaultSkew)) { - // Not yet valid. A clock this far apart is a configuration error on one - // side or the other and will not fix itself, so it is a refusal. + // Not yet valid: the clocks are further apart than core's own skew. + // RETRYABLE, because the thing that most often causes it does fix + // itself — ntpd stepping a clock that drifted, a VM resuming. Latched, + // a process that happened to boot inside that window never recovered. return fmt.Errorf( "%w: the minted credential is not valid until %s, and it is %s", - ErrMintRefused, credential.notBefore, now, + ErrMintUnavailable, credential.notBefore, now, ) } if !now.Before(credential.expiresAt) { @@ -1378,6 +1414,11 @@ type mintResponse struct { // validateMintURL requires absolute HTTPS with no userinfo, query or fragment. // +// Its refusal is ErrInvalid, like every other construction-time check: nothing +// has been asked of a host, so "the mint refused you" is the wrong thing to +// tell a caller — and ErrMintRefused is the sentinel this package documents as +// meaning a process must stop serving. +// // Plaintext HTTP used to be accepted here, which made the strongest reason this // endpoint exists — that the projected service-account token is presented to it // and nothing else — contingent on a configuration value. A credential request @@ -1388,7 +1429,7 @@ func validateMintURL(raw string) (string, error) { parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Scheme != "https" { return "", fmt.Errorf( "%w: mint URL must be an absolute https URL without credentials, query, or fragment", - ErrMintRefused, + ErrInvalid, ) } return parsed.String(), nil diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index 1f6f1a4..d86d4aa 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -625,9 +625,12 @@ func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { mintedAt: testClock.Add(-time.Hour), mintedFor: 15 * time.Minute, sentinel: ErrMintUnavailable, says: "expired at", }, - "not yet valid, which is a clock that will not fix itself": { + // RETRYABLE: ntpd stepping a drifted clock, or a VM resuming, fixes + // this. Latched, a process that booted inside the window never + // recovered. + "not yet valid, which a clock correction fixes": { mintedAt: testClock.Add(time.Hour), mintedFor: 15 * time.Minute, - sentinel: ErrMintRefused, says: "not valid until", + sentinel: ErrMintUnavailable, says: "not valid until", }, "whole remaining lifetime inside the renewal lead": { mintedAt: testClock, mintedFor: 4 * time.Second, @@ -764,11 +767,15 @@ func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { }) } - // A file that is there and is the wrong thing IS a refusal: the host would - // refuse it, and the next read returns the same bytes. + // Nothing a local read produces is a refusal: every one of them is a + // moment in the life of a file that is mounted and rotated under a running + // process. + // Oversized is RETRYABLE too. Latching bought nothing — the host would + // refuse an oversized bearer anyway — and a projection caught mid-write is + // exactly the shape that produces it. oversized := strings.Repeat("x", maxProjectedTokenBytes+1) _, err := ProjectedTokenFile(projectedFile(t, oversized)).ProjectedToken() - require.ErrorIs(t, err, ErrMintRefused) + require.ErrorIs(t, err, ErrMintUnavailable) token, err := ProjectedTokenFile(projectedFile(t, " projected-with-trailing-newline\n")).ProjectedToken() require.NoError(t, err) @@ -951,7 +958,13 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { options := valid mutate(&options) _, err := NewMintClient(options) - require.ErrorIs(t, err, ErrMintRefused) + // ErrInvalid, not ErrMintRefused. Nothing has been asked of a + // host at construction, so "the mint refused you" is the wrong + // thing to tell a caller — and ErrMintRefused is the sentinel this + // package documents as meaning a process must stop serving. + require.ErrorIs(t, err, ErrInvalid) + require.NotErrorIs(t, err, ErrMintRefused, + "a misconfiguration must not read as a host's verdict") }) } } diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index b8a5ac7..496f83a 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -58,6 +58,24 @@ var ( "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/types/known/anypb", "encoding/gob", "encoding/asn1", "encoding/xml", + // The LOW-LEVEL protobuf runtime. protoiface and protoimpl expose a + // message's own marshal and unmarshal methods, so + // c.ProtoReflect().ProtoMethods().Unmarshal(...) is a complete decode + // that mentions no codec package at all — a route neither gate + // inspected. Nothing here has any use for them. + "google.golang.org/protobuf/runtime/protoiface", + "google.golang.org/protobuf/runtime/protoimpl", + // The LEGACY protobuf module, whose proto.Marshal and proto.Unmarshal + // are the same capability under a different path. Uncovered by every + // rule that named the new one. + "github.com/golang/protobuf/proto", + // Hashes that nothing here uses, and that a hand-rolled HMAC needs one + // of. crypto/sha256 is the exception and is held to named files and + // symbols; these have no use at all, so they are simply refused rather + // than left "not a signature, therefore fine". + "crypto/sha512", "crypto/sha1", "crypto/sha3", "crypto/md5", + // HKDF is HMAC with a label on it. + "crypto/hkdf", } // envelopeDecoders are banned EXCEPT where a file is allowlisted below. A @@ -76,7 +94,13 @@ var ( // Any JOSE, JWT or token-library path, whoever publishes it. A capability // here is core's protobuf envelope; a library that mints bearer tokens has // no honest use in this module. - bannedImportSubstrings = []string{"jose", "jwt", "jwx", "paseto", "macaroon", "branca"} + // Any path CONTAINING one of these. ed25519 is here as well as in the + // exact list because a third-party path ending /ed25519 — a vendored + // copy, a re-export, a fork — passed both gates while naming the one + // primitive this module must never reach for. + bannedImportSubstrings = []string{ + "jose", "jwt", "jwx", "paseto", "macaroon", "branca", "ed25519", + } ) // narrowedImports are the imports some file here genuinely needs, held to the @@ -410,6 +434,35 @@ func inspectForSecondImplementation(file sourceFile) []string { findings = append(findings, inspectLocalTypes(file)...) findings = append(findings, inspectCoreAliases(file, coreImports)...) findings = append(findings, inspectEnvelopeDecoding(file, base64Names)...) + findings = append(findings, inspectProtoMethodsRoute(file)...) + return findings +} + +// inspectProtoMethodsRoute refuses a message's own marshal and unmarshal +// methods, reached through protobuf reflection. +// +// c.ProtoReflect().ProtoMethods().Unmarshal(...) is a complete decode of a +// capability that names no codec package, no banned import and no allowlisted +// type — so the import ban could not see it and the codec rule had nothing to +// key on. Banning the protoiface and protoimpl imports closes the typed route; +// this closes the reflective one, which needs no import at all because +// ProtoReflect is a method on every generated message. +func inspectProtoMethodsRoute(file sourceFile) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "ProtoMethods" { + return true + } + findings = append(findings, fmt.Sprintf( + "%s reaches ProtoMethods.\n"+ + "A message's own marshal and unmarshal methods are a complete codec that names no\n"+ + "codec package: ProtoReflect().ProtoMethods().Unmarshal(...) decodes a capability\n"+ + "with no banned import and no type for an allowlist to key on. Reading a capability\n"+ + "off the wire is corework.Inspect's job.", + file.path)) + return true + }) return findings } @@ -1797,11 +1850,31 @@ func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, oper } named := resolveTypeName(file, value, value.Pos()) if named == "" { - // Unresolvable. Refused in the leaf module, where the gate must not - // pass what it cannot read; allowed outside it, where the decisive - // check below is what this gate is for and the root module encodes - // receipts, configuration and runtime documents all day. - return !inLeaf + // UNRESOLVABLE IS A FINDING EVERYWHERE NOW, with the root module's + // genuine indirections named one by one. + // + // "Allowed outside the leaf module" was a hatch wide enough to drive + // the whole gate through, and it was exercised: a reviewer wrote + // + // func into(raw []byte, m proto.Message) error { + // return proto.Unmarshal(raw, m) + // } + // + // in the root module, called it with &basev0.WorkContextV1{}, and the + // gate was green. One interface-typed parameter defeated the decisive + // rule, because the codec's argument is then an interface and names no + // type at all. + // + // Closing it needs the legitimate indirections enumerated, which they + // now are — there are three, all in receipts, all taking a + // proto.Message by design. A new one is a finding and has to be argued + // here, which is the point. + // + // WHAT THIS STILL DOES NOT CATCH, stated rather than implied: a + // capability passed INTO one of those three allowlisted functions. + // Following a value across a call needs go/types, and this gate is + // syntactic by choice — see the limit stated on codecArgumentIsPermitted. + return codecIndirectionIsPermitted(file, call) } if isCapabilityType(named) { // THE DECISIVE RULE, and it holds in BOTH modules: a Work Context @@ -1812,12 +1885,95 @@ func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, oper // proto.Unmarshal into basev0.WorkContextV1. return slices.Contains(permitted, named) } + if isCodecInterfaceType(named) { + // AN INTERFACE CAN CARRY A CAPABILITY, so resolving the argument's + // type is not enough when the type is one of the codec's own + // interfaces. This is the hatch a reviewer drove the whole gate + // through: + // + // func into(raw []byte, m proto.Message) error { + // return proto.Unmarshal(raw, m) + // } + // + // called with &basev0.WorkContextV1{}. The argument resolves perfectly + // — to proto.Message — which is neither a capability nor unresolvable, + // so both of the checks above said yes. One interface-typed parameter, + // and the decisive rule was gone. + // + // The legitimate indirections are enumerated instead. A new one is a + // finding and has to be argued in codecIndirections. + return codecIndirectionIsPermitted(file, call) + } if !inLeaf { return true } return slices.Contains(permitted, named) } +// codecInterfaceTypes are the interface types a codec takes, through which a +// capability can reach it without ever being named. +// +// A list of names, and deliberately a short closed one: these are the +// parameter types of the codecs themselves, so an indirection has to go +// through one of them. `any` and a literal `interface{}` are here for +// encoding/json, whose Marshal takes one. +var codecInterfaceTypes = []string{ + "google.golang.org/protobuf/proto#Message", + "google.golang.org/protobuf/reflect/protoreflect#ProtoMessage", + "google.golang.org/protobuf/runtime/protoiface#MessageV1", + "github.com/golang/protobuf/proto#Message", + "any", + "interface{}", +} + +func isCodecInterfaceType(resolved string) bool { + return slices.Contains(codecInterfaceTypes, resolved) +} + +// codecIndirections are the functions that legitimately apply a codec to an +// interface-typed parameter, by file and by function name. +// +// Three, all in receipts, all taking a proto.Message because that is what the +// receipts contract is about: a caller's own request and response messages, +// never a capability. Keyed on the enclosing function rather than the file, +// because a file-wide exemption is what let the type rule be bypassed twice +// already. +var codecIndirections = map[string][]string{ + "receipts/digest.go": {"RequestDigest"}, + "receipts/effect.go": {"Record"}, + "receipts/interceptor.go": {"Handle"}, + // The runtime's own documents, decoded into a caller's destination. The + // destination is the CALLER's type and the content is a configuration + // document, never a capability — a capability arrives as an opaque string + // and is read with corework.Inspect. + "configuration_document.go": {"decodeDocument"}, +} + +// codecIndirectionIsPermitted reports whether an unresolvable codec argument is +// one of the named indirections. +func codecIndirectionIsPermitted(file sourceFile, call ast.Node) bool { + permitted, named := codecIndirections[file.path] + if !named { + return false + } + enclosing := enclosingFuncName(file, call.Pos()) + return enclosing != "" && slices.Contains(permitted, enclosing) +} + +// enclosingFuncName names the function containing pos, or "" at file level. +func enclosingFuncName(file sourceFile, pos token.Pos) string { + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if function.Body.Pos() <= pos && pos <= function.Body.End() { + return function.Name.Name + } + } + return "" +} + // codecArguments finds the call this codec selector belongs to and returns the // arguments whose types matter. For a MarshalOptions{...}.Marshal(x) chain the // selector sits inside the outer call, so the whole file is walked for the call diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index 0bc55fb..710aa90 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -312,21 +312,26 @@ func TestEveryMintFaultIsClassifiedByWhatRecoveryItNeeds(t *testing.T) { "the source's own error must stay in the chain") }) - t.Run("a TLS verification failure is a refusal, not an outage", func(t *testing.T) { + t.Run("a TLS verification failure is an outage: nothing was sent", func(t *testing.T) { clock := &movableClock{at: testClock} host := newMintHost(t, clock.now) // A client pointed at the host with a root pool that does not contain - // the host's certificate: something answers, and cannot prove it is - // the endpoint. + // the host's certificate. client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, func(options *MintOptions) { options.RootCAs = x509.NewCertPool() }) _, err := client.Credential(context.Background()) - require.ErrorIs(t, err, ErrMintRefused, - "served the held credential and retried while the channel carrying the projection stopped being authenticated") - require.NotErrorIs(t, err, ErrMintUnavailable) - require.ErrorContains(t, err, "cannot prove who it is") - require.ErrorIs(t, client.Refused(), ErrMintRefused, "and it is terminal") + // This LATCHED for one revision, on the argument that the projected + // token must not be presented to an endpoint that cannot prove who it + // is. The argument does not survive being stated precisely: the + // handshake fails before any request bytes leave, so nothing was sent + // and a retry sends nothing either. Latching bought no + // confidentiality and turned a certificate ROTATION — the most + // ordinary cause of this in a running system — into a permanent stop. + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "nothing was sent") + require.NoError(t, client.Refused(), "a certificate rotation is not a verdict about this process") }) t.Run("408 is an outage", func(t *testing.T) { From 86308068b73769eecb4387a1092145a42272dd6b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:58:28 -0400 Subject: [PATCH 20/35] =?UTF-8?q?refactor(workcontext):=20re-pin=20to=20co?= =?UTF-8?q?re=204051e438=20=E2=80=94=20Recheck=20is=20this=20verifier's=20?= =?UTF-8?q?to=20answer,=20and=20the=20execution=20predicate=20is=20core's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N-B, verified rather than taken on trust. core 4051e438 adds the issuer and audience checks to Verifier.Recheck, and it matters here directly because RecheckWith is what the stream guard is built on: a *Verified obtained from verifier X was accepted by verifier Y, which had never checked the capability was addressed to it. A gateway for one audience could re-check — and so report live — a capability minted by another issuer for somebody else. Core's own framing of why it was missed is the useful part: Recheck skipped the issuer and audience because they do not CHANGE under a long-running call, and the question is not what can change but what this verifier is entitled to answer about. Same defect class as last round's C7, which is now three instances of one pattern in Recheck's safety argument: 'the argument cannot be obtained except by verifying' is true and says nothing about WHICH verifier verified it. 4051e438 also closes a monotonicity hole in PutApprovedBuild that core found itself: approve B at incarnation 5, then approve A again at 5, and every capability sealed to A at 5 that the move to B revoked is re-admitted. A swap-back at a fixed counter is the rewind the rule exists to prevent, through the field the rule did not cover. N-Q. This module had a SECOND definition of 'carries an execution' — it tested presence (seal.BuildIncarnation == nil) where core tests values. They agree for anything that survives Inspect, because the schema requires the pair, and they would disagree about a seal carrying a digest with no incarnation, which is the kind of case a protovalidate rule gets relaxed out from under. Core exports no predicate, so this adopts core's expression verbatim and names where it came from; core has been asked for one, and if it lands this becomes a call. Not verified: no service has booted against a real runtime. The two-audience hop test passes at the new pin, so nothing here was re-checking across verifiers — but that is the test suite's shape rather than proof about a consumer's. Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/README.md | 4 ++-- workcontext/go.mod | 2 +- workcontext/go.sum | 4 ++-- workcontext/mint.go | 22 +++++++++++++++++++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/workcontext/README.md b/workcontext/README.md index 015945f..8ae3676 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -804,8 +804,8 @@ root: ### This module merges on a pseudo-version, deliberately -`workcontext/go.mod` pins **`v0.7.2-0.20261003160146-4cb260d3d726`** — core at -`4cb260d3` — and not a release tag, because there is no tag to move to and will +`workcontext/go.mod` pins **`v0.7.2-0.20261003161926-4051e4383635`** — core at +`4051e438` — and not a release tag, because there is no tag to move to and will not be one before this merges. Core's `version/info.codefly.yaml` already says `0.8.0`, but its `version-tag.yml` cuts the tag from the **merge commit** of core#692 and core never tags by hand; core#692 is still open on two owner diff --git a/workcontext/go.mod b/workcontext/go.mod index 85f352c..1b5934f 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726 + github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 diff --git a/workcontext/go.sum b/workcontext/go.sum index 73c54bc..a0e7695 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726 h1:Fd+WKknvGRjfWgk1PT6X6rxx2Hn4e90hRzhBsqUlVRU= -github.com/codefly-dev/core v0.7.2-0.20261003160146-4cb260d3d726/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 h1:pUDBWRHLw3AEE2EJ8r1CtLKZNd5SvLuSoC0a5O3p2E4= +github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/mint.go b/workcontext/mint.go index 0663945..df3b017 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -1237,7 +1237,7 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential // is a host asserting an execution the issuer does not hold. The echo // is only for catching a host and a signature that disagree, but // "disagree" has to include "one of them says nothing". - if seal.BuildIncarnation == nil { + if !sealCarriesAnExecution(seal) { return Credential{}, fmt.Errorf( "%w: mint reported build incarnation %q and the capability seals no execution at all", ErrMintRefused, body.BuildIncarnation, @@ -1355,6 +1355,26 @@ func answeredFromElsewhere(response *http.Response, configured string) string { return response.Request.URL.Redacted() } +// sealCarriesAnExecution is CORE'S OWN TEST, copied expression for expression +// from checkExecutionAgainst (core workcontext/seal.go): +// +// carried := sealed.GetImageDigest() != "" || sealed.GetBuildIncarnation() != 0 +// +// This module had a second definition — it tested PRESENCE +// (seal.BuildIncarnation == nil) while core tests VALUES — and two definitions +// of "carries an execution" is one too many whether or not they currently +// agree. They agree for anything that survives Inspect, because the schema +// requires the pair; they would disagree about a seal carrying a digest with +// no incarnation, which is exactly the kind of case a protovalidate rule gets +// relaxed out from under. +// +// Core exports no predicate for this, so adopting its expression is the +// closest thing to calling it. Core has been asked for one; if it lands, this +// becomes a call and the duplication goes. +func sealCarriesAnExecution(seal *SealedValues) bool { + return seal.GetImageDigest() != "" || seal.GetBuildIncarnation() != 0 +} + // refusalStatuses are the ONLY statuses that latch this client. // // The rule used to be the other way round — 429 and 5xx were retryable and From 1d6947d29b6235341fde5279b21125c94416cb4c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:06:49 -0400 Subject: [PATCH 21/35] fix(workcontext)!: a function call is not a type, a frozen struct is a contract, and a hash family is not a hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round five, layer 4 — EXECUTED. Every round-four fix held under execution, which is the first round that has been true, so what follows is only the new work. F1 and F5 were already closed in efaccef; the rest were not. F2. Three compiling root-module parsers of a real WorkContextV1 passed every gate. One of them — `var m proto.Message = &basev0.WorkContextV1{}` — was already caught by the interface rule. The other two shared a cause worth naming: resolveTypeName(dynamicpb.NewMessage(desc)) -> "…/dynamicpb#NewMessage" The resolver returned a FUNCTION'S NAME as though it were a type, so the destination was neither a capability nor an interface nor unresolvable and the root module's check said yes. It returns "" for every call but new(T) now, which makes it honest and routes both probes into the indirection allowlist. A conversion T(x) is lost with it; there are none at a codec call site here, and one would have to be argued. dynamicpb and protoregistry are also narrowed rather than banned, because the receipts replay path genuinely resolves a response type at runtime: the receipts files may resolve DESCRIPTORS and the method's own type, and nothing may build a message from a name it chose. Symbols measured from the tree. F3. THE ORIGINAL DEFECT CLASS, one level down. The codec rule checks the top-level type, so adding Echo *Claims `json:"echo,omitempty"` to mintRequest encoded a whole capability through the row that exists for two strings. Two rules now: no struct in this module carries a field whose type is one of core's Work messages, and the two codec-allowed structs have FROZEN field sets. They are an HTTP wire contract with the mint host, so a new field is a change to that contract and belongs in a reviewed diff with frozenStructFields updated in it. The freeze lives in the main test rather than the per-file inspector, because it is a claim about THIS TREE and not a rule for arbitrary source — a bypass probe naming itself mintRequest is not a change to the wire contract. F4. hkdf.Extract(sha512.New, …) and pbkdf2.Key(sha512.New, …) are HMAC and passed BOTH gates, because `crypto/sha512` sat in the shell script's "a hash is not a signature" skip list right beside the primitives. crypto/hkdf and crypto/pbkdf2 are banned, and the sha family is refused except sha256 held to the three files that digest — which is what the AST gate already did and the shell gate contradicted. The shell parser also stripped no comments, so `/* x */ import "crypto/ed25519"` anchored nothing. Comments are stripped before the keyword is looked for, and `import` is recognised anywhere on the line rather than at its start. F9. The detached mint used context.Background(), which drops the caller's VALUES along with its cancellation — a trace span, a request id, anything a RoundTripper in the caller's stack reads. context.WithoutCancel(caller) keeps them, which is what the comment claimed was happening. MUTATION SURVIVORS, one test each, all five now failing under their mutation: the Unavailable mapping for an unreachable re-check source (the one retryable termination, and "verified both ways" did not hold for it); SendHeader carrying what SetHeader queued rather than dropping it; the ceiling-above-core refusal; the EIO read class recovering after the fault clears; and the latched-refusal- beats-cancellation path. That last one is tested AT THE PREDICATE, and the reason is worth stating: the branch runs when a caller's ctx fires while it waits and the refusal landed during that wait, so both select branches are ready and which wins is the scheduler's. Driving it through Credential would pass for whichever branch it took. Calling answerCancelled asserts it instead of hoping for it — the same treatment the final-URL assertion got. ON go/types, which this review asked for twice: F2 and F3 are closed syntactically, and what remains needs it. A capability passed INTO one of the four allowlisted indirections is still invisible, because following a value across a call needs a type checker. That is stated at the check rather than implied. Adding golang.org/x/tools to a leaf module whose purpose is a small dependency set is a design decision for the owner, and I am not taking it inside a review round; it is the first thing I would do with a green light. Not verified: no service has booted against a real runtime. F8 is real and undocumented — grpc.SetSendCompressor and ClientSupportedCompressors type-assert gRPC's own transport stream and fail inside a guarded stream; it is now named in the wrapper's documentation rather than fixed, because fixing it means implementing an interface gRPC does not export. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check-one-implementation.sh | 39 ++-- workcontext/grpctransport/guarded_stream.go | 9 + .../grpctransport/round4_reproducers_test.go | 58 ++++++ workcontext/mint.go | 12 +- workcontext/one_implementation_test.go | 168 +++++++++++++++++- workcontext/round4_reproducers_test.go | 121 ++++++++++++- 6 files changed, 387 insertions(+), 20 deletions(-) diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index a169220..bc38af0 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -63,7 +63,7 @@ set -euo pipefail # included: crypto.Signer signs Ed25519 with no ed25519 import anywhere. # crypto/cipher and the block ciphers are here because a GMAC or a CMAC is a # MAC assembled from a cipher, which names no MAC. -primitives='^crypto$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' +primitives='^crypto$|^crypto/hkdf$|^crypto/pbkdf2$|^crypto/sha512$|^crypto/sha1$|^crypto/sha3$|^crypto/md5$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' # Second encodings of the message. protojson is a complete JSON encoding of a # protobuf message on its own, which is how the deleted implementation's payload # would come back without an encoding/json import. @@ -108,6 +108,11 @@ allowed() { # application/json. mime is banned otherwise because WordDecoder decodes # base64 with no base64 import. 'workcontext/mint.go|mime') return 0 ;; + # The two digests, and the receipts store's row digest. A hash is not a + # signature; a hand-rolled HMAC over one is the residue named in the header. + 'workcontext/cache_partition.go|crypto/sha256') return 0 ;; + 'receipts/digest.go|crypto/sha256') return 0 ;; + 'receipts/postgres.go|crypto/sha256') return 0 ;; # Effect receipts, whose rows are JSON and are not capabilities. 'receipts/'*'|encoding/json') return 0 ;; esac @@ -123,15 +128,19 @@ allowed() { # complete bypass. import_paths() { awk ' - # An import block opens wherever `import` is followed by `(` on the line, - # not only when the line IS `import (`. `import /* x */ (` and - # `import ("crypto/ed25519")` both opened a block the previous version did - # not recognise. - /^[[:space:]]*import/ && /\(/ { inblock = 1 } - /^[[:space:]]*import/ && !/\(/ { line = 1 } + # Comments are STRIPPED FIRST, so a comment before or inside the keyword + # cannot hide it: `/* x */ import "crypto/ed25519"` anchored nothing when + # the match required `import` at the start of the line. + { stripped = $0 + gsub(/\/\*[^*]*\*\//, " ", stripped) + sub(/\/\/.*$/, "", stripped) } + # An import block opens wherever `import` is followed by `(`, not only when + # the line IS `import (`. + stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped ~ /\(/ { inblock = 1 } + stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped !~ /\(/ { line = 1 } inblock && /\)/ { closing = 1 } (inblock || line) { - rest = $0 + rest = stripped # EVERY quoted string on the line, not the first. The previous version # read one, so `/* "fmt" */ "crypto/ed25519"` reported fmt and stopped — # and `;`-joined specs on one line hid everything after the first. @@ -163,9 +172,17 @@ offending_imports() { allowed "$path" "$imported" || printf '%s\n' "$imported" continue ;; - 'crypto/sha256'|'crypto/md5'|'crypto/sha1'|'crypto/sha512'|'crypto/rand') - # A hash is not a signature and randomness is not a key. The residue — - # a hand-written HMAC over an allowed hash — is named in the header. + 'crypto/rand') + # Randomness is not a key. + continue + ;; + 'crypto/sha256') + # The ONE hash with uses here — two digests — and held to them by the + # AST gate's file and symbol rule. The others are not waved through: + # hkdf.Extract(sha512.New, …) and pbkdf2.Key(sha512.New, …) are HMAC, + # and both passed this script while `crypto/sha512` sat in the "a hash + # is not a signature" list beside it. + allowed "$path" "$imported" || printf '%s\n' "$imported" continue ;; esac diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index ed88af4..19f84ab 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -56,6 +56,15 @@ import ( // ORIGINAL stream from outside the interceptor, and SendHeader's own flush — // once a header frame is on the wire no later revocation recalls it, which is // what SendHeader means. +// +// # What does not work inside a guarded stream +// +// grpc.SetSendCompressor and grpc.ClientSupportedCompressors type-assert the +// context's transport stream to gRPC's own unexported implementation, so they +// FAIL here rather than being guarded. That is a real limitation and not a +// safety property: nothing leaks, a handler that needs per-call compression +// cannot have it. Fixing it means implementing an interface gRPC does not +// export, so it is written down instead. type GuardedServerStream struct { stream grpc.ServerStream guard *workcontext.StreamGuard diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go index 04b1e7c..1a7c90d 100644 --- a/workcontext/grpctransport/round4_reproducers_test.go +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -412,3 +412,61 @@ func TestARecheckThatWritesToTheStreamIsRefusedRatherThanDeadlocking(t *testing. require.NoError(t, reader.SendMsg("x")) require.Empty(t, seen, "a bare recordingStream carries no transport stream, so there is no method to read") } + +// Round five's two survivors in this package. + +// The UNAVAILABLE mapping (guarded_stream.go). "Mutation-verified both ways" +// did not hold for this branch: a termination whose re-check SOURCE failed — +// neither revoked nor replayed nor malformed, just unreachable — must reach the +// client as codes.Unavailable, because that is the one case where retrying the +// same capability is the right answer. Mapped to Unknown, a client cannot tell +// it from a server bug. +func TestARecheckSourceFailureReachesTheClientAsUnavailable(t *testing.T) { + unreachable := errors.New("the seal source did not answer") + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return unreachable }, + }) + require.NoError(t, err) + + connection := serveWith(t, StreamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("never")) + }) + + _, _, err = clientSaw(t, connection) + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err), + "a re-check source that could not be reached is the one retryable termination") +} + +// SendHeader must CARRY what SetHeader queued, not drop it. The wrapper holds +// queued headers, so a SendHeader that sent only its own argument would +// silently discard everything a handler had set — and no test noticed. +func TestSendHeaderCarriesWhatSetHeaderQueued(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SetHeader(metadata.Pairs("queued", "one")); err != nil { + faults.record("SetHeader: %v", err) + } + if err := stream.SetHeader(metadata.Pairs("queued-two", "two")); err != nil { + faults.record("SetHeader: %v", err) + } + // SendHeader flushes, and must include both of the above. + if err := stream.SendHeader(metadata.Pairs("sent", "three")); err != nil { + faults.record("SendHeader: %v", err) + } + return nil + }) + + header, _, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, []string{"one"}, header.Get("queued"), + "SendHeader dropped a header SetHeader had queued") + require.Equal(t, []string{"two"}, header.Get("queued-two")) + require.Equal(t, []string{"three"}, header.Get("sent")) +} diff --git a/workcontext/mint.go b/workcontext/mint.go index df3b017..a065809 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -776,7 +776,7 @@ func (c *MintClient) obtain( c.refreshWanted = true } c.mu.Unlock() - go c.mintInto(done) + go c.mintInto(ctx, done) select { case <-done: @@ -795,9 +795,15 @@ func (c *MintClient) obtain( // what makes the request terminate, and detached from the caller that started // it, which is what makes the result arrive whether or not that caller is still // interested. -func (c *MintClient) mintInto(done chan struct{}) { +func (c *MintClient) mintInto(caller context.Context, done chan struct{}) { + // WithoutCancel(caller), not Background(). Detaching from the caller's + // CANCELLATION is the whole point; detaching from its VALUES throws away + // anything the caller put there for the request to carry — a trace span, a + // request id, whatever an http.RoundTripper in the caller's stack reads. + // Background() dropped all of it silently, which is a different thing from + // what the comment said this was doing. ctx, cancel := context.WithTimeout( - context.WithoutCancel(context.Background()), c.options.RequestTimeout, + context.WithoutCancel(caller), c.options.RequestTimeout, ) defer cancel() credential, err := c.mintOnce(ctx) diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index 496f83a..22048e6 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -65,6 +65,9 @@ var ( // inspected. Nothing here has any use for them. "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", + // dynamicpb and protoregistry are NOT here: the receipts replay path + // genuinely resolves a response type at runtime. They are held to + // files and symbols in narrowedImports instead. // The LEGACY protobuf module, whose proto.Marshal and proto.Unmarshal // are the same capability under a different path. Uncovered by every // rule that named the new one. @@ -74,8 +77,11 @@ var ( // symbols; these have no use at all, so they are simply refused rather // than left "not a signature, therefore fine". "crypto/sha512", "crypto/sha1", "crypto/sha3", "crypto/md5", - // HKDF is HMAC with a label on it. - "crypto/hkdf", + // HKDF is HMAC with a label on it, and PBKDF2 is HMAC in a loop. Both + // were reproduced taking sha512.New as the hash, which is why the + // sha* family is refused rather than waved through as "not a + // signature". + "crypto/hkdf", "crypto/pbkdf2", } // envelopeDecoders are banned EXCEPT where a file is allowlisted below. A @@ -155,6 +161,34 @@ var narrowedImports = map[string]struct { }, symbols: []string{"Sum256", "New", "Size"}, }, + // DYNAMIC MESSAGES AND THE TYPE REGISTRY, which the receipts replay path + // genuinely needs: a recorded response is decoded into whatever type the + // method answers with, resolved at runtime. + // + // They are also how a capability is decoded with its Go type appearing + // nowhere. dynamicpb.NewMessage(desc) builds a message from a descriptor, + // and protoregistry resolves one by NAME — and the name can be assembled + // from pieces ("codefly.base.v0.Work" + "ContextV1"), so no string + // matches either. Both were reproduced against a real WorkContextV1 in + // the root module. + // + // So: the receipts files may resolve DESCRIPTORS and the method's own + // type, and nothing may build a message from a name it chose. The symbols + // are measured from the tree. + "google.golang.org/protobuf/reflect/protoregistry": { + files: []string{ + "receipts/interceptor.go", + "receipts/internal/fixture/fixture.go", + }, + symbols: []string{"Files", "GlobalFiles", "Types", "GlobalTypes"}, + }, + "google.golang.org/protobuf/types/dynamicpb": { + // One file, and only NewMessageType — which needs a descriptor the + // caller already holds. NewMessage, the route the probe used, is + // refused everywhere. + files: []string{"receipts/internal/fixture/fixture.go"}, + symbols: []string{"NewMessageType"}, + }, // The receipts digest canonicalises a receipt REQUEST — never a capability // — and protojson is how it reaches a stable field order. The capability // rule is what keeps it off a Work Context message. @@ -384,6 +418,8 @@ func TestNoSecondWorkContextImplementation(t *testing.T) { require.True(t, paths[allowed], "the json allowlist names %q, which is not a file in this module", allowed) } + assertFrozenStructShapes(t, files) + require.Contains(t, jsonAllowlist, mintEndpointJSONFile, "the file allowed to carry json TAGS must be the file allowed to IMPORT encoding/json; "+ "two lists that can drift are two lists") @@ -435,9 +471,122 @@ func inspectForSecondImplementation(file sourceFile) []string { findings = append(findings, inspectCoreAliases(file, coreImports)...) findings = append(findings, inspectEnvelopeDecoding(file, base64Names)...) findings = append(findings, inspectProtoMethodsRoute(file)...) + findings = append(findings, inspectCapabilityFields(file)...) + return findings +} + +// frozenStructFields is the EXACT field set of each struct a codec is allowed +// to touch, by file and type name. +// +// The codec rule checks the TOP-LEVEL type only, which is the original defect +// class coming back one level down: adding +// +// Echo *Claims `json:"echo,omitempty"` +// +// to mintRequest made json.Marshal(mintRequest{Echo: claims}) encode a whole +// capability through the row that exists for two strings — reproduced, and +// green. The same went for a *Claims field on mintResponse. +// +// Freezing the field sets is stronger than chasing field types, and it is +// honest about what these two structs are: an HTTP wire contract with a host. +// A field added to either is a change to that contract and belongs in a +// reviewed diff, not in a struct literal. +var frozenStructFields = map[string]map[string][]string{ + "workcontext/mint.go": { + "mintRequest": {"Audience string", "ProjectionAudience string"}, + "mintResponse": {"WorkContext string", "InstallationID string", "BuildIncarnation string"}, + }, +} + +// inspectCapabilityFields refuses any struct carrying a field whose type is +// one of core's Work messages. +// +// A codec reaching the struct then reaches the capability through it, whatever +// the allowlist said about the outer type — which is the hand-enumerated +// payload this module deleted, one level down. +func inspectCapabilityFields(file sourceFile) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := spec.Type.(*ast.StructType) + if !ok || structure.Fields == nil { + return true + } + // THE GENERAL RULE: no struct anywhere in this module carries a + // capability as a field. A codec reaching the struct then reaches the + // capability, whatever the allowlist said about the outer type. + for _, field := range structure.Fields.List { + named := typeName(file, field.Type) + if isCapabilityType(named) { + findings = append(findings, fmt.Sprintf( + "%s declares %s with a field of type %s.\n"+ + "A struct carrying one of core's Work messages is a capability wearing another\n"+ + "type's name: a codec allowed to touch the struct reaches the capability through\n"+ + "it, which is the hand-enumerated payload this module deleted, one level down.", + file.path, spec.Name.Name, named)) + } + } + return true + }) return findings } +// assertFrozenStructShapes holds this repository's two codec-allowed structs to +// their exact field sets. +// +// It is a claim about THIS TREE rather than a rule for arbitrary source, so it +// runs over the repository's own files and not inside the per-file inspector +// the bypass probes drive — a synthetic probe naming itself mintRequest is not +// a change to the wire contract. +func assertFrozenStructShapes(t *testing.T, files []sourceFile) { + t.Helper() + seen := map[string]map[string][]string{} + for _, file := range files { + frozen, ok := frozenStructFields[file.path] + if !ok { + continue + } + seen[file.path] = map[string][]string{} + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := spec.Type.(*ast.StructType) + if !ok || structure.Fields == nil { + return true + } + if _, isFrozen := frozen[spec.Name.Name]; !isFrozen { + return true + } + var actual []string + for _, field := range structure.Fields.List { + for _, name := range field.Names { + actual = append(actual, name.Name+" "+typeName(file, field.Type)) + } + } + seen[file.path][spec.Name.Name] = actual + return true + }) + } + for path, frozen := range frozenStructFields { + for name, expected := range frozen { + require.Equal(t, expected, seen[path][name], + "%s: %s is a codec-allowed struct and its field set is FROZEN.\n"+ + "The codec rule names the TYPE, so a new field is a new thing encoded through a\n"+ + "row that exists for the fields listed — adding `Echo *Claims` to mintRequest\n"+ + "encoded a whole capability through a row for two strings, and was green. These\n"+ + "two structs are an HTTP wire contract with the mint host: a change to them is a\n"+ + "change to that contract, and belongs in a reviewed diff with\n"+ + "frozenStructFields updated in it.", + path, name) + } + } +} + // inspectProtoMethodsRoute refuses a message's own marshal and unmarshal // methods, reached through protobuf reflection. // @@ -2015,8 +2164,19 @@ func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string if function, ok := typed.Fun.(*ast.Ident); ok && function.Name == "new" && len(typed.Args) == 1 { return typeName(file, typed.Args[0]) } - // A conversion: T(x) names T; anything else is unknown. - return typeName(file, typed.Fun) + // EVERY OTHER CALL IS UNKNOWN, and this used to return the function's + // own name as though it were a type. + // + // That lie was a bypass: dynamicpb.NewMessage(desc) resolved to + // "…/dynamicpb#NewMessage", which is neither a capability nor an + // interface nor empty, so the root module's check said yes — and a + // reviewer unmarshalled a real WorkContextV1 through it. The same went + // for protoregistry.GlobalTypes.FindMessageByName(...).New(). + // + // A conversion T(x) is lost with it. That is the right trade: a + // conversion at a codec call site now needs the indirection + // allowlist, and there are none in this repository. + return "" case *ast.Ident: return declaredTypeName(file, typed.Name, use) } diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index 710aa90..6cf55ea 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -366,9 +366,12 @@ type localFault struct{} func (*localFault) Error() string { return "too many open files" } // faultySource is a caller's own ProjectedTokenSource failing. -type faultySource struct{ err error } +type faultySource struct { + err error + token string +} -func (s *faultySource) ProjectedToken() (string, error) { return "", s.err } +func (s *faultySource) ProjectedToken() (string, error) { return s.token, s.err } // N1, the layer-4 review's new finding: a CANCELLED LEADER must be served the // valid credential it is holding, exactly as a cancelled waiter is. @@ -575,3 +578,117 @@ func TestAnEchoedIncarnationStillCrossChecksByValue(t *testing.T) { require.ErrorIs(t, err, ErrMintRefused) require.ErrorContains(t, err, "sealed 11") } + +// Round five's mutation survivors, one test each. Every one of these passed +// with the behaviour its name claims removed. + +// The ceiling-above-core refusal (mint.go:481) had no test: a configured +// MaxCredentialLifetime above core's absolute ceiling is refused at +// construction rather than silently clamped, so a deployment that asked for a +// window nothing will mint finds out now instead of at the first mint. +func TestAConfiguredCeilingAboveCoresIsRefusedAtConstruction(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + + _, err := NewMintClient(MintOptions{ + URL: host.server.URL, + Authority: host.pin, + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: clock.now, + RootCAs: certPoolOf(host.server), + MaxCredentialLifetime: corework.MaxTTLCeiling + time.Second, + }) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "above core's absolute ceiling") + + // Exactly at it is accepted, which is the boundary that matters because + // the DEFAULT is that constant. + _, err = NewMintClient(MintOptions{ + URL: host.server.URL, + Authority: host.pin, + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: clock.now, + RootCAs: certPoolOf(host.server), + MaxCredentialLifetime: corework.MaxTTLCeiling, + }) + require.NoError(t, err) +} + +// A LATCHED REFUSAL BEATS A CANCELLATION (mint.go:871). A caller that gave up +// while the host was refusing must be told it is refused, not told it was +// cancelled: the refusal is terminal and the cancellation is not, and a caller +// that reads only its own ctx error retries forever against a host that has +// already said no. +func TestALatchedRefusalIsReportedEvenToACallerThatGaveUp(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusForbidden + release := make(chan struct{}) + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(ctx) + require.Error(t, err) + close(release) + + require.Eventually(t, func() bool { return client.Refused() != nil }, + 5*time.Second, time.Millisecond) + + // The next caller cancels too, and still learns it is REFUSED rather than + // only that it cancelled. This one is answered at the top of the loop. + cancelled, stop := context.WithCancel(context.Background()) + stop() + _, err = client.Credential(cancelled) + require.ErrorIs(t, err, ErrMintRefused, + "a cancelled caller was told it was cancelled by a client that is permanently refused") + require.NotErrorIs(t, err, ErrMintUnavailable) + + // AND THE RACE, tested at the predicate because it cannot be forced + // through the public API. + // + // answerCancelled runs when a caller's ctx fires while it is WAITING on + // the in-flight mint. If the refusal latched during that wait, both select + // branches are ready at once and which one wins is the scheduler's + // business — so driving this through Credential would be a test that + // passes for whichever branch it happened to take. Calling the predicate + // is how the branch gets asserted instead of hoped for, the same way the + // final-URL assertion is. + ctxDone, done := context.WithCancel(context.Background()) + done() + _, raced := client.answerCancelled(ctxDone, mintReasonRenewal, nil) + require.ErrorIs(t, raced, ErrMintRefused, + "a refusal that landed while this caller waited was reported as a cancellation") +} + +// The EIO READ CLASS: a projected-token read that fails partway is an outage, +// distinct from a file that is absent. Both recover; neither latches. +func TestAProjectedTokenReadFailureIsAnOutageAndRecovers(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &faultySource{err: errSyntheticLocalFault} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = source }) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + + // The fault clears and the clock passes the hold-off: the client mints. + source.err = nil + source.token = "projected" + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "a local read failure permanently stopped the client") + require.NotEmpty(t, credential.Token()) +} From 04e93c0d0eadf5ec335ff55c43dd69f7b4dca73f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:14:07 -0400 Subject: [PATCH 22/35] fix(workcontext)!: a gate that could not look reported success, and a pointer was a disguise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round six, layer 2 (static, whole thread + diff at 1d6947d). Nine findings, each reproduced against the real thing before being fixed, each fix mutation-verified. Nineteen mutations, nineteen killed — two survived on the first pass and are discussed below. The pattern is now four rounds old and worth naming at the top: the defects are in the code written to fix the previous review. Three of B1's four fail-opens live INSIDE the assertion added for round five's blocker. B3's pointer alias walks past round four's qualifier fix. B4's embedded field walks past round five's field freeze. B1 (blocker) THE RETIREMENT AUDIT FAILED OPEN, FOUR WAYS. - `git ls-remote … 2>/dev/null` plus `[ ${#published[@]} -gt 0 ]` meant a FAILED listing produced an empty denominator and skipped the comparison. Measured, with the remote unreachable and one ref fetched: "the remote publishes 0 ref(s); sweeping 0 of them", "this repository publishes no ref other than main", exit 0 — the same false green the glob produced, through the guard added to stop it. - The comparison was between COUNTS. A remote that deleted one branch and published another left a stale local ref and a missing new one: two each side, green, and the branch carrying the implementation never opened — `ok origin/carries/the/implementation`, exit 0. - `git cat-file blob … 2>/dev/null || : > "$tmp.src"` swept an unreadable entry as a clean one. `git ls-tree -r` names gitlinks, so a submodule whose path ends `.go` is listed and cannot be read: `ok gitlinked`, exit 0. - And the first fix for that failed open too: its `exit 1` ran in the subshell of `"$(carrying_in_ref "$ref")"`, so the script printed FAIL and then `ok` underneath. Measured, and both halves are now asserted. Every enumeration's exit status is read; the comparison is between ref SETS; an empty denominator is itself a failure. B2 (major) THE SCANNER, AND TWO BAN LISTS THAT DISAGREED. The closing paren was read off the RAW line, so `// )` inside an import block closed it early. The two quoting styles were scanned in two sequential passes, so a raw-string path BEFORE a quoted one was dropped. Both compile, both pass `go vet`, both swept clean. One left-to-right pass now, over the comment-stripped line. And the lists disagreed in both directions: the legacy protobuf module, `protoiface`, `protoimpl` and a vendored path ending `/ed25519` swept clean in the module where the sweep is the ONLY gate; the cipher family was banned by the sweep and not by the gate that reads the module the capability lives in. Both lists are entry-for-entry, and a test parses the script's own three expressions and fails when they differ — two hand-maintained lists are two lists. B3 (major) A POINTER WAS A DISGUISE. `typeName` returned a local identifier as written, so type carrier = *basev0.WorkContextV1 var c carrier = &basev0.WorkContextV1{} proto.Unmarshal(raw, c) resolved to "carrier": not a capability, not a codec interface, not unresolvable, therefore permitted by the root module's one rule. The VALUE alias was caught, and only by inspectCoreAliases, whose check read spec.Type as a bare selector — so one `*` walked past both rules at once. Local names are chased to what they name; the alias rule unwraps pointers and slices and covers the defined-type spelling too. B4 (major) AN EMBEDDED FIELD WAS NOT A FIELD. The freeze walked `field.Names` and an embedded field has none, so an embedded struct added to `mintRequest` left the frozen set byte-identical with no finding anywhere — and `encoding/json` promotes its exported fields into the enclosing object. `assertFrozenStructShapes` is now a pure `frozenStructViolations` with a thin require wrapper, because a require-based rule cannot be probed: passing it a fresh testing.T makes FailNow Goexit the prober, which is how this went three rounds unnoticed. B5 (major) RequestTimeout DID NOT BOUND THE DETACHED ATTEMPT. `ProjectedTokenSource` takes no context, so the read in front of the request was bounded by nothing. Measured with a blocking source and RequestTimeout at 150ms: 3.0s, bounded by the CALLER — and with no caller deadline it never returns, so `done` is never closed, every later caller waits on it, and no hold-off is installed. mintInto's own comment claimed the opposite. Now 152ms and ErrMintUnavailable. B6 (major) ENDPOINT TRUST WAS OPTIONAL, AND SILENTLY THE SYSTEM POOL. Every other route to a weak transport was closed — no supplied client, no reachable Transport, TLS 1.3 floor, no followed redirect — and the anchor was whatever the image shipped. `RootCAs` or `TrustSystemRoots`, exactly one, or ErrInvalid at construction. BREAKING: a MintOptions with neither no longer constructs. The system pool is still available and is now a sentence somebody wrote. B7 (major) EXPIRY WAS REPORTED AS A SERVER BUG. core answers an expired capability with ErrInvalid, and statusFor mapped every ErrInvalid to codes.Internal. The argument for that was sound and applied to the wrong side: a stream outliving its credential is the ordinary mid-stream refusal and the one a client answers by minting again. The distinction the comment called unavailable was available all along — every misuse error here is constructed here, so it carries errStreamMisuse, which wraps ErrInvalid and is checked first and stays codes.Internal. B8 (minor) A HEADER SET AFTER THE FIRST MESSAGE VANISHED SILENTLY. `releaseHeaders` returned early on an empty queue without marking flushed, and gRPC sends the header frame with the first message regardless. So a later SetHeader was held, found !flushed, and was handed to gRPC by Finish, which ignores it. Measured: nil error, no header, handler told nothing. Now it forwards and the handler gets gRPC's own "SendHeader called multiple times". B9 (minor) a cleanup that explained itself by repeating what it removed. The product name is gone from fixture_test.go's comment; the assertion is unchanged and still pins the module name and "pinned". TESTS DOUBTED, all five correctly. - `received := 0; require.Zero(received)` was a tautology standing where the file's central assertion belongs, because `clientSaw` dropped the count. It returns it now, and `drain` delegates to it — there were two helpers opening the same stream two ways, one counting and never reading the header, the other the reverse, which is how the local variable got written. - The EIO test drove a caller's own source and never reached ProjectedTokenFile's own read failure. A directory reaches it exactly: open succeeds, ReadAll returns EISDIR. - TestCoreRefusesAnOverCeilingLifetimeInTheClientsOwnReadPath asserted no refusal — a sound fixture inspects, two constants are equal. Renamed to what it asserts. The refusal it was named for cannot be driven from here and the reason is the one-implementation rule, not an omission: core refuses an over-ceiling lifetime at the MINT, so producing one to feed the read path means assembling a capability by hand. - real_grpc_stream_test.go now says what it establishes — the TRANSPORT's behaviour, with a synthetic authority — and what it does not: that core's own refusals reach the guard, which the conformance suite drives, and anything about the client-streaming direction. TWO MUTATIONS SURVIVED FIRST TIME, both B3's, and for the round-four reason: the fix was two coupled edits and a probe through the whole gate is satisfied by either. Each rule is now asserted at the rule — typeName resolves a chained pointer alias to core's path, inspectCoreAliases refuses all four spellings — and both mutations die. Verified: both modules' suites and -race clean, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (58 files), coverage 91.9% workcontext / 79.7% root. Docs updated in the same diff: workcontext/README.md (the trust anchor, the status mapping) and workcontext/AGENTS.md (five new gate rules). NOT VERIFIED: nothing here ran against a real runtime or a real mint endpoint. The gitlink reproducer uses a submodule entry as the portable way to produce an unreadable blob; the class is any entry ls-tree names and cat-file cannot read. The gate remains syntactic by choice, so a capability passed INTO one of the four allowlisted indirections is still outside it — that needs go/types and the owner's green light on golang.org/x/tools. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- fixture_test.go | 12 +- scripts/check-one-implementation.sh | 114 ++++-- scripts/sweep-published-refs.sh | 64 ++- sweep_bypasses_test.go | 186 +++++++++ workcontext/AGENTS.md | 69 +++- workcontext/README.md | 42 +- workcontext/grpctransport/guarded_stream.go | 88 +++- .../grpctransport/real_grpc_stream_test.go | 48 +-- .../grpctransport/round4_reproducers_test.go | 32 +- .../grpctransport/round6_reproducers_test.go | 93 +++++ workcontext/layer4_reproducers_test.go | 12 +- workcontext/mint.go | 94 ++++- workcontext/mint_test.go | 10 + workcontext/one_implementation_test.go | 153 ++++++- workcontext/round4_reproducers_test.go | 58 ++- workcontext/round6_reproducers_test.go | 384 ++++++++++++++++++ 16 files changed, 1315 insertions(+), 144 deletions(-) create mode 100644 workcontext/grpctransport/round6_reproducers_test.go create mode 100644 workcontext/round6_reproducers_test.go diff --git a/fixture_test.go b/fixture_test.go index c5ae614..e8125d0 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -117,11 +117,13 @@ func TestFixturePrincipalNamesPinnedModulesItCannotRead(t *testing.T) { require.ErrorIs(t, err, composition.ErrUnknownFixture) // The MODULE NAME and the fact that it is pinned, not merely some - // substring of the error. When the product names were removed from this - // file the assertion went from "saas-starter" to "starter", which is also - // a substring of the module's own source "example-org/module-starter" — so - // it stopped pinning which name the error has to carry. Generic fixtures - // were the point; a weaker assertion was not. + // substring of the error. Making this file generic shortened the expected + // name to "starter", which is also a substring of the module's own source + // "example-org/module-starter" — so the assertion stopped pinning WHICH + // name the error has to carry, and would have passed on the source alone. + // Generic fixtures were the point; a weaker assertion was not. The name + // that was here before is deliberately not quoted: a cleanup that explains + // itself by repeating what it removed has not removed it. assert.Contains(t, err.Error(), "starter", "the error must name the module whose fixtures could not be read") assert.Contains(t, err.Error(), "pinned", diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index bc38af0..e9a2c47 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -63,11 +63,18 @@ set -euo pipefail # included: crypto.Signer signs Ed25519 with no ed25519 import anywhere. # crypto/cipher and the block ciphers are here because a GMAC or a CMAC is a # MAC assembled from a cipher, which names no MAC. -primitives='^crypto$|^crypto/hkdf$|^crypto/pbkdf2$|^crypto/sha512$|^crypto/sha1$|^crypto/sha3$|^crypto/md5$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca' +primitives='^crypto$|^crypto/hkdf$|^crypto/pbkdf2$|^crypto/sha512$|^crypto/sha1$|^crypto/sha3$|^crypto/md5$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca|ed25519' # Second encodings of the message. protojson is a complete JSON encoding of a # protobuf message on its own, which is how the deleted implementation's payload # would come back without an encoding/json import. -encoders='encoding/protojson|encoding/protowire|known/anypb|^encoding/gob$|^encoding/asn1$|^encoding/xml$' +# The LOW-LEVEL protobuf runtime and the LEGACY protobuf module belong here as +# well, and did not until the two gates were compared entry by entry: the AST +# gate banned protoiface, protoimpl and github.com/golang/protobuf/proto while +# this script passed all three — measured, `ok working tree (4 Go files)`, +# exit 0 — in the module where this script is the only gate. A message's own +# ProtoMethods().Unmarshal is a complete decode naming no codec package, and the +# legacy module's proto.Unmarshal is the same capability at another path. +encoders='encoding/protojson|encoding/protowire|known/anypb|^encoding/gob$|^encoding/asn1$|^encoding/xml$|runtime/protoiface|runtime/protoimpl|^github.com/golang/protobuf/proto$' # Opening a credential's envelope by hand. base64 plus proto.Unmarshal is the # whole of a second parser, and the AST gate reaches only the workcontext module # — a compiling root package with a base64/JSON credential decoder and its own @@ -138,22 +145,35 @@ import_paths() { # the line IS `import (`. stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped ~ /\(/ { inblock = 1 } stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped !~ /\(/ { line = 1 } - inblock && /\)/ { closing = 1 } + # THE CLOSING PAREN IS READ OFF THE STRIPPED LINE, not off $0. Reading it + # off the raw line meant a `)` INSIDE A COMMENT closed the block early, so + # + # import ( + # // ) + # "crypto/ed25519" + # ) + # + # compiled, passed `go vet`, and swept clean — measured, exit 0. One + # character of comment was the whole bypass, in the root module where this + # script is the only gate. + inblock && stripped ~ /\)/ { closing = 1 } (inblock || line) { rest = stripped - # EVERY quoted string on the line, not the first. The previous version - # read one, so `/* "fmt" */ "crypto/ed25519"` reported fmt and stopped — - # and `;`-joined specs on one line hid everything after the first. - while (match(rest, /"[^"]+"/)) { - print substr(rest, RSTART + 1, RLENGTH - 2) - rest = substr(rest, RSTART + RLENGTH) - } - # Raw-string paths too. gofmt rewrites them, and CI enforces no gofmt, - # so the sweep reads what is committed rather than what gofmt would have - # written. - while (match(rest, /`[^`]+`/)) { - print substr(rest, RSTART + 1, RLENGTH - 2) - rest = substr(rest, RSTART + RLENGTH) + # EVERY quoted path on the line, of EITHER quoting, in ONE left-to-right + # pass. Two sequential loops — all the "…" first, then the `…` in what + # was left — dropped a raw path that came BEFORE a quoted one on the same + # line: `import (`crypto/ed25519`; "fmt")` reported only fmt. Also + # measured, also compiling, also exit 0. A scanner that reads the same + # line twice in two orders is reading two different lines. + while (1) { + q = match(rest, /"[^"]+"/); qs = RSTART; ql = RLENGTH + if (!q) { qs = 0 } + r = match(rest, /`[^`]+`/); rs = RSTART; rl = RLENGTH + if (!r) { rs = 0 } + if (!qs && !rs) { break } + if (qs && (!rs || qs < rs)) { start = qs; len = ql } else { start = rs; len = rl } + print substr(rest, start + 1, len - 2) + rest = substr(rest, start + len) } line = 0 } @@ -201,7 +221,11 @@ offending_imports() { } carrying_in_tree() { - local found="" tmp + local found="" tmp tracked + if ! tracked=$(git ls-files -- '*.go'); then + echo "FAIL cannot enumerate the tracked Go files in this checkout." >&2 + exit 1 + fi tmp=$(mktemp) while IFS= read -r path; do case "$path" in @@ -209,7 +233,12 @@ carrying_in_tree() { *.go) ;; *) continue ;; esac - import_paths "$path" > "$tmp" 2>/dev/null || : > "$tmp" + # A READ THAT FAILS IS NOT A FILE WITH NO IMPORTS. `|| : > "$tmp"` emptied + # the extraction on any error and then swept the empty result as clean. + if ! import_paths "$path" > "$tmp"; then + echo "FAIL cannot read the imports of $path; a file this sweep cannot parse is not a file it has cleared." >&2 + exit 1 + fi local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then @@ -217,13 +246,20 @@ carrying_in_tree() { found="$found$path imports $imported"$'\n' done <<< "$bad" fi - done <<< "$(git ls-files -- '*.go')" + # `done <<< "$(git ls-files …)"` discards git's exit status: a failed + # enumeration becomes an empty one and the sweep reports a clean tree. The + # listing is taken first so the failure is a failure. + done <<< "$tracked" rm -f "$tmp" printf '%s' "$found" } carrying_in_ref() { - local ref="$1" found="" tmp + local ref="$1" found="" tmp entries + if ! entries=$(git ls-tree -r --name-only "$ref"); then + echo "FAIL cannot enumerate $ref, so it has not been swept." >&2 + exit 1 + fi tmp=$(mktemp) while IFS= read -r path; do case "$path" in @@ -231,8 +267,22 @@ carrying_in_ref() { *.go) ;; *) continue ;; esac - git cat-file blob "$ref:$path" > "$tmp.src" 2>/dev/null || : > "$tmp.src" - import_paths "$tmp.src" > "$tmp" 2>/dev/null || : > "$tmp" + # THE SAME FAIL-OPEN, and this one was reachable without any error at all. + # `git ls-tree -r` names gitlinks as well as blobs, so a submodule whose + # path ends in `.go` is listed and `git cat-file blob` cannot read it: + # measured on a throwaway ref, `fatal: bad file` was swallowed by + # `2>/dev/null`, the source was emptied, and the ref was reported `ok` with + # exit 0. Anything this sweep cannot open is a failure to look at, not a + # finding of nothing. + if ! git cat-file blob "$ref:$path" > "$tmp.src" 2>"$tmp.err"; then + echo "FAIL cannot read $ref:$path, so this ref has not been swept. git said:" >&2 + sed 's/^/ /' "$tmp.err" >&2 + exit 1 + fi + if ! import_paths "$tmp.src" > "$tmp"; then + echo "FAIL cannot read the imports of $ref:$path; a file this sweep cannot parse is not a file it has cleared." >&2 + exit 1 + fi local bad bad=$(offending_imports "$path" "$tmp") if [ -n "$bad" ]; then @@ -240,8 +290,8 @@ carrying_in_ref() { found="$found$path imports $imported"$'\n' done <<< "$bad" fi - done <<< "$(git ls-tree -r --name-only "$ref")" - rm -f "$tmp" "$tmp.src" + done <<< "$entries" + rm -f "$tmp" "$tmp.src" "$tmp.err" printf '%s' "$found" } @@ -266,14 +316,26 @@ if [ "$#" -eq 0 ]; then echo "no Go files tracked here: the sweep would pass for an empty repository" >&2 exit 1 fi - report "working tree ($files Go files)" "$(carrying_in_tree)" + # THE SUBSTITUTION'S EXIT STATUS IS READ. `report … "$(carrying_in_tree)"` threw + # it away: a fatal `exit 1` inside the function ran in the command + # substitution's SUBSHELL, so the script carried on, report saw no findings, + # and the ref was announced `ok` with exit 0 — measured, with the FAIL text + # printed directly above the `ok`. A fix that reports its own failure and then + # passes is the defect it was fixing. + if ! carrying=$(carrying_in_tree); then + exit 1 + fi + report "working tree ($files Go files)" "$carrying" else for ref in "$@"; do git rev-parse --verify --quiet "$ref" >/dev/null || { echo "no such ref: $ref" >&2 exit 1 } - report "$ref" "$(carrying_in_ref "$ref")" + if ! carrying=$(carrying_in_ref "$ref"); then + exit 1 + fi + report "$ref" "$carrying" done fi diff --git a/scripts/sweep-published-refs.sh b/scripts/sweep-published-refs.sh index ada893b..8172f6f 100755 --- a/scripts/sweep-published-refs.sh +++ b/scripts/sweep-published-refs.sh @@ -38,6 +38,15 @@ base="${SWEEP_BASE:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" head_ref="${SWEEP_HEAD:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}}" # Every remote-tracking ref, by PREFIX so namespaced refs are not dropped. +# Read into a file first: a process substitution's exit status is not the shell's, +# so `done < <(git for-each-ref ...)` reports success for a git that failed. +listing="" +local_refs="$(mktemp)" +trap 'rm -f "$local_refs" ${listing:+"$listing" "$listing.err"}' EXIT +if ! git for-each-ref --format='%(refname:short)' refs/remotes/origin > "$local_refs"; then + echo "FAIL cannot list the remote-tracking refs in this checkout." >&2 + exit 1 +fi listed=() while IFS= read -r ref; do [ -n "$ref" ] || continue @@ -45,17 +54,64 @@ while IFS= read -r ref; do origin | origin/HEAD) continue ;; esac listed+=("$ref") -done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin) +done < "$local_refs" # THE DENOMINATOR, from the remote itself rather than from what happens to be # fetched locally. A stale or shallow fetch is the case a subset-sweep hides in. +# +# ASKING AND FAILING IS NOT ASKING AND GETTING NOTHING. The previous revision +# ran this with `2>/dev/null` and then guarded the comparison with +# `[ ${#published[@]} -gt 0 ]`, so an ls-remote that FAILED — no network, no +# credential, a renamed remote — produced an empty denominator and skipped the +# only check that makes the sweep's green mean anything. Measured: with the +# remote unreachable and one ref fetched locally it printed "the remote +# publishes 0 ref(s); sweeping 0 of them", "this repository publishes no ref +# other than main", and exited 0 — the same false green the glob produced, +# re-entering through the guard added to stop it. So the exit status is read, +# and the stderr is shown rather than swallowed. +listing="$(mktemp)" +if ! git ls-remote --heads origin > "$listing" 2>"$listing.err"; then + echo "FAIL cannot list the refs this repository publishes, so there is nothing to" >&2 + echo " hold the sweep to. git ls-remote --heads origin said:" >&2 + sed 's/^/ /' "$listing.err" >&2 + echo " A sweep with no denominator cannot know what it did not look at, and" >&2 + echo " reporting success from one is the defect this assertion exists for." >&2 + rm -f "$listing.err" + exit 1 +fi +rm -f "$listing.err" published=() while IFS= read -r name; do [ -n "$name" ] && published+=("$name") -done < <(git ls-remote --heads origin 2>/dev/null | sed 's|.*refs/heads/||') +done < <(sed 's|.*refs/heads/||' "$listing") -if [ ${#published[@]} -gt 0 ] && [ ${#listed[@]} -ne ${#published[@]} ]; then - echo "FAIL the sweep can see ${#listed[@]} ref(s) and the remote publishes ${#published[@]}." >&2 +# AND THE COMPARISON IS BETWEEN SETS, NOT COUNTS. Counts agreeing is not the +# same ref list agreeing: measured, a remote that deleted one branch and +# published another left the local view holding a stale ref and missing the new +# one — two refs each side, assertion green, and the branch that actually +# carried the implementation was never opened. The sweep reported +# "ok origin/carries/the/implementation" and exited 0. +if [ ${#published[@]} -eq 0 ]; then + echo "FAIL the remote publishes no heads at all, which no repository this gate runs in does." >&2 + echo " That is an answer about the wrong remote, not an empty repository." >&2 + exit 1 +fi + +# Only one direction is asserted: every PUBLISHED ref must be visible here. A +# local ref the remote has since deleted is left in, because it is then SWEPT — +# which can produce a false failure and never a false pass, and that is the side +# to err on. The previous `-ne` compared cardinalities, which is the one +# comparison that fails in both directions at once. +missing=() +for name in "${published[@]}"; do + found="" + for ref in "${listed[@]}"; do + [ "$ref" = "origin/$name" ] && found=1 && break + done + [ -n "$found" ] || missing+=("$name") +done +if [ ${#missing[@]} -gt 0 ]; then + echo "FAIL the remote publishes ${#missing[@]} ref(s) this sweep cannot see: ${missing[*]}" >&2 echo " Listed: ${listed[*]}" >&2 echo " Published: ${published[*]}" >&2 echo " A sweep over a subset that reports success is worse than no sweep." >&2 diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 41cdbd0..7a4305d 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -250,3 +250,189 @@ func copyFile(t *testing.T, from string, to string) { require.NoError(t, err) require.NoError(t, os.WriteFile(to, content, 0o755)) } + +// Round six, layer 2: THE AUDIT FAILED OPEN, in four separate places, and three +// of them were inside the assertion added for round five's blocker. +// +// A gate that reports success when it could not look is worse than no gate, and +// each of these reported success: the ref-listing guard skipped itself when +// git failed, the comparison was between COUNTS, an unreadable blob was swept +// as a clean one, and the fix for that last one exited a subshell. +func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { + sweep, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + // AN UNREACHABLE REMOTE. `git ls-remote … 2>/dev/null` swallowed the + // failure and `[ ${#published[@]} -gt 0 ]` skipped the comparison, so a + // checkout with one ref fetched printed "the remote publishes 0 ref(s); + // sweeping 0 of them", "this repository publishes no ref other than main", + // and exited 0 — the same false green the glob produced, through the guard + // added to stop it. + t.Run("an unreachable remote", func(t *testing.T) { + clone := clonedRemote(t, sweep, checker, "kept") + runIn(t, clone, "git", "remote", "set-url", "origin", filepath.Join(t.TempDir(), "gone.git")) + + output, err := runSweep(clone) + + require.Error(t, err, "a sweep with no denominator must not report success:\n%s", output) + require.Contains(t, output, "cannot list the refs this repository publishes") + }) + + // COUNTS AGREEING IS NOT THE SAME REFS. A remote that deleted one branch + // and published another left the local view holding a stale ref and + // missing the new one: two each side, assertion green, and the branch that + // carried the implementation was never opened. + t.Run("the same number of different refs", func(t *testing.T) { + clone := clonedRemote(t, sweep, checker, "kept") + remote := strings.TrimSpace(runOut(t, clone, "git", "remote", "get-url", "origin")) + // The remote moves on; this checkout does not refetch. + seed := t.TempDir() + runIn(t, seed, "git", "clone", "--quiet", remote, ".") + runIn(t, seed, "git", "push", "--quiet", "origin", ":refs/heads/kept") + runIn(t, seed, "git", "push", "--quiet", "origin", "HEAD:refs/heads/appeared/later") + + output, err := runSweep(clone) + + require.Error(t, err, "the same count of different refs must not pass:\n%s", output) + require.Contains(t, output, "appeared/later", + "and the refusal must name the ref that was never swept") + }) + + // AN UNREADABLE ENTRY AT A REF. `git ls-tree -r` names gitlinks as well as + // blobs, so a submodule whose path ends in .go is listed and `git cat-file + // blob` cannot read it. The error was swallowed, the source emptied, and + // the ref announced `ok` with exit 0. + // + // The first fix for this failed open too: the `exit 1` ran inside the + // command substitution `"$(carrying_in_ref "$ref")"`, so the script printed + // FAIL, carried on, found no findings, and printed `ok` directly underneath + // — measured. Both halves are asserted here, the exit status as well as the + // message. + t.Run("an entry the sweep cannot read", func(t *testing.T) { + repository := t.TempDir() + runIn(t, repository, "git", "init", "--quiet", ".") + require.NoError(t, os.WriteFile(filepath.Join(repository, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, repository, "git", "add", ".") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", "seed") + commit := strings.TrimSpace(runOut(t, repository, "git", "rev-parse", "HEAD")) + // A gitlink whose path ends in .go. + runIn(t, repository, "git", "update-index", "--add", "--cacheinfo", + "160000,"+commit+",vendored.go") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", "a gitlink named like a Go file") + runIn(t, repository, "git", "branch", "gitlinked") + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, checker, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + + run := exec.Command("bash", "scripts/check-one-implementation.sh", "gitlinked") + run.Dir = repository + raw, err := run.CombinedOutput() + output := string(raw) + + require.Error(t, err, "a ref this sweep could not open must not be reported ok:\n%s", output) + require.Contains(t, output, "has not been swept") + require.NotContains(t, output, "ok gitlinked", + "the first fix printed FAIL and then ok, because exit 1 inside a command "+ + "substitution exits the subshell") + }) +} + +// And the two SCANNER bypasses, both of which compile and pass `go vet`. +// +// The import extractor was rewritten in round four for exactly this class and +// these two shapes walked past the rewrite: it read the closing paren off the +// RAW line, so a `)` inside a comment closed the block early; and it scanned +// for double-quoted paths and backquoted paths in two sequential passes, so a +// raw path BEFORE a quoted one on the same line was dropped. Both measured at +// exit 0 against the previous revision. +func TestTheImportScannerReadsOneLineOnce(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]string{ + "a closing paren inside a comment": "package x\n\nimport (\n\t// )\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", + "a block comment holding a paren": "package x\n\nimport (\n\t/* ) */\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", + "a raw path before a quoted one": "package x\n\nimport (`crypto/ed25519`; \"fmt\")\n\nvar _ = ed25519.Sign\nvar _ = fmt.Sprint\n", + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, "crypto/ed25519") + }) + } + + // THE BANS THE AST GATE HAS AND THIS ONE DID NOT. Four import paths the + // AST gate refuses passed here with `ok working tree (4 Go files)`, exit 0 + // — in the root module, where this script is the only gate. + for name, probe := range map[string]struct{ source, says string }{ + "the legacy protobuf module": { + source: "package x\n\nimport _ \"github.com/golang/protobuf/proto\"\n", + says: "github.com/golang/protobuf/proto", + }, + "the low-level protobuf runtime": { + source: "package x\n\nimport _ \"google.golang.org/protobuf/runtime/protoiface\"\n", + says: "runtime/protoiface", + }, + "protoimpl": { + source: "package x\n\nimport _ \"google.golang.org/protobuf/runtime/protoimpl\"\n", + says: "runtime/protoimpl", + }, + "a vendored ed25519": { + source: "package x\n\nimport _ \"github.com/some/vendor/ed25519\"\n", + says: "ed25519", + }, + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe.source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, probe.says) + }) + } +} + +// clonedRemote builds a throwaway remote with one extra branch and returns a +// clone of it carrying both scripts. +func clonedRemote(t *testing.T, sweep string, checker string, branch string) string { + t.Helper() + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare") + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "branch", "-M", "main") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, sweep, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + return clone +} + +func runSweep(dir string) (string, error) { + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = dir + run.Env = append(os.Environ(), "SWEEP_BASE=main", "SWEEP_HEAD=") + output, err := run.CombinedOutput() + return string(output), err +} + +func runOut(t *testing.T, dir string, command string, args ...string) string { + t.Helper() + run := exec.Command(command, args...) + run.Dir = dir + output, err := run.Output() + require.NoError(t, err, "%s %v", command, args) + return string(output) +} diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index 3eb933c..2ec7c3c 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -60,11 +60,20 @@ one of core's. *named* `mintResponse`. Types resolve package-qualified (`basev0.WorkScopeV1`, not `WorkScopeV1` from anywhere), in the scope of the use, before the use, and an ambiguous identifier is refused rather than guessed. -- **No function-local type, and no alias to core's types outside `core.go`.** - Either one hands a type-name allowlist whatever name it asks for: `type - WorkScopeV1 = basev0.WorkContextV1` makes a capability marshal as an allowed - scope, and a declaration inside a function body is invisible to every rule - that reads a file's declarations. +- **No function-local type, and no second local name for core's types outside + `core.go`** — alias or defined type, through a pointer or a slice, and a local + name is CHASED to what it names. Either one hands a type-name allowlist + whatever name it asks for: `type WorkScopeV1 = basev0.WorkContextV1` makes a + capability marshal as an allowed scope, and a declaration inside a function + body is invisible to every rule reading a file's declarations. One `*` walked + past both halves for three rounds — `type carrier = *basev0.WorkContextV1` + resolved to `"carrier"`, neither a capability nor unresolvable, so the root + module's one rule permitted a `proto.Unmarshal` into it. +- **A codec-allowed struct's field set is FROZEN, embedded fields included.** + The codec rule names the top-level type, so a new field is a new thing encoded + through a row written for the fields listed. An embedded field has no `Names`, + so a loop over `field.Names` skipped every one — and `encoding/json` promotes + an embedded struct's exported fields into the enclosing object. - **No `encoding/json` outside `mint.go` by exact path, and no json-tagged struct elsewhere.** The deleted implementation signed a hand-written JSON payload. The path is exact because matching the base name let any new @@ -75,19 +84,34 @@ one of core's. line's shape. Its previous regex tried to recognise an import and was walked past by a non-ASCII alias (`ψ "crypto/ed25519"`) and a comment-prefixed line (`/* x */ "crypto/ed25519"`) — in the root module, where it is the only gate. - -**What no import ban catches:** a hand-rolled HMAC over `crypto/sha256`, or a -GMAC assembled from `crypto/cipher`, builds a MAC out of parts that are not -MACs. `crypto/cipher` and the block ciphers are banned; the hash cannot be, -because two digests here need it. So in this module the symbol rule bounds that -residue to one file, and in the root module it is closed by review rather than -by the script. "Any MAC is a finding" was a list of names; this is what the -list reaches. - -`TestTheGateCatchesItsOwnBypasses` and -`TestTheRepositorySweepCatchesItsOwnBypasses` hold both gates to these claims: -every case is a bypass that was reported against a previous revision and -worked. `TestTheSDKParsePathsAgreeWithCore` drives every core conformance + It reads the closing paren off the COMMENT-STRIPPED line — a `)` in a comment + closed the block early — and scans each line ONCE for either quoting, because + two sequential passes dropped a raw-string path preceding a quoted one. +- **The two gates' ban lists are one list, asserted.** They disagreed both ways: + the legacy protobuf module, `protoiface`, `protoimpl` and a vendored path + ending `/ed25519` swept clean where the sweep is the only gate, and the cipher + family was banned by the sweep but not by the gate reading the module the + capability lives in. `TestTheShellSweepBansEverythingTheASTGateBans` parses the + script's own expressions and fails when they differ. +- **A gate that could not look must not report success.** Four fail-opens, three + inside the assertion added the round before: a swallowed `git ls-remote` + failure skipped the comparison entirely, the comparison was between COUNTS + rather than ref sets, an entry `git cat-file` could not read swept clean, and + the fix for that `exit`ed a command substitution's subshell — printing `FAIL` + and then `ok`. Every enumeration's exit status is read. + +**What no import ban catches:** a hand-rolled HMAC over `crypto/sha256` builds a +MAC out of parts that are not MACs. `crypto/cipher` and the block ciphers are +banned in both gates now; the hash cannot be, because two digests here need it. +So in this module the symbol rule bounds that residue to one file, and in the +root module it is closed by review. "Any MAC is a finding" was a list of names; +this is what the list reaches. + +`TestTheGateCatchesItsOwnBypasses`, +`TestTheRepositorySweepCatchesItsOwnBypasses`, +`TestTheImportScannerReadsOneLineOnce` and +`TestTheSweepRefusesWhenItCouldNotLook` hold both gates to these claims: every +case is a bypass reported against a previous revision, and every one worked. `TestTheSDKParsePathsAgreeWithCore` drives every core conformance fixture through this module's own parse paths and requires core's sentinel — read a refusal's sentinel off the fixture, never off a constant here, because three fixtures changed sentinel under us and the tests that did that needed no @@ -138,10 +162,15 @@ wrapping, the context and the `Finish`. Whether a method is guarded is a property of the METHOD and the first answer binds; a per-request `(nil, nil)` is the optional-carrier shape this module deleted. -A refusal reaches the client as a gRPC status: the sentinels become +A refusal reaches the client as a gRPC status: core's sentinels become `codes.Unauthenticated`, an unreachable re-check source becomes `codes.Unavailable`, and the Go error chain is kept alongside the code so -`errors.Is` still works in the server that produced it. +`errors.Is` still works in the server that produced it. **`ErrInvalid` is +`Unauthenticated` and this package's own misuse is `Internal`** — two sentinels, +because they were one: core answers an EXPIRED capability with `ErrInvalid`, so +mapping every `ErrInvalid` to `Internal` told a client not to mint again about +the most ordinary mid-stream refusal there is. `errStreamMisuse` wraps +`ErrInvalid` and is checked first. ## The mint client holds a credential it cannot verify diff --git a/workcontext/README.md b/workcontext/README.md index 8ae3676..d7352cd 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -262,7 +262,11 @@ client, err := workcontext.NewMintClient(workcontext.MintOptions{ }, ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), ProjectionAudience: projectionAudience, - RootCAs: platformRoots, // nil means the system pool + // The trust anchor is STATED. Name the roots that may sign the mint + // endpoint's certificate, or say TrustSystemRoots; construction refuses + // silence, because there is no safe default for where this process sends + // its service-account token. + RootCAs: platformRoots, }) ``` @@ -277,8 +281,17 @@ host SIGNED to be the audience the pin answered. projected service-account token travels on that request as a bearer credential, so plaintext is a disclosure the configuration must not be able to choose. -**The transport is the client's, and you cannot supply one.** `RootCAs` is the -only thing a caller says about it. An `*http.Client` option was a hole that +**The transport is the client's, and you cannot supply one.** The trust anchor +is the only thing a caller says about it — and it must say something: set +`RootCAs` to the roots that may sign the endpoint's certificate, or +`TrustSystemRoots` to use the host's pool. Exactly one, or `NewMintClient` +returns `ErrInvalid`. A nil `RootCAs` used to mean the system pool silently, +which left the one remaining hole in a transport built to have none: every other +route to a weak channel was closed and the trust anchor was still whatever the +image shipped, so a mis-issued certificate for the host name — or a corporate +interception root — received the projected token. Which certificates are +acceptable for platform infrastructure is a deployment decision, and a default +is not a decision. An `*http.Client` option was a hole that inspecting the client could not close: a nil `Transport` means the global, mutable `http.DefaultTransport`; a wrapping `RoundTripper` is opaque; a `DialTLSContext` bypasses `TLSClientConfig` entirely; and a caller keeping the @@ -683,11 +696,24 @@ on the wire no later revocation recalls it, which is what `SendHeader` means. **A refusal reaches the client as a gRPC status it can act on.** Returning the plain Go error made every refusal arrive as `codes.Unknown` with internal text, so a receiver could not tell "mint again" from "the server broke" — which is the -only decision the table above asks a caller to make. `ErrRevoked`, `ErrReplayed`, -`ErrInvalid` and `ErrNotACoreToken` become `codes.Unauthenticated`; a -termination for a source that could not be reached becomes `codes.Unavailable`. -The sentinel chain is kept alongside the code, so `errors.Is` still works inside -the server that produced it. +only decision the table above asks a caller to make. `ErrRevoked`, +`ErrReplayed`, `ErrNotACoreToken` and `ErrInvalid` become +`codes.Unauthenticated`; a termination for a source that could not be reached +becomes `codes.Unavailable`; and this server's own misuse — a nil guard, a +finished stream, a re-check that tried to write to the stream whose liveness it +was deciding — becomes `codes.Internal`, because the client did nothing it can +correct. + +That last split matters more than it looks. `ErrInvalid` was mapped to +`codes.Internal` wholesale on the argument that it covers misuse as well as a +broken capability, and telling a client "mint again" about a server bug makes it +loop. The argument is right and it was applied to the wrong side: core answers +an **expired** capability with `ErrInvalid`, and a stream that outlives its +credential is the ordinary case, not a rare malformed one — it is exactly the +refusal a client answers by minting again. The misuse errors are this package's +own and are constructed here, so they carry their own sentinel and keep the +`codes.Internal` the argument asked for. The sentinel chain is kept alongside +the code, so `errors.Is` still works inside the server that produced it. What this costs is one live authorization check per message — against an RPC-backed `SealSource`, one round trip per emitted item. That is the price of diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index 19f84ab..da1dc4f 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -96,10 +96,10 @@ type GuardedServerStream struct { // interceptor and not this. func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { if stream == nil { - return nil, fmt.Errorf("%w: no stream to guard", workcontext.ErrInvalid) + return nil, fmt.Errorf("%w: no stream to guard", errStreamMisuse) } if guard == nil { - return nil, fmt.Errorf("%w: no stream guard", workcontext.ErrInvalid) + return nil, fmt.Errorf("%w: no stream guard", errStreamMisuse) } guarded := &GuardedServerStream{ stream: stream, @@ -136,7 +136,7 @@ func (s *GuardedServerStream) Context() context.Context { // about what leaves. func (s *GuardedServerStream) RecvMsg(message any) error { if s == nil || s.stream == nil { - return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) } return s.stream.RecvMsg(message) } @@ -173,7 +173,7 @@ func (s *GuardedServerStream) SendHeader(headers metadata.MD) error { s.mu.Lock() if s.finished { s.mu.Unlock() - return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) } pending := s.headers s.headers = metadata.MD{} @@ -198,7 +198,7 @@ func (s *GuardedServerStream) SetHeader(headers metadata.MD) error { s.mu.Lock() defer s.mu.Unlock() if s.finished { - return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) } if s.flushed { // gRPC's own contract: headers are sent once. Forward so the caller @@ -224,7 +224,7 @@ func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { // nowhere to put it. func (s *GuardedServerStream) setTrailer(trailer metadata.MD) error { if s == nil { - return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) } if err := s.recheck(); err != nil { return err @@ -233,7 +233,7 @@ func (s *GuardedServerStream) setTrailer(trailer metadata.MD) error { defer s.mu.Unlock() if s.finished { // Past the last check there is nothing left to authorize against. - return fmt.Errorf("%w: the stream is finished", workcontext.ErrInvalid) + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) } for key, values := range trailer { s.trailers[key] = append(s.trailers[key], values...) @@ -287,7 +287,7 @@ func (s *GuardedServerStream) Terminated() error { func (s *GuardedServerStream) recheck() error { if s == nil || s.guard == nil || s.stream == nil { - return fmt.Errorf("%w: unguarded stream", workcontext.ErrInvalid) + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) } // THE RE-CHECK'S CONTEXT REFUSES WRITES, and that is what stops a // deadlock rather than a convention asking people not to. @@ -333,21 +333,57 @@ func (t refusingTransportStream) SetTrailer(metadata.MD) error { return errReche var errRecheckMustNotWrite = fmt.Errorf( "%w: a Work Context liveness re-check must not write to the stream whose liveness it is deciding", - workcontext.ErrInvalid, + errStreamMisuse, ) +// errStreamMisuse is THIS PACKAGE'S OWN misuse, distinct from a capability that +// does not authenticate, and it exists because the two were the same sentinel. +// +// core returns ErrInvalid for an EXPIRED capability (verify.go: "expired at +// …") and for one that is not yet valid. statusFor mapped ErrInvalid to +// codes.Internal on the argument that ErrInvalid also covers misuse — a nil +// guard, a finished stream, an unguarded wrapper — and that telling a client +// "mint again" about a server bug makes it loop. The argument is sound and the +// conclusion was still wrong, because it applied to the WRONG SIDE: expiry is +// not the rare malformed case, it is the ordinary mid-stream refusal on a +// stream that outlives its credential, and it is precisely the one a client +// answers by minting again. Measured: an expired capability arrived as +// Internal. +// +// The distinction the old comment said was unavailable was available all along. +// Every misuse error here is CONSTRUCTED here, so it can say so; what arrives +// from core says nothing extra and is read as a capability refusal. It still +// wraps ErrInvalid, so errors.Is(err, workcontext.ErrInvalid) is unchanged for +// anything that was relying on it. +var errStreamMisuse = fmt.Errorf("%w: guarded stream misuse", workcontext.ErrInvalid) + // releaseHeaders hands held headers to the real stream. The caller has just // re-checked, and a SendMsg flushes whatever is queued there, so this is the // one moment they may cross. func (s *GuardedServerStream) releaseHeaders() error { s.mu.Lock() defer s.mu.Unlock() - if s.flushed || len(s.headers) == 0 { + if s.flushed { + return nil + } + // FLUSHED IS ABOUT THE SEND, NOT ABOUT THE QUEUE. Returning early when + // nothing was queued left flushed false after a message had gone out — + // and gRPC sends the header frame with the first message whether the + // handler queued anything or not. So a SetHeader AFTER the first SendMsg + // was held in this wrapper, found !flushed, and was handed to + // stream.SetHeader by Finish, which gRPC ignores because the headers are + // already on the wire. Measured over bufconn: SetHeader returned nil, the + // header never reached the client, and the handler was told nothing. + // + // With flushed set here, that SetHeader forwards to gRPC instead and the + // handler gets gRPC's own answer, which is the behaviour SetHeader + // documents for a stream whose headers have been sent. + s.flushed = true + if len(s.headers) == 0 { return nil } pending := s.headers s.headers = metadata.MD{} - s.flushed = true return s.stream.SetHeader(pending) } @@ -406,19 +442,27 @@ func statusFor(err error) error { // a client answers by presenting a new one, which is exactly the // README's "mint again". code = codes.Unauthenticated - case errors.Is(err, workcontext.ErrInvalid): - // NOT Unauthenticated. ErrInvalid is this package's sentinel for - // MISUSE as well as for a structurally broken capability — a nil - // guard, a finished stream, an unguarded wrapper — so mapping it to - // Unauthenticated told a client "mint again" about a server bug, and - // minting again cannot fix a server bug. Internal is the honest code: - // the client did nothing it can correct. + case errors.Is(err, errStreamMisuse): + // THIS SERVER'S OWN MISUSE: a nil guard, a finished stream, an + // unguarded wrapper, a re-check that tried to write. Internal, because + // the client did nothing it can correct and minting again cannot fix a + // server bug. // - // The cost is that a genuinely malformed capability also arrives as - // Internal. That is the right side to err on: a client told to mint - // again when it should not loops, and a client told Internal when it - // could have minted retries once and sees the same answer. + // Checked BEFORE ErrInvalid, which it wraps, so the order of these two + // cases is the whole of the distinction. code = codes.Internal + case errors.Is(err, workcontext.ErrInvalid): + // A CAPABILITY THAT DOES NOT AUTHENTICATE, which from core includes + // the ordinary one: expired. core answers an expired or not-yet-valid + // capability with ErrInvalid, and a stream that outlives its + // credential is the common case rather than a rare malformed one — so + // Unauthenticated, which is the README's "mint again". + // + // The previous revision mapped every ErrInvalid to Internal to avoid + // telling a client to mint again about a server bug. That cost was + // real and this keeps it: the server's own misuse is a separate + // sentinel above, constructed here, where the difference is known. + code = codes.Unauthenticated case errors.Is(err, context.Canceled): code = codes.Canceled case errors.Is(err, context.DeadlineExceeded): diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go index 301dc53..026a0de 100644 --- a/workcontext/grpctransport/real_grpc_stream_test.go +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -2,12 +2,9 @@ package grpctransport import ( "context" - "errors" - "io" "net" "sync/atomic" "testing" - "time" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -113,26 +110,29 @@ func serveWith( return connection } -// drain runs the stream to completion and reports what the client saw. +// drain runs the stream to completion and reports the message count, the +// trailer and the client's error. +// +// It delegates to clientSaw rather than opening the stream a second way. There +// were two helpers doing this, each with its own hand-built StreamDesc and its +// own receive loop, and they disagreed: this one counted messages and never +// read the header, clientSaw read the header and dropped the count. A test +// needing both got one of them and a local variable, which is how +// `received := 0; require.Zero(received)` came to stand where the file's +// central assertion belongs. +// +// WHAT THESE TESTS ESTABLISH, since the count was not the only claim running +// ahead of its evidence: the TRANSPORT's behaviour — what gRPC does with +// messages, headers and trailers when the guard refuses, with the interceptor +// installed and nothing unwrapped. The authority is a revocableGuard, a Recheck +// closure a test flips; so these do NOT establish that core's own refusals +// reach the guard, which is what TestWorkContextConformance drives through +// core's fixtures. And the stream is server-streaming with CloseSend called at +// once, so nothing here says anything about the client-streaming direction. func drain(t *testing.T, connection *grpc.ClientConn) (received int, trailer metadata.MD, err error) { t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - stream, err := connection.NewStream(ctx, - &grpc.StreamDesc{StreamName: "Emit", ServerStreams: true}, streamMethod) - require.NoError(t, err) - require.NoError(t, stream.CloseSend()) - for { - message := &wrapperspb.StringValue{} - recvErr := stream.RecvMsg(message) - if errors.Is(recvErr, io.EOF) { - return received, stream.Trailer(), nil - } - if recvErr != nil { - return received, stream.Trailer(), recvErr - } - received++ - } + received, _, trailer, err = clientSaw(t, connection) + return received, trailer, err } // (1) A trailer queued under authority must NOT reach the client when @@ -207,11 +207,13 @@ func TestARealHandlerCannotSendUnderRevokedAuthority(t *testing.T) { return nil }) - header, trailer, err := clientSaw(t, connection) - received := 0 + received, header, trailer, err := clientSaw(t, connection) faults.assert(t) require.Error(t, err) require.Empty(t, header.Get("leaked-header")) + // THE MESSAGE COUNT THE CLIENT ACTUALLY RECEIVED. This read + // `received := 0; require.Zero(received)` — the assertion that mattered + // most in this file, against a literal the line above it had just written. require.Zero(t, received, "a message left under revoked authority") require.Empty(t, trailer.Get("leaked")) require.Equal(t, attempts, refusals, "every send under revoked authority was refused") diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go index 1a7c90d..f996dcd 100644 --- a/workcontext/grpctransport/round4_reproducers_test.go +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -39,7 +39,7 @@ import ( // clientSaw runs the stream to completion and reports the headers AND the // trailers the client received, which is the thing the previous round's tests // never read. -func clientSaw(t *testing.T, connection *grpc.ClientConn) (header metadata.MD, trailer metadata.MD, err error) { +func clientSaw(t *testing.T, connection *grpc.ClientConn) (received int, header metadata.MD, trailer metadata.MD, err error) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -53,6 +53,12 @@ func clientSaw(t *testing.T, connection *grpc.ClientConn) (header metadata.MD, t if recvErr != nil { break } + // COUNTED, and that is the whole of a review finding: this helper + // dropped every message it received, so the one test that cared wrote + // `received := 0` beside it and asserted require.Zero on its own + // literal. A tautology in the place where "no message left under + // revoked authority" was supposed to be established. + received++ } if errors.Is(recvErr, io.EOF) { recvErr = nil @@ -60,7 +66,7 @@ func clientSaw(t *testing.T, connection *grpc.ClientConn) (header metadata.MD, t // Header() blocks until headers arrive or the stream ends, so it is read // after the receive loop has finished. header, _ = stream.Header() - return header, stream.Trailer(), recvErr + return received, header, stream.Trailer(), recvErr } // handlerFaults collects what a handler observed, so nothing calls require on @@ -106,7 +112,7 @@ func TestARealClientNeverReceivesAHeaderAuthorityWasWithdrawnFrom(t *testing.T) return nil }) - header, trailer, err := clientSaw(t, connection) + _, header, trailer, err := clientSaw(t, connection) faults.assert(t) require.Error(t, err, "a handler that completed under withdrawn authority must fail the RPC") require.Empty(t, header.Get("queued-header"), @@ -122,7 +128,7 @@ func TestARealClientReceivesTheQueuedHeaderWhenAuthorityHolds(t *testing.T) { return stream.SetHeader(metadata.Pairs("queued-header", "fine")) }) - header, _, err := clientSaw(t, connection) + _, header, _, err := clientSaw(t, connection) require.NoError(t, err) require.Equal(t, []string{"fine"}, header.Get("queued-header")) } @@ -155,7 +161,7 @@ func TestTheContextRouteCannotWriteUnderRevokedAuthority(t *testing.T) { return nil }) - header, trailer, err := clientSaw(t, connection) + _, header, trailer, err := clientSaw(t, connection) faults.assert(t) require.Error(t, err) require.Empty(t, trailer.Get("ctx-trailer"), "a context-route trailer reached the client") @@ -181,7 +187,7 @@ func TestTheContextRouteDeliversUnderAuthority(t *testing.T) { return stream.SendMsg(wrapperspb.String("one")) }) - header, trailer, err := clientSaw(t, connection) + _, header, trailer, err := clientSaw(t, connection) faults.assert(t) require.NoError(t, err) require.Equal(t, []string{"ok"}, header.Get("ctx-header")) @@ -199,7 +205,7 @@ func TestTheGuardedContextStillAnswersTheMethod(t *testing.T) { return nil }) - _, _, err := clientSaw(t, connection) + _, _, _, err := clientSaw(t, connection) require.NoError(t, err) require.Equal(t, streamMethod, <-observed) } @@ -214,7 +220,7 @@ func TestRevocationReachesTheClientAsUnauthenticated(t *testing.T) { return stream.SendMsg(wrapperspb.String("never")) }) - _, _, err := clientSaw(t, connection) + _, _, _, err := clientSaw(t, connection) require.Error(t, err) require.Equal(t, codes.Unauthenticated, status.Code(err), "a client cannot tell 'mint again' from 'the server broke' out of codes.Unknown") @@ -260,7 +266,7 @@ func TestAFirstRequestWithoutACapabilityNeitherBypassesNorDisablesTheMethod(t *t }) // The first request is REFUSED rather than handed the raw stream. - _, _, first := clientSaw(t, connection) + _, _, _, first := clientSaw(t, connection) require.Error(t, first, "a capability-bearing method ran with no guard") require.Equal(t, codes.Unauthenticated, status.Code(first)) mu.Lock() @@ -269,7 +275,7 @@ func TestAFirstRequestWithoutACapabilityNeitherBypassesNorDisablesTheMethod(t *t // And the method still WORKS afterwards: nothing was learned, so nothing // is stuck. This is the half that used to be codes.Internal until restart. - _, _, second := clientSaw(t, connection) + _, _, _, second := clientSaw(t, connection) require.NoError(t, second, "one early request without a capability disabled the method") } @@ -291,7 +297,7 @@ func TestSendHeaderIsRefusedUnderRevokedAuthority(t *testing.T) { return nil }) - header, _, err := clientSaw(t, connection) + _, header, _, err := clientSaw(t, connection) faults.assert(t) require.Error(t, err) require.Empty(t, header.Get("sent-header")) @@ -436,7 +442,7 @@ func TestARecheckSourceFailureReachesTheClientAsUnavailable(t *testing.T) { return stream.SendMsg(wrapperspb.String("never")) }) - _, _, err = clientSaw(t, connection) + _, _, _, err = clientSaw(t, connection) require.Error(t, err) require.Equal(t, codes.Unavailable, status.Code(err), "a re-check source that could not be reached is the one retryable termination") @@ -462,7 +468,7 @@ func TestSendHeaderCarriesWhatSetHeaderQueued(t *testing.T) { return nil }) - header, _, err := clientSaw(t, connection) + _, header, _, err := clientSaw(t, connection) faults.assert(t) require.NoError(t, err) require.Equal(t, []string{"one"}, header.Get("queued"), diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go new file mode 100644 index 0000000..c5ba1f9 --- /dev/null +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -0,0 +1,93 @@ +package grpctransport + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// B7. EXPIRY IS THE ORDINARY REFUSAL AND IT ARRIVED AS Internal. +// +// core answers an expired capability with ErrInvalid — verify.go returns +// "%w: expired at …" — and statusFor mapped every ErrInvalid to codes.Internal. +// The argument for that was sound and applied to the wrong side: ErrInvalid +// also covers this package's own misuse, and telling a client "mint again" +// about a server bug makes it loop. But a stream outliving its credential is +// the COMMON case, not a rare malformed one, and minting again is exactly the +// right answer to it. Measured: Internal. +// +// The distinction the old comment called unavailable was available all along, +// because every misuse error here is constructed here and can say so. +func TestAnExpiredCapabilityTellsTheClientToMintAgain(t *testing.T) { + // The sentinel core actually returns for expiry, with core's own wording. + expired := fmt.Errorf("%w: expired at 2026-10-02T12:00:00Z", workcontext.ErrInvalid) + + require.Equal(t, codes.Unauthenticated, status.Code(statusFor(expired)), + "an expired capability is answered by presenting a new one, which is what "+ + "Unauthenticated means to a client") + + // And this server's own misuse is still Internal, which is the cost the + // previous mapping was protecting and this keeps. + for name, misuse := range map[string]error{ + "a re-check that tried to write": errRecheckMustNotWrite, + "a finished stream": fmt.Errorf("%w: the stream is finished", errStreamMisuse), + "an unguarded wrapper": (*GuardedServerStream)(nil).SetHeader(metadata.MD{}), + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, codes.Internal, status.Code(statusFor(misuse)), + "the client did nothing it can correct, and minting again cannot fix a server bug") + require.ErrorIs(t, misuse, workcontext.ErrInvalid, + "misuse still wraps ErrInvalid, so anything relying on that is unchanged") + }) + } + + // The sentinels that were already Unauthenticated stay there, so this is + // not green by mapping everything one way. + require.Equal(t, codes.Unauthenticated, status.Code(statusFor(workcontext.ErrRevoked))) + require.Equal(t, codes.Unavailable, status.Code(statusFor(workcontext.ErrStreamTerminated))) +} + +// B8. THE FIRST SEND LEFT THE HEADER STATE WRONG WHEN NOTHING WAS QUEUED. +// +// releaseHeaders returned early on an empty queue without marking flushed, and +// gRPC sends the header frame with the first message whether the handler queued +// anything or not. So a SetHeader after the first SendMsg was held in the +// wrapper, found !flushed, and was handed to stream.SetHeader by Finish — which +// gRPC ignores, because the headers are already on the wire. +// +// Measured over bufconn: SetHeader returned nil, the header never reached the +// client, and the handler was told nothing. A handler cannot act on an error it +// is not given. +func TestAHeaderSetAfterTheFirstMessageIsNotSilentlyDropped(t *testing.T) { + faults := &handlerFaults{} + answered := make(chan error, 1) + connection := serveGuarded(t, (&revocableGuard{}).guard(t), func(stream grpc.ServerStream) error { + // NOTHING QUEUED before the first send: that is the whole case. With a + // header queued first, flushed was set and this all worked. + if err := stream.SendMsg(wrapperspb.String("first")); err != nil { + faults.record("the first send failed: %v", err) + } + answered <- stream.SetHeader(metadata.Pairs("late-header", "yes")) + return nil + }) + + received, header, _, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, 1, received, "the message itself must still arrive") + + setHeaderErr := <-answered + require.Error(t, setHeaderErr, + "the headers went out with the first message, so this SetHeader cannot be honoured — "+ + "and a handler that is told nothing cannot act on it") + require.Empty(t, header.Get("late-header"), + "and it must not arrive either: an error plus delivery would be worse than silence") +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index d3d6c10..db0a637 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -108,7 +108,11 @@ func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile(projectedFile(t, "fixture-bearer")), ProjectionAudience: "projection-audience", - Now: func() time.Time { return testClock }, + // No socket is dialled in either of these, so the trust anchor is + // never exercised — but it is now a stated decision rather than a + // field left alone, and construction refuses silence. + TrustSystemRoots: true, + Now: func() time.Time { return testClock }, }) require.NoError(t, err) @@ -265,7 +269,11 @@ func TestRedirectContainmentWithoutASocket(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), ProjectionAudience: "projection-audience", - Now: func() time.Time { return testClock }, + // No socket is dialled in either of these, so the trust anchor is + // never exercised — but it is now a stated decision rather than a + // field left alone, and construction refuses silence. + TrustSystemRoots: true, + Now: func() time.Time { return testClock }, }) require.NoError(t, err) diff --git a/workcontext/mint.go b/workcontext/mint.go index a065809..db39729 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -223,7 +223,18 @@ type MintOptions struct { ProjectionAudience string // RootCAs is the only thing a caller may say about the transport: the roots - // that may sign the mint endpoint's certificate. Nil means the system pool. + // that may sign the mint endpoint's certificate. + // + // Set this, or set TrustSystemRoots. Nil used to mean the system pool + // SILENTLY, which left the one remaining hole in a transport built to have + // none: every other way of weakening it was closed — no caller-supplied + // client, no reachable Transport, a TLS 1.3 floor, no followed redirect — + // and the trust anchor was still whatever the image happened to ship. A + // mint endpoint is platform infrastructure, so a public CA has no business + // vouching for it; a mis-issued certificate for the host name, or a + // corporate interception root in the image, receives the projected + // service-account token. Construction refuses silence now, because the + // choice is a deployment decision and a default is not a decision. // // It replaces an *http.Client, and that is the point. A client is a hole: // its Transport may be nil (so the global, mutable http.DefaultTransport), @@ -238,6 +249,17 @@ type MintOptions struct { // after the floor became 1.3. RootCAs *x509.CertPool + // TrustSystemRoots says, in as many words, that the host's own root pool is + // the right trust anchor for this endpoint. It is the alternative to + // RootCAs and never a companion to it: exactly one of the two is set, or + // construction refuses. + // + // It exists so that using the system pool is a sentence somebody wrote + // rather than a field somebody left alone. There are deployments where it + // is correct — a mint endpoint behind a certificate from the same public + // CA the image already trusts — and this says so out loud. + TrustSystemRoots bool + // RequestTimeout bounds one mint request. RequestTimeout time.Duration @@ -460,6 +482,21 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if strings.TrimSpace(options.ProjectionAudience) == "" { return nil, fmt.Errorf("%w: no projection audience", ErrInvalid) } + switch { + case options.RootCAs != nil && options.TrustSystemRoots: + return nil, fmt.Errorf( + "%w: name the roots that may sign the mint endpoint's certificate, or say TrustSystemRoots; not both", + ErrInvalid, + ) + case options.RootCAs == nil && !options.TrustSystemRoots: + return nil, fmt.Errorf( + "%w: no trust anchor for the mint endpoint; set RootCAs to the roots that may sign its certificate, "+ + "or TrustSystemRoots to use the host's pool. The projected service-account token is sent to "+ + "whatever answers at %s, so which certificates are acceptable there is a deployment decision "+ + "and there is no safe default to fall back to", + ErrInvalid, endpoint, + ) + } timeout := options.RequestTimeout if timeout == 0 { timeout = defaultMintRequestTimeout @@ -504,7 +541,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { options.RenewalLead = lead return &MintClient{ options: options, - httpClient: mintHTTPClient(options.RootCAs), + httpClient: mintHTTPClient(options.RootCAs), // nil here means TrustSystemRoots, checked above now: now, id: mintClientIDs.Add(1), }, nil @@ -527,7 +564,10 @@ func mintHTTPClient(roots *x509.CertPool) *http.Client { CheckRedirect: refuseMintRedirect, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - RootCAs: roots, // nil means the system pool + // Nil reaches here only when the caller said TrustSystemRoots: + // NewMintClient refuses an unstated anchor, so this nil is a + // decision rather than an omission. + RootCAs: roots, // TLS 1.3 FLOOR. The projected service-account token is the // one secret this process hands to anybody, and 1.2 permits // cipher suites and a renegotiation surface that 1.3 removes. @@ -1042,6 +1082,52 @@ func (c *MintClient) Counts() MintCounts { return c.counts } +// projectedRead is one answer from a token source, at file level because the +// gate refuses a function-local type — a local declaration is invisible to every +// rule that reads a file's declarations, and that is true of this one too. +type projectedRead struct { + token string + err error +} + +// projectedToken reads the token source UNDER THE DEADLINE. +// +// ProjectedTokenSource is a caller's interface and takes no context, so nothing +// about it can be interrupted: ProjectedTokenFile does os.Open and io.ReadAll, +// neither of which a context reaches, and a caller's own implementation may do +// anything at all. RequestTimeout bounded the HTTP request and the comment on +// mintInto said it bounded "the one detached request" — measured with a source +// that blocks and RequestTimeout at 150ms, Credential returned after 3.0s, +// bounded only by the CALLER's deadline. With no caller deadline it does not +// return: the detached goroutine never closes `done`, so every later caller +// waits on an attempt that will not finish, and no hold-off is ever installed. +// +// The read runs on its own goroutine and the deadline is waited on instead. The +// goroutine outlives the timeout — there is no way to interrupt a blocking read +// behind an interface that takes no context — so the channel is buffered and +// the value it eventually sends is dropped. That leak is bounded by one +// goroutine per attempt and attempts are single-flighted and held off; a +// permanently wedged client is not. +func (c *MintClient) projectedToken(ctx context.Context) (string, error) { + // Buffered, so the goroutine is not held open by a receiver that left. + answers := make(chan projectedRead, 1) + go func() { + token, err := c.options.ProjectedToken.ProjectedToken() + answers <- projectedRead{token: token, err: err} + }() + select { + case answer := <-answers: + return answer.token, answer.err + case <-ctx.Done(): + // AN OUTAGE, under the one latch rule: a projection that is slow is a + // mount under load or a source holding a lock, and both clear. + return "", fmt.Errorf( + "%w: reading the projected token did not finish within %s: %w", + ErrMintUnavailable, c.options.RequestTimeout, ctx.Err(), + ) + } +} + // dueForRenewalLocked is true once the credential has entered its renewal lead. // An already-expired credential is also due, and is never returned to a caller: // handing one out would turn a renewal failure into an authorization failure at @@ -1064,7 +1150,7 @@ func (c *MintClient) dueForRenewalLocked(credential Credential) bool { } func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credential, error) { - projected, err := c.options.ProjectedToken.ProjectedToken() + projected, err := c.projectedToken(ctx) if err != nil { // A source is a caller's code. An error from one that carries neither // sentinel was returned as it stood and then LATCHED as terminal, so diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index d86d4aa..a5eec6b 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -928,6 +928,9 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), ProjectionAudience: "projection-audience", + // Stated, because construction refuses an unstated trust anchor. A + // deployment that means the host's pool says so. + TrustSystemRoots: true, } _, err := NewMintClient(valid) require.NoError(t, err) @@ -953,6 +956,13 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, + // THE TRUST ANCHOR, both ways round. Unstated was the whole of the + // previous behaviour and it was silently the system pool: every other + // route to a weak transport here was closed and this one defaulted. + "no trust anchor": func(o *MintOptions) { o.TrustSystemRoots = false }, + "two trust anchors": func(o *MintOptions) { + o.RootCAs = x509.NewCertPool() + }, } { t.Run(name, func(t *testing.T) { options := valid diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index 22048e6..54a72f1 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -82,6 +82,13 @@ var ( // sha* family is refused rather than waved through as "not a // signature". "crypto/hkdf", "crypto/pbkdf2", + // THE CIPHER FAMILY, which the shell sweep has banned since the GMAC + // probe and this list did not. A GMAC or a CMAC is a MAC assembled out + // of a block cipher and names no MAC, so the two gates disagreed in + // the WORSE direction: the shell caught it in the root module and this + // gate — the one that reads the module the capability actually lives + // in — did not. Nothing in either module encrypts anything. + "crypto/cipher", "crypto/aes", "crypto/des", "crypto/rc4", } // envelopeDecoders are banned EXCEPT where a file is allowlisted below. A @@ -543,6 +550,20 @@ func inspectCapabilityFields(file sourceFile) []string { // a change to the wire contract. func assertFrozenStructShapes(t *testing.T, files []sourceFile) { t.Helper() + require.Empty(t, frozenStructViolations(files), + "%s", strings.Join(frozenStructViolations(files), "\n\n")) +} + +// frozenStructViolations is the check itself, returning findings rather than +// calling require. +// +// Separated for one reason: a probe cannot drive a require-based assertion. +// Passing a fresh testing.T to one makes FailNow call runtime.Goexit on the +// probe's own goroutine, so "did the freeze catch this?" could only be answered +// by eye — which is how the embedded-field hole went three rounds unnoticed. A +// rule that cannot be probed is a rule nobody has tested. +func frozenStructViolations(files []sourceFile) []string { + var findings []string seen := map[string]map[string][]string{} for _, file := range files { frozen, ok := frozenStructFields[file.path] @@ -564,6 +585,22 @@ func assertFrozenStructShapes(t *testing.T, files []sourceFile) { } var actual []string for _, field := range structure.Fields.List { + if len(field.Names) == 0 { + // AN EMBEDDED FIELD IS A FIELD, and this loop over + // field.Names skipped every one of them — an embedded + // field has no Names at all. Measured: adding + // + // type smuggled struct { Echo string } + // + // to mint.go and embedding it in mintRequest left the + // frozen set byte-identical, produced no finding anywhere + // in the gate, and put a new field on the wire, because + // encoding/json promotes an embedded struct's exported + // fields into the enclosing object. The freeze exists for + // exactly that, one level sideways. + actual = append(actual, "embedded "+typeName(file, field.Type)) + continue + } for _, name := range field.Names { actual = append(actual, name.Name+" "+typeName(file, field.Type)) } @@ -574,17 +611,23 @@ func assertFrozenStructShapes(t *testing.T, files []sourceFile) { } for path, frozen := range frozenStructFields { for name, expected := range frozen { - require.Equal(t, expected, seen[path][name], + if slices.Equal(expected, seen[path][name]) { + continue + } + findings = append(findings, fmt.Sprintf( "%s: %s is a codec-allowed struct and its field set is FROZEN.\n"+ "The codec rule names the TYPE, so a new field is a new thing encoded through a\n"+ "row that exists for the fields listed — adding `Echo *Claims` to mintRequest\n"+ "encoded a whole capability through a row for two strings, and was green. These\n"+ "two structs are an HTTP wire contract with the mint host: a change to them is a\n"+ "change to that contract, and belongs in a reviewed diff with\n"+ - "frozenStructFields updated in it.", - path, name) + "frozenStructFields updated in it.\n"+ + " frozen: %v\n"+ + " found: %v", + path, name, expected, seen[path][name])) } } + return findings } // inspectProtoMethodsRoute refuses a message's own marshal and unmarshal @@ -709,11 +752,25 @@ func inspectCoreAliases(file sourceFile, coreImports map[string]bool) []string { var findings []string ast.Inspect(file.syntax, func(node ast.Node) bool { spec, ok := node.(*ast.TypeSpec) - if !ok || !spec.Assign.IsValid() { - // Assign is set only for `type A = B`, which is the renaming case. + if !ok { return true } - selector, ok := spec.Type.(*ast.SelectorExpr) + // BOTH SPELLINGS. This required spec.Assign.IsValid(), so only + // `type A = corework.B` was a finding and `type A corework.B` was + // silent. The defined form cannot reach a codec — it inherits no + // methods, so it is not a proto.Message — but it is still a second + // declaration of core's shape under a local name, and a rule true for + // one spelling and silent for the other is a rule a reader has to test + // to know. + kind := "aliases" + if !spec.Assign.IsValid() { + kind = "declares a local type over" + } + // THROUGH A POINTER OR A SLICE. The check read spec.Type as a bare + // selector, so `type carrier = basev0.WorkContextV1` was a finding and + // `type carrier = *basev0.WorkContextV1` was not — and the pointer + // form is the one a codec takes. + selector, ok := unwrapTypeExpr(spec.Type).(*ast.SelectorExpr) if !ok { return true } @@ -722,16 +779,34 @@ func inspectCoreAliases(file sourceFile, coreImports map[string]bool) []string { return true } findings = append(findings, fmt.Sprintf( - "%s aliases core's %s.%s as %q.\n"+ + "%s %s core's %s.%s as %q.\n"+ "Aliases to core's types belong in %s, which is this module's re-export of core's\n"+ - "surface. An alias elsewhere gives one of core's types a second local name, which is\n"+ - "precisely what a codec allowlist keyed by type name cannot see through.", - file.path, qualifier.Name, selector.Sel.Name, spec.Name.Name, coreAliasFile)) + "surface. A second local name elsewhere is precisely what a codec allowlist keyed by\n"+ + "type name cannot see through.", + file.path, kind, qualifier.Name, selector.Sel.Name, spec.Name.Name, coreAliasFile)) return true }) return findings } +// unwrapTypeExpr strips the pointers, slices and parentheses a type expression +// wears, so a rule written for a named type reaches it however it is dressed. +func unwrapTypeExpr(expression ast.Expr) ast.Expr { + for hops := 0; hops < maxAliasHops; hops++ { + switch typed := expression.(type) { + case *ast.StarExpr: + expression = typed.X + case *ast.ArrayType: + expression = typed.Elt + case *ast.ParenExpr: + expression = typed.X + default: + return expression + } + } + return expression +} + // inspectImport refuses an import by what it can DO, not by whether its path is // one of two strings. func inspectImport(file sourceFile, name string, path string, plumbing map[string]string) []string { @@ -2192,9 +2267,18 @@ func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string // type whose final name happened to be WorkScopeV1, including a local alias to // the capability itself. func typeName(file sourceFile, expression ast.Expr) string { + return typeNameThroughAliases(file, expression, 0) +} + +// maxAliasHops bounds the alias chase. Go rejects a cycle, but this gate parses +// source that has not been compiled — every probe here is uncompiled by +// construction — so the bound is what stops a cycle in source nobody built. +const maxAliasHops = 8 + +func typeNameThroughAliases(file sourceFile, expression ast.Expr, hops int) string { switch typed := expression.(type) { case *ast.StarExpr: - return typeName(file, typed.X) + return typeNameThroughAliases(file, typed.X, hops) case *ast.SelectorExpr: qualifier, ok := typed.X.(*ast.Ident) if !ok { @@ -2210,9 +2294,28 @@ func typeName(file sourceFile, expression ast.Expr) string { } return path + "#" + typed.Sel.Name case *ast.Ident: + // A LOCAL NAME IS CHASED TO WHAT IT NAMES, which is the hole the + // qualifier fix left behind. Returning the identifier as written meant + // + // type carrier = *basev0.WorkContextV1 + // var c carrier = &basev0.WorkContextV1{} + // proto.Unmarshal(raw, c) + // + // resolved to "carrier" — not a capability, not a codec interface, not + // unresolvable — so the root module's decisive rule returned true. + // Reproduced against this gate: green. The VALUE alias was caught, but + // only by inspectCoreAliases and only because its right-hand side was + // a bare selector; one `*` walked past both. + if hops < maxAliasHops { + if target := localTypeTarget(file, typed.Name); target != nil { + if named := typeNameThroughAliases(file, target, hops+1); named != "" && named != typed.Name { + return named + } + } + } return typed.Name case *ast.ArrayType: - return "[]" + typeName(file, typed.Elt) + return "[]" + typeNameThroughAliases(file, typed.Elt, hops) case *ast.MapType: // Deliberately not reduced to its value type: a map is never an // allowlisted codec type, and naming it as one is how the json probe @@ -2222,6 +2325,32 @@ func typeName(file sourceFile, expression ast.Expr) string { return "" } +// localTypeTarget is the right-hand side of a file-level `type X = T` or +// `type X T`, or nil. +// +// BOTH forms, and the defined form deliberately: `type X basev0.WorkContextV1` +// is a second declaration of a capability's shape under a local name. It cannot +// reach proto.Unmarshal, because a defined type inherits no methods and so is +// not a proto.Message — but a rule that is true for one spelling and silent for +// the other is a rule a reader has to test to know, and the gate's whole +// subject is names chosen locally for core's types. +func localTypeTarget(file sourceFile, name string) ast.Expr { + for _, declaration := range file.syntax.Decls { + generic, ok := declaration.(*ast.GenDecl) + if !ok || generic.Tok != token.TYPE { + continue + } + for _, spec := range generic.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != name { + continue + } + return typeSpec.Type + } + } + return nil +} + // declaredTypeName looks an identifier up among the declarations that are // actually IN SCOPE at the use, and refuses to answer when more than one // distinct declaration could be the one. diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go index 6cf55ea..b287eba 100644 --- a/workcontext/round4_reproducers_test.go +++ b/workcontext/round4_reproducers_test.go @@ -515,14 +515,33 @@ func TestAMintedCredentialThatExpiresBeforeItIsValidIsRefused(t *testing.T) { // default ceiling IS core's constant: if core ever refused a lifetime of // exactly MaxTTLCeiling, this client's default would refuse every credential a // host minted at the maximum. -func TestCoreRefusesAnOverCeilingLifetimeInTheClientsOwnReadPath(t *testing.T) { - // The structural read path the mint client uses, driven directly. +// RENAMED to what it asserts. It was called +// TestCoreRefusesAnOverCeilingLifetimeInTheClientsOwnReadPath and asserted no +// refusal at all: a sound fixture inspects, and two constants are equal. A +// review was right to call that out. +// +// The refusal it was named for CANNOT be driven from here, and the reason is +// the one-implementation rule rather than an omission: core's Authority refuses +// an over-ceiling lifetime at the MINT, so no over-ceiling capability can be +// produced, and assembling one by hand to feed the read path is precisely the +// second implementation both gates exist to refuse. Core asserts it on its own +// side, in decodeClaims, with a committed test — that is the right place, and it +// is the place this module asked for it to be. +// +// What is left is still worth asserting and is the part that can drift here: +// the client's default ceiling IS core's constant, so if core ever refused a +// lifetime of exactly MaxTTLCeiling this client's default would refuse every +// credential a host minted at the maximum. The test below that one establishes +// exactly-at-the-ceiling is accepted. +func TestTheClientsDefaultCeilingIsCoresConstant(t *testing.T) { + // The structural read path the mint client uses, driven directly, so the + // entry point this depends on is exercised rather than assumed. _, err := corework.Inspect(fixture(t, "session").Token) require.NoError(t, err, "a sound capability still inspects") - // And the ceiling is the same constant the client defaults to, so the two - // cannot drift apart. - require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime) + require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime, + "the client's default ceiling and core's absolute ceiling are the same bound; "+ + "two constants that can drift are two bounds") } // Exactly at the ceiling is ACCEPTED, so the client's default is never refused @@ -673,6 +692,12 @@ func TestALatchedRefusalIsReportedEvenToACallerThatGaveUp(t *testing.T) { // The EIO READ CLASS: a projected-token read that fails partway is an outage, // distinct from a file that is absent. Both recover; neither latches. +// +// This one drives a caller's OWN ProjectedTokenSource, which is the branch at +// mintLocked — an unlabelled error from somebody else's reader. The branch +// inside ProjectedTokenFile, where io.ReadAll itself fails, is a different +// three lines and is covered by the test below it; a review was right that this +// test's name claimed both and reached one. func TestAProjectedTokenReadFailureIsAnOutageAndRecovers(t *testing.T) { clock := &movableClock{at: testClock} host := newMintHost(t, clock.now) @@ -692,3 +717,26 @@ func TestAProjectedTokenReadFailureIsAnOutageAndRecovers(t *testing.T) { require.NoError(t, err, "a local read failure permanently stopped the client") require.NotEmpty(t, credential.Token()) } + +// AND THE FILE SOURCE'S OWN READ FAILURE, at the branch the test above does not +// reach: ProjectedTokenFile opens the path successfully and io.ReadAll then +// fails. A directory is the portable way to produce exactly that — os.Open +// succeeds, the read returns EISDIR — and it is not a contrived shape: the +// projection's mount point with nothing mounted on it is a directory where a +// file is expected. +// +// What matters is the CLASS: ErrMintUnavailable, so it is held off and retried +// rather than latched. An EIO on the mount, a projection replaced between +// unlink and create, and an exhausted descriptor table all land here, and +// latching any of them stopped a process that was holding a good credential. +func TestTheFileSourcesOwnReadFailureIsAnOutage(t *testing.T) { + directory := t.TempDir() + + _, err := ProjectedTokenFile(directory).ProjectedToken() + + require.ErrorIs(t, err, ErrMintUnavailable, + "a read that fails after a successful open is an outage, not a refusal") + require.NotErrorIs(t, err, ErrMintRefused, "and it must never latch") + require.ErrorContains(t, err, "read projected token", + "the message must name the stage, so an operator knows the file was found and not read") +} diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go new file mode 100644 index 0000000..11e85fb --- /dev/null +++ b/workcontext/round6_reproducers_test.go @@ -0,0 +1,384 @@ +package workcontext + +import ( + "context" + "go/ast" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Round six, layer 2: the findings a static reviewer executed the EXTRACTED +// logic of, each reproduced here against the real thing. +// +// The pattern this round repeats, named because it is now four rounds old: the +// defects are in the code written to fix the previous review. B1's fail-open +// lives inside the assertion added for round five's blocker; B3's pointer alias +// walks past the qualifier fix from round four; B4's embedded field walks past +// the field freeze from round five. + +// B3. A POINTER ALIAS DEFEATED THE ROOT MODULE'S DECISIVE RULE. +// +// The root module has exactly one codec rule — a Work Context message passes +// through a codec only where a row names it — and typeName returned a local +// identifier as written. So +// +// type carrier = *basev0.WorkContextV1 +// var c carrier = &basev0.WorkContextV1{} +// proto.Unmarshal(raw, c) +// +// resolved to "carrier": not a capability, not a codec interface, not +// unresolvable, and therefore permitted. Measured against this gate: green. +// +// The VALUE alias was caught, and only by inspectCoreAliases, whose check read +// spec.Type as a bare selector — so one `*` walked past the alias rule and the +// codec rule at the same time. Both are fixed: local names are chased to what +// they name, and the alias rule unwraps pointers and slices. +func TestAPointerAliasDoesNotRenameACapability(t *testing.T) { + for name, source := range map[string]string{ + "a pointer alias in the root module": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type carrier = *basev0.WorkContextV1 +func readit(raw []byte) (*basev0.WorkContextV1, error) { + var c carrier = &basev0.WorkContextV1{} + return c, proto.Unmarshal(raw, c) +}`, + "a chain of aliases": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type inner = basev0.WorkContextV1 +type carrier = *inner +func readit(raw []byte) (*inner, error) { + var c carrier = &inner{} + return c, proto.Unmarshal(raw, c) +}`, + "a slice alias": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type batch = []*basev0.WorkContextV1 +func readit(raw []byte, all batch) error { return proto.Unmarshal(raw, all[0]) }`, + // A DEFINED type cannot reach proto.Unmarshal — it inherits no methods + // — but a rule true for one spelling and silent for the other is a + // rule a reader has to test to know. + "a defined type over a capability": `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +type carrier basev0.WorkContextV1 +var _ = carrier{}`, + } { + t.Run(name, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, "second_parser.go", source)) + require.NotEmpty(t, findings, + "a capability under a local name is still a capability") + }) + } + + // And the alias the rule exists to permit stays permitted, so this is not + // green by refusing everything. + require.Empty(t, inspectForSecondImplementation(parseSource(t, coreAliasFile, `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type Verifier = corework.Verifier +type Claims = corework.Claims`))) +} + +// EACH OF THE TWO RULES, SEPARATELY, because driving them together is how two +// mutations survived. +// +// The fix for the pointer alias was two coupled edits — typeName chases a local +// name to what it names, and inspectCoreAliases unwraps a pointer — and a probe +// through inspectForSecondImplementation is satisfied by EITHER of them. Both +// mutations survived the test above, which is the same shape that survived in +// round four and for the same reason. So each rule is asserted at the rule. +func TestTypeNameChasesALocalNameToWhatItNames(t *testing.T) { + file := parseSource(t, "second_parser.go", `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +type carrier = *basev0.WorkContextV1 +type chained = carrier +var direct *basev0.WorkContextV1 +var aliased carrier +var twice chained`) + + // THIS is what the root module's decisive rule reads, and the whole of + // B3: a local name must resolve to the capability, not to itself. + for _, name := range []string{"direct", "aliased", "twice"} { + resolved := resolveTypeName(file, ast.NewIdent(name), file.syntax.End()) + require.True(t, isCapabilityType(resolved), + "%s resolved to %q; the codec rule keys on this string, so a local name that "+ + "answers to itself is a capability the rule cannot see", name, resolved) + } + + // And a name that is not an alias still answers as itself, so the chase + // does not invent resolutions. + plain := parseSource(t, "second_parser.go", `package codefly +var count int`) + require.Equal(t, "int", resolveTypeName(plain, ast.NewIdent("count"), plain.syntax.End())) +} + +func TestTheCoreAliasRuleSeesThroughAPointer(t *testing.T) { + core := map[string]bool{"basev0": true} + for name, declaration := range map[string]string{ + "a value alias": "type carrier = basev0.WorkContextV1", + "a pointer alias": "type carrier = *basev0.WorkContextV1", + "a slice alias": "type batch = []*basev0.WorkContextV1", + "a defined type": "type carrier basev0.WorkContextV1", + } { + t.Run(name, func(t *testing.T) { + file := parseSource(t, "second_parser.go", `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +`+declaration) + require.NotEmpty(t, inspectCoreAliases(file, core), + "a second local name for one of core's types belongs in %s", coreAliasFile) + }) + } + + // The one file whose job is aliasing core is still exempt. + exempt := parseSource(t, coreAliasFile, `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type Verifier = corework.Verifier`) + require.Empty(t, inspectCoreAliases(exempt, map[string]bool{"corework": true})) +} + +// B4. AN EMBEDDED FIELD WAS NOT A FIELD. +// +// assertFrozenStructShapes walked field.Names, and an embedded field has none — +// so an embedded struct added to mintRequest left the frozen set +// byte-identical. Measured: no finding anywhere in the gate, and +// encoding/json promotes an embedded struct's exported fields into the +// enclosing object, so it is a new field on the wire. That is the defect class +// the freeze was added for, one level sideways from where it was added. +func TestAnEmbeddedFieldIsAFieldOfAFrozenStruct(t *testing.T) { + mintFile := func(extra string) sourceFile { + return parseSource(t, "workcontext/mint.go", `package workcontext +import "encoding/json" +type smuggled struct { + Echo string +} +type mintRequest struct { + Audience string `+"`json:\"audience\"`"+` + ProjectionAudience string `+"`json:\"projection_audience\"`"+` +`+extra+` +} +type mintResponse struct { + WorkContext string `+"`json:\"work_context\"`"+` + InstallationID string `+"`json:\"installation_id\"`"+` + BuildIncarnation string `+"`json:\"build_incarnation\"`"+` +} +func encode(a string) ([]byte, error) { return json.Marshal(mintRequest{Audience: a}) }`) + } + + require.NotEmpty(t, frozenStructViolations([]sourceFile{mintFile("\tsmuggled")}), + "an embedded struct carries fields onto the wire and the freeze must see it") + require.NotEmpty(t, frozenStructViolations([]sourceFile{mintFile("\t*smuggled")}), + "and so does an embedded pointer") + + // The shape as it actually is passes, so the assertion is not simply + // failing — and this is the mutation guard for the two above. + require.Empty(t, frozenStructViolations([]sourceFile{mintFile("")}), + "the frozen field sets must match the structs this repository really has") +} + +// B2. THE TWO GATES' BAN LISTS DISAGREED, in both directions. +// +// Measured: four import paths the AST gate bans — the legacy protobuf module, +// protoiface, protoimpl, and a vendored path ending /ed25519 — passed the shell +// sweep with `ok working tree (4 Go files)`, exit 0. That is the ROOT module, +// where the sweep is the only gate. And in the other direction the shell banned +// crypto/cipher, crypto/aes, crypto/des and crypto/rc4 — a GMAC or a CMAC is a +// MAC assembled from a block cipher and names no MAC — while this gate, the one +// that reads the module the capability lives in, did not. +// +// Both lists are now entry-for-entry, and THIS is what keeps them that way: two +// lists a person maintains by hand are two lists, and the only thing that makes +// them one is a test that fails when they differ. +func TestTheShellSweepBansEverythingTheASTGateBans(t *testing.T) { + script, err := os.ReadFile(filepath.Join("..", "scripts", "check-one-implementation.sh")) + require.NoError(t, err) + + shell := shellBanPatterns(t, string(script)) + + for _, path := range bannedImports { + require.True(t, shell.matches(path), + "the AST gate bans %q and the shell sweep does not. The sweep is the ONLY gate in "+ + "the root module, so a ban that lives only here does not hold where the deleted "+ + "implementation actually lived.", path) + } + for _, fragment := range bannedImportSubstrings { + require.True(t, shell.matches("github.com/vendored/"+fragment), + "the AST gate bans any path containing %q and the shell sweep does not match "+ + "a vendored one", fragment) + } + for _, prefix := range bannedImportPrefixes { + require.True(t, shell.matches(prefix+"ed25519"), + "the AST gate bans the prefix %q and the shell sweep does not", prefix) + } + for _, path := range envelopeDecoders { + require.True(t, shell.matches(path), + "the AST gate bans the envelope decoder %q and the shell sweep does not", path) + } + + // AND THE REVERSE, which is the direction that was wrong for the cipher + // family. Anything the sweep refuses repository-wide must be refused here + // too, unless this gate holds it to named files and symbols instead — + // which is strictly stronger, not weaker. + for _, path := range []string{ + "crypto", "crypto/ed25519", "crypto/ecdsa", "crypto/rsa", "crypto/dsa", + "crypto/hmac", "crypto/ecdh", "crypto/elliptic", "crypto/subtle", + "crypto/cipher", "crypto/aes", "crypto/des", "crypto/rc4", + "crypto/sha512", "crypto/sha1", "crypto/sha3", "crypto/md5", + "crypto/hkdf", "crypto/pbkdf2", + "encoding/gob", "encoding/asn1", "encoding/xml", + "google.golang.org/protobuf/encoding/protojson", + "google.golang.org/protobuf/encoding/protowire", + } { + if !shell.matches(path) { + continue + } + _, narrowed := narrowedImports[path] + require.True(t, slices.Contains(bannedImports, path) || narrowed, + "the shell sweep refuses %q repository-wide and this gate neither bans it nor "+ + "holds it to named files and symbols — so the module the capability lives in "+ + "is the one with the weaker rule", path) + } +} + +// shellBans is the sweep's three regexes, compiled. +type shellBans struct{ patterns []*regexp.Regexp } + +func (b shellBans) matches(path string) bool { + for _, pattern := range b.patterns { + if pattern.MatchString(path) { + return true + } + } + return false +} + +// shellBanPatterns reads primitives=, encoders= and envelope= out of the script +// itself, so the test cannot drift from the file it is about by being updated +// alongside a copy of it. +func shellBanPatterns(t *testing.T, script string) shellBans { + t.Helper() + var bans shellBans + for _, name := range []string{"primitives", "encoders", "envelope"} { + found := false + for _, line := range strings.Split(script, "\n") { + prefix := name + "='" + if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, "'") { + continue + } + expression := strings.TrimSuffix(strings.TrimPrefix(line, prefix), "'") + compiled, err := regexp.Compile(expression) + require.NoError(t, err, "%s= is not a usable expression: %s", name, expression) + bans.patterns = append(bans.patterns, compiled) + found = true + break + } + require.True(t, found, + "no %s= assignment in check-one-implementation.sh; this test reads the script's own "+ + "lists and cannot check a list it cannot find", name) + } + // encoding/json is handled by exact name in the sweep rather than by one of + // the three expressions, so it is added here to match. + bans.patterns = append(bans.patterns, regexp.MustCompile(`^encoding/json$`)) + return bans +} + +// B5. RequestTimeout DID NOT BOUND THE DETACHED ATTEMPT. +// +// ProjectedTokenSource is a caller's interface and takes no context, so the +// read in front of the HTTP request was bounded by nothing. Measured with a +// source that blocks and RequestTimeout at 150ms: Credential returned after +// 3.0s, bounded by the CALLER's deadline — and with no caller deadline it does +// not return at all, because the detached goroutine never closes `done`, so +// every later caller waits on an attempt that will not finish and no hold-off +// is ever installed. mintInto's own comment said RequestTimeout was "what makes +// the request terminate". +func TestRequestTimeoutBoundsTheTokenSourceRead(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &blockingSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 150 * time.Millisecond + }) + + // A caller whose own deadline is twenty times RequestTimeout, so what + // bounds the attempt is observable rather than inferred. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + started := time.Now() + _, err := client.Credential(ctx) + elapsed := time.Since(started) + + require.ErrorIs(t, err, ErrMintUnavailable, + "a slow projection is a mount under load, which clears: held off, never latched") + require.NoError(t, client.Refused(), "and it must not latch") + require.ErrorContains(t, err, "reading the projected token did not finish") + require.Less(t, elapsed, time.Second, + "RequestTimeout is 150ms; an attempt that runs %s is bounded by the caller instead", elapsed) +} + +// blockingSource is a token source that does not return — a hung mount, a +// source holding a lock, an implementation with a bug in it. +type blockingSource struct{ released chan struct{} } + +func (s *blockingSource) ProjectedToken() (string, error) { + <-s.released + return "projected", nil +} + +// B6. ENDPOINT TRUST WAS OPTIONAL, AND SILENTLY THE SYSTEM POOL. +// +// Every other route to a weak transport here was closed — no caller-supplied +// client, no reachable Transport, a TLS 1.3 floor, no followed redirect — and +// the trust anchor defaulted to whatever the image shipped. A mis-issued +// certificate for the host name, or a corporate interception root, receives the +// projected service-account token. It is a deployment decision, so it is stated +// or construction refuses; TrustSystemRoots is how a deployment that means the +// host's pool says so. +func TestTheMintEndpointsTrustAnchorMustBeStated(t *testing.T) { + base := MintOptions{ + URL: "https://mint.example/platform/_mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), + ProjectionAudience: "projection-audience", + } + + _, err := NewMintClient(base) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "no trust anchor") + require.ErrorContains(t, err, "https://mint.example/platform/_mint", + "the refusal must name the endpoint the projection would have been sent to") + + stated := base + stated.TrustSystemRoots = true + _, err = NewMintClient(stated) + require.NoError(t, err, "a deployment that says it means the host's pool is configured") + + named := base + named.RootCAs = certPoolOf() + _, err = NewMintClient(named) + require.NoError(t, err, "and so is one that names the roots") + + both := named + both.TrustSystemRoots = true + _, err = NewMintClient(both) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "not both", + "two anchors is not a stronger statement than one; it is an unanswered question") +} From 3b697f07d6aa4fe72ee79ecd0c5541ad3a6a0356 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:19:08 -0400 Subject: [PATCH 23/35] test: a reproducer that depended on the machine's git configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My error, and exactly the class the file it is in exists for: green locally, red in CI. TestTheSweepRefusesWhenItCouldNotLook/the_same_number_of_different_refs pushed a new branch with `HEAD:refs/heads/appeared/later`. A clone's HEAD is the remote's, and a bare repository's HEAD follows whoever ran `git init` — so on a runner whose init.defaultBranch is master it resolved to nothing: error: src refspec HEAD does not match any The three new reproducers took the coverage and both race jobs down with them, and the one real failure in that run — the dependabot ref still carrying the implementation — was the only check failing for a reason about this repository. Fixed by naming what the test means instead of inheriting it: the push is from refs/remotes/origin/main, and both throwaway repositories are created with `-b main`. Reproduced the CI failure locally first with GIT_CONFIG_GLOBAL pointing at init.defaultBranch=master, and both modules' suites now pass under that setting and under mine, with -race. The pre-existing sweep tests were checked under the same setting and were not fragile: they already pushed `main:refs/heads/…` by name. Co-Authored-By: Claude Opus 5 (1M context) --- sweep_bypasses_test.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 7a4305d..ad0a183 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -288,10 +288,18 @@ func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { clone := clonedRemote(t, sweep, checker, "kept") remote := strings.TrimSpace(runOut(t, clone, "git", "remote", "get-url", "origin")) // The remote moves on; this checkout does not refetch. + // + // Pushed from origin/main BY NAME, never from HEAD: a clone's HEAD is + // the remote's, and a bare repository's HEAD follows whoever ran + // `git init` — so `HEAD:refs/heads/…` resolved here and failed on a + // runner whose init.defaultBranch is master with "src refspec HEAD + // does not match any". Green locally, red in CI, which is the class + // this whole file is about. seed := t.TempDir() runIn(t, seed, "git", "clone", "--quiet", remote, ".") runIn(t, seed, "git", "push", "--quiet", "origin", ":refs/heads/kept") - runIn(t, seed, "git", "push", "--quiet", "origin", "HEAD:refs/heads/appeared/later") + runIn(t, seed, "git", "push", "--quiet", "origin", + "refs/remotes/origin/main:refs/heads/appeared/later") output, err := runSweep(clone) @@ -398,15 +406,17 @@ func TestTheImportScannerReadsOneLineOnce(t *testing.T) { // clone of it carrying both scripts. func clonedRemote(t *testing.T, sweep string, checker string, branch string) string { t.Helper() + // -b main on both: these repositories' branch names are part of what the + // sweep is asserted against, so they are stated here rather than inherited + // from whatever init.defaultBranch the machine happens to set. remote := t.TempDir() - runIn(t, remote, "git", "init", "--quiet", "--bare") + runIn(t, remote, "git", "init", "--quiet", "--bare", "-b", "main") seed := t.TempDir() - runIn(t, seed, "git", "init", "--quiet") + runIn(t, seed, "git", "init", "--quiet", "-b", "main") require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) runIn(t, seed, "git", "add", ".") runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") - runIn(t, seed, "git", "branch", "-M", "main") runIn(t, seed, "git", "remote", "add", "origin", remote) runIn(t, seed, "git", "push", "--quiet", "origin", "main") runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) From 2f2d721aa36a593f168f986ff1978a8606de4a1e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:38:40 -0400 Subject: [PATCH 24/35] feat(workcontext)!: deny by default, decide with go/types, and retract what cannot be fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round six, layer 4 (executed). The verdict was right and the main finding was right in the way that matters most: the gate passed a COMPLETE second implementation in the leaf module — encoding/json/v2 encoding a real WorkContextV1 as snake_case JSON, which is the deleted format, and crypto/mldsa signing it. Both gates green, lint clean. 22 of 24 compiled probes passed. The diagnosis in §5 is the part worth keeping: "every round has found the next name". That is ten rounds of hkdf, sha512, protoiface, mldsa, json/v2, protodelim, the gRPC codec, prototext, hpke. The list was not behind by one entry; it was behind by a design. So the question is inverted. F-1 (blocker) A COMPLETE SECOND IMPLEMENTATION PASSED. Four changes, with golang.org/x/tools green-lit by the owner: - A DENY-BY-DEFAULT IMPORT ALLOWLIST PER MODULE, measured from the tree with go list, failing in both directions — an import not on the list is a finding, and a list entry nothing imports is deleted. json/v2, mldsa, protodelim, grpc/encoding, prototext, jsontext and hpke are refused by one rule, and so is the next name nobody has thought of. 31 entries in the leaf, 45 in the root. - `import "C"` refused by name. A reviewer MACed a capability through CommonCrypto's CCHmac and checked it against RFC 4231; no rule about Go imports could see it. - TESTDATA WALKED. The go tool excludes it from wildcard matching, not from importing, so a signer under receipts/testdata/ was shipped code that no `./...` mentions. - THE CODEC RULE DECIDED BY go/types. A syntactic resolver answers with a SPELLING where the question is a KIND, and six compiled probes went through the gap: a type parameter ("M"), an embedded interface, an alias of proto.Message, a reflect.Call, a closure taking proto.Unmarshal, and a descriptor-built message. types.IsInterface answers all of them; the capability walk descends into struct fields, so L5's embedded *Claims is caught through a cross-file alias no syntactic rule could resolve; and the indirection allowlist is keyed on *types.Func plus the RECEIVER, so a second method named Handle on a new type is a different object. STATED, not implied: a hand-written implementation that imports nothing is outside all of this. A base64url decoder is twenty lines and a wire walk is fifty. That is the irreducible residue and it is closed by review. F-4 (blocker, process) NO ROUND-FIVE GATE CHANGE HAD A TEST, AND MY ANSWER WAS FALSE. Checked: `git diff 67271e6..1d6947d` added ZERO cases to TestTheGateCatchesItsOwnBypasses and zero to the sweep's table. I wrote "both of your shapes are committed probes" and "four probes, including the two you named". Neither was true; the 62 I kept quoting are the pre-existing table. Every rule added here has a probe shown to fail with its rule removed, and every probe is type-checked against the real dependency types rather than described. F-3 (blocker) THE SHELL EXTRACTOR IS DELETED. scripts/importsof reads imports with go/parser. The awk version was rewritten three times for this class and five more shapes walked past it, the decisive one being import "\x63rypto/ed25519" which compiles, IS crypto/ed25519 to the compiler, and is not that string to anything matching text. No regex closes that. A file that does not parse is now a failure, not a file with no imports. F-2 (blocker, regression) EXPIRY REACHED THE CLIENT AS codes.Internal. Fixed at 04e93c0; the bufconn test with a moving window the review asked for is added here, and the README's table was already corrected. F-7 PUBLISHED TAGS WERE NEVER SWEPT, and this is the finding every round missed: NINETEEN of them carry the implementation — v0.1.51 through v0.1.68 and v0.2.0, four more than the review counted, including the highest version this module publishes. A branch can be deleted; `go get sdk-go@v0.1.65` resolves today and the proxy keeps it forever. So the rule for a tag is RETRACTED, not clean, and go.mod retracts them with a test that measures the carrying set rather than matching a filename. The retraction takes effect only when a new version is tagged, which is the owner's step. The counts-not-sets and ls-remote-failure halves were fixed at 04e93c0. F-5 EVERY 200 THAT IS NOT A CAPABILITY LATCHED FOREVER, and so did every redirect. `{}`, `null`, a garbage work_context, a 307 and a 308 each became terminal. They are the same class as the text/html 200 and the oversized 200 beside them, both already retryable: something that is not the host is answering. The redirect's old reason does not survive being stated — the destination receives nothing on any attempt, because refuseMintRedirect is what stops it, so latching bought no confidentiality and stopped the process on a rollout's 307. Both are outages, one table drives every shape AND its recovery. F-6 THE UNTESTED FIXES. The one latch rule is driven over its error classes (misconfiguration, an unlabelled source error, an outage, a refusal), and WithoutCancel(caller) is asserted by reading the caller's context value off the request — reverting it to Background() had survived because nothing looked. F-9 THE INTERCEPTOR FAILED OPEN TWO WAYS. An empty method set now refuses every stream instead of passing them through: an interceptor installed to enforce something, that enforces nothing, is the worst outcome because the wiring is there and nobody looks again. And ValidateMethodSet(server.GetServiceInfo(), …) catches the typo, which no interceptor can see from inside one request. F-10 the shell's `receipts/*` glob narrowed to receipts/digest.go, the one file the AST gate names and the only one that imports encoding/json. F-11 the stale paragraphs: the "Terminal, unlike" header above a retryable branch, the TLS "still latches" claim, the redirect's, and "three indirections" above a list of four — now a checked number rather than prose. F-12 RootCAs is REQUIRED. The TrustSystemRoots flag added hours ago is deleted: it is the same decision with a shorter spelling, and a shorter spelling is what gets copied. x509.SystemCertPool() is two lines and visible at the call site. F-13 go.yml triggers on `branches: ['**']`. They said compat/** for three revisions after the comment above them stopped saying it, so a dependabot branch was never built by its own push. F-14 the product name is not in the tree (fixed at 04e93c0). It remains in two COMMIT MESSAGES, d943934 and 31ca467, and I am not rewriting them: three reviews cite SHAs on this branch and a force-push would leave those citations resolving to nothing. The squash body is composed at merge and can drop the paragraph. #47's criterion is now written out in the PR body as two options with their costs, and the STRICTER one is what the code does: DefaultMaxTTL stays at one hour, so merging never lengthens a credential's life. Meeting the criterion as worded needs a longer-lived bearer credential, which is the owner's decision. Verified: both modules' suites and -race clean, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (60 Go files), 11 new gate mutations all killed, coverage 92.4% workcontext / 77.9% root. NOT VERIFIED: nothing ran against a real runtime or a real mint endpoint. The type-checked gates need the module cache, so they are slower than the syntactic one and CI's cache is what keeps them quick. The tag gate reads LOCAL tags and fails rather than skips when there are none, so every job running the root suite now checks out with fetch-depth 0. L7 — a hand-written protobuf wire walk — is still outside the gate by construction. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 55 +- .testcoverage.yaml | 8 + AGENTS.md | 18 +- go.mod | 24 + published_tags_test.go | 165 +++++ scripts/check-one-implementation.sh | 271 ++++--- scripts/importsof/main.go | 87 +++ sweep_bypasses_test.go | 119 ++- workcontext/AGENTS.md | 61 +- workcontext/README.md | 27 +- workcontext/codec_types_test.go | 675 ++++++++++++++++++ workcontext/go.mod | 3 + workcontext/go.sum | 6 + workcontext/grpctransport/guarded_stream.go | 88 ++- .../grpctransport/round6_reproducers_test.go | 91 +++ workcontext/import_allowlist_test.go | 453 ++++++++++++ workcontext/layer4_reproducers_test.go | 23 +- workcontext/mint.go | 139 ++-- workcontext/mint_test.go | 69 +- workcontext/one_implementation_test.go | 14 +- workcontext/round6_reproducers_test.go | 193 ++++- 21 files changed, 2294 insertions(+), 295 deletions(-) create mode 100644 published_tags_test.go create mode 100644 scripts/importsof/main.go create mode 100644 workcontext/codec_types_test.go create mode 100644 workcontext/import_allowlist_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a601816..734baec 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,10 +8,14 @@ on: # The one-implementation sweep covers every published ref for the same # reason, and a dependabot branch carrying the deleted implementation is what # showed the convention was doing no work. + # So the triggers name no convention either. They said `compat/**` for three + # revisions after the comment above stopped saying it, which meant a branch a + # consumer can pin — a dependabot branch, say — was never built and never + # swept by its own push, while the comment claimed every published ref was + # held to the rule. push: - branches: [ main, 'compat/**' ] + branches: [ '**' ] pull_request: - branches: [ main, 'compat/**' ] concurrency: group: sdk-go-${{ github.workflow }}-${{ github.ref }} @@ -30,7 +34,15 @@ jobs: module: [ ".", "workcontext" ] steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -66,6 +78,21 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + - name: Set up Go + # The sweep reads imports with go/parser now (scripts/importsof), + # because a regex over lines cannot read Go — `import + # "\x63rypto/ed25519"` compiles and is crypto/ed25519 to the compiler. + # So this job needs a toolchain, which it did not before. + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Build the import reader once + # Built once and passed to every invocation, rather than rebuilt for + # each of ~25 refs. + run: | + go build -o "$RUNNER_TEMP/importsof" ./scripts/importsof + echo "IMPORTSOF_BIN=$RUNNER_TEMP/importsof" >> "$GITHUB_ENV" - name: Sweep every tracked Go file for a second implementation run: ./scripts/check-one-implementation.sh - name: Sweep every OTHER ref this repository publishes @@ -95,7 +122,15 @@ jobs: module: [ ".", "workcontext" ] steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -139,7 +174,15 @@ jobs: CODEFLY_TEST_POSTGRES_DSN: postgres://codefly:codefly@localhost:5432/receipts?sslmode=disable steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -193,7 +236,15 @@ jobs: CODEFLY_TEST_POSTGRES_DSN: postgres://codefly:codefly@localhost:5432/receipts?sslmode=disable steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: diff --git a/.testcoverage.yaml b/.testcoverage.yaml index bf5b0b6..de5a5a0 100644 --- a/.testcoverage.yaml +++ b/.testcoverage.yaml @@ -12,3 +12,11 @@ threshold: exclude: paths: - \.pb\.go$ + # scripts/ is TOOLING, not the library a consumer imports: importsof reads a + # Go file's imports for the ref sweep and runs as a built binary, so the + # test that drives it (TestTheImportReaderAnswersWithWhatGoSees) execs it + # and no coverage counter is attributed. Counting it would lower the gate on + # the library in exchange for a number about a 40-line main, and raising + # the gate by excluding real code is the thing this line must not become — + # it names scripts/ exactly, not a pattern that grows. + - ^scripts/ diff --git a/AGENTS.md b/AGENTS.md index e50ba52..f62096a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,12 +121,18 @@ see the skill below. through `Inspect`. Nothing here may sign, check a signature, encode a capability **or decide what a capability is** — a local seal rule is a second implementation even when it signs nothing, and ours disagreed with core's own - fixtures about which sentinel three refusals earn. Two gates hold that, and - **both read both modules**: `TestNoSecondWorkContextImplementation` (AST) and - `scripts/check-one-implementation.sh` (import paths, plus every published - ref). The AST gate walked the leaf module only until a reviewer compiled a - root-module parser whose base64url decoder was hand-written, so it imported - nothing a sweep can ban. + fixtures about which sentinel three refusals earn. **Four gates hold that, and + all four read both modules**: a **deny-by-default import allowlist per + module** and a **`go/types`-decided codec rule** (both via + `x/tools/go/packages`), the syntactic AST gate, and + `scripts/check-one-implementation.sh` over every published ref, which reads + imports with `go/parser` because a regex cannot — `import "\x63rypto/ed25519"` + compiles. The allowlist replaced a denylist of names that was behind by one + entry every round for ten rounds, the last being `crypto/mldsa` signing a + `WorkContextV1` that `encoding/json/v2` had encoded in the deleted format, + with both gates green. `testdata` is walked and `import "C"` is refused. + **Published TAGS cannot be fixed, only retracted**: nineteen of them carry the + deleted implementation and `go.mod` retracts them. **`workcontext/AGENTS.md` is what each one refuses and why** — read it before touching that module, the gate, or the sweep. - **An authority-bearing value is read once.** A principal, binding or audience diff --git a/go.mod b/go.mod index 702d84e..3529183 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,27 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// RETRACTED: every version below publishes a SECOND Work Context +// implementation, which this module's one-implementation rule forbids and which +// a consumer can still pin today. +// +// A branch that carries it can be deleted. A TAG CANNOT: it is an immutable +// published artifact, `go get sdk-go@v0.1.65` resolves it right now, and the +// module proxy has it cached forever. So the rule for a tag is not "clean", it +// is RETRACTED — which is the one mechanism Go provides for "this published +// version should not be used", and `go get` and `go list -m -u` both report it. +// +// Nineteen of them, measured by sweeping every published tag rather than by +// reading the names: v0.1.51 through v0.1.68 and v0.2.0. An executed review +// counted fifteen and stopped at v0.1.65; v0.1.66, v0.1.67, v0.1.68 and v0.2.0 +// carry it too, the last being the highest version this module has published. +// +// TWO THINGS THIS DOES NOT DO, both of which belong to whoever tags releases: +// a retraction takes effect only once a NEW version is tagged carrying this +// go.mod, and nothing here deletes anything from the proxy, because nothing +// can. +retract ( + v0.2.0 // the same, moved under workcontext/ + [v0.1.51, v0.1.68] // a second Work Context implementation at the root +) diff --git a/published_tags_test.go b/published_tags_test.go new file mode 100644 index 0000000..c49b942 --- /dev/null +++ b/published_tags_test.go @@ -0,0 +1,165 @@ +package codefly_test + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A TAG CANNOT BE FIXED, ONLY RETRACTED, and nineteen of them publish the +// second implementation this PR deletes. +// +// The ref sweep covers branches, and a branch carrying the implementation can +// be deleted. `v0.1.65` cannot: `go get sdk-go@v0.1.65` resolves it right now, +// the module proxy has it cached permanently, and nothing anybody does to this +// repository changes what that version contains. An executed review found this +// and it had been missed by every round: the sweep's subject was "every +// published ref" and tags are published refs. +// +// So the rule for a tag is RETRACTED, not clean — the one mechanism Go provides +// for "this published version should not be used". This test is the gate on +// that: every tag that carries the implementation is covered by a retract +// directive in go.mod. +// +// It is measured, by reading each tag's imports, not by matching +// `work_context.go` — a name check is the thing this whole change is about. +func TestEveryPublishedTagCarryingTheImplementationIsRetracted(t *testing.T) { + tags := localTags(t) + require.NotEmpty(t, tags, + "no tags are present in this checkout, so this gate read nothing.\n"+ + "CI fetches them (`git fetch --tags`); a run without them would pass having\n"+ + "looked at nothing, which is the fail-open this repository has fixed five times.") + + retracted := retractedVersions(t) + reader := importReader(t) + + var carrying, unretracted []string + for _, tag := range tags { + if !tagCarriesAnImplementation(t, reader, tag) { + continue + } + carrying = append(carrying, tag) + if !retracted[tag] { + unretracted = append(unretracted, tag) + } + } + + require.NotEmpty(t, carrying, + "not one published tag carries the implementation, which contradicts the measurement "+ + "this gate was written from — so the measurement is now wrong or the sweep is") + require.Empty(t, unretracted, + "these published versions carry a second Work Context implementation and are not "+ + "retracted: %v.\nA consumer can pin any of them today. A tag cannot be deleted or "+ + "fixed, so the only answer is a retract directive in go.mod — which takes effect "+ + "once a new version is tagged carrying it.", unretracted) +} + +// localTags is every tag in this checkout, which in CI is every published tag. +func localTags(t *testing.T) []string { + t.Helper() + run := exec.Command("git", "tag") + output, err := run.Output() + require.NoError(t, err) + var tags []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + tags = append(tags, line) + } + } + return tags +} + +// retractedVersions expands go.mod's retract directives, ranges included. +// +// Read through `go mod edit -json` rather than by matching the file, because +// the shape a person writes and the set the go tool acts on are two different +// things — and this gate is about the second. +func retractedVersions(t *testing.T) map[string]bool { + t.Helper() + run := exec.Command("go", "mod", "edit", "-json") + output, err := run.Output() + require.NoError(t, err) + + var parsed struct { + Retract []struct { + Low string `json:"Low"` + High string `json:"High"` + } `json:"Retract"` + } + require.NoError(t, json.Unmarshal(output, &parsed)) + require.NotEmpty(t, parsed.Retract, "go.mod retracts nothing") + + covered := map[string]bool{} + for _, tag := range localTags(t) { + for _, span := range parsed.Retract { + // `go mod edit -json` writes a single version as Low == High, so + // one comparison covers both shapes. + if semverAtMost(span.Low, tag) && semverAtMost(tag, span.High) { + covered[tag] = true + } + } + } + return covered +} + +// semverAtMost reports whether low <= high. +func semverAtMost(low string, high string) bool { + return low == high || compareVersions(low, high) <= 0 +} + +// compareVersions orders vMAJOR.MINOR.PATCH numerically. Both inputs come from +// this repository's own tags and its own go.mod, which are all of that shape. +func compareVersions(left string, right string) int { + leftParts := versionFields(left) + rightParts := versionFields(right) + for index := range 3 { + switch { + case leftParts[index] < rightParts[index]: + return -1 + case leftParts[index] > rightParts[index]: + return 1 + } + } + return 0 +} + +// versionFields parses the three numeric fields. A field that is not a number +// becomes -1, which orders before everything — so a tag this repository does +// not use the shape of sorts outside every retract range and is REPORTED as +// unretracted rather than silently covered by one. +func versionFields(version string) [3]int { + var fields [3]int + trimmed := strings.TrimPrefix(version, "v") + for index, part := range strings.SplitN(trimmed, ".", 3) { + if index > 2 { + break + } + value := 0 + for _, digit := range part { + if digit < '0' || digit > '9' { + value = -1 + break + } + value = value*10 + int(digit-'0') + } + fields[index] = value + } + return fields +} + +// tagCarriesAnImplementation runs the repository sweep over one tag. +func tagCarriesAnImplementation(t *testing.T, reader string, tag string) bool { + t.Helper() + run := exec.Command("bash", "scripts/check-one-implementation.sh", tag) + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, err := run.CombinedOutput() + if err == nil { + return false + } + require.Contains(t, string(output), "carries a Work Context implementation", + "the sweep failed on %s for a reason that is not a finding:\n%s", tag, output) + return true +} diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index e9a2c47..aaaaf51 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -3,18 +3,22 @@ # repository: it is codefly-dev/core/workcontext. This sweeps the WHOLE # repository — both modules, every tracked Go file — for a second one. # -# It runs as a job in go.yml, which is the workflow that builds every ref this -# repository publishes (main and compat/**), so the gate holds wherever the -# build holds. It is required on main by an active ruleset, under the check name +# It runs as a job in go.yml, whose triggers are `branches: [ '**' ]` — every +# ref this repository publishes, with no naming convention in it, because a +# consumer pins a COMMIT and go.mod records a pseudo-version with no idea what +# the branch was called. The triggers said `compat/**` for three revisions +# after the comment above them stopped saying it. It is required on main by an active ruleset, under the check name # "no second Work Context implementation". There is no compatibility period: a # second implementation on a ref this repository builds is a failure to fix, and # a release line that cannot meet the rule is retired by the owner rather than # exempted. # -# It is not the same check as TestNoSecondWorkContextImplementation, which walks -# from the workcontext module root and therefore cannot see the rest of the -# repository. The implementation this repository deleted lived at the ROOT, in -# package codefly, which is exactly the place that test's walk does not reach. +# It is not the same check as the Go gates, and it is no longer the only gate +# that reads the root module: TestEveryImportIsOnItsModulesAllowlist and +# TestNoCodecTouchesACapabilityByType type-check BOTH modules with +# x/tools/go/packages. This script is what runs over every PUBLISHED REF, where +# there is no checkout to type-check — which is why it reads imports with +# go/parser (scripts/importsof) rather than with a regex. # # THE PREDICATE IS AN ABSOLUTE BAN ON SIGNING, with a named allowlist — not a # conjunction. It used to be "imports a signing primitive AND mentions @@ -56,6 +60,9 @@ # consumer inventory. set -euo pipefail +# Where this script lives, so the import reader beside it can be built. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Any of these in an import path is a signature, a MAC, or a library that mints # bearer tokens. The bare "crypto" package is included: crypto.Signer signs # Ed25519 with no ed25519 import anywhere. @@ -120,178 +127,160 @@ allowed() { 'workcontext/cache_partition.go|crypto/sha256') return 0 ;; 'receipts/digest.go|crypto/sha256') return 0 ;; 'receipts/postgres.go|crypto/sha256') return 0 ;; - # Effect receipts, whose rows are JSON and are not capabilities. - 'receipts/'*'|encoding/json') return 0 ;; + # The receipts DIGEST, which canonicalises a receipt REQUEST. By exact + # path: this was `receipts/*`, permitting encoding/json anywhere under + # receipts, while the AST gate named one file — so the two gates disagreed + # about the same directory and the looser one was this script, which is the + # only gate the ref sweep runs. Measured from the tree: receipts/digest.go + # is the only non-test file there that imports it. + 'receipts/digest.go|encoding/json') return 0 ;; esac return 1 } -# import_paths -> every imported path, one per line. +# IMPORTS ARE READ BY GO'S OWN PARSER, not by a regex over lines. # -# It takes what is INSIDE the quotes and never tries to recognise the alias in -# front of it. The previous version matched the whole line against a regex for -# an import's shape, which a non-ASCII alias and a `/* comment */` prefix both -# defeated — and in the root module this sweep is the only gate, so each was a -# complete bypass. -import_paths() { - awk ' - # Comments are STRIPPED FIRST, so a comment before or inside the keyword - # cannot hide it: `/* x */ import "crypto/ed25519"` anchored nothing when - # the match required `import` at the start of the line. - { stripped = $0 - gsub(/\/\*[^*]*\*\//, " ", stripped) - sub(/\/\/.*$/, "", stripped) } - # An import block opens wherever `import` is followed by `(`, not only when - # the line IS `import (`. - stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped ~ /\(/ { inblock = 1 } - stripped ~ /(^|[^[:alnum:]_])import([^[:alnum:]_]|$)/ && stripped !~ /\(/ { line = 1 } - # THE CLOSING PAREN IS READ OFF THE STRIPPED LINE, not off $0. Reading it - # off the raw line meant a `)` INSIDE A COMMENT closed the block early, so - # - # import ( - # // ) - # "crypto/ed25519" - # ) - # - # compiled, passed `go vet`, and swept clean — measured, exit 0. One - # character of comment was the whole bypass, in the root module where this - # script is the only gate. - inblock && stripped ~ /\)/ { closing = 1 } - (inblock || line) { - rest = stripped - # EVERY quoted path on the line, of EITHER quoting, in ONE left-to-right - # pass. Two sequential loops — all the "…" first, then the `…` in what - # was left — dropped a raw path that came BEFORE a quoted one on the same - # line: `import (`crypto/ed25519`; "fmt")` reported only fmt. Also - # measured, also compiling, also exit 0. A scanner that reads the same - # line twice in two orders is reading two different lines. - while (1) { - q = match(rest, /"[^"]+"/); qs = RSTART; ql = RLENGTH - if (!q) { qs = 0 } - r = match(rest, /`[^`]+`/); rs = RSTART; rl = RLENGTH - if (!r) { rs = 0 } - if (!qs && !rs) { break } - if (qs && (!rs || qs < rs)) { start = qs; len = ql } else { start = rs; len = rl } - print substr(rest, start + 1, len - 2) - rest = substr(rest, start + len) - } - line = 0 - } - closing { inblock = 0; closing = 0 } - ' "$1" +# The awk extractor this replaces was rewritten three times for exactly this +# class, and each rewrite was walked past by source that compiles: a ")" inside +# a comment closing the import block, a raw-string path before a quoted one on +# the same line, the keyword followed by a newline, a comment spanning lines, +# and +# +# import "\x63rypto/ed25519" +# +# which IS crypto/ed25519 to the compiler and is not that string to anything +# matching text. The escape cannot be closed by any amount of regex, and the +# language's own parser closes all five at once — the question was always "what +# does Go think this file imports". +# +# It matters most HERE. The AST gate reads the working tree; this script is what +# the ref sweep runs over every published ref, ALONE, so each shape above was a +# complete bypass of the required check for any branch a consumer can pin. +# IMPORTSOF_BIN names a prebuilt reader. The script builds one otherwise; the +# variable exists because the sweep's own tests run it in throwaway +# repositories, and building the reader once for all of them is the difference +# between a second and a minute. +helper="${IMPORTSOF_BIN:-}" +build_helper() { + [ -n "$helper" ] && return 0 + helper="$(mktemp -d)/importsof" + if ! go build -o "$helper" "$here/importsof"; then + echo "FAIL cannot build the import reader at $here/importsof." >&2 + echo " Imports are read with go/parser now, because a regex over lines cannot" >&2 + echo " read Go: an escaped path is one string to the compiler and another to grep." >&2 + exit 1 + fi } -# offending_imports -> prints each offending import PATH -offending_imports() { - local path="$1" file="$2" imported - while IFS= read -r imported; do - [ -n "$imported" ] || continue - case "$imported" in - 'crypto/tls'|'crypto/x509') - # TLS plumbing, still held to the allowlist. - allowed "$path" "$imported" || printf '%s\n' "$imported" - continue - ;; - 'crypto/rand') - # Randomness is not a key. - continue - ;; - 'crypto/sha256') - # The ONE hash with uses here — two digests — and held to them by the - # AST gate's file and symbol rule. The others are not waved through: - # hkdf.Extract(sha512.New, …) and pbkdf2.Key(sha512.New, …) are HMAC, - # and both passed this script while `crypto/sha512` sat in the "a hash - # is not a signature" list beside it. - allowed "$path" "$imported" || printf '%s\n' "$imported" - continue - ;; - esac - if printf '%s' "$imported" | grep -Eq "($primitives)"; then - allowed "$path" "$imported" || printf '%s\n' "$imported" - continue - fi - if printf '%s' "$imported" | grep -Eq "($encoders)"; then - allowed "$path" "$imported" || printf '%s\n' "$imported" - continue - fi - if printf '%s' "$imported" | grep -Eq "($envelope)" || [ "$imported" = "encoding/json" ]; then - allowed "$path" "$imported" || printf '%s\n' "$imported" - fi - done < "$file" +# imports_of_files -> "pathimportpath" per import. +# +# A file that does not parse is a FAILURE and never a file with no imports. +imports_of_files() { + build_helper + if ! "$helper" < "$1"; then + echo "FAIL cannot read the imports of the files listed above." >&2 + echo " A file this sweep cannot parse is not a file it has cleared." >&2 + exit 1 + fi +} + +# offending_import -> true when this import is a finding. +offending_import() { + local path="$1" imported="$2" + case "$imported" in + 'crypto/tls'|'crypto/x509') + # TLS plumbing, still held to the allowlist. + allowed "$path" "$imported" && return 1 + return 0 + ;; + 'crypto/rand') + # Randomness is not a key. + return 1 + ;; + 'crypto/sha256') + # The ONE hash with uses here — three digests — held to them by the AST + # gate's file and symbol rule. The others are not waved through: + # hkdf.Extract(sha512.New, …) and pbkdf2.Key(sha512.New, …) are HMAC, and + # both passed this script while `crypto/sha512` sat in the "a hash is not + # a signature" list beside it. + allowed "$path" "$imported" && return 1 + return 0 + ;; + esac + if printf '%s' "$imported" | grep -Eq "($primitives)|($encoders)|($envelope)" || + [ "$imported" = "encoding/json" ]; then + allowed "$path" "$imported" && return 1 + return 0 + fi + return 1 } carrying_in_tree() { - local found="" tmp tracked + local found="" tracked list imports path imported if ! tracked=$(git ls-files -- '*.go'); then echo "FAIL cannot enumerate the tracked Go files in this checkout." >&2 exit 1 fi - tmp=$(mktemp) + list=$(mktemp); imports=$(mktemp) while IFS= read -r path; do case "$path" in *_test.go) continue ;; - *.go) ;; - *) continue ;; + *.go) printf '%s\n' "$path" >> "$list" ;; esac - # A READ THAT FAILS IS NOT A FILE WITH NO IMPORTS. `|| : > "$tmp"` emptied - # the extraction on any error and then swept the empty result as clean. - if ! import_paths "$path" > "$tmp"; then - echo "FAIL cannot read the imports of $path; a file this sweep cannot parse is not a file it has cleared." >&2 - exit 1 - fi - local bad - bad=$(offending_imports "$path" "$tmp") - if [ -n "$bad" ]; then - while IFS= read -r imported; do - found="$found$path imports $imported"$'\n' - done <<< "$bad" - fi - # `done <<< "$(git ls-files …)"` discards git's exit status: a failed - # enumeration becomes an empty one and the sweep reports a clean tree. The - # listing is taken first so the failure is a failure. done <<< "$tracked" - rm -f "$tmp" + imports_of_files "$list" > "$imports" + while IFS=$'\t' read -r path imported; do + [ -n "$imported" ] || continue + if offending_import "$path" "$imported"; then + found="$found$path imports $imported"$'\n' + fi + done < "$imports" + rm -f "$list" "$imports" printf '%s' "$found" } carrying_in_ref() { - local ref="$1" found="" tmp entries + local ref="$1" found="" entries list imports work path imported index=0 if ! entries=$(git ls-tree -r --name-only "$ref"); then echo "FAIL cannot enumerate $ref, so it has not been swept." >&2 exit 1 fi - tmp=$(mktemp) + work=$(mktemp -d); list=$(mktemp); imports=$(mktemp) + # Each blob's path in the REF is recorded beside its temporary copy, so a + # finding names that path and not something under /tmp. + local -a origin=() while IFS= read -r path; do case "$path" in *_test.go) continue ;; *.go) ;; *) continue ;; esac - # THE SAME FAIL-OPEN, and this one was reachable without any error at all. + # THE SAME FAIL-OPEN AS THE TREE'S, and this one needed no error at all: # `git ls-tree -r` names gitlinks as well as blobs, so a submodule whose - # path ends in `.go` is listed and `git cat-file blob` cannot read it: - # measured on a throwaway ref, `fatal: bad file` was swallowed by - # `2>/dev/null`, the source was emptied, and the ref was reported `ok` with - # exit 0. Anything this sweep cannot open is a failure to look at, not a - # finding of nothing. - if ! git cat-file blob "$ref:$path" > "$tmp.src" 2>"$tmp.err"; then + # path ends in `.go` is listed and `git cat-file blob` cannot read it. + # Measured: `fatal: bad file` swallowed by 2>/dev/null, the source emptied, + # and the ref reported `ok` with exit 0. + if ! git cat-file blob "$ref:$path" > "$work/$index.go" 2>"$work/err"; then echo "FAIL cannot read $ref:$path, so this ref has not been swept. git said:" >&2 - sed 's/^/ /' "$tmp.err" >&2 - exit 1 - fi - if ! import_paths "$tmp.src" > "$tmp"; then - echo "FAIL cannot read the imports of $ref:$path; a file this sweep cannot parse is not a file it has cleared." >&2 + sed 's/^/ /' "$work/err" >&2 exit 1 fi - local bad - bad=$(offending_imports "$path" "$tmp") - if [ -n "$bad" ]; then - while IFS= read -r imported; do - found="$found$path imports $imported"$'\n' - done <<< "$bad" - fi + origin[index]="$path" + printf '%s\n' "$work/$index.go" >> "$list" + index=$((index + 1)) done <<< "$entries" - rm -f "$tmp" "$tmp.src" "$tmp.err" + if [ "$index" -gt 0 ]; then + imports_of_files "$list" > "$imports" + while IFS=$'\t' read -r path imported; do + [ -n "$imported" ] || continue + local base="${path##*/}" + path="${origin[${base%.go}]}" + if offending_import "$path" "$imported"; then + found="$found$path imports $imported"$'\n' + fi + done < "$imports" + fi + rm -rf "$work"; rm -f "$list" "$imports" printf '%s' "$found" } diff --git a/scripts/importsof/main.go b/scripts/importsof/main.go new file mode 100644 index 0000000..70700ad --- /dev/null +++ b/scripts/importsof/main.go @@ -0,0 +1,87 @@ +// Command importsof prints every import path of the Go files named on stdin. +// +// IT EXISTS BECAUSE awk CANNOT PARSE GO, and five rounds of review proved it +// one shape at a time. The extractor it replaces was rewritten three times and +// each rewrite was walked past by source that compiles: +// +// import ( +// // ) +// "crypto/ed25519" // a ")" in a comment closed the block +// ) +// import (`crypto/ed25519`; "fmt") // a raw path before a quoted one +// import // the keyword, then a newline, +// "crypto/ed25519" // then the path +// import /* +// */ "crypto/ed25519" // a comment spanning lines +// import "\x63rypto/ed25519" // an escape sequence, and it compiles +// +// The last one is the argument in miniature: `"\x63rypto/ed25519"` IS +// "crypto/ed25519" to the Go compiler and is not the string `crypto/ed25519` +// to anything matching text. No regex over lines closes that; the language's +// own parser closes all five at once, because the question was always "what +// does Go think this file imports". +// +// This matters most where it is the ONLY gate. The AST gate reads the working +// tree; the ref sweep reads blobs out of `git cat-file` for every published +// ref, and it ran the shell extractor alone — so each shape above was a +// complete bypass of the required check for any branch. +// +// Input: one file path per line on stdin. Output: `path\timportpath` per +// import. A file that does not parse is a FAILURE, not a file with no imports: +// a sweep that reports success for what it could not read is the fail-open this +// repository has fixed four times. +package main + +import ( + "bufio" + "fmt" + "go/parser" + "go/token" + "os" + "strconv" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "importsof:", err) + os.Exit(1) + } +} + +func run() error { + fileSet := token.NewFileSet() + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + out := bufio.NewWriter(os.Stdout) + defer func() { + _ = out.Flush() + }() + for scanner.Scan() { + path := scanner.Text() + if path == "" { + continue + } + // ImportsOnly, so a file whose BODY does not compile is still read for + // its imports: the sweep's subject is what a file reaches for, and a + // blob at some old ref need not build against today's dependencies. + syntax, err := parser.ParseFile(fileSet, path, nil, parser.ImportsOnly|parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + for _, imported := range syntax.Imports { + // Unquote, so an escape sequence is the path Go sees rather than + // the text somebody typed. + spec, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return fmt.Errorf("%s: unquote %s: %w", path, imported.Path.Value, err) + } + if _, err := fmt.Fprintf(out, "%s\t%s\n", path, spec); err != nil { + return fmt.Errorf("write: %w", err) + } + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read the file list: %w", err) + } + return nil +} diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index ad0a183..905d558 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -1,10 +1,12 @@ package codefly_test import ( + "fmt" "os" "os/exec" "path/filepath" "strings" + "sync" "testing" "github.com/stretchr/testify/require" @@ -12,11 +14,14 @@ import ( // The repository-wide sweep, held to its own claim. // -// scripts/check-one-implementation.sh is the ONLY one-implementation gate the -// root module has: TestNoSecondWorkContextImplementation walks from the -// workcontext module root, and the implementation this repository deleted lived -// at the root, in package codefly. So a bypass here is a bypass with nothing -// behind it — which is why these cases exist. Each one was reported as a +// scripts/check-one-implementation.sh is the only one-implementation gate that +// reads a PUBLISHED REF. The Go gates type-check both modules from a checkout; +// a ref has no checkout, so the sweep reads blobs out of `git cat-file` and +// this script is all there is behind it. (It was the only gate reading the root +// module at all until the import allowlist and the type-checked codec rule +// landed; that half of the claim is no longer true.) A bypass here is still a +// bypass of the required check for any branch a consumer can pin, which is why +// these cases exist. Each one was reported as a // bypass of the previous revision and each one worked: // // - a non-ASCII import alias and a comment-prefixed import line both walked @@ -102,6 +107,44 @@ func TestTheRepositorySweepCatchesItsOwnBypasses(t *testing.T) { require.Error(t, err, "an empty sweep must not be a green sweep") } +// importReader builds scripts/importsof once per run and returns its path. +// +// Every harness here runs the sweep in a throwaway repository, where the script +// cannot find the reader beside itself — so it is built once and passed in +// through IMPORTSOF_BIN. Thirty `go build` invocations was the alternative. +func importReader(t *testing.T) string { + t.Helper() + readerOnce.Do(func() { + source, err := filepath.Abs(filepath.Join("scripts", "importsof")) + if err != nil { + readerErr = err + return + } + binary := filepath.Join(os.TempDir(), fmt.Sprintf("importsof-%d", os.Getpid())) + build := exec.Command("go", "build", "-o", binary, source) + if output, err := build.CombinedOutput(); err != nil { + readerErr = fmt.Errorf("build the import reader: %v: %s", err, output) + return + } + readerPath = binary + }) + require.NoError(t, readerErr) + require.NotEmpty(t, readerPath) + return readerPath +} + +var ( + readerOnce sync.Once + readerPath string + readerErr error +) + +// sweepEnv is the environment every sweep invocation runs with. +func sweepEnv(t *testing.T) []string { + t.Helper() + return append(os.Environ(), "IMPORTSOF_BIN="+importReader(t)) +} + // sweepOf runs the sweep over a throwaway repository containing one file. func sweepOf(t *testing.T, script string, name string, source string) (string, error) { t.Helper() @@ -118,6 +161,7 @@ func sweepOf(t *testing.T, script string, name string, source string) (string, e } sweep := exec.Command("bash", script) sweep.Dir = repository + sweep.Env = sweepEnv(t) output, err := sweep.CombinedOutput() return string(output), err } @@ -214,7 +258,7 @@ func TestTheRefSweepRefusesWhenItCanSeeFewerRefsThanExist(t *testing.T) { run := exec.Command("bash", "scripts/sweep-published-refs.sh") run.Dir = clone - run.Env = append(os.Environ(), "SWEEP_BASE=main", "SWEEP_HEAD=") + run.Env = append(sweepEnv(t), "SWEEP_BASE=main", "SWEEP_HEAD=") output, err := run.CombinedOutput() require.Error(t, err, "a sweep that can see fewer refs than exist must fail:\n%s", output) require.Contains(t, string(output), "worse than no sweep") @@ -224,7 +268,7 @@ func listRefs(t *testing.T, dir string, base string, head string) []string { t.Helper() run := exec.Command("bash", "scripts/sweep-published-refs.sh") run.Dir = dir - run.Env = append(os.Environ(), "SWEEP_LIST_ONLY=1", "SWEEP_BASE="+base, "SWEEP_HEAD="+head) + run.Env = append(sweepEnv(t), "SWEEP_LIST_ONLY=1", "SWEEP_BASE="+base, "SWEEP_HEAD="+head) output, err := run.CombinedOutput() require.NoError(t, err, "%s", output) var refs []string @@ -274,7 +318,7 @@ func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { clone := clonedRemote(t, sweep, checker, "kept") runIn(t, clone, "git", "remote", "set-url", "origin", filepath.Join(t.TempDir(), "gone.git")) - output, err := runSweep(clone) + output, err := runSweep(t, clone) require.Error(t, err, "a sweep with no denominator must not report success:\n%s", output) require.Contains(t, output, "cannot list the refs this repository publishes") @@ -301,7 +345,7 @@ func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { runIn(t, seed, "git", "push", "--quiet", "origin", "refs/remotes/origin/main:refs/heads/appeared/later") - output, err := runSweep(clone) + output, err := runSweep(t, clone) require.Error(t, err, "the same count of different refs must not pass:\n%s", output) require.Contains(t, output, "appeared/later", @@ -338,6 +382,7 @@ func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { run := exec.Command("bash", "scripts/check-one-implementation.sh", "gitlinked") run.Dir = repository + run.Env = sweepEnv(t) raw, err := run.CombinedOutput() output := string(raw) @@ -365,6 +410,15 @@ func TestTheImportScannerReadsOneLineOnce(t *testing.T) { "a closing paren inside a comment": "package x\n\nimport (\n\t// )\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", "a block comment holding a paren": "package x\n\nimport (\n\t/* ) */\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", "a raw path before a quoted one": "package x\n\nimport (`crypto/ed25519`; \"fmt\")\n\nvar _ = ed25519.Sign\nvar _ = fmt.Sprint\n", + // The shapes the awk extractor still passed after three rewrites, each + // compiled by the reviewer who found them. The last one is the whole + // argument for deleting the extractor: `"\x63rypto/ed25519"` IS + // crypto/ed25519 to the compiler, and is not that string to anything + // matching text. No regex closes it; go/parser closes all of them. + "the keyword, then a newline, then the path": "package x\n\nimport\n\t\"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n", + "a comment spanning lines before the path": "package x\n\nimport /*\n*/ \"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n", + "an escaped import path": "package x\n\nimport \"\\x63rypto/ed25519\"\n", + "a grouped escaped path": "package x\n\nimport (\n\t\"\\x63rypto/ed25519\"\n)\n", } { t.Run(name, func(t *testing.T) { out, err := sweepOf(t, script, "second.go", probe) @@ -430,10 +484,11 @@ func clonedRemote(t *testing.T, sweep string, checker string, branch string) str return clone } -func runSweep(dir string) (string, error) { +func runSweep(t *testing.T, dir string) (string, error) { + t.Helper() run := exec.Command("bash", "scripts/sweep-published-refs.sh") run.Dir = dir - run.Env = append(os.Environ(), "SWEEP_BASE=main", "SWEEP_HEAD=") + run.Env = append(sweepEnv(t), "SWEEP_BASE=main", "SWEEP_HEAD=") output, err := run.CombinedOutput() return string(output), err } @@ -446,3 +501,45 @@ func runOut(t *testing.T, dir string, command string, args ...string) string { require.NoError(t, err, "%s %v", command, args) return string(output) } + +// AND A FILE THAT DOES NOT PARSE IS A FAILURE, not a file with no imports. +// +// This is the fail-open class one more time, in the new reader: `awk` produced +// an empty list for anything it could not make sense of, and an empty list is +// indistinguishable from "imports nothing". The reader returns a non-zero exit +// instead, and the script stops. +func TestAFileTheSweepCannotParseIsNotAFileItHasCleared(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + out, err := sweepOf(t, script, "broken.go", "package x\n\nimport (\n\t\"crypto/ed25519\"\n// never closed\n") + require.Error(t, err, "a file that does not parse must not be swept as clean:\n%s", out) + require.Contains(t, out, "cannot read the imports") +} + +// The import reader is Go, so it is held to the same question directly: what +// does Go think this file imports? +func TestTheImportReaderAnswersWithWhatGoSees(t *testing.T) { + reader := importReader(t) + directory := t.TempDir() + path := filepath.Join(directory, "probe.go") + require.NoError(t, os.WriteFile(path, []byte( + "package x\n\nimport (\n\t// )\n\t\"\\x63rypto/ed25519\"\n\t`encoding/base64`\n)\n"), 0o600)) + + run := exec.Command(reader) + run.Dir = directory + run.Stdin = strings.NewReader(path + "\n") + output, err := run.Output() + require.NoError(t, err, "%s", output) + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var paths []string + for _, line := range lines { + _, imported, found := strings.Cut(line, "\t") + require.True(t, found, "each line is pathimport: %q", line) + paths = append(paths, imported) + } + require.ElementsMatch(t, []string{"crypto/ed25519", "encoding/base64"}, paths, + "the escape is unquoted to the path the compiler sees, the raw string is read, "+ + "and the \")\" in the comment does not end the block") +} diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index 2ec7c3c..be18ac8 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -12,17 +12,43 @@ budget. The root file carries the rule; this carries what the gates refuse. ## What the two gates refuse, and why each rule exists -**Both gates read BOTH modules.** `TestNoSecondWorkContextImplementation` walks -the repository's AST; `scripts/check-one-implementation.sh` sweeps every tracked -Go file by import path, and over every published ref besides. - -The AST gate used to walk this module only, which was a hole with nothing behind -it: the deleted implementation lived at the repository root, in package -`codefly`, and the sweep bans *imports* rather than reading code. A reviewer -built the consequence and ran it — a root file with a **hand-written base64url -decoder**, so no banned import at all, and `proto.Unmarshal` into -`basev0.WorkContextV1` — and it compiled, linted at 0 issues and passed the -sweep as `ok working tree (58 Go files)`. +**There are four gates and they read BOTH modules.** Two are type-checked with +`x/tools/go/packages`: `TestEveryImportIsOnItsModulesAllowlist` is a +**deny-by-default import allowlist per module**, and +`TestNoCodecTouchesACapabilityByType` decides the codec rule with `go/types`. +`TestNoSecondWorkContextImplementation` is the syntactic gate that remains. +`scripts/check-one-implementation.sh` sweeps every published ref, reading +imports with `go/parser`. + +**Deny by default is the spine, and it replaces an argument that lost ten +times.** Every revision banned a LIST OF NAMES and every review found the next +name — `hkdf`, then `sha512`, then `protoiface`, and finally `crypto/mldsa` +signing a `WorkContextV1` that `encoding/json/v2` had encoded as snake_case +JSON, which is the exact format this repository deleted, with both gates green +and lint clean. The list was not behind by one entry; it was behind by a design. +Each module now declares the imports it HAS, measured from the tree, and +anything else is a finding — so `protodelim`, the gRPC codec registry, +`prototext`, `jsontext` and `hpke` are refused by one rule, and so is the one +nobody has thought of. `import "C"` is refused by name, because a MAC built +through CommonCrypto names no Go package at all. + +**go/types is necessary and not sufficient**, which is why both halves exist. A +syntactic resolver answers with a SPELLING where the question is a KIND: six +compiled probes decoded a real capability through `func into[M proto.Message]`, +an embedded interface, an alias of `proto.Message`, a `reflect.Call`, a closure +taking `proto.Unmarshal`, and a descriptor-built message. `types.IsInterface` +and object identity answer all of them, and the indirection allowlist is keyed +on `*types.Func` — so a second method named `Handle` on a new type is a +different object. **The irreducible residue** is a hand-written implementation +that imports nothing: a base64url decoder is twenty lines and a wire walk is +fifty. That is closed by review, and saying so is part of the rule. + +The syntactic gate used to walk this module only, which was a hole with nothing +behind it: the deleted implementation lived at the repository root, and the +sweep bans *imports* rather than reading code. A reviewer built the consequence +— a root file with a **hand-written base64url decoder**, so no banned import at +all, and `proto.Unmarshal` into `basev0.WorkContextV1` — and it compiled, linted +clean and swept green. The two modules are held to different rules, deliberately. **In here the capability is the subject**, so every codec use is allowlisted by name. **Outside @@ -33,9 +59,7 @@ message**, recognised as a type under core's module whose name begins with `Work`. A prefix, so a message core adds later is covered the day it exists. Type names resolve to the imported **package PATH**, never to the local -qualifier, because the qualifier is the author's choice: `basev0 -"some/other/pkg"` would otherwise let a foreign type answer to a row written for -one of core's. +qualifier, because the qualifier is the author's choice. - **By capability, never by name.** A signature, MAC or JOSE/JWT primitive, anything under `x/crypto`, `protojson`/`protowire`/`anypb`. The bare `crypto` @@ -43,12 +67,11 @@ one of core's. The predicate used to be "imports a primitive AND mentions WorkContext", which a second implementation defeats by putting the signer in one file and the wrapper in another. -- **Allowed imports are held to FILES and SYMBOLS.** `crypto/tls`, +- **An allowlisted import is held to FILES and SYMBOLS.** `crypto/tls`, `crypto/x509`, `mime`, `crypto/sha256` and `encoding/base64` each have an - honest use in exactly one file. An import allowed whole-file is allowed for - everything in that package — which is how `x509.ParsePKCS8PrivateKey`, a - signer getting a key without naming `ed25519`, would be a permitted use of an - allowed import rather than a finding. + honest use in exactly one file. Whole-file is allowed-for-everything — which + is how `x509.ParsePKCS8PrivateKey`, a signer getting a key without naming + `ed25519`, would be a permitted use of an allowed import. - **base64 may ENCODE and never DECODE.** `cache_partition.go` builds a cache key. base64 decoding plus `proto.Unmarshal` is the entire second parser this module deleted, and the symbol rule alone cannot see it: `base64.StdEncoding` diff --git a/workcontext/README.md b/workcontext/README.md index d7352cd..2d56e98 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -262,10 +262,9 @@ client, err := workcontext.NewMintClient(workcontext.MintOptions{ }, ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), ProjectionAudience: projectionAudience, - // The trust anchor is STATED. Name the roots that may sign the mint - // endpoint's certificate, or say TrustSystemRoots; construction refuses - // silence, because there is no safe default for where this process sends - // its service-account token. + // REQUIRED: the roots that may sign the mint endpoint's certificate. + // There is no safe default for where this process sends its + // service-account token, so there is no default. RootCAs: platformRoots, }) ``` @@ -281,17 +280,15 @@ host SIGNED to be the audience the pin answered. projected service-account token travels on that request as a bearer credential, so plaintext is a disclosure the configuration must not be able to choose. -**The transport is the client's, and you cannot supply one.** The trust anchor -is the only thing a caller says about it — and it must say something: set -`RootCAs` to the roots that may sign the endpoint's certificate, or -`TrustSystemRoots` to use the host's pool. Exactly one, or `NewMintClient` -returns `ErrInvalid`. A nil `RootCAs` used to mean the system pool silently, -which left the one remaining hole in a transport built to have none: every other -route to a weak channel was closed and the trust anchor was still whatever the -image shipped, so a mis-issued certificate for the host name — or a corporate -interception root — received the projected token. Which certificates are -acceptable for platform infrastructure is a deployment decision, and a default -is not a decision. An `*http.Client` option was a hole that +**The transport is the client's, and you cannot supply one.** `RootCAs` is the +only thing a caller says about it, and it is **required**: `NewMintClient` +returns `ErrInvalid` without it. A nil `RootCAs` used to mean the system pool +silently, which left the one remaining hole in a transport built to have none — +every other route to a weak channel was closed and the trust anchor was still +whatever the image shipped, so a mis-issued certificate for the host name, or a +corporate interception root, received the projected token. A deployment that +really means the host's pool writes `x509.SystemCertPool()`, which is two lines +and is visible at the call site. An `*http.Client` option was a hole that inspecting the client could not close: a nil `Transport` means the global, mutable `http.DefaultTransport`; a wrapping `RoundTripper` is opaque; a `DialTLSContext` bypasses `TLSClientConfig` entirely; and a caller keeping the diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go new file mode 100644 index 0000000..40ec9c4 --- /dev/null +++ b/workcontext/codec_types_test.go @@ -0,0 +1,675 @@ +package workcontext + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +// THE CODEC RULE, DECIDED BY go/types. +// +// The syntactic version answered with a SPELLING where the question was a +// KIND, and a reviewer walked six probes through the gap, each compiled and +// each decoding a real capability: +// +// func into[M proto.Message](raw []byte, m M) error // resolved to "M" +// type r6msg interface{ proto.Message } // resolved to "r6msg" +// type r6carrier = proto.Message // resolved to "r6carrier" +// reflect.ValueOf(proto.Unmarshal).Call(…) // resolved to "[]reflect#Value" +// r6use(proto.Unmarshal)(raw, c) // a closure, no call to read +// func (r6other) Handle(…) { proto.Unmarshal(…) } // allowlisted BY NAME +// +// None of those is a type name question. "Is this argument an interface or a +// type parameter", "is this codec being used as a value rather than called", +// and "is this the function my allowlist named, or another one spelled the +// same" are all questions about objects and types, and go/types answers all +// three. The allowlist is keyed on *types.Func identity now, so a second +// method named Handle on a new type is a different object and is refused. +// +// What this still does NOT reach: a hand-written wire walk with no codec at +// all. That is the irreducible residue and it is closed by review — see the +// note on allowedImports. + +// codecFunctions are the functions and methods that move a message between +// bytes and a value, by PACKAGE PATH and name. +// +// The set is closed by the import allowlist: a codec in a package neither +// module may import cannot be reached, which is why protodelim and the gRPC +// codec registry are absent here and refused there instead. Two rules, one +// each for the two halves of the question. +var codecFunctions = map[string][]string{ + "google.golang.org/protobuf/proto": { + "Marshal", "Unmarshal", "MarshalOptions", "UnmarshalOptions", + }, + "google.golang.org/protobuf/encoding/protojson": { + "Marshal", "Unmarshal", "MarshalOptions", "UnmarshalOptions", + }, + "encoding/json": { + "Marshal", "MarshalIndent", "Unmarshal", + "NewEncoder", "NewDecoder", "Encode", "Decode", + }, +} + +// codecIndirectionObjects are the functions that legitimately apply a codec to +// a value whose type is not statically known, keyed on PACKAGE PATH and the +// function's own name — which with go/types identifies the object rather than +// the spelling. +// +// Four, all taking an interface by design: three in receipts, whose subject is +// a caller's own request and response messages, and the runtime's own +// configuration documents, decoded into a caller's destination. The syntactic +// gate's comment said "three" for two rounds after the fourth was added, which +// is why the count is checked rather than written: see the test below. +var codecIndirectionObjects = map[string][]string{ + // package path -> "Receiver.Function", or "Function" for a plain function. + "github.com/codefly-dev/sdk-go/receipts": { + "RequestDigest", "Record", "Interceptor.Handle", + }, + "github.com/codefly-dev/sdk-go": {"decodeDocument"}, +} + +// TestNoCodecTouchesACapabilityByType is the type-checked codec rule over both +// modules. +func TestNoCodecTouchesACapabilityByType(t *testing.T) { + var findings []string + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil { + continue + } + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + findings = append(findings, inspectCodecUses(loaded, file, path)...) + } + } + } + require.Empty(t, findings, "%s", strings.Join(findings, "\n\n")) +} + +// inspectCodecUses is the rule over one type-checked file. +func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []string { + var findings []string + // THE Fun POSITION, and the identifier inside it. ast.Inspect descends into + // a SelectorExpr, so recording only call.Fun left `Marshal` — the Sel of a + // perfectly ordinary `json.Marshal(x)` — looking like a codec mentioned + // without being called. Both are recorded. + calls := map[ast.Expr]bool{} + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + calls[call.Fun] = true + if selector, ok := call.Fun.(*ast.SelectorExpr); ok { + calls[selector.Sel] = true + // proto.MarshalOptions{…}.Marshal: the receiver is itself a + // composite literal whose Type names the codec. + if literal, ok := selector.X.(*ast.CompositeLit); ok { + calls[literal.Type] = true + if inner, ok := literal.Type.(*ast.SelectorExpr); ok { + calls[inner.Sel] = true + } + } + if inner, ok := selector.X.(*ast.SelectorExpr); ok { + calls[inner.Sel] = true + } + } + return true + }) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + function := codecFunctionOf(loaded, call.Fun) + if function == nil { + return true + } + for _, argument := range call.Args { + kind := loaded.TypesInfo.TypeOf(argument) + if kind == nil { + continue + } + // A TYPE PARAMETER OR AN INTERFACE IS NOT A TYPE, it is a + // promise that one will turn up at the call site — which is + // exactly where a capability turned up in six separate probes. + if isUnknownToTheGate(kind) { + if permittedIndirection(loaded, file, call.Pos()) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s applies %s to a value of type %s, which is an interface or a type parameter.\n"+ + "A codec whose argument's type is not known statically can be handed a\n"+ + "capability at any call site, and six compiled probes did exactly that —\n"+ + "a generic `func into[M proto.Message]`, an embedded interface, an alias of\n"+ + "proto.Message, a reflect.Call, a closure and a descriptor-built message.\n"+ + "The legitimate indirections are %v, keyed on the function's own object.", + path, function.FullName(), kind, codecIndirectionObjects)) + continue + } + if named := capabilityWithin(kind, map[types.Type]bool{}); named != "" { + if permittedCapabilityCodec(path, named) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s applies %s to %s, which is or contains %s.\n"+ + "A Work Context message passes through a codec only where a row names it.\n"+ + "Reading one off the wire is corework.Inspect's job, and it returns the claims\n"+ + "it decoded. This catches it THROUGH a field as well: a *Claims embedded in\n"+ + "mintRequest put a whole capability on the wire through a row written for two\n"+ + "strings, and the alias lived in another file, so no syntactic rule could see it.", + path, function.FullName(), kind, named)) + } + } + return true + }) + + // A CODEC USED AS A VALUE. `reflect.ValueOf(proto.Unmarshal)` and + // `r6use(proto.Unmarshal)` both reach a decode with no call for the rule + // above to read, and both were green. Nothing in either module passes a + // codec around, so the reference itself is the finding. + ast.Inspect(file, func(node ast.Node) bool { + expression, ok := node.(ast.Expr) + if !ok || calls[expression] { + return true + } + switch expression.(type) { + case *ast.Ident, *ast.SelectorExpr: + default: + return true + } + function := codecFunctionOf(loaded, expression) + if function == nil { + return true + } + findings = append(findings, fmt.Sprintf( + "%s mentions %s without calling it.\n"+ + "A codec passed as a value reaches a decode that no call-site rule can read:\n"+ + "reflect.ValueOf(proto.Unmarshal).Call(…) and a closure taking proto.Unmarshal were\n"+ + "both compiled, both decoded a real capability, and both passed. Nothing here needs\n"+ + "to pass a codec around.", + path, function.FullName())) + return true + }) + return findings +} + +// codecFunctionOf resolves an expression to a codec function's OBJECT, or nil. +func codecFunctionOf(loaded *packages.Package, expression ast.Expr) *types.Func { + var identifier *ast.Ident + switch typed := expression.(type) { + case *ast.Ident: + identifier = typed + case *ast.SelectorExpr: + identifier = typed.Sel + default: + return nil + } + object, ok := loaded.TypesInfo.Uses[identifier] + if !ok { + object = loaded.TypesInfo.Defs[identifier] + } + function, ok := object.(*types.Func) + if !ok || function.Pkg() == nil { + return nil + } + named, ok := codecFunctions[function.Pkg().Path()] + if !ok { + return nil + } + for _, name := range named { + if function.Name() == name { + return function + } + } + // A composite-literal codec — proto.MarshalOptions{}.Marshal — arrives as + // the METHOD, whose package is the same, so the receiver's own name is + // checked too. + if signature, ok := function.Type().(*types.Signature); ok && signature.Recv() != nil { + receiver := signature.Recv().Type() + if pointer, ok := receiver.(*types.Pointer); ok { + receiver = pointer.Elem() + } + if receiverNamed, ok := receiver.(*types.Named); ok { + for _, name := range named { + if receiverNamed.Obj().Name() == name { + return function + } + } + } + } + return nil +} + +// isUnknownToTheGate reports whether a type says "whatever the caller passes" +// — an interface, or a type parameter, which is the same answer. +// +// It had an explicit *types.TypeParam branch, and a mutation showed the branch +// was dead: a type parameter's underlying type IS its constraint interface, so +// types.IsInterface already returns true for one. Two readings of the same +// question, one of them untestable, so it is deleted rather than left as a +// branch no test can reach. +func isUnknownToTheGate(kind types.Type) bool { + // A pointer to an interface is still an interface's worth of unknown. + for pointer, ok := types.Unalias(kind).(*types.Pointer); ok; pointer, ok = types.Unalias(kind).(*types.Pointer) { + kind = pointer.Elem() + } + return types.IsInterface(kind) +} + +// capabilityWithin names the capability a type is, or carries in a field, or +// "". +// +// Through pointers, slices, arrays, maps, aliases and STRUCT FIELDS, embedded +// ones included. `seen` stops the walk on protobuf's recursive messages. +func capabilityWithin(kind types.Type, seen map[types.Type]bool) string { + if kind == nil || seen[kind] { + return "" + } + seen[kind] = true + switch typed := types.Unalias(kind).(type) { + case *types.Pointer: + return capabilityWithin(typed.Elem(), seen) + case *types.Slice: + return capabilityWithin(typed.Elem(), seen) + case *types.Array: + return capabilityWithin(typed.Elem(), seen) + case *types.Map: + return capabilityWithin(typed.Elem(), seen) + case *types.Named: + object := typed.Obj() + if object != nil && object.Pkg() != nil { + if isCapabilityType(object.Pkg().Path() + "#" + object.Name()) { + return object.Pkg().Path() + "#" + object.Name() + } + } + // A STRUCT CARRYING ONE, walked into. The exception is core's own + // packages: a capability's sub-messages, and the fields of a Verified, + // are core's business and finding one there says nothing about this + // repository. + // + // Written as "only walk into types under this repository's module + // path" first, which was wrong in a way the probes caught: a probe's + // package is not under that path, so the rule could not be driven at + // all and the test would have passed having walked nothing. + if strings.HasPrefix(packagePathOf(object), coreModulePath) { + return "" + } + return capabilityWithin(typed.Underlying(), seen) + case *types.Struct: + for index := range typed.NumFields() { + if named := capabilityWithin(typed.Field(index).Type(), seen); named != "" { + return named + } + } + } + return "" +} + +func packagePathOf(object *types.TypeName) string { + if object == nil || object.Pkg() == nil { + return "" + } + return object.Pkg().Path() +} + +// capabilityCodecRows is every (file, capability type) pair a codec may touch. +// +// One. cache_partition.go proto-marshals a WorkScopeV1 to build a cache key, +// and `Work`-prefixed under core is what makes a type a capability — a prefix, +// so a message core adds later is covered the day it exists, at the cost of +// this one honest use needing a row. +var capabilityCodecRows = map[string][]string{ + "workcontext/cache_partition.go": {basev0Path + "#WorkScopeV1"}, +} + +func permittedCapabilityCodec(path string, named string) bool { + return slices.Contains(capabilityCodecRows[path], named) +} + +// permittedIndirection reports whether the function enclosing pos is one of +// the named indirections, BY OBJECT. +// +// This is what closes the allowlist's last hole: it used to be keyed on the +// enclosing function's NAME, so a reviewer declared a second method called +// Handle on a new type, put a decode of a real capability inside it, and was +// permitted. An object carries the package and the receiver; a name carries +// neither. +func permittedIndirection(loaded *packages.Package, file *ast.File, pos token.Pos) bool { + path, key := indirectionKeyAt(loaded, file, pos) + if key == "" { + return false + } + return slices.Contains(codecIndirectionObjects[path], key) +} + +// indirectionKeyAt names the function enclosing pos as the allowlist keys it: +// the package path, and "Receiver.Function" or "Function". +// +// Returned rather than compared inside permittedIndirection so a probe can +// assert the KEY. Asserting only the decision let two mutations survive — +// dropping the receiver from the key, and writing the row name-only, each +// harmless alone and a complete bypass together. That is the coupled-edit +// shape that has now survived three times in this repository, so the rule is +// pinned at the rule. +func indirectionKeyAt(loaded *packages.Package, file *ast.File, pos token.Pos) (string, string) { + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if pos < function.Body.Pos() || pos > function.Body.End() { + continue + } + object, ok := loaded.TypesInfo.Defs[function.Name].(*types.Func) + if !ok || object.Pkg() == nil { + return "", "" + } + // THE RECEIVER IS PART OF THE NAME. A method is permitted only on the + // type the row was written for, so a second method called Handle on a + // new type — which a reviewer wrote, with a decode of a real + // capability inside it — is a different object and is refused. + if function.Recv == nil { + return object.Pkg().Path(), object.Name() + } + receiver := loaded.TypesInfo.TypeOf(function.Recv.List[0].Type) + if pointer, ok := types.Unalias(receiver).(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := types.Unalias(receiver).(*types.Named) + if !ok || named.Obj() == nil { + return "", "" + } + return object.Pkg().Path(), named.Obj().Name() + "." + object.Name() + } + return "", "" +} + +// TestTheCodecRuleAnswersAboutKindsNotSpellings drives each probe shape at the +// predicate, because the whole-gate route is satisfied by any one rule. +func TestTheCodecRuleAnswersAboutKindsNotSpellings(t *testing.T) { + require.True(t, isCapabilityType("github.com/codefly-dev/core/generated/go/codefly/base/v0#WorkContextV1")) +} + +// typeCheckedProbe type-checks a probe against the REAL dependency types. +// +// No temporary module and no network: packages.Load has already type-checked +// both modules and everything they import, so those *types.Package values are +// handed to go/types as an importer. The probe is then checked exactly as +// shipped code would be, which is what makes "this compiles and decodes a real +// capability" a thing a committed test can assert instead of a thing a review +// has to execute in a throwaway worktree. +func typeCheckedProbe(t *testing.T, source string) (*packages.Package, *ast.File) { + return typeCheckedProbeIn(t, "probe", source) +} + +// typeCheckedProbeIn checks a probe UNDER A GIVEN PACKAGE PATH, which is what +// lets the indirection allowlist be driven: it is keyed on the package and the +// receiver, and a probe compiled as package "probe" is refused by the package +// key before the receiver key is ever consulted. A mutation showed exactly +// that — removing the receiver from the key changed nothing, because no probe +// had reached it. +func typeCheckedProbeIn(t *testing.T, packagePath string, source string) (*packages.Package, *ast.File) { + t.Helper() + importer := probeImporter(t) + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, "probe.go", source, parser.SkipObjectResolution) + require.NoError(t, err, "the probe does not parse, so it is not a bypass of anything") + + info := &types.Info{ + Types: map[ast.Expr]types.TypeAndValue{}, + Defs: map[*ast.Ident]types.Object{}, + Uses: map[*ast.Ident]types.Object{}, + } + config := &types.Config{Importer: importer} + _, err = config.Check(packagePath, fileSet, []*ast.File{file}, info) + require.NoError(t, err, "the probe does not type-check, so it is not a bypass of anything") + + return &packages.Package{Fset: fileSet, Syntax: []*ast.File{file}, TypesInfo: info}, file +} + +// loadedTypeImporter is every package both modules already depend on, as types. +type loadedTypeImporter map[string]*types.Package + +func (i loadedTypeImporter) Import(path string) (*types.Package, error) { + if found, ok := i[path]; ok { + return found, nil + } + return nil, fmt.Errorf("probe imports %q, which neither module depends on; "+ + "the import allowlist is the rule for that", path) +} + +func probeImporter(t *testing.T) loadedTypeImporter { + t.Helper() + importer := loadedTypeImporter{} + for _, module := range loadModules(t) { + packages.Visit(module.packages, nil, func(one *packages.Package) { + if one.Types != nil && one.Types.Complete() { + importer[one.PkgPath] = one.Types + } + }) + } + require.Contains(t, importer, "google.golang.org/protobuf/proto", + "the probe importer is empty, so every probe below would fail to type-check "+ + "for the wrong reason and the test would pass having asserted nothing") + return importer +} + +// EVERY PROBE THE EXECUTED REVIEW COMPILED AND RAN, at the rule. +// +// Each of these decoded a real WorkContextV1 minted by core's Authority and +// printed its tenant, against the previous revision of this gate, with both +// gates green. They are not readings of the code. +func TestTheCodecRuleRefusesEveryShapeThatPassedBefore(t *testing.T) { + const preamble = `package probe + +import ( + "encoding/json" + "reflect" + + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Sinks, so every probe shares one import block without "imported and not +// used". Neither is a codec use, so neither can be the finding. +var ( + _ = reflect.TypeOf + _ = basev0.File_codefly_base_v0_work_context_proto + _ = json.Valid + // proto.Size is not a codec, so naming it keeps the import live for every + // probe without being the finding in any of them. + _ = proto.Size +) + +` + for name, probe := range map[string]struct { + source string + says string + }{ + // R1: a type parameter. Resolved to the spelling "M". + "a generic codec wrapper": { + source: `func into[M proto.Message](raw []byte, m M) error { return proto.Unmarshal(raw, m) } +var _ = into[*basev0.WorkContextV1]`, + says: "interface or a type parameter", + }, + // R2: interface embedding. Resolved to "r6msg". + "an embedded interface": { + source: `type carrier interface{ proto.Message } +func into(raw []byte, m carrier) error { return proto.Unmarshal(raw, m) }`, + says: "interface or a type parameter", + }, + // R3: an alias of proto.Message. Only aliases of CORE's types were + // refused, and proto.Message is not one. + "an alias of the codec's own interface": { + source: `type carrier = proto.Message +func into(raw []byte, m carrier) error { return proto.Unmarshal(raw, m) }`, + says: "interface or a type parameter", + }, + // R4: reflect. Resolved to "[]reflect#Value". + "a codec reached through reflect": { + source: `func into(raw []byte, c *basev0.WorkContextV1) { + reflect.ValueOf(proto.Unmarshal).Call([]reflect.Value{reflect.ValueOf(raw), reflect.ValueOf(c)}) +}`, + says: "without calling it", + }, + // L6: a closure. codecArguments read the OUTER call's arguments. + "a codec handed to a closure": { + source: `func use(f func([]byte, proto.Message) error) func([]byte, proto.Message) error { return f } +func into(raw []byte, c *basev0.WorkContextV1) error { return use(proto.Unmarshal)(raw, c) }`, + says: "without calling it", + }, + // The concrete capability, named outright, outside its one row. + "the capability named outright": { + source: `func into(raw []byte) (*basev0.WorkContextV1, error) { + c := &basev0.WorkContextV1{} + return c, proto.Unmarshal(raw, c) +}`, + says: "which is or contains", + }, + // L5's other half: the capability reached THROUGH A FIELD, by an alias + // declared in another file. No syntactic rule could resolve it. + // L5, as executed: json.Marshal of a struct whose EMBEDDED field is a + // capability, through an alias declared in another file. The argument's + // own type is concrete — mintRequest — so no interface rule fires and + // no type-name rule could resolve the alias across files. The walk + // finds it inside the struct. + "a capability embedded in a permitted struct": { + source: `type claims = basev0.WorkContextV1 +type mintRequest struct { + Audience string + *claims +} +func out(audience string, c *claims) ([]byte, error) { + return json.Marshal(mintRequest{Audience: audience, claims: c}) +}`, + says: "which is or contains", + }, + } { + t.Run(name, func(t *testing.T) { + loaded, file := typeCheckedProbe(t, preamble+probe.source) + findings := inspectCodecUses(loaded, file, "second_implementation.go") + require.NotEmpty(t, findings, "the gate accepted a shape that decodes a capability") + require.Contains(t, strings.Join(findings, "\n"), probe.says) + }) + } + + // R8: a SECOND method named Handle, on a new type, IN THE RECEIPTS PACKAGE + // — so the package key passes and the receiver key is what has to refuse + // it. The allowlist was keyed on the function's name, and a reviewer wrote + // this and was permitted. + const receiptsPath = "github.com/codefly-dev/sdk-go/receipts" + t.Run("a second method spelled like an allowlisted one", func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+`type other struct{} + +func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`) + require.NotEmpty(t, inspectCodecUses(loaded, file, "receipts/interceptor.go"), + "the allowlist named Interceptor.Handle, not every method spelled Handle") + }) + + // And the method the row was actually written for, in the same package, is + // permitted — which is what makes the case above about the RECEIVER and + // not about the package. + t.Run("the method the row was written for", func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+`type Interceptor struct{} + +func (*Interceptor) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`) + require.Empty(t, inspectCodecUses(loaded, file, "receipts/interceptor.go"), + "receipts.Interceptor.Handle takes a proto.Message by design") + }) + + // THE KEY ITSELF, because the decision alone was satisfied either way by + // two coupled edits. + t.Run("the allowlist key carries the receiver", func(t *testing.T) { + for name, probe := range map[string]struct{ source, key string }{ + "a method on the type the row names": { + source: `type Interceptor struct{} + +func (*Interceptor) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`, + key: "Interceptor.Handle", + }, + "the same method name on another type": { + source: `type other struct{} + +func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`, + key: "other.Handle", + }, + "a plain function": { + source: `func RequestDigest(m proto.Message) ([]byte, error) { return proto.Marshal(m) }`, + key: "RequestDigest", + }, + } { + t.Run(name, func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+probe.source) + var found string + ast.Inspect(file, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok && found == "" { + if _, key := indirectionKeyAt(loaded, file, call.Pos()); key != "" { + found = key + } + } + return true + }) + require.Equal(t, probe.key, found, + "the allowlist is keyed on this string; a key without the receiver permits "+ + "every method spelled the same") + }) + } + }) + + // A POINTER TO AN INTERFACE is still an interface's worth of unknown, and + // json.Marshal takes `any`, so it is a shape that compiles. + t.Run("a pointer to an interface", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, preamble+`func out(m *proto.Message) ([]byte, error) { return json.Marshal(m) }`) + require.NotEmpty(t, inspectCodecUses(loaded, file, "second_implementation.go"), + "what the pointer points at is chosen by the caller, capability included") + }) + + // AND THE FOUR REAL INDIRECTIONS STAY PERMITTED, which is the mutation + // guard for all of the above: a rule that refuses everything is not a rule. + t.Run("the real indirections are still permitted", func(t *testing.T) { + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil { + continue + } + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + require.Empty(t, inspectCodecUses(loaded, file, path), + "%s is code this repository ships", path) + } + } + } + }) +} + +// The indirection count is CHECKED, not written in prose. "Three, all in +// receipts" sat above a list of four for two rounds — the C10 class, which this +// repository has now had five times — so the prose says "four" and this fails +// if it stops being four. +func TestTheIndirectionListIsAsSmallAsItClaims(t *testing.T) { + var total int + for _, names := range codecIndirectionObjects { + total += len(names) + } + require.Equal(t, 4, total, + "there are %d codec indirections now. Each one is a function allowed to apply a "+ + "codec to a value whose type nothing knows statically, so the list is the "+ + "gate's widest hole and its size belongs in a reviewed diff — update the "+ + "comment above codecIndirectionObjects and this number together.", total) +} diff --git a/workcontext/go.mod b/workcontext/go.mod index 1b5934f..363dce3 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -5,6 +5,7 @@ go 1.27.0 require ( github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 github.com/stretchr/testify v1.12.1 + golang.org/x/tools v0.51.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 ) @@ -18,7 +19,9 @@ require ( github.com/google/uuid v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect + golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect diff --git a/workcontext/go.sum b/workcontext/go.sum index a0e7695..e730e35 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -44,12 +44,18 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index da1dc4f..a45d9ed 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -554,10 +554,32 @@ func StreamServerInterceptor( handler grpc.StreamHandler, ) error { if guardFor == nil { - return fmt.Errorf( + return statusFor(fmt.Errorf( "%w: a stream interceptor needs a guard for each stream", - workcontext.ErrInvalid, - ) + errStreamMisuse, + )) + } + if len(guarded) == 0 { + // AN EMPTY SET GUARDS NOTHING, so it refuses everything instead. + // + // StreamServerInterceptor(nil, guardFor) read as "no method carries + // a capability" and passed every stream through unguarded — + // measured, a message delivered under revoked authority with zero + // re-checks. An interceptor installed to enforce something, that + // enforces nothing, is the worst available outcome: the wiring is + // there, so nobody looks again. + // + // "Guard no method" is not a thing to say by omission. A server + // with no capability-bearing streams installs no interceptor. + // Through statusFor, like every other refusal here: returned bare, + // a misconfiguration arrived as codes.Unknown with internal text, + // which is the thing statusFor exists to stop. + return statusFor(fmt.Errorf( + "%w: a stream interceptor was installed with an empty capability-bearing "+ + "method set, which would guard nothing; name the methods, or install no "+ + "interceptor", + errStreamMisuse, + )) } method := "" if info != nil { @@ -567,6 +589,12 @@ func StreamServerInterceptor( // Declared as carrying no capability. The decision was made at // construction, where a reviewer can read it, rather than inferred // from whichever request happened to arrive first. + // + // A METHOD MISSING FROM THE SET IS STILL UNGUARDED HERE, and that + // is what the set means — but a NAME that matches nothing the + // server serves is a typo, not a decision, and this cannot tell + // the difference from inside one request. ValidateMethodSet is how + // a server catches it at startup, and the README says to call it. return handler(server, stream) } guard, err := guardFor(stream.Context(), info) @@ -589,3 +617,57 @@ func StreamServerInterceptor( return guarded.Finish(handler(server, guarded)) } } + +// ValidateMethodSet refuses a declared method name the server does not serve. +// +// It exists because a TYPO SILENTLY UNGUARDS A METHOD. The capability-bearing +// set is matched against grpc's full method name, so +// "/codefly.Streamer/Emmit" guards nothing at all and looks exactly like a +// method that was considered and declared — measured, a message delivered +// under revoked authority with zero re-checks, and nothing anywhere said so. +// +// The interceptor cannot catch this from inside a request: "this method is not +// in the set" and "this method is in the set under another spelling" are the +// same observation there. The server knows, so the server is asked, once, at +// startup: +// +// interceptor := grpctransport.StreamServerInterceptor(bearing, guardFor) +// server := grpc.NewServer(grpc.StreamInterceptor(interceptor)) +// pb.RegisterStreamerServer(server, impl) +// if err := grpctransport.ValidateMethodSet(server.GetServiceInfo(), bearing); err != nil { +// return err +// } +// +// Call it AFTER registering services and BEFORE Serve. It is a function rather +// than something the interceptor does because grpc hands the interceptor no +// server, and inventing a registration hook to reach one would be a bigger +// surface than a call a server makes. +func ValidateMethodSet(served map[string]grpc.ServiceInfo, capabilityBearing []string) error { + known := map[string]bool{} + for service, info := range served { + for _, method := range info.Methods { + known["/"+service+"/"+method.Name] = true + } + } + var unknown []string + for _, method := range capabilityBearing { + if !known[method] { + unknown = append(unknown, method) + } + } + if len(unknown) > 0 { + return fmt.Errorf( + "%w: these methods are declared capability-bearing and this server does not "+ + "serve them: %v. A name that matches nothing guards nothing, and reads exactly "+ + "like a method that was considered", + errStreamMisuse, unknown, + ) + } + if len(capabilityBearing) == 0 { + return fmt.Errorf( + "%w: no method is declared capability-bearing, so the interceptor would guard "+ + "nothing", errStreamMisuse, + ) + } + return nil +} diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go index c5ba1f9..5ffb46c 100644 --- a/workcontext/grpctransport/round6_reproducers_test.go +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -1,7 +1,9 @@ package grpctransport import ( + "context" "fmt" + "sync/atomic" "testing" "github.com/stretchr/testify/require" @@ -91,3 +93,92 @@ func TestAHeaderSetAfterTheFirstMessageIsNotSilentlyDropped(t *testing.T) { require.Empty(t, header.Get("late-header"), "and it must not arrive either: an error plus delivery would be worse than silence") } + +// F-9. THE INTERCEPTOR FAILED OPEN, two ways, both executed. +// +// A method missing from the declared set, and an EMPTY set, each delivered a +// message under revoked authority with ZERO re-checks. The second is the worse +// one: StreamServerInterceptor(nil, guardFor) read as "no method carries a +// capability", so the wiring was present and enforced nothing — and wiring +// that is present is wiring nobody looks at again. +func TestAnEmptyMethodSetGuardsNothingAndSoRefusesEverything(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + + connection := serveWith(t, StreamServerInterceptor(nil, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("delivered under revoked authority")) + }) + + received, _, _, err := clientSaw(t, connection) + + require.Error(t, err, "an interceptor that guards nothing must refuse, not pass through") + require.Zero(t, received, "a message left under revoked authority with no guard installed") + require.Equal(t, codes.Internal, status.Code(err), + "guarding nothing is this server's own misconfiguration, which the client cannot correct") + require.Contains(t, status.Convert(err).Message(), "would guard nothing") +} + +// And the typo, which no interceptor can catch from inside one request: "not in +// the set" and "in the set under another spelling" are the same observation +// there. The server knows, so ValidateMethodSet asks it. +func TestADeclaredMethodTheServerDoesNotServeIsRefusedAtStartup(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{{Name: "Emit", IsServerStream: true}}}, + } + + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}), + "the method this server really serves is declared correctly") + + err := ValidateMethodSet(served, []string{"/" + streamService + "/Emmit"}) + require.Error(t, err, "a name that matches nothing guards nothing") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "Emmit", + "the refusal must name the method, because the whole defect is that it looks right") + + require.Error(t, ValidateMethodSet(served, nil), + "an empty set would guard nothing, and saying so by omission is not saying it") +} + +// F-2, AS EXECUTED: a real core Verifier, a moving clock, and a capability that +// expires mid-stream. The unit mapping is asserted in +// TestAnExpiredCapabilityTellsTheClientToMintAgain; this is the path the review +// drove over bufconn, where it observed codes.Internal. +func TestACapabilityThatExpiresMidStreamTellsTheClientToMintAgain(t *testing.T) { + expired := fmt.Errorf("stream terminated: %w: expired at 2026-10-02T12:15:00Z", + workcontext.ErrInvalid) + + connection := serveWith(t, StreamServerInterceptor([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + // A guard whose re-check answers exactly what core's Verifier + // answers for a capability whose window has passed: ErrInvalid, + // with core's own wording. + var checks atomic.Uint64 + return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if checks.Add(1) > 1 { + return expired + } + return nil + }, + }) + }), func(stream grpc.ServerStream) error { + for range 3 { + if err := stream.SendMsg(wrapperspb.String("payload")); err != nil { + return err + } + } + return nil + }) + + received, _, _, err := clientSaw(t, connection) + + require.Error(t, err) + require.Equal(t, 1, received, "the first message goes out, the rest do not") + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a stream that outlives its credential is answered by minting a new one; this "+ + "arrived as codes.Internal, which tells a client the server is broken") + require.Contains(t, status.Convert(err).Message(), "expired") +} diff --git a/workcontext/import_allowlist_test.go b/workcontext/import_allowlist_test.go new file mode 100644 index 0000000..15170a0 --- /dev/null +++ b/workcontext/import_allowlist_test.go @@ -0,0 +1,453 @@ +package workcontext + +import ( + "fmt" + "go/ast" + "go/types" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +// DENY BY DEFAULT. This is the gate's spine, and it replaces an argument that +// lost ten times running. +// +// Every previous revision banned a LIST OF NAMES and every review found the +// next name: `hkdf`, then `sha512`, then `protoiface`, then — in the round that +// prompted this file — `crypto/mldsa` signing a `WorkContextV1` that +// `encoding/json/v2` had encoded as snake_case JSON, which is the exact format +// this repository deleted. Both gates were green. `golangci-lint` reported 0 +// issues. The denylist was not behind by one entry; it was behind by a design. +// +// So the question is inverted. Each module declares the imports it HAS, and +// anything else is a finding until somebody adds it here with a reason. The +// next name costs a reviewed line in this file rather than a round of review: +// `protodelim`, `grpc/encoding`, `json/v2`, `mldsa`, `prototext`, `jsontext` +// and `hpke` are all refused by the same rule, and so is the one nobody has +// thought of. +// +// Three things this does NOT reach, stated rather than implied: +// +// - A hand-written implementation that imports nothing. A base64url decoder +// is twenty lines, a protobuf wire walk is fifty, and neither names a +// package. That is the irreducible residue, the same class as a hand-rolled +// HMAC over an allowed hash, and it is closed by review. +// - Test files, which are exempt: `minting_test.go` imports `crypto/ed25519` +// to hold the verifier's public-key map, and that is the right thing for a +// test of a verifier to do. A `_test.go` cannot be imported by shipped +// code, so a second implementation there ships to nobody. +// - Anything a dependency does. This is a rule about what THIS repository +// spells. + +// allowedImports is every import path a module's non-test code may use. +// +// Measured from the tree with `go list`, not written from memory, and the test +// below fails in BOTH directions: an import that is not here is a finding, and +// an entry here that nothing imports is deleted, because a permission nobody +// uses is precedent for the next reader. +var allowedImports = map[string][]string{ + "root": { + "bufio", "bytes", "context", "database/sql", "embed", + "encoding/binary", "encoding/json", "errors", "fmt", "io", "os", + "path/filepath", "runtime/debug", "sort", "strconv", "strings", + "sync", "time", + // scripts/importsof reads a Go file's imports WITH GO'S OWN PARSER, + // because the sweep's awk extractor was walked past five more times — + // `import "\x63rypto/ed25519"` compiles, is crypto/ed25519 to the + // compiler, and is not that string to anything matching text. It is a + // main package that ships to nobody; being here is what makes it + // visible rather than exempt. + "go/parser", "go/token", + // The ONE hash, for three digests. A hand-rolled HMAC over it is the + // residue named above. + "crypto/sha256", + // The workload's own TLS, which is this module's job. + "crypto/tls", "crypto/x509", + "connectrpc.com/connect", + "github.com/codefly-dev/core/composition", + "github.com/codefly-dev/core/configurations", + "github.com/codefly-dev/core/generated/go/codefly/base/v0", + "github.com/codefly-dev/core/generated/go/codefly/runnable/v0", + "github.com/codefly-dev/core/network", + "github.com/codefly-dev/core/resources", + "github.com/codefly-dev/core/runnable", + "github.com/codefly-dev/core/standards", + "github.com/codefly-dev/core/wool", + "github.com/codefly-dev/sdk-go/receipts", + "google.golang.org/grpc", + "google.golang.org/grpc/codes", + "google.golang.org/grpc/metadata", + "google.golang.org/grpc/status", + // The receipts digest canonicalises a receipt REQUEST, never a + // capability, and the replay path resolves a response type at runtime. + // Each is held to files and symbols by the rules in + // one_implementation_test.go; being on this list is necessary and not + // sufficient. + "google.golang.org/protobuf/encoding/protojson", + "google.golang.org/protobuf/proto", + "google.golang.org/protobuf/reflect/protodesc", + "google.golang.org/protobuf/reflect/protoreflect", + "google.golang.org/protobuf/reflect/protoregistry", + "google.golang.org/protobuf/types/descriptorpb", + "google.golang.org/protobuf/types/dynamicpb", + "google.golang.org/protobuf/types/known/durationpb", + }, + "leaf": { + "bytes", "context", "encoding/binary", "encoding/hex", "errors", + "fmt", "io", "mime", "net/http", "net/url", "os", "slices", + "strconv", "strings", "sync", "sync/atomic", "time", + "crypto/sha256", + "crypto/tls", "crypto/x509", + // Held to ENCODE-only in one file, and to two types in one file, + // by the rules in one_implementation_test.go. + "encoding/base64", "encoding/json", + "github.com/codefly-dev/core/generated/go/codefly/base/v0", + "github.com/codefly-dev/core/workcontext", + "github.com/codefly-dev/sdk-go/workcontext", + "google.golang.org/grpc", + "google.golang.org/grpc/codes", + "google.golang.org/grpc/metadata", + "google.golang.org/grpc/status", + "google.golang.org/protobuf/proto", + }, +} + +// TestEveryImportIsOnItsModulesAllowlist is the deny-by-default rule. +func TestEveryImportIsOnItsModulesAllowlist(t *testing.T) { + for _, module := range loadModules(t) { + _, ok := allowedImports[module.name] + require.True(t, ok, "no allowlist for module %q", module.name) + + for _, finding := range unallowedImports(module.name, importsOfModule(t, module)) { + t.Error(finding) + } + + } +} + +// An entry nothing imports is a permission in force for nobody, which the next +// reader takes as precedent. The allowlist is measured from the tree, so it +// stays measured. +func TestTheAllowlistHasNoUnusedPermission(t *testing.T) { + for _, module := range loadModules(t) { + used := importsOfModule(t, module) + for _, spec := range allowedImports[module.name] { + require.Contains(t, used, spec, + "allowedImports[%q] permits %q and no file in that module imports it. "+ + "An unused permission is precedent; delete the line.", module.name, spec) + } + } +} + +// TESTDATA IS SHIPPED CODE. The go tool excludes a `testdata` directory from +// WILDCARD matching, not from importing — so a package there is compiled into +// anything that imports it by path, and no `./...` ever mentions it. A reviewer +// put a complete Ed25519 signer under receipts/testdata/ and imported it from a +// root file; the syntactic walk skipped the directory by name and both gates +// were green. +// +// This asserts the loader's patterns reach such a package, because the rule +// above is only as wide as what it was handed. +func TestTestdataIsNotAHidingPlace(t *testing.T) { + repository, err := filepath.Abs("..") + require.NoError(t, err) + // A real package under testdata, created for this test and removed with it. + directory := filepath.Join(repository, "receipts", "testdata", "gateprobe") + require.NoError(t, os.MkdirAll(directory, 0o750)) + t.Cleanup(func() { + _ = os.RemoveAll(filepath.Join(repository, "receipts", "testdata")) + }) + require.NoError(t, os.WriteFile(filepath.Join(directory, "signer.go"), + []byte(`package gateprobe + +import "crypto/ed25519" + +// Sign is the shape a reviewer imported from a root file. +func Sign(key ed25519.PrivateKey, payload []byte) []byte { + return ed25519.Sign(key, payload) +} +`), 0o600)) + + patterns := testdataPatterns(t, repository) + require.Contains(t, patterns, "./receipts/testdata/...", + "the loader must be given the testdata packages explicitly; ./... does not match them") + + // And the rule refuses it, which is the point of reaching it. + findings := unallowedImports("root", map[string][]string{ + "crypto/ed25519": {"receipts/testdata/gateprobe/signer.go"}, + }) + require.NotEmpty(t, findings, "a signer under testdata is still a signer") +} + +// A package that does not type-check is a package this gate has not read, and +// passing what it could not read is the fail-open this repository has fixed +// four times in three rounds. The loader fails on it rather than skipping it. +func TestTheGateRefusesAPackageItCannotTypeCheck(t *testing.T) { + // Asserted on the loader's own contract rather than by breaking the tree: + // every package loaded here carries zero errors, so if the check below + // were removed nothing would notice until something was already broken. + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + require.Empty(t, loaded.Errors, + "%s did not type-check; the type-based rules cannot run on it", loaded.PkgPath) + require.NotNil(t, loaded.TypesInfo, + "%s carries no type information, so every go/types rule read nothing there", + loaded.PkgPath) + } + } +} + +// importsOfModule is what a module's non-test files import, by path. +func importsOfModule(t *testing.T, module loadedModule) map[string][]string { + t.Helper() + used := map[string][]string{} + for _, loaded := range module.packages { + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + for _, imported := range file.Imports { + spec, err := strconv.Unquote(imported.Path.Value) + require.NoError(t, err) + used[spec] = append(used[spec], path) + } + } + } + return used +} + +// unallowedImports is the rule itself, over a module name and what its files +// import, returning findings rather than calling require. +// +// Separated so a probe can drive it: the test above reads the real tree, so the +// only way to ask "would this refuse crypto/mldsa?" through it is to commit +// crypto/mldsa. A rule that cannot be probed is a rule nobody has tested, which +// is the finding that prompted this whole file. +func unallowedImports(module string, used map[string][]string) []string { + allowed := allowedImports[module] + var findings []string + for _, spec := range sortedKeys(used) { + files := strings.Join(used[spec], ", ") + // `import "C"` IS NOT AN IMPORT PATH, it is an escape from Go + // entirely: a reviewer MACed a capability through CommonCrypto's + // CCHmac and checked it against RFC 4231, and no rule written about Go + // imports could see it. There is no honest use for cgo in an SDK whose + // job is resolving values. + if spec == "C" { + findings = append(findings, fmt.Sprintf("%s imports \"C\".\n"+ + "cgo is not an import path, it is an exit from every rule here: a MAC built with\n"+ + "the host's crypto library names no Go package at all. Nothing in this repository\n"+ + "needs it.", files)) + continue + } + if slices.Contains(allowed, spec) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s (module %s) imports %q, which is not on that module's allowlist.\n"+ + "THIS LIST IS DENY-BY-DEFAULT, and that is the point. A denylist of names was\n"+ + "behind by one entry every round — hkdf, then sha512, then protoiface, then\n"+ + "crypto/mldsa signing a WorkContextV1 that encoding/json/v2 had encoded in the\n"+ + "deleted format, with both gates green. If this import is honest, add it to\n"+ + "allowedImports[%q] WITH A REASON, in a reviewed diff. That is the cost, and it is\n"+ + "one line instead of a round of review.", + files, module, spec, module)) + } + return findings +} + +func sortedKeys(from map[string][]string) []string { + keys := make([]string, 0, len(from)) + for key := range from { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// THE PROBES, every one of them a shape a reviewer compiled and ran as a +// decoder, a signer or a MAC of a real WorkContextV1 minted by core's +// Authority, against the previous revision of this gate. Every one passed. +func TestTheImportAllowlistRefusesTheNextName(t *testing.T) { + for name, probe := range map[string]struct { + module string + spec string + }{ + // The two halves of the complete second implementation: json/v2 + // encoded a real capability as snake_case JSON — the deleted format — + // and mldsa signed it, 2420 bytes, verified. Both gates green. + "encoding/json/v2, which is not encoding/json": {"leaf", "encoding/json/v2"}, + "crypto/mldsa, a signature nobody had listed": {"leaf", "crypto/mldsa"}, + "protodelim, a codec with different method names": {"leaf", "google.golang.org/protobuf/encoding/protodelim"}, + "the gRPC codec registry": {"leaf", "google.golang.org/grpc/encoding"}, + // Named in the review as inferred, never compiled. They cost nothing + // here, which is the whole argument for inverting the question. + "prototext": {"leaf", "google.golang.org/protobuf/encoding/prototext"}, + "jsontext": {"leaf", "encoding/json/jsontext"}, + "hpke, an AEAD and so MAC-capable": {"leaf", "crypto/hpke"}, + // And in the root module, where the shell sweep is the only other gate. + "json/v2 in the root module": {"root", "encoding/json/v2"}, + "protodelim in the root module": {"root", "google.golang.org/protobuf/encoding/protodelim"}, + // cgo, which is not an import path at all. + "cgo": {"leaf", "C"}, + } { + t.Run(name, func(t *testing.T) { + findings := unallowedImports(probe.module, map[string][]string{ + probe.spec: {"second_implementation.go"}, + }) + require.NotEmpty(t, findings, + "%q is not on the %s allowlist and must be a finding", probe.spec, probe.module) + if probe.spec == "C" { + // THE DIAGNOSIS, not only the refusal. `"C"` is refused by the + // allowlist anyway — a mutation of the cgo branch changed + // nothing — so what that branch is for is saying WHY: cgo is + // not a package whose name a reviewer can add to a list, it is + // an exit from every rule here, and a reader told "not on the + // allowlist" would reasonably try adding it. + require.Contains(t, strings.Join(findings, "\n"), "cgo is not an import path") + } + }) + } + + // And every import the tree really has stays allowed, so this is not green + // by refusing everything — which is also the mutation guard for the cases + // above. + for _, module := range []string{"root", "leaf"} { + used := map[string][]string{} + for _, spec := range allowedImports[module] { + used[spec] = []string{"real.go"} + } + require.Empty(t, unallowedImports(module, used), + "module %s refuses an import it actually has", module) + } +} + +// loadedModule is one module's type-checked packages. +type loadedModule struct { + name string + dir string + packages []*packages.Package +} + +// relative is a file's path relative to the REPOSITORY root, so a finding reads +// the same way whichever module it is in. +func (m loadedModule) relative(loaded *packages.Package, file *ast.File) string { + position := loaded.Fset.Position(file.Pos()) + if position.Filename == "" { + return "" + } + repository, err := filepath.Abs("..") + if err != nil { + return "" + } + path, err := filepath.Rel(repository, position.Filename) + if err != nil || strings.HasPrefix(path, "..") { + return "" + } + return filepath.ToSlash(path) +} + +// loadModules type-checks both modules with x/tools/go/packages. +// +// TESTDATA IS INCLUDED, which a syntactic walk had skipped: a reviewer put a +// complete Ed25519 signer under receipts/testdata/ and imported it from a root +// file. The go tool excludes `testdata` from WILDCARD matching, not from +// importing, so a package there is shipped code that no `./...` ever mentions. +// It is loaded by explicit pattern for that reason. +func loadModules(t *testing.T) []loadedModule { + t.Helper() + repository, err := filepath.Abs("..") + require.NoError(t, err) + + modules := []loadedModule{ + {name: "root", dir: repository}, + {name: "leaf", dir: filepath.Join(repository, "workcontext")}, + } + for index := range modules { + patterns := append([]string{"./..."}, testdataPatterns(t, modules[index].dir)...) + loaded, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo | packages.NeedDeps | + packages.NeedImports, + Dir: modules[index].dir, + Tests: true, + }, patterns...) + require.NoError(t, err, "loading module %s", modules[index].name) + require.NotEmpty(t, loaded, "module %s loaded no packages", modules[index].name) + for _, one := range loaded { + // A package that does not type-check is a package this gate has not + // read, and a gate that passes what it could not read is the + // fail-open this repository has now fixed four times. + for _, problem := range one.Errors { + t.Fatalf("module %s: %s did not type-check: %s.\n"+ + "The type-based rules cannot run on a package that does not compile, and "+ + "skipping it would be a gate reporting success for code it never read.", + modules[index].name, one.PkgPath, problem) + } + } + modules[index].packages = loaded + } + return modules +} + +// testdataPatterns names every package under a testdata directory, which +// `./...` deliberately does not match. +func testdataPatterns(t *testing.T, dir string) []string { + t.Helper() + var patterns []string + require.NoError(t, filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil || !entry.IsDir() { + return err //nolint:wrapcheck // the walk's own error, returned as-is + } + switch entry.Name() { + case ".git", ".github", ".claude", "node_modules": + return filepath.SkipDir + } + if entry.Name() != "testdata" { + return nil + } + patterns = append(patterns, "./"+filepath.ToSlash(mustRel(t, dir, path))+"/...") + return filepath.SkipDir + })) + sort.Strings(patterns) + return patterns +} + +func mustRel(t *testing.T, base string, path string) string { + t.Helper() + relative, err := filepath.Rel(base, path) + require.NoError(t, err) + return relative +} + +// typesAreAvailable is a guard on the loader itself: if NeedTypes ever stops +// being requested, every type-based rule below silently answers "nothing to +// see" and the gate goes green by reading nothing. +func TestTheGateActuallyHasTypeInformation(t *testing.T) { + var checked int + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil || loaded.Types == nil { + continue + } + for expression, kind := range loaded.TypesInfo.Types { + if kind.Type != nil && expression != nil { + checked++ + break + } + } + } + } + require.Positive(t, checked, + "no package carried type information, so every go/types rule in this gate "+ + "was deciding nothing. A gate with no inputs passes everything.") + var _ types.Type = types.Typ[types.Bool] // the import is load-bearing above +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go index db0a637..d3f4223 100644 --- a/workcontext/layer4_reproducers_test.go +++ b/workcontext/layer4_reproducers_test.go @@ -108,11 +108,10 @@ func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile(projectedFile(t, "fixture-bearer")), ProjectionAudience: "projection-audience", - // No socket is dialled in either of these, so the trust anchor is - // never exercised — but it is now a stated decision rather than a - // field left alone, and construction refuses silence. - TrustSystemRoots: true, - Now: func() time.Time { return testClock }, + // No socket is dialled in either of these, so the anchor is never + // exercised — but RootCAs is required, so it is named. + RootCAs: x509.NewCertPool(), + Now: func() time.Time { return testClock }, }) require.NoError(t, err) @@ -269,11 +268,10 @@ func TestRedirectContainmentWithoutASocket(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), ProjectionAudience: "projection-audience", - // No socket is dialled in either of these, so the trust anchor is - // never exercised — but it is now a stated decision rather than a - // field left alone, and construction refuses silence. - TrustSystemRoots: true, - Now: func() time.Time { return testClock }, + // No socket is dialled in either of these, so the anchor is never + // exercised — but RootCAs is required, so it is named. + RootCAs: x509.NewCertPool(), + Now: func() time.Time { return testClock }, }) require.NoError(t, err) @@ -298,8 +296,11 @@ func TestRedirectContainmentWithoutASocket(t *testing.T) { _, err = client.Credential(t.Context()) require.EqualValues(t, 0, destination.Load(), "the redirect destination received a request carrying the projection") - require.ErrorIs(t, err, ErrMintRefused) + // RETRYABLE: the destination receives nothing either way, so latching + // bought no confidentiality and stopped the process on an ordinary 307. + require.ErrorIs(t, err, ErrMintUnavailable) require.ErrorContains(t, err, "redirected") + require.NoError(t, client.Refused(), "a redirect must not latch") } type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/workcontext/mint.go b/workcontext/mint.go index db39729..939c7b8 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -225,16 +225,26 @@ type MintOptions struct { // RootCAs is the only thing a caller may say about the transport: the roots // that may sign the mint endpoint's certificate. // - // Set this, or set TrustSystemRoots. Nil used to mean the system pool - // SILENTLY, which left the one remaining hole in a transport built to have - // none: every other way of weakening it was closed — no caller-supplied - // client, no reachable Transport, a TLS 1.3 floor, no followed redirect — - // and the trust anchor was still whatever the image happened to ship. A - // mint endpoint is platform infrastructure, so a public CA has no business - // vouching for it; a mis-issued certificate for the host name, or a - // corporate interception root in the image, receives the projected - // service-account token. Construction refuses silence now, because the - // choice is a deployment decision and a default is not a decision. + // REQUIRED. Nil used to mean the system pool SILENTLY, which left the one + // remaining hole in a transport built to have none: every other way of + // weakening it was closed — no caller-supplied client, no reachable + // Transport, a TLS 1.3 floor, no followed redirect — and the trust anchor + // was still whatever the image happened to ship. A mint endpoint is + // platform infrastructure, so a public CA has no business vouching for it; + // a mis-issued certificate for the host name, or a corporate interception + // root in the image, receives the projected service-account token. + // + // A revision in between accepted a TrustSystemRoots flag beside this, so + // the system pool was a sentence somebody wrote rather than a field + // somebody left alone. That is better than a default and it is not the + // posture this module is held to: the flag is the same decision with a + // shorter spelling, and a shorter spelling is what gets copied. A + // deployment that really means the host's pool says + // + // pool, err := x509.SystemCertPool() + // + // which is two lines, is a thing a reader of the call site can see, and + // cannot be arrived at by leaving a field alone. // // It replaces an *http.Client, and that is the point. A client is a hole: // its Transport may be nil (so the global, mutable http.DefaultTransport), @@ -249,17 +259,6 @@ type MintOptions struct { // after the floor became 1.3. RootCAs *x509.CertPool - // TrustSystemRoots says, in as many words, that the host's own root pool is - // the right trust anchor for this endpoint. It is the alternative to - // RootCAs and never a companion to it: exactly one of the two is set, or - // construction refuses. - // - // It exists so that using the system pool is a sentence somebody wrote - // rather than a field somebody left alone. There are deployments where it - // is correct — a mint endpoint behind a certificate from the same public - // CA the image already trusts — and this says so out loud. - TrustSystemRoots bool - // RequestTimeout bounds one mint request. RequestTimeout time.Duration @@ -482,18 +481,13 @@ func NewMintClient(options MintOptions) (*MintClient, error) { if strings.TrimSpace(options.ProjectionAudience) == "" { return nil, fmt.Errorf("%w: no projection audience", ErrInvalid) } - switch { - case options.RootCAs != nil && options.TrustSystemRoots: - return nil, fmt.Errorf( - "%w: name the roots that may sign the mint endpoint's certificate, or say TrustSystemRoots; not both", - ErrInvalid, - ) - case options.RootCAs == nil && !options.TrustSystemRoots: + if options.RootCAs == nil { return nil, fmt.Errorf( - "%w: no trust anchor for the mint endpoint; set RootCAs to the roots that may sign its certificate, "+ - "or TrustSystemRoots to use the host's pool. The projected service-account token is sent to "+ - "whatever answers at %s, so which certificates are acceptable there is a deployment decision "+ - "and there is no safe default to fall back to", + "%w: no trust anchor for the mint endpoint. RootCAs names the roots that may sign "+ + "the certificate at %s, which is where this process sends its service-account "+ + "token, so there is no safe default to fall back to. A deployment that really "+ + "means the host's pool passes x509.SystemCertPool(), which a reader of the call "+ + "site can see", ErrInvalid, endpoint, ) } @@ -541,7 +535,7 @@ func NewMintClient(options MintOptions) (*MintClient, error) { options.RenewalLead = lead return &MintClient{ options: options, - httpClient: mintHTTPClient(options.RootCAs), // nil here means TrustSystemRoots, checked above + httpClient: mintHTTPClient(options.RootCAs), now: now, id: mintClientIDs.Add(1), }, nil @@ -564,9 +558,9 @@ func mintHTTPClient(roots *x509.CertPool) *http.Client { CheckRedirect: refuseMintRedirect, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - // Nil reaches here only when the caller said TrustSystemRoots: - // NewMintClient refuses an unstated anchor, so this nil is a - // decision rather than an omission. + // Never nil: NewMintClient refuses a client with no trust + // anchor, so there is no path here that falls back to the + // system pool by omission. RootCAs: roots, // TLS 1.3 FLOOR. The projected service-account token is the // one secret this process hands to anybody, and 1.2 permits @@ -592,13 +586,21 @@ func mintHTTPClient(roots *x509.CertPool) *http.Client { // second would carry it to an address nothing configured. // // It refuses rather than returning http.ErrUseLastResponse so the outcome is a -// named refusal instead of a 3xx that later checks have to recognise, and it -// carries ErrMintRefused rather than ErrMintUnavailable: retrying would send -// the projection again. +// named refusal instead of a 3xx that later checks have to recognise. +// +// RETRYABLE, which is the opposite of the previous revision, and the reason it +// gave does not survive being stated precisely. It carried ErrMintRefused +// because "retrying would send the projection again" — but the projection is +// sent to the CONFIGURED endpoint on every attempt either way, and the +// redirect's destination receives nothing, now or on a retry, because this +// function is what stops it. Latching bought no confidentiality at all. What it +// did buy was a permanent stop on a 307 during a rollout or a 308 adding a +// trailing slash, both measured, both of which clear in seconds. Exactly the +// TLS-verification mistake, one layer up. func refuseMintRedirect(request *http.Request, via []*http.Request) error { return fmt.Errorf( - "%w: the mint endpoint redirected to %s after %d request(s); a credential request is not followed to an address the configuration did not name", - ErrMintRefused, request.URL.Redacted(), len(via), + "%w: the mint endpoint redirected to %s after %d request(s); a credential request is not followed to an address the configuration did not name, and this retries", + ErrMintUnavailable, request.URL.Redacted(), len(via), ) } @@ -1184,9 +1186,8 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia request.Header.Set("Authorization", "Bearer "+projected) response, err := c.httpClient.Do(request) if err != nil { - // A refused redirect arrives here already named. It is a refusal and - // not an outage: retrying it would send the projection again. - if errors.Is(err, ErrMintRefused) { + // A refused redirect arrives here already named and already classed. + if errors.Is(err, ErrMintRefused) || errors.Is(err, ErrMintUnavailable) { return Credential{}, err } if reason := tlsVerificationFailure(err); reason != "" { @@ -1271,11 +1272,12 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia return Credential{}, fmt.Errorf("%w: read mint response: %w", ErrMintUnavailable, err) } if len(payload) > maxMintResponseBytes { - // Terminal, unlike the read failure above: the host answered, and - // answered with something this client will not accept. Retrying gets - // the same answer. - // Retryable for the same reason: an oversized body on a 200 is - // something in the middle answering, not the host refusing. + // Retryable: an oversized body on a 200 is something in the middle + // answering, not the host refusing. (A "Terminal, unlike the read + // failure above" paragraph sat directly on top of this one for three + // revisions after the classification changed under it — the C10 class, + // and the reason both the refusal and the recovery are now asserted in + // one table.) return Credential{}, fmt.Errorf( "%w: mint response exceeds %d bytes", ErrMintUnavailable, maxMintResponseBytes, ) @@ -1308,7 +1310,28 @@ func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential token := body.WorkContext claims, seal, binding, err := sealOf(token) if err != nil { - return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + // RETRYABLE, and this was the last place in the 200 path that latched. + // + // Measured over httptest-TLS: a 200 carrying `{}`, a 200 carrying + // `null`, and a 200 carrying `{"work_context":"not-a-capability"}` each + // latched ErrMintRefused and stayed refused forever, through the host + // recovering and the clock passing every hold-off. ErrMintRefused is + // documented as "the host will give the same answer again", and this is + // the shape where that is least likely to be true: a body with no + // capability in it is a rollout mid-flight, a cache, a mesh, an ingress + // — something answering that is not the mint — and it is the same class + // as the text/html 200 and the oversized 200 immediately above, both of + // which were already retryable. Three readings of one situation, one of + // them terminal. + // + // What still latches is a capability the HOST SIGNED that contradicts + // what this client asked for: a wrong audience, a wrong installation, a + // wrong execution. Those are decisions, and a decision repeats. + return Credential{}, fmt.Errorf( + "%w: the mint answered 200 with no readable capability (%v); "+ + "something that is not the host may be answering, and this retries", + ErrMintUnavailable, err, + ) } if body.InstallationID != "" && body.InstallationID != seal.GetInstallationId() { return Credential{}, fmt.Errorf( @@ -1490,10 +1513,18 @@ func sealCarriesAnExecution(seal *SealedValues) bool { // "retryable" costs one request per hold-off, and a wrong "terminal" costs the // process. // -// A refusal this client decides for ITSELF — an audience that does not match -// the pin, a seal the host contradicted, a refused redirect, a certificate that -// did not verify — still latches, because those are this client's own verdicts -// and not a status somebody in the middle chose. +// A refusal this client decides for ITSELF latches only when the HOST SIGNED +// the thing being refused: an audience that does not match the pin, a seal or +// an execution the host's own token contradicts. Those are verdicts, and a +// verdict repeats. +// +// Three things that read like that and are NOT: a refused redirect (the +// destination receives nothing either way, so latching bought no +// confidentiality and stopped the process on a rollout's 307), a certificate +// that did not verify (the handshake fails before any request bytes leave, and +// a rotation is the ordinary cause), and a 200 whose body holds no readable +// capability (a mesh, a cache or an ingress answering). Each of those latched +// once, each was measured latching forever, and each is an outage now. var refusalStatuses = []int{ http.StatusUnauthorized, http.StatusForbidden, diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go index a5eec6b..52a6641 100644 --- a/workcontext/mint_test.go +++ b/workcontext/mint_test.go @@ -64,6 +64,11 @@ type mintHost struct { // before runs at the top of every request, so a test can hold one open. before func() + // answer, when set, replaces the whole response with a canned one — the + // shapes something that is NOT the host sends: an ingress error page, a + // mesh's default body, a cache's empty object, a rollout's redirect. + answer *cannedAnswer + // noApprovedBuild mints for a principal the issuer holds no approved build // for, which is the human-session shape: the capability seals no execution. noApprovedBuild bool @@ -82,6 +87,32 @@ type mintHost struct { faults []string } +// cannedAnswer is a response written verbatim, headers included, so a test can +// present what an ingress or a service mesh presents. +type cannedAnswer struct { + status int + headers map[string]string + body string +} + +func (a *cannedAnswer) write(writer http.ResponseWriter) { + declared := false + for key, value := range a.headers { + if key == "Content-Type" { + declared = true + if value == "" { + continue + } + } + writer.Header().Set(key, value) + } + if !declared && a.headers["Content-Type"] == "" && len(a.headers) == 0 { + writer.Header().Set("Content-Type", "application/json") + } + writer.WriteHeader(a.status) + _, _ = writer.Write([]byte(a.body)) +} + func newMintHost(t *testing.T, now func() time.Time) *mintHost { t.Helper() host := &mintHost{ @@ -105,6 +136,10 @@ func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { h.before() } h.presented.Store(request.Header.Get("Authorization")) + if h.answer != nil { + h.answer.write(writer) + return + } if h.refuseWith != 0 { writer.WriteHeader(h.refuseWith) return @@ -868,9 +903,16 @@ func TestARedirectedMintSendsNothingToTheDestination(t *testing.T) { require.EqualValues(t, 0, elsewhereRequests.Load(), "the redirect destination must receive no request: the first one carried the projection") - require.ErrorIs(t, err, ErrMintRefused) - require.NotErrorIs(t, err, ErrMintUnavailable, - "a redirected credential request is not something to retry") + // RETRYABLE, which reverses what this asserted, and the reason it asserted + // it does not hold: "a redirected credential request is not something to + // retry" was argued from the projection being re-sent, and the projection + // goes to the CONFIGURED endpoint on every attempt either way, while the + // redirect's destination receives nothing on any of them — the assertion + // above this one is what says so. Measured: a 307 during a rollout and a + // 308 adding a trailing slash each latched and stayed refused forever. + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused, "a redirect must not latch") + require.NoError(t, client.Refused()) require.ErrorContains(t, err, "redirected") require.Equal(t, MintCounts{}, client.Counts()) } @@ -918,7 +960,10 @@ func TestTheMintTransportIsOwnedByTheClient(t *testing.T) { // http.ErrUseLastResponse, so net/http never sends the second request. request, err := http.NewRequest(http.MethodGet, "https://example.invalid/_mint", nil) require.NoError(t, err) - require.ErrorIs(t, client.CheckRedirect(request, nil), ErrMintRefused) + // AN OUTAGE, not a refusal. The destination receives nothing either way — + // this function is what stops it — so latching bought no confidentiality + // and did permanently stop a process on a 307 during a rollout. + require.ErrorIs(t, client.CheckRedirect(request, nil), ErrMintUnavailable) } func TestNewMintClientValidatesItsConfiguration(t *testing.T) { @@ -928,9 +973,8 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { Audience: testAudienceName, ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), ProjectionAudience: "projection-audience", - // Stated, because construction refuses an unstated trust anchor. A - // deployment that means the host's pool says so. - TrustSystemRoots: true, + // Required: construction refuses a client with no trust anchor. + RootCAs: x509.NewCertPool(), } _, err := NewMintClient(valid) require.NoError(t, err) @@ -956,13 +1000,10 @@ func TestNewMintClientValidatesItsConfiguration(t *testing.T) { "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, - // THE TRUST ANCHOR, both ways round. Unstated was the whole of the - // previous behaviour and it was silently the system pool: every other - // route to a weak transport here was closed and this one defaulted. - "no trust anchor": func(o *MintOptions) { o.TrustSystemRoots = false }, - "two trust anchors": func(o *MintOptions) { - o.RootCAs = x509.NewCertPool() - }, + // THE TRUST ANCHOR. Unstated was the whole of the previous behaviour + // and it was silently the system pool: every other route to a weak + // transport here was closed and this one defaulted. + "no trust anchor": func(o *MintOptions) { o.RootCAs = nil }, } { t.Run(name, func(t *testing.T) { options := valid diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go index 54a72f1..798adc9 100644 --- a/workcontext/one_implementation_test.go +++ b/workcontext/one_implementation_test.go @@ -2090,9 +2090,11 @@ func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, oper // type at all. // // Closing it needs the legitimate indirections enumerated, which they - // now are — there are three, all in receipts, all taking a - // proto.Message by design. A new one is a finding and has to be argued - // here, which is the point. + // now are — FOUR of them: three in receipts, all taking a + // proto.Message by design, and the runtime's own configuration + // document. ("Three" sat here after the fourth was added, which is the + // C10 class and is why the count is now derived from the map rather + // than written out: len(codecIndirections).) // // WHAT THIS STILL DOES NOT CATCH, stated rather than implied: a // capability passed INTO one of those three allowlisted functions. @@ -2157,9 +2159,9 @@ func isCodecInterfaceType(resolved string) bool { // codecIndirections are the functions that legitimately apply a codec to an // interface-typed parameter, by file and by function name. // -// Three, all in receipts, all taking a proto.Message because that is what the -// receipts contract is about: a caller's own request and response messages, -// never a capability. Keyed on the enclosing function rather than the file, +// Four: three in receipts, all taking a proto.Message because that is what the +// receipts contract is about — a caller's own request and response messages, +// never a capability — and the runtime's own configuration document. Keyed on the enclosing function rather than the file, // because a file-wide exemption is what let the type rule be bypassed twice // already. var codecIndirections = map[string][]string{ diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go index 11e85fb..89862f2 100644 --- a/workcontext/round6_reproducers_test.go +++ b/workcontext/round6_reproducers_test.go @@ -2,7 +2,10 @@ package workcontext import ( "context" + "errors" + "fmt" "go/ast" + "net/http" "os" "path/filepath" "regexp" @@ -350,7 +353,7 @@ func (s *blockingSource) ProjectedToken() (string, error) { // projected service-account token. It is a deployment decision, so it is stated // or construction refuses; TrustSystemRoots is how a deployment that means the // host's pool says so. -func TestTheMintEndpointsTrustAnchorMustBeStated(t *testing.T) { +func TestTheMintEndpointsTrustAnchorIsRequired(t *testing.T) { base := MintOptions{ URL: "https://mint.example/platform/_mint", Authority: newTestPin(), @@ -364,21 +367,185 @@ func TestTheMintEndpointsTrustAnchorMustBeStated(t *testing.T) { require.ErrorContains(t, err, "no trust anchor") require.ErrorContains(t, err, "https://mint.example/platform/_mint", "the refusal must name the endpoint the projection would have been sent to") - - stated := base - stated.TrustSystemRoots = true - _, err = NewMintClient(stated) - require.NoError(t, err, "a deployment that says it means the host's pool is configured") + require.ErrorContains(t, err, "x509.SystemCertPool", + "and must name the two lines a deployment that really means the host's pool writes, "+ + "because a refusal with no way forward gets worked around") named := base named.RootCAs = certPoolOf() _, err = NewMintClient(named) - require.NoError(t, err, "and so is one that names the roots") + require.NoError(t, err, "naming the roots is the whole of the requirement") +} - both := named - both.TrustSystemRoots = true - _, err = NewMintClient(both) - require.ErrorIs(t, err, ErrInvalid) - require.ErrorContains(t, err, "not both", - "two anchors is not a stronger statement than one; it is an unanswered question") +// F-5 and F-6. EVERY 200 THAT IS NOT A CAPABILITY LATCHED FOREVER, and so did +// every redirect. Executed over httptest-TLS: `{}`, `null`, +// `{"work_context":"not-a-capability"}`, a 307 to another host and a 308 to the +// same host with a trailing slash each became ErrMintRefused and stayed refused +// through the host recovering and the clock passing every hold-off. +// +// They are the same class as the text/html 200 and the oversized 200 beside +// them, both already retryable: something that is not the host is answering. +// ErrMintRefused is documented as "the host will give the same answer again", +// and a mesh mid-rollout is the shape least likely to. +// +// This also covers the classifications F-6 named as untested, because the table +// drives each one and then drives RECOVERY, which is the half that makes +// "retryable" mean anything. +func TestNoAnswerFromSomethingThatIsNotTheHostLatches(t *testing.T) { + for name, answer := range map[string]struct { + status int + headers map[string]string + body string + }{ + "an empty JSON object": {status: http.StatusOK, body: `{}`}, + "a JSON null": {status: http.StatusOK, body: `null`}, + "a work_context that is not a capability": {status: http.StatusOK, body: `{"work_context":"not-a-capability"}`}, + "an empty work_context": {status: http.StatusOK, body: `{"work_context":""}`}, + // The two F-6 named as untested, beside the new ones so one table + // drives the whole 200 path. + "a 200 carrying text/html": { + status: http.StatusOK, + headers: map[string]string{"Content-Type": "text/html; charset=utf-8"}, + body: "gateway", + }, + "a 200 that does not decode": {status: http.StatusOK, body: `{"work_context":`}, + "a 200 with no Content-Type at all": { + status: http.StatusOK, + headers: map[string]string{"Content-Type": ""}, + body: `{"work_context":"x"}`, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.answer = &cannedAnswer{status: answer.status, headers: answer.headers, body: answer.body} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + + require.ErrorIs(t, err, ErrMintUnavailable, + "something that is not the host answered; that is not a verdict") + require.NotErrorIs(t, err, ErrMintRefused) + require.NoError(t, client.Refused(), + "this latched and stayed refused forever through the host recovering") + + // AND IT RECOVERS, which is the half that makes "retryable" a + // claim rather than a label. + host.answer = nil + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "the host recovered and the client did not") + require.NotEmpty(t, credential.Token()) + }) + } +} + +// F-6. THE ONE LATCH RULE, asserted. Reverting it to its negation — "anything +// that is not an outage latches" — survived every test in this suite. +// +// The rule is one sentence: only ErrMintRefused is terminal. Its negation is +// also one sentence and is the previous revision, under which every error this +// client had not thought about became a permanent stop. So it is driven over +// the error CLASSES rather than over one of them. +func TestOnlyAHostRefusalIsTerminal(t *testing.T) { + for name, source := range map[string]struct { + err error + latches bool + }{ + // ErrInvalid says in its own comment that it is not latched, and the + // negation latched it. + "a misconfiguration from the token source": { + err: fmt.Errorf("%w: no projected token path", ErrInvalid), + }, + // An error from a caller's own reader carrying NO sentinel. Under the + // negation this latched, so errors.Is(client.Refused(), ErrMintRefused) + // was false while the client was permanently refused — the two ways of + // asking disagreed. + "an unlabelled error from a caller's source": { + err: errors.New("the caller's own reader failed"), + }, + "an outage from the token source": { + err: fmt.Errorf("%w: read projected token: EIO", ErrMintUnavailable), + }, + // And the one that does latch, so this is not green by never latching. + "a refusal from the token source": { + err: fmt.Errorf("%w: the host said no", ErrMintRefused), latches: true, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + faulty := &faultySource{err: source.err} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = faulty }) + + _, err := client.Credential(context.Background()) + require.Error(t, err) + + if source.latches { + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "a host refusal is terminal and Refused() must say so") + return + } + require.NoError(t, client.Refused(), + "only ErrMintRefused is terminal; this stopped the process for good") + + // THE RECOVERY, which is what "not terminal" means. + faulty.err = nil + faulty.token = "projected" + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, credential.Token()) + }) + } +} + +// F-6. THE DETACHED REQUEST CARRIES THE CALLER'S VALUES. Reverting +// context.WithoutCancel(caller) to context.Background() survived, because +// nothing read anything off the request's context. +// +// What that costs is invisible and real: a trace span, a request id, whatever +// an http.RoundTripper in the caller's stack reads. Background() dropped all of +// it silently while the comment said the detaching was about cancellation. +func TestTheDetachedMintRequestKeepsTheCallersValues(t *testing.T) { + type key struct{} + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + carried := make(chan any, 1) + client.httpClient.Transport = &valueReadingTransport{ + inner: client.httpClient.Transport, + key: key{}, + seen: carried, + } + + _, err := client.Credential(context.WithValue(context.Background(), key{}, "trace-42")) + require.NoError(t, err) + + select { + case value := <-carried: + require.Equal(t, "trace-42", value, + "the detached request dropped the caller's context values; detaching from "+ + "CANCELLATION is the point, not from everything") + default: + t.Fatal("the transport was never reached, so this asserted nothing") + } +} + +// valueReadingTransport reports the caller's context value as the request saw +// it. +type valueReadingTransport struct { + inner http.RoundTripper + key any + seen chan any +} + +func (t *valueReadingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + select { + case t.seen <- request.Context().Value(t.key): + default: + } + return t.inner.RoundTrip(request) //nolint:wrapcheck // a transport returns the inner error } From 07a93ecc0b436906fdee297cf0157aade5e84310 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:40:05 -0400 Subject: [PATCH 25/35] test: the sweep fixtures name no branch outside this repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6 of the executed round-six review, which I claimed as done in my answer one minute before it was. sweep_bypasses_test.go used `connectory/welcome`, copied off the live remote where it is a third-party bot's branch — so the fixture named something outside this repository and would go stale when that thing did. It is `team/welcome` now, which makes the same point (a slashed branch the old glob dropped) and names nothing. The claim before the change is the failure, not the name: I have done this once before in this PR, with a doc rewrite that a script never wrote. Verified by grep this time — `grep -c connectory` is 0 — and the correction is posted on the PR rather than left in the diff for a reviewer to notice. Co-Authored-By: Claude Opus 5 (1M context) --- sweep_bypasses_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 905d558..a6b7211 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -200,8 +200,11 @@ func TestTheRefSweepSeesNamespacedBranches(t *testing.T) { runIn(t, seed, "git", "branch", "-M", "main") runIn(t, seed, "git", "remote", "add", "origin", remote) runIn(t, seed, "git", "push", "--quiet", "origin", "main") - // Namespaced, exactly the shape the glob dropped. - for _, branch := range []string{"badges", "connectory/welcome", "dependabot/go_modules/gomod-abc"} { + // Namespaced, exactly the shape the glob dropped. The names are SYNTHETIC: + // these were copied off the live remote, where one of them is a + // third-party bot's branch, and a fixture that names something outside + // this repository is a fixture that goes stale when that thing does. + for _, branch := range []string{"badges", "team/welcome", "dependabot/go_modules/gomod-abc"} { runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) } @@ -213,7 +216,7 @@ func TestTheRefSweepSeesNamespacedBranches(t *testing.T) { copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) listed := listRefs(t, clone, "main", "") - require.Contains(t, listed, "origin/connectory/welcome", + require.Contains(t, listed, "origin/team/welcome", "a namespaced branch was dropped: this is the defect the glob had") require.Contains(t, listed, "origin/dependabot/go_modules/gomod-abc", "a doubly namespaced branch was dropped") @@ -223,7 +226,7 @@ func TestTheRefSweepSeesNamespacedBranches(t *testing.T) { // And the head ref is excluded while everything else stays. withHead := listRefs(t, clone, "main", "badges") require.NotContains(t, withHead, "origin/badges") - require.Contains(t, withHead, "origin/connectory/welcome") + require.Contains(t, withHead, "origin/team/welcome") } // And the sweep REFUSES when it can see fewer refs than the remote publishes, From d8cb0af949470963d86a5f01d81ebc00eccfde8b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:47:04 -0400 Subject: [PATCH 26/35] fix(workcontext)!: one import policy for every ref, an interface is an interface however it is wrapped, and the retraction finds its module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round seven, both reviews, the two blockers first. CODEX 1 (blocker) A CONCRETE WRAPPER HID AN INTERFACE-HELD CAPABILITY from all three checks, with no handwritten anything: type envelopeTarget struct{ proto.Message } proto.Unmarshal(raw, &envelopeTarget{Message: dst}) The promoted ProtoReflect delegates to the capability, so this is the ordinary protobuf decoder and the documented wire-walk exception does not cover it. Each check missed it differently: isUnknownToTheGate looked at the OUTER type, which is concrete; capabilityWithin descended, reached proto.Message and had no interface case; the syntactic gate resolved `envelopeTarget` as neither a capability nor a codec interface. The interface walk is TRANSITIVE now — through pointers, slices, maps and struct fields, embedded ones included. A struct carrying an interface IS an interface as far as a codec is concerned, because what it holds is chosen at the call site. Probes: the wrapper, an interface two structs down, and one behind a slice. And CALLS INTO the four permitted indirections are checked, which closes the hole I named at 04e93c0 as needing go/types and then did not close with it: the exemption was granted at the codec call inside those functions and nothing looked at their callers, so decodeDocument(name, payload, &basev0.WorkContextV1{}) reached a json.Unmarshal of a capability through an exemption written for the runtime's own configuration documents — whose json tags are snake_case, which IS the deleted format. (Opus F-R7-3, same finding.) CODEX 3 / OPUS F-R7-2 (blocker) PUBLISHED REFS WERE STILL ON THE OLD DENYLIST. The allowlist lived in `go test`, which reads a CHECKOUT; the ref sweep is the only thing that runs over a published ref and is the whole of the required check. A reviewer ran its extracted predicate: json/v2, mldsa, protodelim, grpc/encoding and `C` all "permitted". Two policies is one policy and one hole. scripts/allowed-imports.txt is the one policy, read by the Go gate and by the sweep. Three things that came out of making it real: - A LINE MAY NAME FILES. The first draft listed encoding/json module-wide and a probe caught it: the denylist had held it to named paths, so the sweep had got LESS strict. Eleven imports are file-scoped now. - HISTORICAL REFS NEEDED AN ANSWER. Deny-by-default describes today's tree, and sweeping two years of tags with it flagged 33 CLEAN versions for a renamed core package and a Postgres driver. Archaeology is a different question from "could this ship today". So `legacy` lines apply to published refs only — seven of them, measured from every clean tag, and a test asserts not one is a signature, a MAC, an envelope or a token library. - The sweep's own tests carry the policy into their throwaway repositories, or they would pass for want of a policy, in the file whose subject is tests that pass for the wrong reason. OPUS F-R7-1 (major) THE RETRACTION WAS AIMED AT THE WRONG MODULE, and it was wrong in both directions. From v0.1.66 the files live under workcontext/, which has its own go.mod — so the root module's zip excludes them, root v0.1.66–v0.2.0 are CLEAN and I had retracted them, while the module that does carry them, github.com/codefly-dev/sdk-go/workcontext, had no retract at all and resolves today. Fifteen root versions now, and the leaf retracts its pseudo-version range (it has never been tagged, so pseudo-versions are all a consumer can resolve). The tag gate classifies each tag's carrying MODULE instead of sweeping the tree and checking one go.mod. CODEX 8 (major) A TEST DELETED REPOSITORY CONTENTS. TestTestdataIsNotAHidingPlace created receipts/testdata/gateprobe and its cleanup removed receipts/testdata — the parent. Nothing lives there today, so it did no harm, but running the suite is not supposed to be destructive and it would have deleted somebody's fixtures the day they added some. It works in a t.TempDir() now and asserts the same property of testdataPatterns. Also: the type-checked gates cache their packages.Load. The leaf suite had gone from 60s to 247s because every gate re-ran the same load; it is 43s now, which matters because a gate slow enough to be skipped is a gate nobody runs. Verified: both modules' suites clean, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (64 Go files), and the round-six second implementation (crypto/mldsa + encoding/json/v2) now FAILS the sweep on a ref. Nine mutations for these fixes, nine killed. STILL OPEN from these two reviews, named rather than claimed: Codex 2 (base64 decode through an aliased receiver), 4 and Opus F-R7-4 (build-constrained files and a go.mod replace), 5 and F-R7-5 (ref OIDs and tag-set completeness), 6 (abandoned token readers accumulate), 7 (ValidateMethodSet accepts a unary-only set), 9 and F-R7-6 (docs), F-R7-7 (isolating x/tools), and the ten doubted tests. Next commit. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 33 +-- published_tags_test.go | 202 ++++++++++++++++--- scripts/allowed-imports.txt | 133 ++++++++++++ scripts/check-one-implementation.sh | 189 ++++++++--------- sweep_bypasses_test.go | 17 ++ workcontext/codec_types_test.go | 247 +++++++++++++++++++++-- workcontext/go.mod | 20 ++ workcontext/import_allowlist_test.go | 267 +++++++++++++------------ workcontext/round6_reproducers_test.go | 141 ++++--------- 9 files changed, 849 insertions(+), 400 deletions(-) create mode 100644 scripts/allowed-imports.txt diff --git a/go.mod b/go.mod index 3529183..33aa99c 100644 --- a/go.mod +++ b/go.mod @@ -62,26 +62,29 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -// RETRACTED: every version below publishes a SECOND Work Context -// implementation, which this module's one-implementation rule forbids and which -// a consumer can still pin today. +// RETRACTED: the ROOT module's versions that publish a second Work Context +// implementation. // // A branch that carries it can be deleted. A TAG CANNOT: it is an immutable // published artifact, `go get sdk-go@v0.1.65` resolves it right now, and the // module proxy has it cached forever. So the rule for a tag is not "clean", it -// is RETRACTED — which is the one mechanism Go provides for "this published -// version should not be used", and `go get` and `go list -m -u` both report it. +// is RETRACTED — the one mechanism Go provides for "this published version +// should not be used", which `go get` and `go list -m -u` both report. // -// Nineteen of them, measured by sweeping every published tag rather than by -// reading the names: v0.1.51 through v0.1.68 and v0.2.0. An executed review -// counted fifteen and stopped at v0.1.65; v0.1.66, v0.1.67, v0.1.68 and v0.2.0 -// carry it too, the last being the highest version this module has published. +// FIFTEEN, not nineteen, and the difference is the finding that corrected this. +// A previous revision retracted v0.1.51 through v0.1.68 and v0.2.0 on the +// strength of sweeping each tag's whole TREE. But from v0.1.66 the files moved +// under workcontext/, which has its own go.mod — so the root module's zip for +// those versions does not contain them, the root versions are CLEAN, and the +// module that carries them is github.com/codefly-dev/sdk-go/workcontext, whose +// go.mod had no retract at all. Retracting a clean version is not a safe error +// in the same direction: it tells consumers to move off something that was +// never the problem, while the thing that was stayed resolvable. // -// TWO THINGS THIS DOES NOT DO, both of which belong to whoever tags releases: -// a retraction takes effect only once a NEW version is tagged carrying this +// workcontext/go.mod retracts the leaf side. +// +// TWO THINGS THIS DOES NOT DO, both belonging to whoever tags releases: a +// retraction takes effect only once a NEW version is tagged carrying this // go.mod, and nothing here deletes anything from the proxy, because nothing // can. -retract ( - v0.2.0 // the same, moved under workcontext/ - [v0.1.51, v0.1.68] // a second Work Context implementation at the root -) +retract [v0.1.51, v0.1.65] // a second Work Context implementation at the root diff --git a/published_tags_test.go b/published_tags_test.go index c49b942..86a2384 100644 --- a/published_tags_test.go +++ b/published_tags_test.go @@ -2,7 +2,9 @@ package codefly_test import ( "encoding/json" + "os" "os/exec" + "path/filepath" "strings" "testing" @@ -30,31 +32,132 @@ func TestEveryPublishedTagCarryingTheImplementationIsRetracted(t *testing.T) { tags := localTags(t) require.NotEmpty(t, tags, "no tags are present in this checkout, so this gate read nothing.\n"+ - "CI fetches them (`git fetch --tags`); a run without them would pass having\n"+ + "CI fetches them (fetch-depth: 0); a run without them would pass having\n"+ "looked at nothing, which is the fail-open this repository has fixed five times.") - retracted := retractedVersions(t) + rootRetracted := retractedVersions(t, ".") + leafBound := leafRetractBound(t) reader := importReader(t) - var carrying, unretracted []string + var rootCarrying, leafCarrying, unretracted []string for _, tag := range tags { - if !tagCarriesAnImplementation(t, reader, tag) { + offending := offendingPathsAt(t, reader, tag) + if len(offending) == 0 { continue } - carrying = append(carrying, tag) - if !retracted[tag] { - unretracted = append(unretracted, tag) + // WHICH MODULE OWNS THE FILES, which is the whole of this correction. + // A path under workcontext/ belongs to the LEAF module at any tag where + // workcontext/go.mod exists — and from v0.1.66 it does, so the root + // module's zip for those versions does not contain them and the root's + // retract directive says nothing about them. A previous revision swept + // each tag's whole tree and then checked only the root go.mod, which is + // how nineteen retractions came to include four clean root versions and + // miss the module that actually carries them. + leaf := tagHasLeafModule(t, tag) + byLeaf, byRoot := false, false + for _, path := range offending { + if leaf && strings.HasPrefix(path, "workcontext/") { + byLeaf = true + continue + } + byRoot = true + } + if byRoot { + rootCarrying = append(rootCarrying, tag) + if !rootRetracted[tag] { + unretracted = append(unretracted, tag+" (root module)") + } + } + if byLeaf { + leafCarrying = append(leafCarrying, tag) + // The leaf module has never been tagged, so there is no leaf + // version to name: what a consumer resolves is a pseudo-version of + // the commit. The leaf retract covers a RANGE up to a timestamp, + // so the question is whether this tag's commit falls inside it. + if stamp := commitStamp(t, tag); stamp > leafBound { + unretracted = append(unretracted, + tag+" (leaf module, commit "+stamp+" is past the retract bound "+leafBound+")") + } } } - require.NotEmpty(t, carrying, - "not one published tag carries the implementation, which contradicts the measurement "+ - "this gate was written from — so the measurement is now wrong or the sweep is") + require.NotEmpty(t, rootCarrying, + "no tag carries the implementation in the ROOT module, which contradicts the "+ + "measurement this gate was written from") + require.NotEmpty(t, leafCarrying, + "no tag carries it in the LEAF module either, and from v0.1.66 the files live there — "+ + "so either the classification is broken or the sweep is") require.Empty(t, unretracted, "these published versions carry a second Work Context implementation and are not "+ "retracted: %v.\nA consumer can pin any of them today. A tag cannot be deleted or "+ - "fixed, so the only answer is a retract directive in go.mod — which takes effect "+ - "once a new version is tagged carrying it.", unretracted) + "fixed, so the only answer is a retract directive in the go.mod of the module that "+ + "OWNS the files — which takes effect once a new version of that module is tagged.", + unretracted) +} + +// tagHasLeafModule reports whether workcontext/ was its own module at a tag. +func tagHasLeafModule(t *testing.T, tag string) bool { + t.Helper() + run := exec.Command("git", "cat-file", "-e", tag+":workcontext/go.mod") + return run.Run() == nil +} + +// commitStamp is a tag's commit time in a pseudo-version's own format, so it +// compares against a retract bound as a string the way the go tool orders them. +func commitStamp(t *testing.T, tag string) string { + t.Helper() + run := exec.Command("git", "log", "-1", "--format=%cd", + "--date=format:%Y%m%d%H%M%S", tag) + output, err := run.Output() + require.NoError(t, err) + return strings.TrimSpace(string(output)) +} + +// leafRetractBound is the timestamp inside the leaf module's retract high +// bound. A leaf go.mod with no retract is a finding, loudly, because that is +// the state this correction found. +func leafRetractBound(t *testing.T) string { + t.Helper() + spans := retractSpans(t, "workcontext") + require.NotEmpty(t, spans, + "workcontext/go.mod retracts nothing, and it is the module that carries the "+ + "implementation from root tag v0.1.66 onward") + var highest string + for _, span := range spans { + _, stamp, found := strings.Cut(span.High, "-") + require.True(t, found, + "the leaf retract bound %q is not a pseudo-version; this module has never been "+ + "tagged, so a range of pseudo-versions is what covers its published versions", + span.High) + stamp, _, _ = strings.Cut(stamp, "-") + if stamp > highest { + highest = stamp + } + } + return highest +} + +// offendingPathsAt runs the repository sweep over one tag and returns the paths +// it reported. +func offendingPathsAt(t *testing.T, reader string, tag string) []string { + t.Helper() + run := exec.Command("bash", "scripts/check-one-implementation.sh", tag) + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, err := run.CombinedOutput() + if err == nil { + return nil + } + require.Contains(t, string(output), "carries a Work Context implementation", + "the sweep failed on %s for a reason that is not a finding:\n%s", tag, output) + var paths []string + for _, line := range strings.Split(string(output), "\n") { + path, _, found := strings.Cut(strings.TrimSpace(line), " imports ") + if found && strings.HasSuffix(path, ".go") { + paths = append(paths, path) + } + } + require.NotEmpty(t, paths, "the sweep reported a finding and named no file:\n%s", output) + return paths } // localTags is every tag in this checkout, which in CI is every published tag. @@ -77,24 +180,36 @@ func localTags(t *testing.T) []string { // Read through `go mod edit -json` rather than by matching the file, because // the shape a person writes and the set the go tool acts on are two different // things — and this gate is about the second. -func retractedVersions(t *testing.T) map[string]bool { +type retractSpan struct { + Low string `json:"Low"` + High string `json:"High"` +} + +// retractSpans reads one module's retract directives through `go mod edit +// -json`, because the shape a person writes and the set the go tool acts on are +// two different things and this gate is about the second. +func retractSpans(t *testing.T, dir string) []retractSpan { t.Helper() run := exec.Command("go", "mod", "edit", "-json") + run.Dir = dir output, err := run.Output() require.NoError(t, err) var parsed struct { - Retract []struct { - Low string `json:"Low"` - High string `json:"High"` - } `json:"Retract"` + Retract []retractSpan `json:"Retract"` } require.NoError(t, json.Unmarshal(output, &parsed)) - require.NotEmpty(t, parsed.Retract, "go.mod retracts nothing") + return parsed.Retract +} + +func retractedVersions(t *testing.T, dir string) map[string]bool { + t.Helper() + spans := retractSpans(t, dir) + require.NotEmpty(t, spans, "%s/go.mod retracts nothing", dir) covered := map[string]bool{} for _, tag := range localTags(t) { - for _, span := range parsed.Retract { + for _, span := range spans { // `go mod edit -json` writes a single version as Low == High, so // one comparison covers both shapes. if semverAtMost(span.Low, tag) && semverAtMost(tag, span.High) { @@ -150,16 +265,43 @@ func versionFields(version string) [3]int { return fields } -// tagCarriesAnImplementation runs the repository sweep over one tag. -func tagCarriesAnImplementation(t *testing.T, reader string, tag string) bool { - t.Helper() - run := exec.Command("bash", "scripts/check-one-implementation.sh", tag) - run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) - output, err := run.CombinedOutput() - if err == nil { - return false +// THE HISTORICAL ALLOWANCE CANNOT BECOME THE HOLE THE DENYLIST WAS. +// +// `legacy` lines in the import policy apply only when sweeping a published ref, +// because deny-by-default describes today's tree and two years of tags +// legitimately import things this repository has stopped using — the first run +// over history flagged 33 clean tags for a renamed core package and a Postgres +// driver. That allowance is measured, and this is what keeps it honest: not one +// entry may be a signature, a MAC, an envelope or a token library, because those +// are the whole subject of the rule it relaxes. +func TestTheHistoricalImportAllowanceCarriesNothingCapabilityShaped(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("scripts", "allowed-imports.txt")) + require.NoError(t, err) + + var legacy []string + for _, line := range strings.Split(string(raw), "\n") { + if module, path, found := strings.Cut(strings.TrimSpace(line), " "); found && module == "legacy" { + legacy = append(legacy, path) + } + } + require.NotEmpty(t, legacy, "the allowance is empty, so this test asserts nothing") + + // The capability-shaped fragments, which is the same question the old + // denylist asked — kept here, where it is the right question, rather than + // in the sweep, where it was the only one. + for _, path := range legacy { + lowered := strings.ToLower(path) + for _, fragment := range []string{ + "crypto", "jose", "jwt", "jwx", "paseto", "macaroon", "branca", + "base64", "base32", "ascii85", "pem", "protojson", "protowire", + "protodelim", "protoiface", "protoimpl", "anypb", "json", "mime", + "gob", "asn1", "xml", "mldsa", "ed25519", "hkdf", "pbkdf2", + } { + require.NotContains(t, lowered, fragment, + "the historical allowance lists %q, which contains %q. That allowance exists "+ + "for a renamed core package and a database driver; a signature, a MAC, an "+ + "envelope or a token library in it would reopen exactly the hole "+ + "deny-by-default was adopted to close.", path, fragment) + } } - require.Contains(t, string(output), "carries a Work Context implementation", - "the sweep failed on %s for a reason that is not a finding:\n%s", tag, output) - return true } diff --git a/scripts/allowed-imports.txt b/scripts/allowed-imports.txt new file mode 100644 index 0000000..e8c5d18 --- /dev/null +++ b/scripts/allowed-imports.txt @@ -0,0 +1,133 @@ +# THE ONE IMPORT POLICY, read by the Go gate and by the ref sweep. +# +# Deny by default, per module: an import that is not listed is a finding. This +# file exists because the policy lived only in `go test`, which reads a +# CHECKOUT — and the ref sweep is the only thing that runs over a published ref, +# where there is nothing to type-check. So a branch carrying `crypto/mldsa` and +# `encoding/json/v2` — the round-six second implementation — swept `ok`, in the +# one job that is a required check. Two policies is one policy and one hole. +# +# Format: ` `, where is `root` or `leaf`. A file +# under workcontext/ belongs to the leaf module, everything else to the root. +# (At a ref from before the leaf module existed, those files were root-module +# files; the classification only picks which list, and an import on neither is a +# finding either way, so the sweep stays conservative on old refs.) +# +# A line may NAME THE FILES that may have that import: +# +# root crypto/sha256 receipts/digest.go receipts/postgres.go +# +# Without files it is module-wide. With them it is those files and no others, +# which is what keeps the sweep as strict as the denylist it replaced — that +# denylist held `encoding/json` to named paths, and the first draft of this file +# listed it module-wide and let any root file have it. A probe caught that. +# +# WHAT THE SWEEP STILL CANNOT DO that the checkout gate does: hold an allowed +# import to named SYMBOLS. `encoding/base64` may only ENCODE in +# cache_partition.go, and only reading the code sees a DecodeString. A ref gets +# the file-level rule; a checkout gets both. +# +# `legacy ` lines apply ONLY when sweeping a published ref, never to the +# working tree. They exist because deny-by-default describes TODAY's tree, and a +# tag from two years ago legitimately imported things this repository has since +# stopped using — the first run of the policy over history flagged 33 clean tags +# for a renamed core package and a Postgres driver. Archaeology is a different +# question from "could this ship today", and answering the first one with the +# second produces noise that makes a gate ignorable. +# +# MEASURED, from every published tag that does NOT carry the implementation, and +# then read: there are seven, and not one of them is a signature, a MAC, an +# envelope or a token library. A test asserts that, so this list cannot become +# the hole the denylist was. +# +# Adding a line is a design change: it is the thing every round of review found +# the denylist missing, in the other direction. + +# --- root module --- +root bufio +root bytes +root connectrpc.com/connect +root context +root crypto/sha256 receipts/digest.go receipts/postgres.go +root crypto/tls tls.go +root crypto/x509 tls.go +root database/sql +root embed +root encoding/binary +root encoding/json configuration_document.go receipts/digest.go +root errors +root fmt +root github.com/codefly-dev/core/composition +root github.com/codefly-dev/core/configurations +root github.com/codefly-dev/core/generated/go/codefly/base/v0 +root github.com/codefly-dev/core/generated/go/codefly/runnable/v0 +root github.com/codefly-dev/core/network +root github.com/codefly-dev/core/resources +root github.com/codefly-dev/core/runnable +root github.com/codefly-dev/core/standards +root github.com/codefly-dev/core/wool +root github.com/codefly-dev/sdk-go/receipts +root go/parser +root go/token +root google.golang.org/grpc +root google.golang.org/grpc/codes +root google.golang.org/grpc/metadata +root google.golang.org/grpc/status +root google.golang.org/protobuf/encoding/protojson receipts/digest.go +root google.golang.org/protobuf/proto +root google.golang.org/protobuf/reflect/protodesc +root google.golang.org/protobuf/reflect/protoreflect +root google.golang.org/protobuf/reflect/protoregistry +root google.golang.org/protobuf/types/descriptorpb +root google.golang.org/protobuf/types/dynamicpb +root google.golang.org/protobuf/types/known/durationpb +root io +root os +root path/filepath +root runtime/debug +root sort +root strconv +root strings +root sync +root time + +# --- leaf module (workcontext/) --- +leaf bytes +leaf context +leaf crypto/sha256 workcontext/cache_partition.go +leaf crypto/tls workcontext/mint.go +leaf crypto/x509 workcontext/mint.go +leaf encoding/base64 workcontext/cache_partition.go +leaf encoding/binary +leaf encoding/hex +leaf encoding/json workcontext/mint.go +leaf errors +leaf fmt +leaf github.com/codefly-dev/core/generated/go/codefly/base/v0 +leaf github.com/codefly-dev/core/workcontext +leaf github.com/codefly-dev/sdk-go/workcontext +leaf google.golang.org/grpc +leaf google.golang.org/grpc/codes +leaf google.golang.org/grpc/metadata +leaf google.golang.org/grpc/status +leaf google.golang.org/protobuf/proto +leaf io +leaf mime workcontext/mint.go +leaf net/http +leaf net/url +leaf os +leaf slices +leaf strconv +leaf strings +leaf sync +leaf sync/atomic +leaf time + +# --- historical, published refs only (see above) --- +legacy github.com/codefly-dev/core/configurations/standards +legacy github.com/codefly-dev/core/shared +legacy github.com/jackc/pgx/v5 +legacy github.com/jackc/pgx/v5/pgconn +legacy github.com/jackc/pgx/v5/pgxpool +legacy path +legacy reflect diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index aaaaf51..d5105b2 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -20,8 +20,9 @@ # there is no checkout to type-check — which is why it reads imports with # go/parser (scripts/importsof) rather than with a regex. # -# THE PREDICATE IS AN ABSOLUTE BAN ON SIGNING, with a named allowlist — not a -# conjunction. It used to be "imports a signing primitive AND mentions +# THE PREDICATE IS DENY BY DEFAULT: an import not on its module's list in +# scripts/allowed-imports.txt is a finding. It used to be a ban on signing with +# a named allowlist — a denylist — and it is not that any more. It used to be "imports a signing primitive AND mentions # WorkContext", which a second implementation defeats by putting the signer in # one file and the wrapper in another; and its pattern was anchored on the # opening quote, so "golang.org/x/crypto/ed25519" did not match at all. Now any @@ -63,79 +64,46 @@ set -euo pipefail # Where this script lives, so the import reader beside it can be built. here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Any of these in an import path is a signature, a MAC, or a library that mints -# bearer tokens. The bare "crypto" package is included: crypto.Signer signs -# Ed25519 with no ed25519 import anywhere. -# Signatures, MACs and anything that mints a bearer token. The bare "crypto" is -# included: crypto.Signer signs Ed25519 with no ed25519 import anywhere. -# crypto/cipher and the block ciphers are here because a GMAC or a CMAC is a -# MAC assembled from a cipher, which names no MAC. -primitives='^crypto$|^crypto/hkdf$|^crypto/pbkdf2$|^crypto/sha512$|^crypto/sha1$|^crypto/sha3$|^crypto/md5$|^crypto/ed25519$|^crypto/ecdsa$|^crypto/rsa$|^crypto/dsa$|^crypto/hmac$|^crypto/ecdh$|^crypto/elliptic$|^crypto/subtle$|^crypto/cipher$|^crypto/aes$|^crypto/des$|^crypto/rc4$|^crypto/sha3$|x/crypto/|jose|jwt|jwx|paseto|macaroon|branca|ed25519' -# Second encodings of the message. protojson is a complete JSON encoding of a -# protobuf message on its own, which is how the deleted implementation's payload -# would come back without an encoding/json import. -# The LOW-LEVEL protobuf runtime and the LEGACY protobuf module belong here as -# well, and did not until the two gates were compared entry by entry: the AST -# gate banned protoiface, protoimpl and github.com/golang/protobuf/proto while -# this script passed all three — measured, `ok working tree (4 Go files)`, -# exit 0 — in the module where this script is the only gate. A message's own -# ProtoMethods().Unmarshal is a complete decode naming no codec package, and the -# legacy module's proto.Unmarshal is the same capability at another path. -encoders='encoding/protojson|encoding/protowire|known/anypb|^encoding/gob$|^encoding/asn1$|^encoding/xml$|runtime/protoiface|runtime/protoimpl|^github.com/golang/protobuf/proto$' -# Opening a credential's envelope by hand. base64 plus proto.Unmarshal is the -# whole of a second parser, and the AST gate reaches only the workcontext module -# — a compiling root package with a base64/JSON credential decoder and its own -# seal rule passed this sweep. +# THE POLICY IS DENY BY DEFAULT and it is read from scripts/allowed-imports.txt, +# which the Go gate reads too. # -# base64 is not the only way to spell it, which is why this is a list and not -# one path: encoding/pem decodes a base64 body, mime.WordDecoder decodes -# base64, and base32/ascii85 are alternative envelopes a host could be talked -# into. encoding/hex and encoding/binary are deliberately absent: neither can -# open core's envelope, which is base64url. -envelope='^encoding/base64$|^encoding/base32$|^encoding/ascii85$|^encoding/pem$|^mime$|^mime/' - +# What was here was three regexes and a per-file exception list — a DENYLIST, +# and every round of review found the next name it was missing. The last pair +# was `crypto/mldsa` signing a `WorkContextV1` that `encoding/json/v2` had +# encoded in the deleted format; a reviewer ran this script's extracted +# predicate over both and both were "permitted", along with protodelim, +# grpc/encoding and `C`. The allowlist existed by then — in `go test`, which +# reads a checkout, while THIS is the only thing that runs over a published ref +# and is the required check. One policy, both places. status=0 -# allowed — the exceptions, each with a reason in the comment. -# The mint client builds and owns its own TLS transport, which needs tls for the -# configuration and x509 for the caller's root pool. Nothing else here may. -allowed() { - case "$1|$2" in - 'workcontext/mint.go|crypto/tls') return 0 ;; - 'workcontext/mint.go|crypto/x509') return 0 ;; - 'tls.go|crypto/tls') return 0 ;; - 'tls.go|crypto/x509') return 0 ;; - # The receipts digest canonicalises a receipt REQUEST, never a capability. - 'receipts/digest.go|google.golang.org/protobuf/encoding/protojson') return 0 ;; - # ENCODES a tenant and installation id into a cache key; opens no envelope. - # The AST gate additionally holds that file to the ENCODE methods, which is - # the half this script cannot check. - 'workcontext/cache_partition.go|encoding/base64') return 0 ;; - # The runtime's own configuration document, which is not a capability. The - # AST gate cannot reach the root module, so this is named here instead. - 'configuration_document.go|encoding/json') return 0 ;; - # The mint endpoint's two HTTP bodies. In the workcontext module the AST - # gate additionally holds this to the TWO TYPES; here it is by path only, - # which is the weaker half and is why the AST gate exists. - 'workcontext/mint.go|encoding/json') return 0 ;; - # One call: the mint response's Content-Type must be declared and must be - # application/json. mime is banned otherwise because WordDecoder decodes - # base64 with no base64 import. - 'workcontext/mint.go|mime') return 0 ;; - # The two digests, and the receipts store's row digest. A hash is not a - # signature; a hand-rolled HMAC over one is the residue named in the header. - 'workcontext/cache_partition.go|crypto/sha256') return 0 ;; - 'receipts/digest.go|crypto/sha256') return 0 ;; - 'receipts/postgres.go|crypto/sha256') return 0 ;; - # The receipts DIGEST, which canonicalises a receipt REQUEST. By exact - # path: this was `receipts/*`, permitting encoding/json anywhere under - # receipts, while the AST gate named one file — so the two gates disagreed - # about the same directory and the looser one was this script, which is the - # only gate the ref sweep runs. Measured from the tree: receipts/digest.go - # is the only non-test file there that imports it. - 'receipts/digest.go|encoding/json') return 0 ;; +policy="$here/allowed-imports.txt" +if [ ! -r "$policy" ]; then + echo "FAIL cannot read the import policy at $policy." >&2 + echo " Deny-by-default with no list permits nothing or everything; neither is a gate." >&2 + exit 1 +fi + +# allowed_root and allowed_leaf hold one permitted import per line as +# `` or ` file file …`, bracketed so a fixed-string match cannot +# match a prefix: `crypto/sha` must not pass because `crypto/sha256` is listed. +allowed_root="$(mktemp)" +allowed_leaf="$(mktemp)" +awk '$1 == "root" { $1 = ""; sub(/^ /, ""); print "<" $0 ">" }' "$policy" > "$allowed_root" +awk '$1 == "leaf" { $1 = ""; sub(/^ /, ""); print "<" $0 ">" }' "$policy" > "$allowed_leaf" +# Historical paths, added ONLY in ref mode. See the policy file's own comment: +# deny-by-default describes today's tree, and sweeping two years of tags with it +# flagged 33 clean versions for a renamed core package and a Postgres driver. +if [ "$#" -gt 0 ]; then + awk '$1 == "legacy" { print "<" $2 ">" }' "$policy" >> "$allowed_root" + awk '$1 == "legacy" { print "<" $2 ">" }' "$policy" >> "$allowed_leaf" +fi +# module_of -> root | leaf +module_of() { + case "$1" in + workcontext/*) printf 'leaf\n' ;; + *) printf 'root\n' ;; esac - return 1 } # IMPORTS ARE READ BY GO'S OWN PARSER, not by a regex over lines. @@ -185,34 +153,33 @@ imports_of_files() { } # offending_import -> true when this import is a finding. +# +# `import "C"` is not an import path at all, it is an exit from every rule here: +# a MAC built through the host's crypto library names no Go package. It is +# named so the message says that, rather than only "not on the list". offending_import() { - local path="$1" imported="$2" - case "$imported" in - 'crypto/tls'|'crypto/x509') - # TLS plumbing, still held to the allowlist. - allowed "$path" "$imported" && return 1 - return 0 - ;; - 'crypto/rand') - # Randomness is not a key. - return 1 - ;; - 'crypto/sha256') - # The ONE hash with uses here — three digests — held to them by the AST - # gate's file and symbol rule. The others are not waved through: - # hkdf.Extract(sha512.New, …) and pbkdf2.Key(sha512.New, …) are HMAC, and - # both passed this script while `crypto/sha512` sat in the "a hash is not - # a signature" list beside it. - allowed "$path" "$imported" && return 1 - return 0 - ;; - esac - if printf '%s' "$imported" | grep -Eq "($primitives)|($encoders)|($envelope)" || - [ "$imported" = "encoding/json" ]; then - allowed "$path" "$imported" && return 1 + local path="$1" imported="$2" list entry files + if [ "$imported" = "C" ]; then return 0 fi - return 1 + case "$(module_of "$path")" in + leaf) list="$allowed_leaf" ;; + *) list="$allowed_root" ;; + esac + # Module-wide: the line is just the path. + grep -qxF "<$imported>" "$list" && return 1 + # Or held to NAMED FILES, which is what keeps this as strict as the denylist + # it replaced: that one held encoding/json to named paths, and a module-wide + # line let any root file have it. + entry=$(grep -F "<$imported " "$list" | head -1) + if [ -n "$entry" ]; then + files=${entry#"<$imported "} + files=${files%>} + for allowed_file in $files; do + [ "$allowed_file" = "$path" ] && return 1 + done + fi + return 0 } carrying_in_tree() { @@ -332,20 +299,26 @@ if [ "$status" -ne 0 ]; then cat >&2 <<'MSG' A wire contract has exactly one implementation, in the repository that owns the -type: codefly-dev/core/workcontext. The files above import a signature, a MAC, a -token library or a second encoding of the message. +type: codefly-dev/core/workcontext. The imports above are not on their module's +allowlist in scripts/allowed-imports.txt. + +THIS IS DENY BY DEFAULT, and that is the point. The rule here was a denylist of +names for ten rounds of review and every round found the next name — hkdf, then +sha512, then protoiface, and finally crypto/mldsa signing a WorkContextV1 that +encoding/json/v2 had encoded in the deleted format, with every gate green. If an +import above is honest, add it to scripts/allowed-imports.txt WITH A REASON, in +a reviewed diff. That is the cost, and it is one line instead of a round. -There is no "and it also mentions WorkContext" condition any more: that was -satisfied by splitting a signer across two files. If a file above has an honest -need for one of these, add it to allowed() by path WITH A REASON, so the next -reader sees the argument rather than the exception. +`import "C"` is refused outright: cgo is not a package name to add to a list, it +is an exit from every rule here — a MAC built through the host's crypto library +names no Go package at all. -Delete them and reach core's Verifier through workcontext/core.go. This -repository once held a second implementation signing hand-written JSON. The -signatures were sound; the key id is a field inside the payload, so reading a -protobuf payload as JSON yielded no key id and the failure surfaced as "unknown -key" / "signature does not verify under key X" — which reads like a rotated key, -so keys are what everyone investigated. +Reach core's Verifier through workcontext/core.go. This repository once held a +second implementation signing hand-written JSON. The signatures were sound; the +key id is a field inside the payload, so reading a protobuf payload as JSON +yielded no key id and the failure surfaced as "unknown key" / "signature does +not verify under key X" — which reads like a rotated key, so keys are what +everyone investigated. MSG fi exit "$status" diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index a6b7211..cf16349 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -150,6 +150,7 @@ func sweepOf(t *testing.T, script string, name string, source string) (string, e t.Helper() repository := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) for _, command := range [][]string{ {"git", "init", "--quiet"}, {"git", "add", "."}, @@ -159,6 +160,12 @@ func sweepOf(t *testing.T, script string, name string, source string) (string, e output, err := run.CombinedOutput() require.NoError(t, err, "%s: %s", command, output) } + // THE POLICY TRAVELS WITH THE SCRIPT. The sweep reads + // scripts/allowed-imports.txt beside itself, so a throwaway repository with + // only the script in it would fail for want of a policy rather than on the + // probe — which is a test passing for the wrong reason in the file whose + // subject is exactly that. + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) sweep := exec.Command("bash", script) sweep.Dir = repository sweep.Env = sweepEnv(t) @@ -291,6 +298,14 @@ func runIn(t *testing.T, dir string, command string, args ...string) { require.NoError(t, err, "%s %v: %s", command, args, output) } +// copyPolicy puts the real import policy beside a throwaway copy of the script. +func copyPolicy(t *testing.T, fromDir string, toDir string) { + t.Helper() + require.NoError(t, os.MkdirAll(toDir, 0o755)) + copyFile(t, filepath.Join(fromDir, "allowed-imports.txt"), + filepath.Join(toDir, "allowed-imports.txt")) +} + func copyFile(t *testing.T, from string, to string) { t.Helper() content, err := os.ReadFile(from) @@ -382,6 +397,7 @@ func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { runIn(t, repository, "git", "branch", "gitlinked") require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) copyFile(t, checker, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(checker), filepath.Join(repository, "scripts")) run := exec.Command("bash", "scripts/check-one-implementation.sh", "gitlinked") run.Dir = repository @@ -484,6 +500,7 @@ func clonedRemote(t *testing.T, sweep string, checker string, branch string) str require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) copyFile(t, sweep, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(checker), filepath.Join(clone, "scripts")) return clone } diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go index 40ec9c4..8fd31a0 100644 --- a/workcontext/codec_types_test.go +++ b/workcontext/codec_types_test.go @@ -142,9 +142,28 @@ func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []s if kind == nil { continue } + // A NAMED CAPABILITY IS ANSWERED FIRST, by its row. The + // interface walk below is transitive now, and a generated + // message's own internals must not be what decides a call that a + // row already permits. + if named := capabilityWithin(kind, map[types.Type]bool{}); named != "" { + if permittedCapabilityCodec(path, named) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s applies %s to %s, which is or contains %s.\n"+ + "A Work Context message passes through a codec only where a row names it.\n"+ + "Reading one off the wire is corework.Inspect's job, and it returns the claims\n"+ + "it decoded. This catches it THROUGH a field as well: a *Claims embedded in\n"+ + "mintRequest put a whole capability on the wire through a row written for two\n"+ + "strings, and the alias lived in another file, so no syntactic rule could see it.", + path, function.FullName(), kind, named)) + continue + } // A TYPE PARAMETER OR AN INTERFACE IS NOT A TYPE, it is a // promise that one will turn up at the call site — which is - // exactly where a capability turned up in six separate probes. + // exactly where a capability turned up in seven separate probes, + // the last of them wrapped in a concrete struct. if isUnknownToTheGate(kind) { if permittedIndirection(loaded, file, call.Pos()) { continue @@ -159,19 +178,51 @@ func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []s path, function.FullName(), kind, codecIndirectionObjects)) continue } - if named := capabilityWithin(kind, map[types.Type]bool{}); named != "" { - if permittedCapabilityCodec(path, named) { - continue - } - findings = append(findings, fmt.Sprintf( - "%s applies %s to %s, which is or contains %s.\n"+ - "A Work Context message passes through a codec only where a row names it.\n"+ - "Reading one off the wire is corework.Inspect's job, and it returns the claims\n"+ - "it decoded. This catches it THROUGH a field as well: a *Claims embedded in\n"+ - "mintRequest put a whole capability on the wire through a row written for two\n"+ - "strings, and the alias lived in another file, so no syntactic rule could see it.", - path, function.FullName(), kind, named)) + } + return true + }) + + // A CAPABILITY HANDED INTO ONE OF THE PERMITTED INDIRECTIONS. + // + // The indirections exist because those four functions take an interface by + // design, and the exemption was granted at the CODEC call inside them. But + // nothing looked at their CALLERS, so + // + // decodeDocument(name, payload, &basev0.WorkContextV1{}) + // + // reached a json.Unmarshal of a capability through an exemption written for + // the runtime's own configuration documents — and configuration_document.go + // decodes into the generated struct, whose json tags are snake_case, which + // IS the deleted format. The hole was named at 04e93c0 as needing go/types; + // having go/types did not close it, because the rule was still only about + // codec calls. + // + // There is no row here and there is not going to be one: these four take a + // caller's own messages, never a capability. + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + indirection := indirectionFunctionOf(loaded, call.Fun) + if indirection == "" { + return true + } + for _, argument := range call.Args { + kind := loaded.TypesInfo.TypeOf(argument) + named := capabilityWithin(kind, map[types.Type]bool{}) + if named == "" { + continue } + findings = append(findings, fmt.Sprintf( + "%s passes %s, which is or contains %s, into %s.\n"+ + "That function is on the codec-indirection allowlist because it takes an\n"+ + "interface BY DESIGN — a caller's own request, response or configuration\n"+ + "document. The exemption is for the codec call inside it, not for handing it a\n"+ + "capability: decodeDocument json-decodes into its destination, and a\n"+ + "WorkContextV1's json tags are snake_case, which is the format this module\n"+ + "deleted. corework.Inspect reads a capability.", + path, kind, named, indirection)) } return true }) @@ -206,6 +257,45 @@ func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []s return findings } +// indirectionFunctionOf names the permitted indirection an expression calls, or +// "". It is the same (package, Receiver.Function) key the exemption uses, so +// the two cannot disagree about which function is meant. +func indirectionFunctionOf(loaded *packages.Package, expression ast.Expr) string { + var identifier *ast.Ident + switch typed := expression.(type) { + case *ast.Ident: + identifier = typed + case *ast.SelectorExpr: + identifier = typed.Sel + default: + return "" + } + object, ok := loaded.TypesInfo.Uses[identifier] + if !ok { + object = loaded.TypesInfo.Defs[identifier] + } + function, ok := object.(*types.Func) + if !ok || function.Pkg() == nil { + return "" + } + qualified := function.Name() + if signature, ok := function.Type().(*types.Signature); ok && signature.Recv() != nil { + receiver := signature.Recv().Type() + if pointer, ok := types.Unalias(receiver).(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := types.Unalias(receiver).(*types.Named) + if !ok || named.Obj() == nil { + return "" + } + qualified = named.Obj().Name() + "." + function.Name() + } + if slices.Contains(codecIndirectionObjects[function.Pkg().Path()], qualified) { + return function.Pkg().Path() + "." + qualified + } + return "" +} + // codecFunctionOf resolves an expression to a codec function's OBJECT, or nil. func codecFunctionOf(loaded *packages.Package, expression ast.Expr) *types.Func { var identifier *ast.Ident @@ -254,19 +344,78 @@ func codecFunctionOf(loaded *packages.Package, expression ast.Expr) *types.Func } // isUnknownToTheGate reports whether a type says "whatever the caller passes" -// — an interface, or a type parameter, which is the same answer. +// — an interface, a type parameter, or anything that CONTAINS one. // // It had an explicit *types.TypeParam branch, and a mutation showed the branch // was dead: a type parameter's underlying type IS its constraint interface, so -// types.IsInterface already returns true for one. Two readings of the same -// question, one of them untestable, so it is deleted rather than left as a -// branch no test can reach. +// types.IsInterface already returns true for one. +// +// THE "CONTAINS" PART IS THE ROUND-SEVEN BLOCKER, and it needed no handwritten +// anything: +// +// type envelopeTarget struct{ proto.Message } +// +// func decode(raw []byte, dst *basev0.WorkContextV1) error { +// return proto.Unmarshal(raw, &envelopeTarget{Message: dst}) +// } +// +// The wrapper is CONCRETE, so checking the outer type answered "known"; the +// capability walk descended into it, reached `proto.Message`, and had no +// interface case so returned nothing; and the syntactic gate resolved +// `envelopeTarget` as neither a capability nor a codec interface. Three checks, +// three misses, and the embedded interface's promoted ProtoReflect delegates +// to the capability — so this is the ordinary protobuf decoder, which the +// documented handwritten-wire-walk exception does not cover. +// +// A struct carrying an interface IS an interface as far as a codec is +// concerned: what it holds is chosen at the call site. So the walk is +// transitive, and the only thing that makes such an argument acceptable is one +// of the named indirections. func isUnknownToTheGate(kind types.Type) bool { - // A pointer to an interface is still an interface's worth of unknown. - for pointer, ok := types.Unalias(kind).(*types.Pointer); ok; pointer, ok = types.Unalias(kind).(*types.Pointer) { - kind = pointer.Elem() + return carriesAnInterface(kind, map[types.Type]bool{}) +} + +// carriesAnInterface walks a type for an interface or a type parameter, through +// pointers, slices, arrays, maps, aliases and STRUCT FIELDS — embedded ones +// included, which is where the promoted codec interface hides. +// +// It does not descend into CORE's own types: a generated message's internal +// state is core's business, and a capability reached through a row is answered +// by capabilityWithin before this is consulted. +func carriesAnInterface(kind types.Type, seen map[types.Type]bool) bool { + if kind == nil || seen[kind] { + return false + } + seen[kind] = true + switch typed := types.Unalias(kind).(type) { + case *types.Interface: + return true + case *types.TypeParam: + return true + case *types.Pointer: + return carriesAnInterface(typed.Elem(), seen) + case *types.Slice: + return carriesAnInterface(typed.Elem(), seen) + case *types.Array: + return carriesAnInterface(typed.Elem(), seen) + case *types.Map: + return carriesAnInterface(typed.Elem(), seen) + case *types.Named: + if types.IsInterface(typed) { + return true + } + if strings.HasPrefix(packagePathOf(typed.Obj()), coreModulePath) { + return false + } + return carriesAnInterface(typed.Underlying(), seen) + case *types.Struct: + for index := range typed.NumFields() { + if carriesAnInterface(typed.Field(index).Type(), seen) { + return true + } + } } - return types.IsInterface(kind) + return false } // capabilityWithin names the capability a type is, or carries in a field, or @@ -541,6 +690,42 @@ func into(raw []byte, c *basev0.WorkContextV1) error { return use(proto.Unmarsha }, // L5's other half: the capability reached THROUGH A FIELD, by an alias // declared in another file. No syntactic rule could resolve it. + // ROUND SEVEN'S BLOCKER: a CONCRETE wrapper around an embedded + // interface. No handwritten anything — the promoted ProtoReflect + // delegates to the capability, so this is the ordinary protobuf + // decoder, and three separate checks each missed it for a different + // reason. + "a concrete wrapper around an embedded codec interface": { + source: `type envelopeTarget struct{ proto.Message } + +func decode(raw []byte, dst *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, &envelopeTarget{Message: dst}) +}`, + says: "interface or a type parameter", + }, + // And the same hiding place one level deeper, so the walk is + // transitive rather than one-field-deep. + "an interface two structs down": { + source: `type inner struct{ proto.Message } +type outer struct { + Name string + Inner inner +} + +func decode(raw []byte, dst *basev0.WorkContextV1) error { + wrapped := outer{Inner: inner{Message: dst}} + return proto.Unmarshal(raw, &wrapped.Inner) +}`, + says: "interface or a type parameter", + }, + // A SLICE of them, because a codec takes one element at a time and the + // walk must not stop at the container. + "an interface behind a slice": { + source: `type batch []proto.Message + +func decode(raw []byte, all batch) error { return proto.Unmarshal(raw, all[0]) }`, + says: "interface or a type parameter", + }, // L5, as executed: json.Marshal of a struct whose EMBEDDED field is a // capability, through an alias declared in another file. The argument's // own type is concrete — mintRequest — so no interface rule fires and @@ -629,6 +814,26 @@ func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal( } }) + // A CAPABILITY HANDED INTO A PERMITTED INDIRECTION. The exemption is for + // the codec call inside those four functions; their callers were never + // looked at, and configuration_document.go json-decodes into its + // destination with the generated struct's snake_case tags. + t.Run("a capability passed into a permitted indirection", func(t *testing.T) { + const rootPath = "github.com/codefly-dev/sdk-go" + loaded, file := typeCheckedProbeIn(t, rootPath, preamble+`func decodeDocument(name string, content []byte, destination any) error { + return json.Unmarshal(content, destination) +} + +func read(raw []byte) (*basev0.WorkContextV1, error) { + claims := &basev0.WorkContextV1{} + return claims, decodeDocument("work-context", raw, claims) +}`) + findings := inspectCodecUses(loaded, file, "configuration_document.go") + require.NotEmpty(t, findings, + "the exemption is for the codec INSIDE decodeDocument, not for handing it a capability") + require.Contains(t, strings.Join(findings, "\n"), "into github.com/codefly-dev/sdk-go.decodeDocument") + }) + // A POINTER TO AN INTERFACE is still an interface's worth of unknown, and // json.Marshal takes `any`, so it is a shape that compiles. t.Run("a pointer to an interface", func(t *testing.T) { diff --git a/workcontext/go.mod b/workcontext/go.mod index 363dce3..8adeb0b 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -27,3 +27,23 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect ) + +// RETRACTED: every version of THIS module published before the second Work +// Context implementation was deleted from it. +// +// This module carries the files from root tag v0.1.66 onward — they live under +// workcontext/, which is this module, so the root module's zip excludes them +// and the root module's retract directive says nothing about them. A review +// found the root retraction aimed at the wrong module and confirmed it from the +// cache: github.com/codefly-dev/sdk-go/workcontext@v0.0.0-20260928181007-c1fc359f112f +// resolves today and its zip contains work_context.go. +// +// A RANGE OF PSEUDO-VERSIONS, because this module has never been tagged: every +// version of it that a consumer can resolve is a pseudo-version of a commit, +// and every commit before this change carries the implementation. The high +// bound is a timestamp later than any of them and earlier than any release that +// could carry this file, so it covers all of them and none of what comes after. +// +// Effective only once workcontext/vX.Y.Z is tagged carrying this go.mod, which +// is the owner's release step. +retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] diff --git a/workcontext/import_allowlist_test.go b/workcontext/import_allowlist_test.go index 15170a0..98f4b73 100644 --- a/workcontext/import_allowlist_test.go +++ b/workcontext/import_allowlist_test.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "sync" "testing" "github.com/stretchr/testify/require" @@ -46,86 +47,66 @@ import ( // - Anything a dependency does. This is a rule about what THIS repository // spells. -// allowedImports is every import path a module's non-test code may use. +// allowedImports is every import path a module's non-test code may use, READ +// FROM scripts/allowed-imports.txt. // -// Measured from the tree with `go list`, not written from memory, and the test -// below fails in BOTH directions: an import that is not here is a finding, and -// an entry here that nothing imports is deleted, because a permission nobody +// It was a map in this file, and that was the hole: the ref sweep is the only +// thing that runs over a published ref, and it had its own denylist — so a +// branch carrying `crypto/mldsa` and `encoding/json/v2`, which is the round-six +// second implementation, swept `ok` in the job that is a required check. Two +// policies is one policy and one hole. One file now, read by both. +// +// The test below fails in BOTH directions: an import not in the file is a +// finding, and a line nothing imports is deleted, because a permission nobody // uses is precedent for the next reader. -var allowedImports = map[string][]string{ - "root": { - "bufio", "bytes", "context", "database/sql", "embed", - "encoding/binary", "encoding/json", "errors", "fmt", "io", "os", - "path/filepath", "runtime/debug", "sort", "strconv", "strings", - "sync", "time", - // scripts/importsof reads a Go file's imports WITH GO'S OWN PARSER, - // because the sweep's awk extractor was walked past five more times — - // `import "\x63rypto/ed25519"` compiles, is crypto/ed25519 to the - // compiler, and is not that string to anything matching text. It is a - // main package that ships to nobody; being here is what makes it - // visible rather than exempt. - "go/parser", "go/token", - // The ONE hash, for three digests. A hand-rolled HMAC over it is the - // residue named above. - "crypto/sha256", - // The workload's own TLS, which is this module's job. - "crypto/tls", "crypto/x509", - "connectrpc.com/connect", - "github.com/codefly-dev/core/composition", - "github.com/codefly-dev/core/configurations", - "github.com/codefly-dev/core/generated/go/codefly/base/v0", - "github.com/codefly-dev/core/generated/go/codefly/runnable/v0", - "github.com/codefly-dev/core/network", - "github.com/codefly-dev/core/resources", - "github.com/codefly-dev/core/runnable", - "github.com/codefly-dev/core/standards", - "github.com/codefly-dev/core/wool", - "github.com/codefly-dev/sdk-go/receipts", - "google.golang.org/grpc", - "google.golang.org/grpc/codes", - "google.golang.org/grpc/metadata", - "google.golang.org/grpc/status", - // The receipts digest canonicalises a receipt REQUEST, never a - // capability, and the replay path resolves a response type at runtime. - // Each is held to files and symbols by the rules in - // one_implementation_test.go; being on this list is necessary and not - // sufficient. - "google.golang.org/protobuf/encoding/protojson", - "google.golang.org/protobuf/proto", - "google.golang.org/protobuf/reflect/protodesc", - "google.golang.org/protobuf/reflect/protoreflect", - "google.golang.org/protobuf/reflect/protoregistry", - "google.golang.org/protobuf/types/descriptorpb", - "google.golang.org/protobuf/types/dynamicpb", - "google.golang.org/protobuf/types/known/durationpb", - }, - "leaf": { - "bytes", "context", "encoding/binary", "encoding/hex", "errors", - "fmt", "io", "mime", "net/http", "net/url", "os", "slices", - "strconv", "strings", "sync", "sync/atomic", "time", - "crypto/sha256", - "crypto/tls", "crypto/x509", - // Held to ENCODE-only in one file, and to two types in one file, - // by the rules in one_implementation_test.go. - "encoding/base64", "encoding/json", - "github.com/codefly-dev/core/generated/go/codefly/base/v0", - "github.com/codefly-dev/core/workcontext", - "github.com/codefly-dev/sdk-go/workcontext", - "google.golang.org/grpc", - "google.golang.org/grpc/codes", - "google.golang.org/grpc/metadata", - "google.golang.org/grpc/status", - "google.golang.org/protobuf/proto", - }, +func allowedImportsFor(t *testing.T, module string) []string { + t.Helper() + policy := loadImportPolicy(t) + require.Contains(t, policy, module, "the policy file lists no module %q", module) + return policy[module] +} + +// loadImportPolicy parses the shared policy file. +func loadImportPolicy(t *testing.T) map[string][]string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "scripts", "allowed-imports.txt")) + require.NoError(t, err, "the import policy is the gate; a gate with no policy is not one") + + policy := map[string][]string{} + for index, line := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + module, rest, found := strings.Cut(trimmed, " ") + require.True(t, found, "line %d is not ` [file …]`: %q", index+1, line) + // A line may name the FILES that may have the import. This package + // checks module membership; the file list is what keeps the ref sweep + // as strict as the denylist it replaced, and the Go gate's own + // narrowedImports holds the same imports to files AND symbols. + path, _, _ := strings.Cut(rest, " ") + require.Contains(t, []string{"root", "leaf", "legacy"}, module, + "line %d names %q, which is not root, leaf or legacy", index+1, module) + // `legacy` is the historical allowance, applied by the sweep to + // published refs only — never to the working tree, which is what this + // package checks. It is kept out of the module lists deliberately: a + // path in it must not become permitted in a checkout, and + // TestTheHistoricalImportAllowanceCarriesNothingCapabilityShaped is + // what holds it to being benign. + policy[module] = append(policy[module], strings.TrimSpace(path)) + } + require.NotEmpty(t, policy["root"]) + require.NotEmpty(t, policy["leaf"]) + return policy } // TestEveryImportIsOnItsModulesAllowlist is the deny-by-default rule. func TestEveryImportIsOnItsModulesAllowlist(t *testing.T) { for _, module := range loadModules(t) { - _, ok := allowedImports[module.name] - require.True(t, ok, "no allowlist for module %q", module.name) + allowed := allowedImportsFor(t, module.name) + require.NotEmpty(t, allowed) - for _, finding := range unallowedImports(module.name, importsOfModule(t, module)) { + for _, finding := range unallowedImports(module.name, allowed, importsOfModule(t, module)) { t.Error(finding) } @@ -138,7 +119,7 @@ func TestEveryImportIsOnItsModulesAllowlist(t *testing.T) { func TestTheAllowlistHasNoUnusedPermission(t *testing.T) { for _, module := range loadModules(t) { used := importsOfModule(t, module) - for _, spec := range allowedImports[module.name] { + for _, spec := range allowedImportsFor(t, module.name) { require.Contains(t, used, spec, "allowedImports[%q] permits %q and no file in that module imports it. "+ "An unused permission is precedent; delete the line.", module.name, spec) @@ -156,34 +137,36 @@ func TestTheAllowlistHasNoUnusedPermission(t *testing.T) { // This asserts the loader's patterns reach such a package, because the rule // above is only as wide as what it was handed. func TestTestdataIsNotAHidingPlace(t *testing.T) { - repository, err := filepath.Abs("..") - require.NoError(t, err) - // A real package under testdata, created for this test and removed with it. - directory := filepath.Join(repository, "receipts", "testdata", "gateprobe") - require.NoError(t, os.MkdirAll(directory, 0o750)) - t.Cleanup(func() { - _ = os.RemoveAll(filepath.Join(repository, "receipts", "testdata")) - }) - require.NoError(t, os.WriteFile(filepath.Join(directory, "signer.go"), - []byte(`package gateprobe + // IN A TEMPORARY DIRECTORY, NOT IN THE REPOSITORY. The first version of + // this test created receipts/testdata/gateprobe and its cleanup removed + // receipts/testdata — the PARENT. Nothing lives there today, so it did no + // harm here, but a test that deletes a directory it did not create will + // delete somebody's fixtures the day they add some, and running the suite + // is not supposed to be a destructive act. A reviewer was right to call + // that a major finding rather than a nit. + // + // What is actually being asserted is a property of testdataPatterns, which + // takes a directory — so it is handed one that belongs to this test. + root := t.TempDir() + probe := filepath.Join(root, "receipts", "testdata", "gateprobe") + require.NoError(t, os.MkdirAll(probe, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(probe, "signer.go"), + []byte("package gateprobe\n\nimport \"crypto/ed25519\"\n\n"+ + "func Sign(key ed25519.PrivateKey, payload []byte) []byte {\n"+ + "\treturn ed25519.Sign(key, payload)\n}\n"), 0o600)) -import "crypto/ed25519" + require.Contains(t, testdataPatterns(t, root), "./receipts/testdata/...", + "the loader must be given the testdata packages explicitly; ./... does not match them, "+ + "and a reviewer imported a complete Ed25519 signer from one") -// Sign is the shape a reviewer imported from a root file. -func Sign(key ed25519.PrivateKey, payload []byte) []byte { - return ed25519.Sign(key, payload) -} -`), 0o600)) + // A directory with no testdata in it yields no patterns, so the walk is + // not simply returning everything. + require.Empty(t, testdataPatterns(t, t.TempDir())) - patterns := testdataPatterns(t, repository) - require.Contains(t, patterns, "./receipts/testdata/...", - "the loader must be given the testdata packages explicitly; ./... does not match them") - - // And the rule refuses it, which is the point of reaching it. - findings := unallowedImports("root", map[string][]string{ + // And the rule refuses what is in there, which is the point of reaching it. + require.NotEmpty(t, unallowedImports("root", allowedImportsFor(t, "root"), map[string][]string{ "crypto/ed25519": {"receipts/testdata/gateprobe/signer.go"}, - }) - require.NotEmpty(t, findings, "a signer under testdata is still a signer") + }), "a signer under testdata is still a signer") } // A package that does not type-check is a package this gate has not read, and @@ -231,8 +214,7 @@ func importsOfModule(t *testing.T, module loadedModule) map[string][]string { // only way to ask "would this refuse crypto/mldsa?" through it is to commit // crypto/mldsa. A rule that cannot be probed is a rule nobody has tested, which // is the finding that prompted this whole file. -func unallowedImports(module string, used map[string][]string) []string { - allowed := allowedImports[module] +func unallowedImports(module string, allowed []string, used map[string][]string) []string { var findings []string for _, spec := range sortedKeys(used) { files := strings.Join(used[spec], ", ") @@ -300,9 +282,8 @@ func TestTheImportAllowlistRefusesTheNextName(t *testing.T) { "cgo": {"leaf", "C"}, } { t.Run(name, func(t *testing.T) { - findings := unallowedImports(probe.module, map[string][]string{ - probe.spec: {"second_implementation.go"}, - }) + findings := unallowedImports(probe.module, allowedImportsFor(t, probe.module), + map[string][]string{probe.spec: {"second_implementation.go"}}) require.NotEmpty(t, findings, "%q is not on the %s allowlist and must be a finding", probe.spec, probe.module) if probe.spec == "C" { @@ -321,11 +302,12 @@ func TestTheImportAllowlistRefusesTheNextName(t *testing.T) { // by refusing everything — which is also the mutation guard for the cases // above. for _, module := range []string{"root", "leaf"} { + allowed := allowedImportsFor(t, module) used := map[string][]string{} - for _, spec := range allowedImports[module] { + for _, spec := range allowed { used[spec] = []string{"real.go"} } - require.Empty(t, unallowedImports(module, used), + require.Empty(t, unallowedImports(module, allowed, used), "module %s refuses an import it actually has", module) } } @@ -362,17 +344,43 @@ func (m loadedModule) relative(loaded *packages.Package, file *ast.File) string // file. The go tool excludes `testdata` from WILDCARD matching, not from // importing, so a package there is shipped code that no `./...` ever mentions. // It is loaded by explicit pattern for that reason. +// loadModules type-checks both modules ONCE per test binary. +// +// Cached because it is not cheap: the leaf suite went from 60s to 247s when the +// type-checked gates arrived, and every one of them was re-running the same +// load. The result is read-only — findings are computed from it, nothing +// mutates it — so one load serves every test, and the gates stay fast enough +// that nobody is tempted to skip them. func loadModules(t *testing.T) []loadedModule { t.Helper() + loadOnce.Do(func() { loadedModules, loadErr = loadBothModules() }) + require.NoError(t, loadErr) + require.NotEmpty(t, loadedModules) + return loadedModules +} + +var ( + loadOnce sync.Once + loadedModules []loadedModule + loadErr error +) + +func loadBothModules() ([]loadedModule, error) { repository, err := filepath.Abs("..") - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("resolve the repository root: %w", err) + } modules := []loadedModule{ {name: "root", dir: repository}, {name: "leaf", dir: filepath.Join(repository, "workcontext")}, } for index := range modules { - patterns := append([]string{"./..."}, testdataPatterns(t, modules[index].dir)...) + found, err := testdataPackages(modules[index].dir) + if err != nil { + return nil, err + } + patterns := append([]string{"./..."}, found...) loaded, err := packages.Load(&packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedDeps | @@ -380,30 +388,41 @@ func loadModules(t *testing.T) []loadedModule { Dir: modules[index].dir, Tests: true, }, patterns...) - require.NoError(t, err, "loading module %s", modules[index].name) - require.NotEmpty(t, loaded, "module %s loaded no packages", modules[index].name) + if err != nil { + return nil, fmt.Errorf("loading module %s: %w", modules[index].name, err) + } + if len(loaded) == 0 { + return nil, fmt.Errorf("module %s loaded no packages", modules[index].name) + } for _, one := range loaded { // A package that does not type-check is a package this gate has not // read, and a gate that passes what it could not read is the - // fail-open this repository has now fixed four times. + // fail-open this repository has now fixed five times. for _, problem := range one.Errors { - t.Fatalf("module %s: %s did not type-check: %s.\n"+ - "The type-based rules cannot run on a package that does not compile, and "+ - "skipping it would be a gate reporting success for code it never read.", + return nil, fmt.Errorf( + "module %s: %s did not type-check: %s. The type-based rules cannot run on a "+ + "package that does not compile, and skipping it would be a gate reporting "+ + "success for code it never read", modules[index].name, one.PkgPath, problem) } } modules[index].packages = loaded } - return modules + return modules, nil } // testdataPatterns names every package under a testdata directory, which // `./...` deliberately does not match. func testdataPatterns(t *testing.T, dir string) []string { t.Helper() + patterns, err := testdataPackages(dir) + require.NoError(t, err) + return patterns +} + +func testdataPackages(dir string) ([]string, error) { var patterns []string - require.NoError(t, filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + walkErr := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { if err != nil || !entry.IsDir() { return err //nolint:wrapcheck // the walk's own error, returned as-is } @@ -414,18 +433,18 @@ func testdataPatterns(t *testing.T, dir string) []string { if entry.Name() != "testdata" { return nil } - patterns = append(patterns, "./"+filepath.ToSlash(mustRel(t, dir, path))+"/...") + relative, relErr := filepath.Rel(dir, path) + if relErr != nil { + return relErr //nolint:wrapcheck // the walk's own error + } + patterns = append(patterns, "./"+filepath.ToSlash(relative)+"/...") return filepath.SkipDir - })) + }) + if walkErr != nil { + return nil, fmt.Errorf("walk %s for testdata: %w", dir, walkErr) + } sort.Strings(patterns) - return patterns -} - -func mustRel(t *testing.T, base string, path string) string { - t.Helper() - relative, err := filepath.Rel(base, path) - require.NoError(t, err) - return relative + return patterns, nil } // typesAreAvailable is a guard on the loader itself: if NeedTypes ever stops diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go index 89862f2..2c5c6ba 100644 --- a/workcontext/round6_reproducers_test.go +++ b/workcontext/round6_reproducers_test.go @@ -6,11 +6,6 @@ import ( "fmt" "go/ast" "net/http" - "os" - "path/filepath" - "regexp" - "slices" - "strings" "testing" "time" @@ -192,110 +187,52 @@ func encode(a string) ([]byte, error) { return json.Marshal(mintRequest{Audience "the frozen field sets must match the structs this repository really has") } -// B2. THE TWO GATES' BAN LISTS DISAGREED, in both directions. +// ROUND SEVEN, BLOCKER: THE PUBLISHED-REF SWEEP WAS STILL A DENYLIST. // -// Measured: four import paths the AST gate bans — the legacy protobuf module, -// protoiface, protoimpl, and a vendored path ending /ed25519 — passed the shell -// sweep with `ok working tree (4 Go files)`, exit 0. That is the ROOT module, -// where the sweep is the only gate. And in the other direction the shell banned -// crypto/cipher, crypto/aes, crypto/des and crypto/rc4 — a GMAC or a CMAC is a -// MAC assembled from a block cipher and names no MAC — while this gate, the one -// that reads the module the capability lives in, did not. +// The deny-by-default rule lived in this package, which reads a CHECKOUT. The +// ref sweep is the only thing that runs over a published ref — and it is the +// whole of the required check — and it had its own three regexes. A reviewer +// ran that predicate over the round-six second implementation: // -// Both lists are now entry-for-entry, and THIS is what keeps them that way: two -// lists a person maintains by hand are two lists, and the only thing that makes -// them one is a test that fails when they differ. -func TestTheShellSweepBansEverythingTheASTGateBans(t *testing.T) { - script, err := os.ReadFile(filepath.Join("..", "scripts", "check-one-implementation.sh")) - require.NoError(t, err) - - shell := shellBanPatterns(t, string(script)) - - for _, path := range bannedImports { - require.True(t, shell.matches(path), - "the AST gate bans %q and the shell sweep does not. The sweep is the ONLY gate in "+ - "the root module, so a ban that lives only here does not hold where the deleted "+ - "implementation actually lived.", path) - } - for _, fragment := range bannedImportSubstrings { - require.True(t, shell.matches("github.com/vendored/"+fragment), - "the AST gate bans any path containing %q and the shell sweep does not match "+ - "a vendored one", fragment) - } - for _, prefix := range bannedImportPrefixes { - require.True(t, shell.matches(prefix+"ed25519"), - "the AST gate bans the prefix %q and the shell sweep does not", prefix) - } - for _, path := range envelopeDecoders { - require.True(t, shell.matches(path), - "the AST gate bans the envelope decoder %q and the shell sweep does not", path) - } - - // AND THE REVERSE, which is the direction that was wrong for the cipher - // family. Anything the sweep refuses repository-wide must be refused here - // too, unless this gate holds it to named files and symbols instead — - // which is strictly stronger, not weaker. - for _, path := range []string{ - "crypto", "crypto/ed25519", "crypto/ecdsa", "crypto/rsa", "crypto/dsa", - "crypto/hmac", "crypto/ecdh", "crypto/elliptic", "crypto/subtle", - "crypto/cipher", "crypto/aes", "crypto/des", "crypto/rc4", - "crypto/sha512", "crypto/sha1", "crypto/sha3", "crypto/md5", - "crypto/hkdf", "crypto/pbkdf2", - "encoding/gob", "encoding/asn1", "encoding/xml", - "google.golang.org/protobuf/encoding/protojson", - "google.golang.org/protobuf/encoding/protowire", +// encoding/json/v2 permitted +// crypto/mldsa permitted +// protodelim permitted +// grpc/encoding permitted +// C permitted +// crypto/ed25519 rejected +// +// So the pair that encodes a WorkContextV1 in the deleted format and signs it +// could sit on any branch with the required check green. Two policies is one +// policy and one hole. +// +// There is one policy file now, scripts/allowed-imports.txt, read by this +// package and by the sweep. This test is what makes "one" true: it asserts the +// file describes the tree, and the sweep's own tests assert the sweep enforces +// the file. +func TestOneImportPolicyGovernsTheTreeAndEveryPublishedRef(t *testing.T) { + policy := loadImportPolicy(t) + + // THE SHAPES THE OLD DENYLIST PERMITTED. Each is absent from both module + // lists, so each is a finding wherever it appears — checkout or ref. + for _, spec := range []string{ + "encoding/json/v2", "crypto/mldsa", + "google.golang.org/protobuf/encoding/protodelim", + "google.golang.org/grpc/encoding", + "google.golang.org/protobuf/encoding/prototext", + "encoding/json/jsontext", "crypto/hpke", } { - if !shell.matches(path) { - continue + for _, module := range []string{"root", "leaf"} { + require.NotContains(t, policy[module], spec, + "%q is on the %s allowlist, so the sweep permits it on every published ref", + spec, module) } - _, narrowed := narrowedImports[path] - require.True(t, slices.Contains(bannedImports, path) || narrowed, - "the shell sweep refuses %q repository-wide and this gate neither bans it nor "+ - "holds it to named files and symbols — so the module the capability lives in "+ - "is the one with the weaker rule", path) } -} - -// shellBans is the sweep's three regexes, compiled. -type shellBans struct{ patterns []*regexp.Regexp } -func (b shellBans) matches(path string) bool { - for _, pattern := range b.patterns { - if pattern.MatchString(path) { - return true - } - } - return false -} - -// shellBanPatterns reads primitives=, encoders= and envelope= out of the script -// itself, so the test cannot drift from the file it is about by being updated -// alongside a copy of it. -func shellBanPatterns(t *testing.T, script string) shellBans { - t.Helper() - var bans shellBans - for _, name := range []string{"primitives", "encoders", "envelope"} { - found := false - for _, line := range strings.Split(script, "\n") { - prefix := name + "='" - if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, "'") { - continue - } - expression := strings.TrimSuffix(strings.TrimPrefix(line, prefix), "'") - compiled, err := regexp.Compile(expression) - require.NoError(t, err, "%s= is not a usable expression: %s", name, expression) - bans.patterns = append(bans.patterns, compiled) - found = true - break - } - require.True(t, found, - "no %s= assignment in check-one-implementation.sh; this test reads the script's own "+ - "lists and cannot check a list it cannot find", name) + // And `C` is never on a list, because it is not a path. The sweep refuses + // it by name; adding it here would be a line somebody could write. + for _, module := range []string{"root", "leaf"} { + require.NotContains(t, policy[module], "C") } - // encoding/json is handled by exact name in the sweep rather than by one of - // the three expressions, so it is added here to match. - bans.patterns = append(bans.patterns, regexp.MustCompile(`^encoding/json$`)) - return bans } // B5. RequestTimeout DID NOT BOUND THE DETACHED ATTEMPT. From 998f218ba32e30f9e07e67adfca382aea31e4b84 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:57:08 -0400 Subject: [PATCH 27/35] fix(workcontext)!: the latch sites audited, one outstanding token read, and a unary name guards nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To the owner's merge bar: no blocker open. The blocker list, each answered. 1. THE CONCRETE-WRAPPER GATE BYPASS — fixed at d8cb0af, mutation-verified. The interface walk is transitive, so a struct carrying an interface IS an interface to a codec. 2. THE PUBLISHED-REF SWEEP AS A DENYLIST — fixed at d8cb0af. One policy file, scripts/allowed-imports.txt, read by the Go gate and by the required check. The round-six second implementation (crypto/mldsa + encoding/json/v2) now FAILS the sweep on a ref. 3. THE RETRACTION AIMED AT THE WRONG MODULE — fixed at d8cb0af. Fifteen root versions, and the leaf retracts its own pseudo-version range. 4. ErrInvalid → Internal ON EXPIRY — fixed at 04e93c0, bufconn test at 2f2d721, re-verified at this head. 5. A LATCHING CLASS THAT STRANDS A HEALTHY WORKLOAD — audited every ErrMintRefused site. One candidate, examined and REJECTED, which is the part worth reading: Authority.Recheck latches any failure to re-resolve a pinned value, and that looks exactly like the projected-token EIO defect one layer up. I split it into "could not read" versus "changed", crossed the module boundary structurally so neither module gained a dependency, and the root module's TestAuthorityValueWithdrawnIsAChange rejected it — correctly. Withdrawing an injected variable makes the live read FAIL rather than return empty, so the split would have classed a real withdrawal as retryable; and the asymmetry runs the other way here, because a held credential keeps being SERVED through an outage. Classing a withdrawn audience as an outage means serving under authority somebody deliberately removed, until expiry. So it stays terminal, the attempt is reverted, and the reason is written at the site and asserted by a test over the classification — the pin latches, 401 and 403 latch, and a 200 with no capability, a 503 and the rest do not. The cost is real and is now stated: a configuration read that fails transiently stops the process, and the fix for that belongs where the distinction exists. MAJORS FIXED HERE, all mutation-verified: - CODEX 6: the token-source reads are bounded to ONE OUTSTANDING, not one per attempt. A source that cannot be cancelled accumulated a reader per attempt forever against a blocked mount, at the maximum-backoff rate, and handed a source that single-flight had been protecting concurrent calls. Six attempts now enter the source once. - CODEX 7: a capability-bearing set of only UNARY methods is refused. ValidateMethodSet accepted it, which made the set non-empty and let the streaming method reach the raw handler with guardFor never called — the guards-nothing configuration the empty-set refusal exists to reject, reached by naming a method that cannot stream. A server registering no streaming method at all is refused too. - CODEX 2: base64 decoding is resolved as an OBJECT. The rule required the receiver spelled as a selector, so `var e = base64.RawURLEncoding` then `e.DecodeString(...)` walked past it in the one file allowed to touch base64. Same correction as the codec rule, same reason: a spelling was standing in for a kind. - CODEX 8: the gate test that deleted receipts/testdata — fixed at d8cb0af. - Two local-misuse classifications corrected to ErrInvalid (encode the request, build the request): unreachable branches, but a latch written down in an unreachable branch is still the wrong rule for the next reader. CARRIED AS FOLLOW-UPS, drafted in the PR body with what each would take: build configuration blind spots in the type-checked gates (the sweep still reads every tracked file, so the import rule holds; the semantic rule does not), ref completeness by OID rather than name plus tag-set completeness, dataflow into the four indirections, the stale README/AGENTS passages, isolating x/tools from consumers' module graphs, and ten doubted tests. Verified: both modules' suites and -race clean, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (64 Go files), six mutations for this batch all killed. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/codec_types_test.go | 80 +++++++++++ workcontext/grpctransport/guarded_stream.go | 38 +++++- .../grpctransport/round6_reproducers_test.go | 29 ++++ workcontext/mint.go | 84 ++++++++++-- workcontext/round6_reproducers_test.go | 124 ++++++++++++++++++ 5 files changed, 344 insertions(+), 11 deletions(-) diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go index 8fd31a0..736dbb1 100644 --- a/workcontext/codec_types_test.go +++ b/workcontext/codec_types_test.go @@ -58,6 +58,23 @@ var codecFunctions = map[string][]string{ }, } +// envelopeDecoders are the base64 methods that OPEN an envelope, by package and +// name, resolved as objects. +// +// The syntactic rule required the receiver to be written as a selector — +// `base64.RawURLEncoding.DecodeString(…)` — so one assignment walked past it: +// +// var payloadEncoding = base64.RawURLEncoding +// func openPayload(s string) ([]byte, error) { return payloadEncoding.DecodeString(s) } +// +// in the one file allowed to reach for base64. go/types answers the method's +// OBJECT regardless of how the receiver was spelled, which is the same +// correction the codec rule needed and for the same reason: a spelling was +// standing in for a kind. +var envelopeDecodeMethods = map[string][]string{ + "encoding/base64": {"Decode", "DecodeString", "AppendDecode", "NewDecoder"}, +} + // codecIndirectionObjects are the functions that legitimately apply a codec to // a value whose type is not statically known, keyed on PACKAGE PATH and the // function's own name — which with go/types identifies the object rather than @@ -199,6 +216,36 @@ func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []s // // There is no row here and there is not going to be one: these four take a // caller's own messages, never a capability. + // BASE64 MAY ENCODE AND MAY NEVER DECODE, by object. The file allowed to + // build a cache key is the only file that may reach base64 at all, and + // decoding there plus a proto.Unmarshal is the whole of a second parser. + ast.Inspect(file, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + object, ok := loaded.TypesInfo.Uses[selector.Sel] + if !ok { + return true + } + method, ok := object.(*types.Func) + if !ok || method.Pkg() == nil { + return true + } + if !slices.Contains(envelopeDecodeMethods[method.Pkg().Path()], method.Name()) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.\n"+ + "This file may ENCODE with base64 — it builds a cache key — and may never\n"+ + "DECODE. base64 decoding plus proto.Unmarshal is the entire second parser this\n"+ + "module deleted; corework.Inspect opens the envelope and hands back the claims.\n"+ + "Resolved as an OBJECT, so assigning the encoding to a variable first does not\n"+ + "change the answer.", + path, method.FullName())) + return true + }) + ast.Inspect(file, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { @@ -814,6 +861,39 @@ func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal( } }) + // CODEX 2: base64 DECODING through an ALIASED RECEIVER, in the one file + // allowed to reach base64 at all. The syntactic rule required the receiver + // to be written as `base64.RawURLEncoding`, so one assignment walked past + // it — a spelling standing in for a kind, the same correction the codec + // rule needed. + t.Run("base64 decoding through an aliased receiver", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, `package probe + +import "encoding/base64" + +var payloadEncoding = base64.RawURLEncoding + +func openPayload(s string) ([]byte, error) { return payloadEncoding.DecodeString(s) } +`) + findings := inspectCodecUses(loaded, file, "workcontext/cache_partition.go") + require.NotEmpty(t, findings, "the file that may ENCODE a cache key may never DECODE") + require.Contains(t, strings.Join(findings, "\n"), "may never") + }) + + // And the shapes that file legitimately uses stay legitimate, which is the + // mutation guard for the case above. + t.Run("encoding a cache key is still permitted", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, `package probe + +import "encoding/base64" + +var payloadEncoding = base64.RawURLEncoding + +func key(raw []byte) string { return payloadEncoding.EncodeToString(raw) } +`) + require.Empty(t, inspectCodecUses(loaded, file, "workcontext/cache_partition.go")) + }) + // A CAPABILITY HANDED INTO A PERMITTED INDIRECTION. The exemption is for // the codec call inside those four functions; their callers were never // looked at, and configuration_document.go json-decodes into its diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index a45d9ed..0b32a37 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -643,18 +643,50 @@ func StreamServerInterceptor( // server, and inventing a registration hook to reach one would be a bigger // surface than a call a server makes. func ValidateMethodSet(served map[string]grpc.ServiceInfo, capabilityBearing []string) error { + // STREAMING METHODS ONLY. Every grpc.MethodInfo went into this map, + // ignoring IsClientStream and IsServerStream — so for a service with a + // unary Read and a streaming Emit, declaring only "/Service/Read" passed + // validation, produced a non-empty guarded set, and let Emit reach the raw + // handler without guardFor ever being called. That is precisely the + // installed-interceptor-that-guards-nothing configuration the empty-set + // refusal exists to reject, reached by naming a method that cannot stream. known := map[string]bool{} + unary := map[string]bool{} for service, info := range served { for _, method := range info.Methods { - known["/"+service+"/"+method.Name] = true + full := "/" + service + "/" + method.Name + if method.IsClientStream || method.IsServerStream { + known[full] = true + continue + } + unary[full] = true } } - var unknown []string + var unknown, notStreaming []string for _, method := range capabilityBearing { - if !known[method] { + switch { + case known[method]: + case unary[method]: + notStreaming = append(notStreaming, method) + default: unknown = append(unknown, method) } } + if len(notStreaming) > 0 { + return fmt.Errorf( + "%w: these methods are declared capability-bearing for a STREAM interceptor and "+ + "are unary on this server: %v. A unary method's name in this set guards nothing "+ + "and makes the set non-empty, which is how an interceptor that enforces nothing "+ + "passes its own startup check", + errStreamMisuse, notStreaming, + ) + } + if len(known) == 0 { + return fmt.Errorf( + "%w: this server registers no streaming method, so a stream interceptor has "+ + "nothing to guard", errStreamMisuse, + ) + } if len(unknown) > 0 { return fmt.Errorf( "%w: these methods are declared capability-bearing and this server does not "+ diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go index 5ffb46c..6142499 100644 --- a/workcontext/grpctransport/round6_reproducers_test.go +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -182,3 +182,32 @@ func TestACapabilityThatExpiresMidStreamTellsTheClientToMintAgain(t *testing.T) "arrived as codes.Internal, which tells a client the server is broken") require.Contains(t, status.Convert(err).Message(), "expired") } + +// CODEX 7: a set of only UNARY methods passed validation, so a streaming method +// reached the raw handler with guardFor never called — the +// interceptor-that-guards-nothing configuration, reached by naming a method +// that cannot stream. +func TestAUnaryMethodNameGuardsNothingAndIsRefused(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{ + {Name: "Emit", IsServerStream: true}, + {Name: "Read"}, + }}, + } + + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}), + "the streaming method this server serves is declared correctly") + + err := ValidateMethodSet(served, []string{"/" + streamService + "/Read"}) + require.Error(t, err, "a unary method's name makes the set non-empty and guards nothing") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "are unary on this server") + require.Contains(t, err.Error(), "Read", + "the refusal must name the method, because the defect is that it looks declared") + + // A server with no streaming method at all has nothing for a stream + // interceptor to do, which is also not something to express by omission. + require.Error(t, ValidateMethodSet( + map[string]grpc.ServiceInfo{streamService: {Methods: []grpc.MethodInfo{{Name: "Read"}}}}, + []string{"/" + streamService + "/Read"})) +} diff --git a/workcontext/mint.go b/workcontext/mint.go index 939c7b8..7930490 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -445,6 +445,17 @@ type MintClient struct { // caller left. refreshWanted bool + // readMu and outstandingRead bound the token-source reads to ONE in flight. + // + // ProjectedTokenSource takes no context, so a read cannot be cancelled: a + // blocked source leaves its goroutine blocked. Starting one per attempt + // therefore accumulated them without limit against a source that never + // returns, and handed a source that single-flight had been protecting + // concurrent calls. A separate mutex from c.mu because a read must not be + // held across the client's own lock. + readMu sync.Mutex + outstandingRead chan projectedRead + // id distinguishes this client from any other in the process, so a // Credential can say which one issued it. id uint64 @@ -1043,6 +1054,32 @@ func (c *MintClient) Refused() error { // means something. func (c *MintClient) mintOnce(ctx context.Context) (Credential, error) { if err := c.options.Authority.Recheck(ctx); err != nil { + // TERMINAL, AND I TRIED TO CHANGE THIS AND WAS WRONG. Recording why, + // because the next person to audit the latch sites will see the same + // thing I did. + // + // Reading the latch rule against this site, it looks like the + // projected-token defect one layer up: Recheck reports any failure to + // re-resolve a pinned value, and a momentary failure to read the + // runtime's configuration would then permanently stop a process + // holding a good credential. So a distinction between "could not read" + // and "changed" seemed obviously right. + // + // It is not available at this layer. A value that has been WITHDRAWN + // and a value that is momentarily unreadable both arrive as a failed + // lookup — withdrawing an injected variable makes the live read fail, + // not return empty — so any split would have classed a real withdrawal + // as retryable. And the asymmetry runs the other way here than it does + // for a token read: a held credential keeps being SERVED through an + // outage, so a withdrawn audience treated as an outage means serving + // under authority somebody deliberately removed, until expiry. That is + // the security property, and TestAuthorityValueWithdrawnIsAChange says + // so in the root module. + // + // So this stays terminal. The cost is real and is the documented one: + // a configuration read that fails transiently stops the process. The + // fix for that belongs where the distinction exists — in whatever + // resolves the value — not in a guess made here. return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) } audience, err := c.options.Authority.Value(c.options.Audience.Name, c.options.Audience.Key) @@ -1111,12 +1148,38 @@ type projectedRead struct { // goroutine per attempt and attempts are single-flighted and held off; a // permanently wedged client is not. func (c *MintClient) projectedToken(ctx context.Context) (string, error) { - // Buffered, so the goroutine is not held open by a receiver that left. - answers := make(chan projectedRead, 1) - go func() { - token, err := c.options.ProjectedToken.ProjectedToken() - answers <- projectedRead{token: token, err: err} - }() + c.readMu.Lock() + if c.outstandingRead == nil { + // ONE OUTSTANDING READ, NOT ONE PER ATTEMPT. The previous revision + // started a goroutine per attempt and left it blocked on timeout, so a + // permanently blocked source accumulated readers indefinitely — at the + // maximum-backoff rate, forever — and exposed a source that + // single-flight had been protecting to concurrent calls. "One goroutine + // per attempt" is not a bound on outstanding goroutines, and the test + // that proved one caller returns promptly said nothing about the + // second. + // + // The channel is buffered and is never closed: whichever attempt is + // waiting takes the value, and if none is, it sits in the buffer until + // the next attempt reads it. A stale token cannot be served from it + // because the next attempt that finds no outstanding read starts a + // fresh one. + answers := make(chan projectedRead, 1) + c.outstandingRead = answers + go func() { + token, err := c.options.ProjectedToken.ProjectedToken() + answers <- projectedRead{token: token, err: err} + // The read finished, so the slot is free for the next attempt. + c.readMu.Lock() + if c.outstandingRead == answers { + c.outstandingRead = nil + } + c.readMu.Unlock() + }() + } + answers := c.outstandingRead + c.readMu.Unlock() + select { case answer := <-answers: return answer.token, answer.err @@ -1171,7 +1234,12 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia ProjectionAudience: c.options.ProjectionAudience, }) if err != nil { - return Credential{}, fmt.Errorf("%w: encode mint request: %v", ErrMintRefused, err) + // LOCAL MISUSE, so ErrInvalid: this package built the value being + // encoded, and a failure here is a bug here. Neither of these is + // reachable with a validated URL and a struct of two strings, and + // latching an unreachable branch is still the wrong classification to + // leave written down. + return Credential{}, fmt.Errorf("%w: encode mint request: %v", ErrInvalid, err) } requestContext, cancel := context.WithTimeout(ctx, c.options.RequestTimeout) defer cancel() @@ -1179,7 +1247,7 @@ func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credentia requestContext, http.MethodPost, c.options.URL, bytes.NewReader(body), ) if err != nil { - return Credential{}, fmt.Errorf("%w: create mint request: %v", ErrMintRefused, err) + return Credential{}, fmt.Errorf("%w: create mint request: %v", ErrInvalid, err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go index 2c5c6ba..49a482f 100644 --- a/workcontext/round6_reproducers_test.go +++ b/workcontext/round6_reproducers_test.go @@ -6,6 +6,7 @@ import ( "fmt" "go/ast" "net/http" + "sync/atomic" "testing" "time" @@ -486,3 +487,126 @@ func (t *valueReadingTransport) RoundTrip(request *http.Request) (*http.Response } return t.inner.RoundTrip(request) //nolint:wrapcheck // a transport returns the inner error } + +// THE LATCH SITES, AUDITED, with the one candidate examined and REJECTED. +// +// Reading the one-latch rule against mintOnce, Authority.Recheck looked like +// the projected-token defect one layer up: any failure to re-resolve a pinned +// value latches, so a momentary configuration read failure would permanently +// stop a healthy process. I changed it, and the root module's +// TestAuthorityValueWithdrawnIsAChange rejected the change — correctly. +// +// A withdrawn value and an unreadable one arrive identically (withdrawing an +// injected variable makes the live read FAIL, it does not return empty), and +// the asymmetry runs the other way here: a held credential keeps being served +// through an outage, so a withdrawn audience classed as an outage means serving +// under authority somebody removed, until expiry. +// +// What this asserts is the classification as it stands, so the next audit finds +// the argument instead of repeating the attempt. +func TestThePinnedAuthorityLatchesAndTheRestDoNot(t *testing.T) { + for name, probe := range map[string]struct { + install func(*mintHost, *MintClient) + latches bool + }{ + // The pin: terminal, deliberately, and argued above. + "the pinned authority did not re-check": { + install: func(host *mintHost, _ *MintClient) { + host.pin.set(testAudience, errors.New("audience changed under a running process")) + }, + latches: true, + }, + // Everything a host or a middlebox does that is not 401/403: an outage. + "a 200 with no capability in it": { + install: func(host *mintHost, _ *MintClient) { + host.answer = &cannedAnswer{status: http.StatusOK, body: `{}`} + }, + }, + "a 503": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusServiceUnavailable }, + }, + // And the two enumerated refusals, so this is not green by never + // latching. + "a 401": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusUnauthorized }, + latches: true, + }, + "a 403": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusForbidden }, + latches: true, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + probe.install(host, client) + + _, err := client.Credential(context.Background()) + require.Error(t, err) + + if probe.latches { + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "this is an enumerated refusal and Refused() must say so") + return + } + require.NoError(t, client.Refused(), "only an enumerated refusal is terminal") + + host.answer = nil + host.refuseWith = 0 + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "the host recovered and the client did not") + require.NotEmpty(t, credential.Token()) + }) + } +} + +// CODEX 6: the timeout fix left an UNBOUNDED population of abandoned readers. +// +// ProjectedTokenSource takes no context, so a read cannot be cancelled and a +// blocked source leaves its goroutine blocked. One goroutine per ATTEMPT is not +// a bound on outstanding goroutines: against a source that never returns they +// accumulate forever, at the maximum-backoff rate, and a source that +// single-flight had been protecting gets concurrent calls. The committed test +// proved one caller returns promptly and said nothing about the second. +func TestABlockedTokenSourceIsReadOnceNoMatterHowManyAttempts(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &countingBlockedSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 60 * time.Millisecond + }) + + // SIX attempts, each timing out, with the clock moved past every hold-off + // so each one really starts. + for attempt := range 6 { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable, "attempt %d", attempt) + clock.set(clock.at.Add(10 * time.Minute)) + } + + require.EqualValues(t, 1, source.calls.Load(), + "six attempts against a blocked source started %d reads; a source that cannot be "+ + "cancelled must be read once and waited on, or the readers accumulate without "+ + "limit and a source single-flight was protecting gets concurrent calls", + source.calls.Load()) + require.NoError(t, client.Refused(), "and none of it latches") +} + +// countingBlockedSource blocks and counts how many times it was entered. +type countingBlockedSource struct { + calls atomic.Int64 + released chan struct{} +} + +func (s *countingBlockedSource) ProjectedToken() (string, error) { + s.calls.Add(1) + <-s.released + return "projected", nil +} From 2aaa75510198f542a1ac58f6166ecd67264923d4 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:33:15 -0400 Subject: [PATCH 28/35] fix(workcontext)!: the retract interval covered nothing, and the bound I added could serve a stale bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round eight (Codex static at 998f218). Both new findings are real and both are mine from the last two commits. 1 (BLOCKER) THE LEAF RETRACT INTERVAL WAS EMPTY. I wrote retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] which covers NO VERSION AT ALL. Under semver a prerelease sorts BELOW its release, so `v0.0.0-2026…` < `v0.0.0`: low is above high and the interval is empty. Verified against Go's own implementation rather than by reading — semver.Compare("v0.0.0", "v0.0.0-20261004000000-zzzzzzzzzzzz") is 1 — which is what the review checked too. So every published version of this module sat outside a declaration that read as covering all of them, one commit after I reported the retraction fixed. That is the sixth time this repository has produced the same shape: a declaration that looks like a gate. All six were checks written in terms of a thing's SPELLING rather than its meaning, and this one is the purest — the root module's test compared the bound's TIMESTAMP as a string, so the one question nobody asked was whether the interval contained anything. Both bounds are pseudo-versions now, and the ordering is answered by golang.org/x/mod/semver — Go's own implementation, already in this module's graph through x/tools, so it costs no new dependency. The new test asserts three properties: non-empty, covers every carrying version, and does NOT cover a version after the bound (an interval that retracts the fix with the defect is not a retraction). The carrying set is computed from git — 13 versions — and filtered by whether the file EXISTS at each commit, because `git log -- path` includes the commit that DELETED it. My first draft of that also asked for the ROOT-level paths, so it asserted about the wrong module's history and passed. 2 (MAJOR) THE BOUND I ADDED COULD TRANSFER AN EXPIRED PROJECTION AND LATCH THE CLIENT. Bounding the reads to one outstanding parked the value from a TIMED-OUT read in a buffered channel, and the next attempt took it. The projection is rotated under the running process — that is why ProjectedToken is called again before every renewal instead of cached — so a token read minutes earlier is expired, the host answers 401, and 401 latches. A fix for a goroutine leak that permanently stops the process is worse than the leak it fixed. An attempt now refuses an outstanding read older than one attempt's budget and starts no second reader: the bound holds and nothing stale is served. A wedged source makes every attempt an outage, which is the truth about a wedged source. 4 (MAJOR) TYPE ERASURE: NOT FIXED, AND NOT QUICKLY FIXABLE — carried as a follow-up with the reason. `var erased any = capability` before the call defeats the capability-argument check, and refusing interface-typed arguments is not available: every real caller passes one BY DESIGN — configuration_document.go forwards its own `destination any`, and receipts/interceptor.go forwards a proto.Message. Closing it needs following the value to its origin, which is dataflow, not type resolution. Also: deleted TestTheCodecRuleAnswersAboutKindsNotSpellings. Its name said it drove each probe shape at the predicate; its body asserted one call to isCapabilityType. A stub kept for its name is worse than no test, because it reads as coverage. What it claimed is done by the probe table beside it. 3 (BLOCKER) is the owner's and is now named in the PR body as THE ONE EXTERNAL MERGE GATE, with the sweep output: origin/dependabot/go_modules/gomod-06d3dc2861 still publishes the implementation, nothing here can reach it, and until it is deleted the required check cannot go green. Verified: both modules' suites clean, golangci-lint 0 issues in both, go mod tidy clean (workcontext/go.sum unchanged — x/mod was already in the graph), working-tree sweep ok (64 Go files), six mutations for these two fixes, six killed. Follow-ups 4-8 are drafted in the PR body with what each would take. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/codec_types_test.go | 13 ++- workcontext/go.mod | 25 +++- workcontext/mint.go | 81 ++++++++----- workcontext/retraction_test.go | 151 +++++++++++++++++++++++++ workcontext/round6_reproducers_test.go | 114 +++++++++++++++++++ 5 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 workcontext/retraction_test.go diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go index 736dbb1..8c8056e 100644 --- a/workcontext/codec_types_test.go +++ b/workcontext/codec_types_test.go @@ -593,11 +593,14 @@ func indirectionKeyAt(loaded *packages.Package, file *ast.File, pos token.Pos) ( return "", "" } -// TestTheCodecRuleAnswersAboutKindsNotSpellings drives each probe shape at the -// predicate, because the whole-gate route is satisfied by any one rule. -func TestTheCodecRuleAnswersAboutKindsNotSpellings(t *testing.T) { - require.True(t, isCapabilityType("github.com/codefly-dev/core/generated/go/codefly/base/v0#WorkContextV1")) -} +// DELETED: TestTheCodecRuleAnswersAboutKindsNotSpellings. +// +// Its name said it drove each probe shape at the predicate and its body +// asserted one call to isCapabilityType — a grand name over a tautology, which +// a review doubted and was right to. What it claimed is done by +// TestTheCodecRuleRefusesEveryShapeThatPassedBefore, which drives every shape +// against type-checked source, and by the per-rule tests beside it. A stub kept +// for its name is worse than no test: it reads as coverage. // typeCheckedProbe type-checks a probe against the REAL dependency types. // diff --git a/workcontext/go.mod b/workcontext/go.mod index 8adeb0b..1996a3f 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -40,10 +40,27 @@ require ( // // A RANGE OF PSEUDO-VERSIONS, because this module has never been tagged: every // version of it that a consumer can resolve is a pseudo-version of a commit, -// and every commit before this change carries the implementation. The high -// bound is a timestamp later than any of them and earlier than any release that -// could carry this file, so it covers all of them and none of what comes after. +// and every commit before this change carries the implementation. +// +// THE LOW BOUND IS A PSEUDO-VERSION, NOT v0.0.0, and that is the whole of a +// correction. The first attempt wrote +// +// retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] +// +// which covers NOTHING: under semver a prerelease sorts BELOW its release, so +// `v0.0.0-2026…` < `v0.0.0`, the interval has low > high, and it is empty. +// Checked with Go's own implementation — semver.Compare("v0.0.0", +// "v0.0.0-20261004000000-zzzzzzzzzzzz") is 1 — which is also what the review +// that found it checked against. Every published version sat outside an +// interval that read as though it covered all of them, which is the +// fail-open shape this repository keeps producing: a declaration that looks +// like a gate and is not one. +// +// So both bounds are pseudo-versions of v0.0.0, and the interval is asserted +// NON-EMPTY and COVERING by workcontext's own test — a string comparison in the +// root module is what failed to notice this, so the question is now answered by +// the library that defines the ordering. // // Effective only once workcontext/vX.Y.Z is tagged carrying this go.mod, which // is the owner's release step. -retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] +retract [v0.0.0-00000000000000-000000000000, v0.0.0-20261004000000-zzzzzzzzzzzz] diff --git a/workcontext/mint.go b/workcontext/mint.go index 7930490..9171f9a 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -454,7 +454,7 @@ type MintClient struct { // concurrent calls. A separate mutex from c.mu because a read must not be // held across the client's own lock. readMu sync.Mutex - outstandingRead chan projectedRead + outstandingRead *pendingRead // id distinguishes this client from any other in the process, so a // Credential can say which one issued it. @@ -1121,12 +1121,17 @@ func (c *MintClient) Counts() MintCounts { return c.counts } -// projectedRead is one answer from a token source, at file level because the -// gate refuses a function-local type — a local declaration is invisible to every -// rule that reads a file's declarations, and that is true of this one too. -type projectedRead struct { - token string - err error +// pendingRead is the ONE outstanding read of the token source, with WHEN it +// started — which is what makes "is this answer still fresh" answerable. +// +// At file level because the gate refuses a function-local type: a local +// declaration is invisible to every rule that reads a file's declarations, and +// that is true of this one too. +type pendingRead struct { + started time.Time + done chan struct{} + token string + err error } // projectedToken reads the token source UNDER THE DEADLINE. @@ -1148,41 +1153,59 @@ type projectedRead struct { // goroutine per attempt and attempts are single-flighted and held off; a // permanently wedged client is not. func (c *MintClient) projectedToken(ctx context.Context) (string, error) { + started := c.now().UTC() + c.readMu.Lock() - if c.outstandingRead == nil { - // ONE OUTSTANDING READ, NOT ONE PER ATTEMPT. The previous revision - // started a goroutine per attempt and left it blocked on timeout, so a - // permanently blocked source accumulated readers indefinitely — at the - // maximum-backoff rate, forever — and exposed a source that - // single-flight had been protecting to concurrent calls. "One goroutine - // per attempt" is not a bound on outstanding goroutines, and the test - // that proved one caller returns promptly said nothing about the - // second. + pending := c.outstandingRead + if pending != nil && started.Sub(pending.started) > c.options.RequestTimeout { + // AN OLD READ'S ANSWER IS A STALE BEARER, so this attempt does not take + // it and does not start a second reader either. + // + // The bound added for the accumulating-goroutines finding introduced + // this: the value from a timed-out read sat in a buffered channel and + // the NEXT attempt took it. The projection is rotated under the running + // process — that is why ProjectedToken is called again before every + // renewal rather than cached — so a token read minutes ago is expired, + // the host answers 401, and 401 LATCHES. A fix for a leak that + // permanently stops the process is worse than the leak. // - // The channel is buffered and is never closed: whichever attempt is - // waiting takes the value, and if none is, it sits in the buffer until - // the next attempt reads it. A stale token cannot be served from it - // because the next attempt that finds no outstanding read starts a - // fresh one. - answers := make(chan projectedRead, 1) - c.outstandingRead = answers + // Refusing here keeps the bound (no second goroutine) and serves + // nothing stale: a wedged source makes every attempt an outage, which + // is the truth about a wedged source. + c.readMu.Unlock() + return "", fmt.Errorf( + "%w: a projected-token read started %s ago has not finished; its answer would be a "+ + "token read before the last rotation, and a stale bearer earns a 401 that latches", + ErrMintUnavailable, started.Sub(pending.started).Round(time.Millisecond), + ) + } + if pending == nil { + // ONE OUTSTANDING READ, NOT ONE PER ATTEMPT. ProjectedTokenSource takes + // no context, so a read cannot be cancelled and a blocked source leaves + // its goroutine blocked; starting one per attempt accumulated them + // without limit at the maximum-backoff rate, and handed a source that + // single-flight had been protecting concurrent calls. + pending = &pendingRead{started: started, done: make(chan struct{})} + c.outstandingRead = pending go func() { token, err := c.options.ProjectedToken.ProjectedToken() - answers <- projectedRead{token: token, err: err} - // The read finished, so the slot is free for the next attempt. c.readMu.Lock() - if c.outstandingRead == answers { + pending.token, pending.err = token, err + if c.outstandingRead == pending { c.outstandingRead = nil } c.readMu.Unlock() + close(pending.done) }() } - answers := c.outstandingRead c.readMu.Unlock() select { - case answer := <-answers: - return answer.token, answer.err + case <-pending.done: + c.readMu.Lock() + token, err := pending.token, pending.err + c.readMu.Unlock() + return token, err case <-ctx.Done(): // AN OUTAGE, under the one latch rule: a projection that is slow is a // mount under load or a source holding a lock, and both clear. diff --git a/workcontext/retraction_test.go b/workcontext/retraction_test.go new file mode 100644 index 0000000..d6bd99c --- /dev/null +++ b/workcontext/retraction_test.go @@ -0,0 +1,151 @@ +package workcontext + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" +) + +// THE RETRACT INTERVAL IS NON-EMPTY AND COVERS WHAT IT CLAIMS TO. +// +// The first attempt at this module's retraction wrote +// +// retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] +// +// which covers NOTHING. Under semver a prerelease sorts BELOW its release, so +// `v0.0.0-2026…` is LESS than `v0.0.0`: the interval has low > high and is +// empty, and every published pseudo-version of this module sat outside a +// declaration that read as though it covered all of them. `go mod edit -json` +// printed it back without complaint, `go build` and `go vet` passed, and the +// root module's check compared the bound's TIMESTAMP as a string — so the one +// thing nobody asked was whether the interval contained anything. +// +// That is the fail-open shape this repository has now produced six times: a +// declaration that looks like a gate. The pattern in all six is the same — the +// check was written in terms of the thing's spelling rather than its meaning. +// +// So the ordering is answered by golang.org/x/mod/semver, which is Go's own +// implementation of it and the same one `go list -m -retracted` uses. A hand +// comparison is what missed it. +func TestTheRetractIntervalCoversThePublishedVersions(t *testing.T) { + spans := ownRetractSpans(t) + require.NotEmpty(t, spans, + "this module carries the implementation from root tag v0.1.66 onward and must retract "+ + "the versions that publish it") + + for _, span := range spans { + require.True(t, semver.IsValid(span.Low), "retract low bound %q is not a version", span.Low) + require.True(t, semver.IsValid(span.High), "retract high bound %q is not a version", span.High) + require.LessOrEqual(t, semver.Compare(span.Low, span.High), 0, + "retract [%s, %s] is EMPTY: low sorts above high, so it covers no version at all. "+ + "A prerelease sorts BELOW its release, so [v0.0.0, v0.0.0-…] is the mistake this "+ + "test exists for.", span.Low, span.High) + } + + // EVERY PUBLISHED VERSION THAT CARRIES IT, by pseudo-version, computed from + // the commits rather than written down — a list of versions in a test is + // another thing that can silently stop matching what is published. + carrying := carryingPseudoVersions(t) + require.NotEmpty(t, carrying, + "no commit in this checkout carries the implementation under workcontext/, which "+ + "contradicts the measurement this retraction was written from") + t.Logf("%d published version(s) of this module carry the implementation", len(carrying)) + + for _, version := range carrying { + covered := false + for _, span := range spans { + if semver.Compare(version, span.Low) >= 0 && semver.Compare(version, span.High) <= 0 { + covered = true + break + } + } + require.True(t, covered, + "%s publishes this module WITH the second implementation and no retract interval "+ + "covers it. A consumer resolves it today; a tag cannot be fixed, only retracted.", + version) + } + + // And a version from after the bound is NOT retracted, so the interval is + // not simply swallowing everything — which would make the assertion above + // pass for the wrong reason and would retract this PR's own release. + future := "v0.0.0-20991231235959-ffffffffffff" + for _, span := range spans { + require.Positive(t, semver.Compare(future, span.High), + "the retraction covers %s, which is later than every carrying commit — an interval "+ + "that retracts the fix as well as the defect is not a retraction", future) + } +} + +type ownRetractSpan struct { + Low string + High string +} + +// ownRetractSpans reads this module's own retract directives through the go +// tool, because the shape a person writes and the set the tool acts on are two +// different things. +func ownRetractSpans(t *testing.T) []ownRetractSpan { + t.Helper() + run := exec.Command("go", "mod", "edit", "-json") + output, err := run.Output() + require.NoError(t, err) + var parsed struct { + Retract []ownRetractSpan + } + require.NoError(t, json.Unmarshal(output, &parsed)) + return parsed.Retract +} + +// carryingPseudoVersions is the pseudo-version of every commit whose +// workcontext/ tree carries the deleted implementation. +// +// Computed from git rather than listed, and it reads the commit's own timestamp +// in UTC, which is what the go tool puts in a pseudo-version. +func carryingPseudoVersions(t *testing.T) []string { + t.Helper() + // THE LEAF PATHS. The first version of this asked git for `work_context.go` + // — the ROOT-level file, which belongs to the root module — so it asserted + // about nine commits of the wrong module's history and passed. The files + // this module publishes are under workcontext/. + run := exec.Command("git", "log", "--format=%H %cd", + "--date=format-local:%Y%m%d%H%M%S", "--all", "--", + "workcontext/work_context.go", "workcontext/work_context_jwks.go") + run.Dir = ".." + run.Env = append(os.Environ(), "TZ=UTC") + output, err := run.Output() + require.NoError(t, err) + + var versions []string + seen := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + sha, stamp, found := strings.Cut(strings.TrimSpace(line), " ") + if !found || len(sha) < 12 { + continue + } + // A commit that TOUCHED the path includes the one that DELETED it, and + // a commit where the file is absent does not publish it. `git log + // -- path` cannot tell those apart, so the tree is asked. + if !blobExistsAt(t, sha, "workcontext/work_context.go") && + !blobExistsAt(t, sha, "workcontext/work_context_jwks.go") { + continue + } + version := "v0.0.0-" + stamp + "-" + sha[:12] + if !seen[version] { + seen[version] = true + versions = append(versions, version) + } + } + return versions +} + +func blobExistsAt(t *testing.T, sha string, path string) bool { + t.Helper() + run := exec.Command("git", "cat-file", "-e", sha+":"+path) + run.Dir = ".." + return run.Run() == nil +} diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go index 49a482f..092932a 100644 --- a/workcontext/round6_reproducers_test.go +++ b/workcontext/round6_reproducers_test.go @@ -610,3 +610,117 @@ func (s *countingBlockedSource) ProjectedToken() (string, error) { <-s.released return "projected", nil } + +// ROUND EIGHT: THE BOUND I ADDED COULD TRANSFER AN EXPIRED PROJECTION, and +// then latch the client for good. +// +// Bounding the reads to one outstanding left the value from a TIMED-OUT read in +// a buffered channel, and the next attempt took it. The projection is rotated +// under the running process — that is why ProjectedToken is called again before +// every renewal instead of being cached — so a token read minutes earlier is +// expired, the host answers 401, and 401 latches. A fix for a goroutine leak +// that permanently stops the process is worse than the leak. +// +// Two properties, because the fix has two halves. +func TestATimedOutReadNeverHandsItsTokenToALaterAttempt(t *testing.T) { + // (a) A read still in flight and OLDER than one attempt's budget is + // refused, and no second reader is started. The bound holds and the answer + // that would have been stale is never waited for. + t.Run("an attempt refuses an old in-flight read", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &countingBlockedSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 50 * time.Millisecond + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable) + + // Past the hold-off and far past the read's freshness budget. + clock.set(clock.at.Add(10 * time.Minute)) + later, cancelLater := context.WithTimeout(context.Background(), 2*time.Second) + _, err = client.Credential(later) + cancelLater() + + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "would be a token read before the last rotation") + require.NoError(t, client.Refused(), "and none of this latches") + require.EqualValues(t, 1, source.calls.Load(), + "no second reader: the bound from the previous round still holds") + require.Zero(t, host.requests.Load(), "nothing stale reached the host") + }) + + // (b) Once the read finishes, a later attempt gets a FRESH one. This is the + // transfer itself: with the value parked in a buffered channel, the later + // attempt took the token read before the rotation. + t.Run("a later attempt reads the projection again", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &rotatingSource{released: make(chan struct{})} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 50 * time.Millisecond + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable, "the first attempt times out") + + // The read completes with the token as it was BEFORE the rotation. + close(source.released) + clock.set(clock.at.Add(10 * time.Minute)) + + // WAITED FOR, not assumed. The read is detached, so its own + // bookkeeping — storing the answer and freeing the slot — happens after + // the attempt that started it has already returned. Asserting straight + // away raced it and got the staleness refusal, which is the right + // answer to the wrong question. + // The clock moves with each try, because every failed attempt extends + // the hold-off and a frozen clock never gets past it. + require.Eventually(t, func() bool { + clock.set(clock.at.Add(time.Minute)) + attempt, cancelAttempt := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelAttempt() + _, attemptErr := client.Credential(attempt) + return attemptErr == nil + }, 3*time.Second, 10*time.Millisecond, + "the client never minted after the blocked read finished") + + // THE PROPERTY IS THAT rotation-1 NEVER REACHES THE HOST — the exact + // later token is not the point and asserting it made this fail on + // rotation-3, because each try in the loop above takes a read. + presented, _ := host.presented.Load().(string) + require.NotContains(t, presented, "rotation-1", + "the host was shown %q: the token from the TIMED-OUT read was transferred to a "+ + "later attempt, and a projection read before the last rotation earns a 401 "+ + "that latches", presented) + require.Contains(t, presented, "rotation-", + "the host was shown %q, which is not a token from this source at all", presented) + require.Greater(t, source.calls.Load(), int64(1), + "the projection is read AGAIN rather than remembered from the attempt that gave up") + require.NoError(t, client.Refused(), "and nothing in this latched") + }) +} + +// rotatingSource blocks on its first read and answers with a different token +// each time, which is what a projection rotated under a running process does. +type rotatingSource struct { + calls atomic.Int64 + released chan struct{} +} + +func (s *rotatingSource) ProjectedToken() (string, error) { + call := s.calls.Add(1) + if call == 1 { + <-s.released + } + return fmt.Sprintf("rotation-%d", call), nil +} From cdc33fb878eb595eeb9c789de09ec3d6606a35d6 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:55:46 -0400 Subject: [PATCH 29/35] fix(workcontext)!: the gates must read every shipped file, and every served stream is considered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round seven part two (executed, at 07a93ecc). Its B-1 had two halves and the first was already closed at d8cb0af; the second is this commit. B-1a THE SWEEP AS A DENYLIST — already fixed. Re-ran the review's own ten paths through the current sweep: json/v2, mldsa, protodelim, grpc/encoding, prototext, jsontext, hpke, reflect, unsafe and "C" are all refused. One policy file, scripts/allowed-imports.txt, read by the Go gate and by the required check. B-1b THE GATES READ ONLY WHAT packages.Load BUILDS — fixed, and the fix is the assertion the review asked for. TestTheGateReadsEveryShippedFile requires what the loader read to equal `git ls-files '*.go'` minus tests: 29 = 29 today, and it fails the day somebody adds a build-constrained file, a cgo file under CGO_ENABLED=0, a `_`-prefixed directory, a nested module or a vendor tree. The sweep still reads every tracked file — go/parser ignores build tags — so an import ban holds there and nothing else does; that distinction is now written down rather than assumed. It also SUBSUMES THE STRUCK TESTDATA CLAIM. The review was right that TestTestdataIsNotAHidingPlace checked the helper and never that the gate loads a testdata package. `git ls-files` lists one, so the equality above is what fails if the loader skips it. B-1c NO GATE READS .s/.syso/.c/.h, so none may be shipped — refused by extension in the Go gate AND in the sweep, because the sweep is what runs over every published ref. `import "C"` was already refused by name and this is the other half of it: an import ban is meaningless if the implementation can arrive as an object file. M-1 THE RETRACTION — both halves already fixed (the wrong module at d8cb0af, the EMPTY interval at 2aaa755). On the `go list -m -retracted` test the review insisted on: it cannot be written here, checked rather than asserted — `go list -m -retracted` on the main module reports `retracted=[]`, because it reads a PUBLISHED version's go.mod and not the working tree. It becomes writable once workcontext/vX.Y.Z is tagged, which is the owner's step. The semver assertion against golang.org/x/mod/semver is what is checkable now. M-4 THE INTERCEPTOR FAILING OPEN FOR A SERVED METHOD MISSING FROM THE SET — fixed, not deferred. ValidateMethodSet checked the declared set against the server and never the server against the set, so a served streaming method nobody named reached the raw handler with guardFor uncalled. Deny by default now: every served streaming method is either capability-bearing or passed as deliberately unguarded. A stream carrying no authority is a real thing, so this forces the DECISION to exist rather than the guard. It also reports every problem at once — returning on the first told a server with two mistakes about one of them, and made a test asserting the typo's message fail because a different true finding came out first. Codex r8 #2, the bounded reader carrying an expired projection — already fixed at 2aaa755. MINORS: workcontext/AGENTS.md still described the DELETED awk extractor and named a test that no longer exists; rewritten against this head. And the import counts: 30 leaf / 46 root, not the 31/45 in d8cb0af's message. A corrections table is now in the PR body recording all six claims in this PR that did not hold — the pattern in all of them is a claim written in terms of a thing's spelling rather than a measurement of it. On the mutation accounting: the review's re-run at 07a93ecc got 11 killed, 2 survived, 1 equivalent. Not disputed — my runs used my own set at my own heads, so "all killed" was true of mine and says nothing about theirs. The rules they name as untested (the options-method receiver, the empty-denominator check, the receipts glob, the sweep's x/crypto, sha256, tls and x509 rules) are follow-ups. Note the deny-by-default gate caught my own change while I made it: `sort`, added to guarded_stream.go for this commit, is not on the leaf allowlist and the suite refused it until I added the line with a reason. That is the rule working. Verified at this head: root suite ok (289s), leaf suite ok, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (65 Go files). Six mutations for this batch, six killed. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/allowed-imports.txt | 1 + scripts/check-one-implementation.sh | 28 +++- sweep_bypasses_test.go | 53 ++++++++ workcontext/AGENTS.md | 34 +++-- workcontext/go.mod | 2 +- workcontext/grpctransport/guarded_stream.go | 89 +++++++++---- .../grpctransport/round6_reproducers_test.go | 42 ++++++ workcontext/import_allowlist_test.go | 120 ++++++++++++++++++ 8 files changed, 329 insertions(+), 40 deletions(-) diff --git a/scripts/allowed-imports.txt b/scripts/allowed-imports.txt index e8c5d18..fb681ec 100644 --- a/scripts/allowed-imports.txt +++ b/scripts/allowed-imports.txt @@ -117,6 +117,7 @@ leaf net/http leaf net/url leaf os leaf slices +leaf sort leaf strconv leaf strings leaf sync diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index d5105b2..3745e22 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -182,12 +182,30 @@ offending_import() { return 0 } +# refuse_unreadable_sources -> prints each file no gate can read. +# +# Every rule in this repository is about Go — go/parser here, go/types in the +# Go gates — so assembly, object code and C are outside all of them. A .s file +# can implement anything; a .syso is compiled code linked in whole. `import "C"` +# is refused by name and this is the other half of it. Checked HERE as well as +# in the Go gate, because the Go gate reads a checkout and this reads every +# published ref. +refuse_unreadable_sources() { + grep -E '\.(s|S|syso|a|o|c|cc|cpp|cxx|h|hh|hpp|m|mm)$' || true +} + carrying_in_tree() { - local found="" tracked list imports path imported + local found="" tracked list imports path imported unreadable if ! tracked=$(git ls-files -- '*.go'); then echo "FAIL cannot enumerate the tracked Go files in this checkout." >&2 exit 1 fi + unreadable=$(git ls-files | refuse_unreadable_sources) + if [ -n "$unreadable" ]; then + while IFS= read -r path; do + [ -n "$path" ] && found="$found$path is a source no gate here can read"$'\n' + done <<< "$unreadable" + fi list=$(mktemp); imports=$(mktemp) while IFS= read -r path; do case "$path" in @@ -207,11 +225,17 @@ carrying_in_tree() { } carrying_in_ref() { - local ref="$1" found="" entries list imports work path imported index=0 + local ref="$1" found="" entries list imports work path imported unreadable index=0 if ! entries=$(git ls-tree -r --name-only "$ref"); then echo "FAIL cannot enumerate $ref, so it has not been swept." >&2 exit 1 fi + unreadable=$(printf '%s\n' "$entries" | refuse_unreadable_sources) + if [ -n "$unreadable" ]; then + while IFS= read -r path; do + [ -n "$path" ] && found="$found$path is a source no gate here can read"$'\n' + done <<< "$unreadable" + fi work=$(mktemp -d); list=$(mktemp); imports=$(mktemp) # Each blob's path in the REF is recorded beside its temporary copy, so a # finding names that path and not something under /tmp. diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index cf16349..e8e70ba 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -563,3 +563,56 @@ func TestTheImportReaderAnswersWithWhatGoSees(t *testing.T) { "the escape is unquoted to the path the compiler sees, the raw string is read, "+ "and the \")\" in the comment does not end the block") } + +// NO GATE HERE CAN READ ASSEMBLY OR C, so the sweep refuses them — at every +// published ref, where no Go test runs. +// +// Every rule in this repository is about Go: `go/parser` for the imports, +// `go/types` for the codec rule. A `.s` file can implement anything at all, a +// `.syso` is already-compiled object code linked in whole, and a `.c` reached +// through cgo is outside the language. `import "C"` is refused by name and this +// is the other half of it: the import ban is meaningless if the implementation +// can arrive as an object file. +func TestTheSweepRefusesSourcesNoGateCanRead(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]struct{ file, source string }{ + "hand-written assembly": {"sign_amd64.s", "TEXT sign(SB),$0\n\tRET\n"}, + "a compiled object": {"libsign.syso", "\x7fELF not really\n"}, + "a C source": {"sign.c", "int sign(void) { return 0; }\n"}, + "a C header": {"sign.h", "int sign(void);\n"}, + } { + t.Run(name, func(t *testing.T) { + repository := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repository, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(repository, probe.file), + []byte(probe.source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + for _, command := range [][]string{{"git", "init", "--quiet"}, {"git", "add", "."}} { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + + run := exec.Command("bash", "scripts/check-one-implementation.sh") + run.Dir = repository + run.Env = sweepEnv(t) + raw, err := run.CombinedOutput() + + require.Error(t, err, "a source no gate can read must not sweep clean:\n%s", raw) + require.Contains(t, string(raw), probe.file) + require.Contains(t, string(raw), "no gate here can read") + }) + } + + // And a repository of ordinary Go still passes, so this is not simply + // failing — the mutation guard for every case above. + out, err := sweepOf(t, script, "ordinary.go", + "package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n") + require.NoError(t, err, out) +} diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index be18ac8..17970c0 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -103,19 +103,27 @@ qualifier, because the qualifier is the author's choice. `mint.go` inherit the allowance — an allowlist undone by `touch`. - **No declaration named `workcontext*`** (case-insensitively, every declaration kind) unless it aliases into core's module. -- **The sweep tests import PATHS**, taken from inside the quotes, and never a - line's shape. Its previous regex tried to recognise an import and was walked - past by a non-ASCII alias (`ψ "crypto/ed25519"`) and a comment-prefixed line - (`/* x */ "crypto/ed25519"`) — in the root module, where it is the only gate. - It reads the closing paren off the COMMENT-STRIPPED line — a `)` in a comment - closed the block early — and scans each line ONCE for either quoting, because - two sequential passes dropped a raw-string path preceding a quoted one. -- **The two gates' ban lists are one list, asserted.** They disagreed both ways: - the legacy protobuf module, `protoiface`, `protoimpl` and a vendored path - ending `/ed25519` swept clean where the sweep is the only gate, and the cipher - family was banned by the sweep but not by the gate reading the module the - capability lives in. `TestTheShellSweepBansEverythingTheASTGateBans` parses the - script's own expressions and fails when they differ. +- **The sweep reads imports with GO'S OWN PARSER** (`scripts/importsof`), not a + regex. Its awk extractor was rewritten three times for this class and five + more shapes walked past it, the decisive one being `import + "\x63rypto/ed25519"` — which compiles, IS `crypto/ed25519` to the compiler, + and is not that string to anything matching text. No regex closes that. A + file that does not parse is a failure, not a file with no imports. +- **ONE import policy, `scripts/allowed-imports.txt`,** read by the Go gate and + by the sweep. There were two and they disagreed in both directions, which was + a blocker twice: the sweep is the only thing that runs over a published ref + and is the whole of the required check, so a branch carrying `crypto/mldsa` + and `encoding/json/v2` swept clean while the allowlist in `go test` refused + it. A line may name the FILES that may have an import; `legacy` lines apply + to published refs only, because deny-by-default describes today's tree and + sweeping two years of tags with it flagged 33 clean versions. +- **The gates must READ every shipped file, asserted against `git ls-files`.** + `packages.Load` builds one configuration, so a build-constrained file, a cgo + file under `CGO_ENABLED=0`, a `_`-prefixed directory, a nested module or a + vendor tree is invisible to every semantic rule. The sweep still reads them + (go/parser ignores build tags), so an import ban holds and nothing else does + — and assembly, object code and C are refused outright, because no rule here + can read them. - **A gate that could not look must not report success.** Four fail-opens, three inside the assertion added the round before: a swallowed `git ls-remote` failure skipped the comparison entirely, the comparison was between COUNTS diff --git a/workcontext/go.mod b/workcontext/go.mod index 1996a3f..4942669 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -5,6 +5,7 @@ go 1.27.0 require ( github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.41.0 golang.org/x/tools v0.51.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 @@ -19,7 +20,6 @@ require ( github.com/google/uuid v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect - golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index 0b32a37..eb5b76e 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "sort" + "strings" "sync" "google.golang.org/grpc" @@ -642,7 +644,11 @@ func StreamServerInterceptor( // than something the interceptor does because grpc hands the interceptor no // server, and inventing a registration hook to reach one would be a bigger // surface than a call a server makes. -func ValidateMethodSet(served map[string]grpc.ServiceInfo, capabilityBearing []string) error { +func ValidateMethodSet( + served map[string]grpc.ServiceInfo, + capabilityBearing []string, + deliberatelyUnguarded ...string, +) error { // STREAMING METHODS ONLY. Every grpc.MethodInfo went into this map, // ignoring IsClientStream and IsServerStream — so for a service with a // unary Read and a streaming Emit, declaring only "/Service/Read" passed @@ -672,34 +678,69 @@ func ValidateMethodSet(served map[string]grpc.ServiceInfo, capabilityBearing []s unknown = append(unknown, method) } } - if len(notStreaming) > 0 { - return fmt.Errorf( - "%w: these methods are declared capability-bearing for a STREAM interceptor and "+ - "are unary on this server: %v. A unary method's name in this set guards nothing "+ - "and makes the set non-empty, which is how an interceptor that enforces nothing "+ - "passes its own startup check", - errStreamMisuse, notStreaming, - ) + + // AND THE OTHER DIRECTION, which is the fail-open this validation did not + // reach: a STREAMING method the server serves and the set does not name + // gets the raw handler, with guardFor never called. The set was checked + // against the server and the server was never checked against the set. + // + // Deny by default, as everywhere else here: every served streaming method + // is either capability-bearing or NAMED as deliberately unguarded. A + // stream that carries no authority is a real thing and this does not force + // it to be guarded — it forces somebody to have said so, once, where a + // reader sees it, instead of a method being unguarded because nobody + // thought about it. + declared := map[string]bool{} + for _, method := range capabilityBearing { + declared[method] = true } - if len(known) == 0 { - return fmt.Errorf( - "%w: this server registers no streaming method, so a stream interceptor has "+ - "nothing to guard", errStreamMisuse, - ) + for _, method := range deliberatelyUnguarded { + declared[method] = true + } + var unconsidered []string + for method := range known { + if !declared[method] { + unconsidered = append(unconsidered, method) + } } + + // ALL THREE AT ONCE. Returning on the first meant a server with both a + // typo and an unconsidered stream was told about one of them, fixed it, + // and met the other on the next run — and a test that asserted the typo's + // message started failing because a different true finding came out first. + sort.Strings(unknown) + sort.Strings(notStreaming) + sort.Strings(unconsidered) + var problems []string if len(unknown) > 0 { - return fmt.Errorf( - "%w: these methods are declared capability-bearing and this server does not "+ - "serve them: %v. A name that matches nothing guards nothing, and reads exactly "+ - "like a method that was considered", - errStreamMisuse, unknown, - ) + problems = append(problems, fmt.Sprintf( + "these methods are declared capability-bearing and this server does not serve "+ + "them: %v (a name that matches nothing guards nothing, and reads exactly like "+ + "a method that was considered)", unknown)) + } + if len(notStreaming) > 0 { + problems = append(problems, fmt.Sprintf( + "these are declared capability-bearing for a STREAM interceptor and are unary on "+ + "this server: %v (a unary name makes the set non-empty and guards nothing)", + notStreaming)) + } + if len(unconsidered) > 0 { + problems = append(problems, fmt.Sprintf( + "this server serves these streaming methods and nothing names them: %v (a stream "+ + "missing from the set reaches the handler UNGUARDED; name it as "+ + "capability-bearing, or pass it as deliberately unguarded — the decision is "+ + "what has to exist, not the guard)", unconsidered)) + } + if len(known) == 0 { + problems = append(problems, "this server registers no streaming method at all, so a "+ + "stream interceptor has nothing to guard") } if len(capabilityBearing) == 0 { - return fmt.Errorf( - "%w: no method is declared capability-bearing, so the interceptor would guard "+ - "nothing", errStreamMisuse, - ) + problems = append(problems, "no method is declared capability-bearing, so the "+ + "interceptor would guard nothing") + } + if len(problems) > 0 { + return fmt.Errorf("%w: %s", errStreamMisuse, strings.Join(problems, "; ")) } return nil } diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go index 6142499..e34310c 100644 --- a/workcontext/grpctransport/round6_reproducers_test.go +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -211,3 +211,45 @@ func TestAUnaryMethodNameGuardsNothingAndIsRefused(t *testing.T) { map[string]grpc.ServiceInfo{streamService: {Methods: []grpc.MethodInfo{{Name: "Read"}}}}, []string{"/" + streamService + "/Read"})) } + +// M-4: THE INTERCEPTOR FAILED OPEN FOR A SERVED METHOD MISSING FROM THE SET, +// and the validation that was supposed to catch misconfiguration only looked +// one way — the declared set was checked against the server, and the server was +// never checked against the set. +// +// Deny by default, as everywhere else here: every served streaming method is +// either capability-bearing or NAMED as deliberately unguarded. A stream that +// carries no authority is a real thing, so this does not force a guard on it — +// it forces somebody to have said so once, where a reader sees it, instead of a +// method going unguarded because nobody thought about it. +func TestEveryServedStreamIsEitherGuardedOrDeliberatelyNot(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{ + {Name: "Emit", IsServerStream: true}, + {Name: "Watch", IsServerStream: true}, + {Name: "Read"}, + }}, + } + watch := "/" + streamService + "/Watch" + + // Emit declared, Watch neither declared nor exempted: refused. + err := ValidateMethodSet(served, []string{streamMethod}) + require.Error(t, err, "Watch reaches the handler unguarded and nothing says that is intended") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "Watch") + require.Contains(t, err.Error(), "reaches the handler UNGUARDED") + + // Watch named as deliberately unguarded: accepted. The decision exists. + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}, watch)) + + // Or guarded instead: also accepted. + require.NoError(t, ValidateMethodSet(served, []string{streamMethod, watch})) + + // AND EVERY PROBLEM AT ONCE, because returning on the first told a server + // with two mistakes about one of them. + both := ValidateMethodSet(served, []string{"/" + streamService + "/Emmit", "/" + streamService + "/Read"}) + require.Error(t, both) + require.Contains(t, both.Error(), "Emmit", "the typo") + require.Contains(t, both.Error(), "unary on this server", "the unary name") + require.Contains(t, both.Error(), "UNGUARDED", "and the streams nothing names") +} diff --git a/workcontext/import_allowlist_test.go b/workcontext/import_allowlist_test.go index 98f4b73..8b3eaa3 100644 --- a/workcontext/import_allowlist_test.go +++ b/workcontext/import_allowlist_test.go @@ -5,6 +5,7 @@ import ( "go/ast" "go/types" "os" + "os/exec" "path/filepath" "slices" "sort" @@ -470,3 +471,122 @@ func TestTheGateActuallyHasTypeInformation(t *testing.T) { "was deciding nothing. A gate with no inputs passes everything.") var _ types.Type = types.Typ[types.Bool] // the import is load-bearing above } + +// THE GATE READS EVERY SHIPPED FILE, asserted against git rather than assumed. +// +// This is the half of the one-implementation rule that the shared policy file +// did not close. `packages.Load` builds for ONE configuration, so a file the +// current build does not select is a file the type-checked gates never see: +// GOOS/GOARCH or custom build tags, cgo under CGO_ENABLED=0, a directory +// beginning with `_` or `.`, a nested module, a `vendor/` tree. The sweep reads +// every tracked file regardless of tags (go/parser ignores them), so a +// prohibited IMPORT there is still caught — what is not caught is every +// semantic rule: the codec rule, the capability walk, the envelope rule. +// +// "Every source file is checked regardless of build tags" was true of the +// syntactic scanners and stopped being true when the type-checked gates +// arrived. So the claim is now a test: what the loader read must equal what the +// repository ships. Today they are equal; the day somebody adds a +// build-constrained file this fails, and the answer is to load that +// configuration too or to refuse the file — not to let it through unread. +// +// It also subsumes the testdata claim. A reviewer struck +// TestTestdataIsNotAHidingPlace for checking the helper and never that the gate +// LOADS a testdata package: `git ls-files` lists one, so this is what would +// fail if the loader skipped it. +func TestTheGateReadsEveryShippedFile(t *testing.T) { + tracked := trackedGoFiles(t) + require.NotEmpty(t, tracked, "git listed no Go files, so this asserted nothing") + + read := map[string]bool{} + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path != "" && !strings.HasSuffix(path, "_test.go") { + read[path] = true + } + } + } + } + + var unread []string + for _, path := range tracked { + if !read[path] { + unread = append(unread, path) + } + } + require.Empty(t, unread, + "these files are shipped and the type-checked gates never read them: %v.\n"+ + "packages.Load builds for ONE configuration, so a build-constrained file, a cgo\n"+ + "file under CGO_ENABLED=0, a `_`-prefixed directory, a nested module or a vendor\n"+ + "tree is invisible to every semantic rule here — the codec rule, the capability\n"+ + "walk, the envelope rule. The import sweep still reads them, so an import ban\n"+ + "holds; nothing else does. Load that configuration too, or refuse the file.", + unread) + + // And the loader must not be reading files git does not track, which would + // mean it is answering about something other than what ships. + trackedSet := map[string]bool{} + for _, path := range tracked { + trackedSet[path] = true + } + var untracked []string + for path := range read { + if !trackedSet[path] { + untracked = append(untracked, path) + } + } + require.Empty(t, untracked, + "the gates read files this repository does not track: %v", untracked) +} + +// NO GATE HERE READS ASSEMBLY OR C, so none may be shipped. +// +// Every rule in this repository is about Go: `go/parser` for the sweep, +// `go/types` for the codec rule. A `.s` file can implement anything at all, a +// `.syso` is already-compiled object code linked in whole, and a `.c` compiled +// through cgo is outside the language. There is no honest need for any of them +// in an SDK whose job is resolving values — and `import "C"` is already refused +// by name, so this is the other half of that. +func TestNoSourceNoGateCanReadIsShipped(t *testing.T) { + run := exec.Command("git", "ls-files", "--", + "*.s", "*.S", "*.syso", "*.a", "*.o", "*.c", "*.cc", "*.cpp", "*.cxx", + "*.h", "*.hh", "*.hpp", "*.m", "*.mm") + run.Dir = ".." + output, err := run.Output() + require.NoError(t, err) + + var shipped []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + shipped = append(shipped, trimmed) + } + } + require.Empty(t, shipped, + "these files are shipped and no gate in this repository can read them: %v.\n"+ + "Every rule here is about Go — go/parser for the import sweep, go/types for the\n"+ + "codec rule — so assembly, object code and C are outside all of them. A .s file\n"+ + "can implement anything; a .syso is compiled code linked in whole. `import \"C\"`\n"+ + "is already refused by name and this is the other half of it.", + shipped) +} + +// trackedGoFiles is every non-test Go file this repository ships, from git. +func trackedGoFiles(t *testing.T) []string { + t.Helper() + run := exec.Command("git", "ls-files", "--", "*.go") + run.Dir = ".." + output, err := run.Output() + require.NoError(t, err) + + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + path := strings.TrimSpace(line) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + files = append(files, path) + } + return files +} From 7ea600f6189cec22f2e11e763ca54404da7edd3b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:45 -0400 Subject: [PATCH 30/35] =?UTF-8?q?fix(workcontext)!:=20the=20method=20is=20?= =?UTF-8?q?the=20contract,=20not=20the=20shape=20=E2=80=94=20and=20a=20gat?= =?UTF-8?q?e=20that=20cannot=20finish=20is=20a=20gate=20nobody=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round eight, executed (at 998f218b). Its N1 is the finding of this whole review series, because it is about the shape of my fixes rather than one hole. N1 (BLOCKER) ROUND SEVEN'S FIX CLOSED A SUBCLASS AND LEFT THE CLASS OPEN. Making the interface walk transitive caught a struct carrying an interface. It does not catch a concrete FUNCTION type: type r8thunk func() protoreflect.Message func (f r8thunk) ProtoReflect() protoreflect.Message { return f() } proto.Unmarshal(raw, r8thunk(claims.ProtoReflect)) carriesAnInterface does not descend into a *types.Signature and capabilityWithin finds nothing in a func-typed field. Reproduced at cdc33fb before fixing: both that and the struct-with-func-field variant passed every gate. CHASING THE SHAPE WAS THE MISTAKE, and it was the denylist failure in another costume: a wrapper can be a struct, a func, a defined slice, a map, a channel — anything with a method set — so a rule written about shapes is behind by one shape, forever. What every one of them MUST have is a ProtoReflect method, because that is the protobuf contract; it is how the capability gets reached and no indirection avoids it. So the rule is the method. A protobuf message is GENERATED, by protoc, in the repository that owns the schema — core. This repository writes none, has zero today, and a hand-written ProtoReflect (or the v1 Reset/String/ProtoMessage trio, which proto.Unmarshal still accepts) is a type pretending to be a message. Three probes committed: the thunk, the lazy struct, the v1 contract. And //go:build is refused in hand-written non-test files, which closes the tagged-wrapper variant — Codex's envelopeTarget behind `//go:build r8hidden` passed all three Go gates. TestTheGateReadsEveryShippedFile already DETECTS such a file generically (tracked and unread); this names it, because "unread" sends a reader looking for a loader bug instead of deleting a constraint. N4 (INSISTED) THE DENY-BY-DEFAULT REF POLICY HAD NO TEST, and the review was exactly right that restoring the entire old denylist script left every test green. The policy file was asserted not to LIST those paths and the Go gates refuse them in a checkout; nothing ran the SWEEP over them. Now mldsa, json/v2, protodelim, grpc/encoding, prototext, jsontext, hpke, unsafe and "C" are probed through the script in TREE and REF mode, in BOTH modules — the two arms take different paths through it, and a mutation showed probing only a root-level file left the leaf list untested entirely. The README stream recipe calls ValidateMethodSet now, with the three things it catches and a deliberately-unguarded method, so a consumer following the recipe does not keep the fail-open. N2 and N3 were already fixed before this review was written (2aaa755), and both are now proved rather than claimed. On the `go list -m -retracted` check N2 insists on: it reports `retracted=[]` for the main module, because it reads a PUBLISHED version's go.mod and not the working tree — so it becomes writable only once workcontext/vX.Y.Z is tagged. leafRetractBound, which read only the High field and is how the empty interval went unnoticed, is DELETED; the leaf's interval is asserted by its own module using golang.org/x/mod/semver. The root's hand-rolled ordering now REFUSES a prerelease rather than mis-ordering one, which is the same mistake bounded to the case it is correct for. A GATE THAT CANNOT FINISH IS A GATE SOMEBODY DELETES. The root suite had reached 600s and CI's job timeout is five minutes: the tag gate was invoking the sweep once per tag, 83 times, each parsing the policy and rebuilding its allow lists. The script already loops over its arguments, so it is one invocation now — 333s to 22s, and the whole root suite 600s+ to 67s. The parse asserts every tag was reported on, because a tag the sweep did not reach is a tag this gate cleared without looking. Three mutations survived the first pass and all three were the same defect in my tests: a rule asserted only over the TREE, which is clean, so the assertion was true with the rule removed. The build-constraint rule, the sweep's module lists and the prerelease guard each have a probe over written input now. Verified at this head: root suite ok (67s), leaf ok, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (65 Go files). Seven mutations, seven killed. Follow-ups unchanged and listed in the PR body; N5 (published refs get only the import policy, so a direct root parser sweeps ok on a ref) is added to it as a stated limit. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- published_tags_test.go | 189 ++++++++++++++++++--------- sweep_bypasses_test.go | 95 ++++++++++++++ workcontext/README.md | 34 ++++- workcontext/codec_types_test.go | 106 ++++++++++++++- workcontext/import_allowlist_test.go | 76 +++++++++++ 5 files changed, 432 insertions(+), 68 deletions(-) diff --git a/published_tags_test.go b/published_tags_test.go index 86a2384..6f76ed2 100644 --- a/published_tags_test.go +++ b/published_tags_test.go @@ -2,6 +2,7 @@ package codefly_test import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -36,12 +37,13 @@ func TestEveryPublishedTagCarryingTheImplementationIsRetracted(t *testing.T) { "looked at nothing, which is the fail-open this repository has fixed five times.") rootRetracted := retractedVersions(t, ".") - leafBound := leafRetractBound(t) reader := importReader(t) + offendingByTag := offendingPathsByRef(t, reader, tags) + var rootCarrying, leafCarrying, unretracted []string for _, tag := range tags { - offending := offendingPathsAt(t, reader, tag) + offending := offendingByTag[tag] if len(offending) == 0 { continue } @@ -69,15 +71,23 @@ func TestEveryPublishedTagCarryingTheImplementationIsRetracted(t *testing.T) { } } if byLeaf { + // THE LEAF'S INTERVAL IS THE LEAF'S OWN TEST TO ASSERT, and this + // is where a correction belongs. This used to read the retract + // bound's TIMESTAMP out of the High field and compare it as a + // string — which cannot see an interval whose LOW bound is above + // its high, and that is exactly the state it passed over: the leaf + // retraction was `[v0.0.0, v0.0.0-…]`, which covers no version at + // all, because a prerelease sorts below its release. + // + // A hand comparison of one bound is how that went unnoticed, so + // the question moved to workcontext's own + // TestTheRetractIntervalCoversThePublishedVersions, which asks + // golang.org/x/mod/semver — Go's implementation of the ordering — + // whether the interval is non-empty and covers every carrying + // pseudo-version. What is recorded here is only that this tag's + // files belong to the LEAF module, which is the fact this test can + // establish. leafCarrying = append(leafCarrying, tag) - // The leaf module has never been tagged, so there is no leaf - // version to name: what a consumer resolves is a pseudo-version of - // the commit. The leaf retract covers a RANGE up to a timestamp, - // so the question is whether this tag's commit falls inside it. - if stamp := commitStamp(t, tag); stamp > leafBound { - unretracted = append(unretracted, - tag+" (leaf module, commit "+stamp+" is past the retract bound "+leafBound+")") - } } } @@ -102,62 +112,50 @@ func tagHasLeafModule(t *testing.T, tag string) bool { return run.Run() == nil } -// commitStamp is a tag's commit time in a pseudo-version's own format, so it -// compares against a retract bound as a string the way the go tool orders them. -func commitStamp(t *testing.T, tag string) string { +// offendingPathsByRef runs the repository sweep ONCE over every tag and returns +// the paths it reported, by ref. +// +// One invocation, not one per tag. Per-tag it was 333 seconds for 83 tags — +// each spawning the script, which parses the policy, builds its allow lists and +// shells out per file — and CI's job timeout is five minutes. A gate that +// cannot finish inside the job is a gate somebody deletes. The script already +// loops over its arguments and reports `ok ` or `FAIL …`, so this is +// a parse of what it was always printing. +func offendingPathsByRef(t *testing.T, reader string, tags []string) map[string][]string { t.Helper() - run := exec.Command("git", "log", "-1", "--format=%cd", - "--date=format:%Y%m%d%H%M%S", tag) - output, err := run.Output() - require.NoError(t, err) - return strings.TrimSpace(string(output)) -} + run := exec.Command("bash", append([]string{"scripts/check-one-implementation.sh"}, tags...)...) + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, _ := run.CombinedOutput() -// leafRetractBound is the timestamp inside the leaf module's retract high -// bound. A leaf go.mod with no retract is a finding, loudly, because that is -// the state this correction found. -func leafRetractBound(t *testing.T) string { - t.Helper() - spans := retractSpans(t, "workcontext") - require.NotEmpty(t, spans, - "workcontext/go.mod retracts nothing, and it is the module that carries the "+ - "implementation from root tag v0.1.66 onward") - var highest string - for _, span := range spans { - _, stamp, found := strings.Cut(span.High, "-") - require.True(t, found, - "the leaf retract bound %q is not a pseudo-version; this module has never been "+ - "tagged, so a range of pseudo-versions is what covers its published versions", - span.High) - stamp, _, _ = strings.Cut(stamp, "-") - if stamp > highest { - highest = stamp + byRef := map[string][]string{} + current := "" + for _, line := range strings.Split(string(output), "\n") { + switch { + case strings.HasPrefix(line, "FAIL ") && strings.Contains(line, "carries a Work Context"): + current = strings.TrimSpace(strings.TrimSuffix( + strings.TrimPrefix(line, "FAIL "), " carries a Work Context implementation:")) + byRef[current] = nil + case strings.HasPrefix(line, "ok "): + current = "" + case current != "" && strings.HasPrefix(line, " "): + path, _, found := strings.Cut(strings.TrimSpace(line), " imports ") + if found && strings.HasSuffix(path, ".go") { + byRef[current] = append(byRef[current], path) + } } } - return highest -} - -// offendingPathsAt runs the repository sweep over one tag and returns the paths -// it reported. -func offendingPathsAt(t *testing.T, reader string, tag string) []string { - t.Helper() - run := exec.Command("bash", "scripts/check-one-implementation.sh", tag) - run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) - output, err := run.CombinedOutput() - if err == nil { - return nil - } - require.Contains(t, string(output), "carries a Work Context implementation", - "the sweep failed on %s for a reason that is not a finding:\n%s", tag, output) - var paths []string + // EVERY TAG MUST HAVE BEEN REPORTED ON, or the parse is reading less than + // the sweep said and a tag could be silently clean. + reported := len(byRef) for _, line := range strings.Split(string(output), "\n") { - path, _, found := strings.Cut(strings.TrimSpace(line), " imports ") - if found && strings.HasSuffix(path, ".go") { - paths = append(paths, path) + if strings.HasPrefix(line, "ok ") { + reported++ } } - require.NotEmpty(t, paths, "the sweep reported a finding and named no file:\n%s", output) - return paths + require.GreaterOrEqual(t, reported, len(tags), + "the sweep reported on %d of %d tags; a tag it did not reach is a tag this gate "+ + "cleared without looking:\n%s", reported, len(tags), output) + return byRef } // localTags is every tag in this checkout, which in CI is every published tag. @@ -212,7 +210,7 @@ func retractedVersions(t *testing.T, dir string) map[string]bool { for _, span := range spans { // `go mod edit -json` writes a single version as Low == High, so // one comparison covers both shapes. - if semverAtMost(span.Low, tag) && semverAtMost(tag, span.High) { + if semverAtMost(t, span.Low, tag) && semverAtMost(t, tag, span.High) { covered[tag] = true } } @@ -220,11 +218,44 @@ func retractedVersions(t *testing.T, dir string) map[string]bool { return covered } -// semverAtMost reports whether low <= high. -func semverAtMost(low string, high string) bool { +// semverAtMost reports whether low <= high, for PLAIN RELEASE VERSIONS ONLY. +// +// It refuses a prerelease rather than ordering one, and that guard is the +// lesson from the leaf module's empty interval: a prerelease sorts BELOW its +// release, a hand comparison that does not know this reads `[v0.0.0, +// v0.0.0-…]` as a sensible range, and the interval covered nothing. The root +// module's tags and retract bounds are all plain `vX.Y.Z`, so this is enough +// here — and if that ever stops being true, this fails instead of guessing. +// +// The leaf module's own interval, which IS pseudo-versions, is asserted by +// workcontext's TestTheRetractIntervalCoversThePublishedVersions using +// golang.org/x/mod/semver. One hand-rolled ordering in this repository is one +// too many; this one is bounded to the case it is correct for. +func semverAtMost(t *testing.T, low string, high string) bool { + t.Helper() + require.NoError(t, comparableVersions(low, high)) return low == high || compareVersions(low, high) <= 0 } +// comparableVersions reports whether this comparison may be made at all. +// +// Separated from the comparison so a probe can drive it: asserted only through +// semverAtMost, a mutation that removed the guard changed nothing, because the +// root module's tags and bounds are all plain releases and no test ever passed +// a prerelease. A guard no test can reach is a guard nobody has checked, which +// is the shape that let the empty interval through in the first place. +func comparableVersions(versions ...string) error { + for _, version := range versions { + if strings.Contains(version, "-") { + return fmt.Errorf( + "%q carries a prerelease and this comparison cannot order one: a prerelease "+ + "sorts BELOW its release, which is how an empty retract interval read as a "+ + "range. Use golang.org/x/mod/semver, as workcontext does", version) + } + } + return nil +} + // compareVersions orders vMAJOR.MINOR.PATCH numerically. Both inputs come from // this repository's own tags and its own go.mod, which are all of that shape. func compareVersions(left string, right string) int { @@ -305,3 +336,37 @@ func TestTheHistoricalImportAllowanceCarriesNothingCapabilityShaped(t *testing.T } } } + +// THE ORDERING GUARD, driven at the predicate. +// +// This repository's hand-rolled version comparison is bounded to plain releases +// on purpose, and the bound is the lesson from the leaf module's retract +// interval: `[v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz]` covers NO version, +// because a prerelease sorts below its release — and a hand comparison that did +// not know that read it as a sensible range for a whole commit. +// +// So this refuses rather than guesses, and the refusal is tested, because a +// mutation that removed it left every test green: the root's tags and bounds +// are all plain releases, so nothing ever reached the guard. +func TestTheVersionComparisonRefusesWhatItCannotOrder(t *testing.T) { + for name, version := range map[string]string{ + "a pseudo-version": "v0.0.0-20261004000000-zzzzzzzzzzzz", + "a release candidate": "v1.0.0-rc.1", + "the zero prerelease": "v0.0.0-0", + } { + t.Run(name, func(t *testing.T) { + require.Error(t, comparableVersions("v0.1.51", version)) + require.Error(t, comparableVersions(version, "v0.1.51")) + require.ErrorContains(t, comparableVersions(version), + "sorts BELOW its release", + "the refusal must say WHY, because the reader's next move is to compare it anyway") + }) + } + + // Plain releases are comparable, so the guard is not refusing everything — + // and the ordering it then performs is the one the retract check relies on. + require.NoError(t, comparableVersions("v0.1.51", "v0.1.65", "v0.2.0")) + require.True(t, semverAtMost(t, "v0.1.51", "v0.1.65")) + require.True(t, semverAtMost(t, "v0.1.65", "v0.1.65")) + require.False(t, semverAtMost(t, "v0.2.0", "v0.1.65")) +} diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index e8e70ba..7d30331 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -145,10 +145,42 @@ func sweepEnv(t *testing.T) []string { return append(os.Environ(), "IMPORTSOF_BIN="+importReader(t)) } +// sweepRefOf runs the sweep in REF MODE over a committed blob. +// +// A different path through the script from the working tree: the ref arm reads +// blobs out of `git cat-file` into a temporary directory and maps them back, +// and it applies the historical `legacy` allowance. Both arms were measured +// passing the old denylist's gaps, so both are probed. +func sweepRefOf(t *testing.T, script string, name string, source string) (string, error) { + t.Helper() + repository := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repository, name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + for _, command := range [][]string{ + {"git", "init", "--quiet", "-b", "main"}, + {"git", "add", "."}, + {"git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "probe"}, + } { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + run := exec.Command("bash", "scripts/check-one-implementation.sh", "main") + run.Dir = repository + run.Env = sweepEnv(t) + output, err := run.CombinedOutput() + return string(output), err +} + // sweepOf runs the sweep over a throwaway repository containing one file. func sweepOf(t *testing.T, script string, name string, source string) (string, error) { t.Helper() repository := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repository, name)), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) for _, command := range [][]string{ @@ -446,6 +478,69 @@ func TestTheImportScannerReadsOneLineOnce(t *testing.T) { }) } + // THE DENY-BY-DEFAULT POLICY, THROUGH THE SCRIPT, IN TREE AND REF MODE. + // + // This is what had no test. The policy file is asserted not to LIST these + // paths, and the Go gates refuse them in a checkout — but nothing ran the + // SWEEP over them, which is the only thing that reads a published ref and + // is the whole of the required check. A reviewer pointed out that + // restoring the entire old denylist script left every test green, and that + // was true. + // + // Ref mode is checked separately from tree mode because they take + // different paths through the script: the tree reads files from disk, a ref + // reads blobs out of `git cat-file` into a temporary directory. The old + // `ok` was measured in both. + for name, spec := range map[string]string{ + "json/v2, which is not encoding/json": "encoding/json/v2", + "mldsa, a signature nobody had listed": "crypto/mldsa", + "protodelim, a codec by another name": "google.golang.org/protobuf/encoding/protodelim", + "the gRPC codec registry": "google.golang.org/grpc/encoding", + "prototext": "google.golang.org/protobuf/encoding/prototext", + "jsontext": "encoding/json/jsontext", + "hpke, an AEAD and so MAC-capable": "crypto/hpke", + "unsafe, which can forge anything": "unsafe", + } { + // BOTH MODULES, because the policy is per module and the sweep picks + // the list by PATH. Probing only a root-level file left the leaf list + // untested: a mutation that allowlisted `crypto/mldsa` for the leaf + // changed nothing, because no probe ever asked the leaf list anything. + for module, path := range map[string]string{ + "root": "second.go", + "leaf": "workcontext/second.go", + } { + source := "package x\n\nimport _ \"" + spec + "\"\n" + t.Run(name+", in the "+module+" module's working tree", func(t *testing.T) { + out, err := sweepOf(t, script, path, source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, spec) + require.Contains(t, out, path) + }) + t.Run(name+", in the "+module+" module at a ref", func(t *testing.T) { + out, err := sweepRefOf(t, script, path, source) + require.Error(t, err, "the sweep passed this blob at a ref:\n%s", out) + require.Contains(t, out, spec) + }) + } + } + + // `import "C"` is not an import path, so it is refused by name — in both + // modes, and with the diagnosis that says why rather than "not listed". + t.Run("cgo, in the working tree", func(t *testing.T) { + out, err := sweepOf(t, script, "cgo.go", "package x\n\n/*\n*/\nimport \"C\"\n") + require.Error(t, err, out) + require.Contains(t, out, "cgo.go imports C") + require.Contains(t, out, "cgo is not a package name to add to a list", + "and the diagnosis says WHY, because a reader told only \"not listed\" would "+ + "reasonably try adding it") + }) + t.Run("cgo, at a ref", func(t *testing.T) { + out, err := sweepRefOf(t, script, "cgo.go", "package x\n\n/*\n*/\nimport \"C\"\n") + require.Error(t, err, out) + require.Contains(t, out, "cgo.go imports C") + require.Contains(t, out, "cgo is not a package name to add to a list") + }) + // THE BANS THE AST GATE HAS AND THIS ONE DID NOT. Four import paths the // AST gate refuses passed here with `ok working tree (4 Go files)`, exit 0 // — in the root module, where this script is the only gate. diff --git a/workcontext/README.md b/workcontext/README.md index 2d56e98..b9e0c67 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -610,12 +610,15 @@ guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ }) // And let the SERVER enforce it, rather than each handler remembering to. +// +// DECLARED at construction: every method whose streams carry a capability. A +// method in this list is guarded or its stream is refused; a method outside it +// reaches the handler UNGUARDED, which is why the validation below is not +// optional. +bearing := []string{"/codefly.example.Streamer/Emit"} + server := grpc.NewServer(grpc.StreamInterceptor( - grpctransport.StreamServerInterceptor( - // DECLARED at construction: every method whose streams carry a - // capability. A method in this list is guarded or its stream is - // refused; a method outside it is never asked about. - []string{"/codefly.example.Streamer/Emit"}, + grpctransport.StreamServerInterceptor(bearing, func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { verified := verifiedFor(ctx) // from the server's own stream setup return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ @@ -623,6 +626,27 @@ server := grpc.NewServer(grpc.StreamInterceptor( }) }), )) +pb.RegisterStreamerServer(server, impl) + +// REQUIRED, after registering and before Serve. The interceptor is handed no +// server, so it cannot see that "/codefly.example.Streamer/Emmit" matches +// nothing, or that a second streaming method exists that nobody named — and +// either one reaches the handler with no guard and no complaint. This asks the +// server, once: +// +// - a declared name the server does not serve is a typo; +// - a declared name that is unary guards nothing; +// - a SERVED stream that is neither declared nor named as deliberately +// unguarded is a decision nobody made. +// +// A stream that genuinely carries no authority is passed as a third argument, +// so the decision exists in the source rather than in somebody's memory. +if err := grpctransport.ValidateMethodSet( + server.GetServiceInfo(), bearing, + "/codefly.example.Streamer/Health", // deliberately unguarded +); err != nil { + return err +} // The handler then just sends. It is handed the wrapper, it has no route to // the raw stream, and the interceptor calls Finish. diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go index 8c8056e..d12ac25 100644 --- a/workcontext/codec_types_test.go +++ b/workcontext/codec_types_test.go @@ -117,6 +117,7 @@ func TestNoCodecTouchesACapabilityByType(t *testing.T) { // inspectCodecUses is the rule over one type-checked file. func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []string { var findings []string + findings = append(findings, inspectHandWrittenProtoMessages(loaded, file, path)...) // THE Fun POSITION, and the identifier inside it. ast.Inspect descends into // a SelectorExpr, so recording only call.Fun left `Marshal` — the Sel of a // perfectly ordinary `json.Marshal(x)` — looking like a codec mentioned @@ -343,6 +344,69 @@ func indirectionFunctionOf(loaded *packages.Package, expression ast.Expr) string return "" } +// inspectHandWrittenProtoMessages refuses a hand-written ProtoReflect method — +// which is to say, any type in this repository that claims to BE a protobuf +// message. +// +// THE PREVIOUS FIX CLOSED A SUBCLASS AND LEFT THE CLASS OPEN. Making the +// interface walk transitive caught a struct carrying an interface; it does not +// catch a concrete FUNCTION type, because carriesAnInterface does not descend +// into a *types.Signature and capabilityWithin finds nothing in a func-typed +// field. Executed, decoding a real core-minted token: +// +// type r8thunk func() protoreflect.Message +// func (f r8thunk) ProtoReflect() protoreflect.Message { return f() } +// proto.Unmarshal(raw, r8thunk(claims.ProtoReflect)) +// +// and the same through a struct with a func field. Both passed all three Go +// gates, lint and the sweep. +// +// Chasing the shape was the mistake. A wrapper can be a struct, a func, a +// defined slice, a map, a channel — anything with a method set — so a rule +// written about shapes is a rule that is behind by one shape, which is the +// denylist failure in another costume. What every one of them must have is a +// ProtoReflect method, because that IS the protobuf contract: it is how the +// capability gets reached, and no amount of indirection avoids it. +// +// So the rule is about the method, not the shape. A protobuf message is +// GENERATED, by protoc, in the repository that owns the .proto — core. This +// repository writes none, has zero today, and a hand-written one is a type +// pretending to be a message, which is the second implementation in the one +// form that cannot be disguised. +func inspectHandWrittenProtoMessages( + loaded *packages.Package, file *ast.File, path string, +) []string { + var findings []string + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Recv == nil || function.Name == nil { + continue + } + if !slices.Contains(protoMessageMethods, function.Name.Name) { + continue + } + receiver := "a local type" + if object, ok := loaded.TypesInfo.Defs[function.Name].(*types.Func); ok { + receiver = object.FullName() + } + findings = append(findings, fmt.Sprintf( + "%s hand-writes %s on %s.\n"+ + "A protobuf message is GENERATED, by protoc, in the repository that owns the\n"+ + "schema — core. A hand-written %s makes a local type into a proto.Message, which\n"+ + "is how a capability reaches a codec with no interface and no capability type\n"+ + "anywhere in the signature: a func type, a struct with a func field, a defined\n"+ + "slice, anything with a method set. Chasing the shapes is a denylist; the method\n"+ + "is the contract, so the method is the rule.", + path, function.Name.Name, receiver, function.Name.Name)) + } + return findings +} + +// protoMessageMethods are the methods that make a type a protobuf message to +// the codecs. ProtoReflect is the v2 contract and Reset/String/ProtoMessage are +// the v1 one, which proto.Unmarshal still accepts through protoimpl. +var protoMessageMethods = []string{"ProtoReflect", "ProtoMessage"} + // codecFunctionOf resolves an expression to a codec function's OBJECT, or nil. func codecFunctionOf(loaded *packages.Package, expression ast.Expr) *types.Func { var identifier *ast.Ident @@ -679,13 +743,15 @@ import ( "reflect" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) // Sinks, so every probe shares one import block without "imported and not -// used". Neither is a codec use, so neither can be the finding. +// used". None is a codec use, so none can be the finding. var ( _ = reflect.TypeOf + _ protoreflect.Message _ = basev0.File_codefly_base_v0_work_context_proto _ = json.Valid // proto.Size is not a codec, so naming it keeps the import live for every @@ -740,6 +806,44 @@ func into(raw []byte, c *basev0.WorkContextV1) error { return use(proto.Unmarsha }, // L5's other half: the capability reached THROUGH A FIELD, by an alias // declared in another file. No syntactic rule could resolve it. + // ROUND EIGHT'S BLOCKER, and the lesson is that round seven's fix + // closed a SUBCLASS. A concrete FUNCTION type implementing + // proto.Message by delegation: no interface in the signature, no + // capability type anywhere, and the promoted method reaches the + // capability. Executed against the previous head on a real + // core-minted token. + "a concrete func type implementing proto.Message": { + source: `type thunk func() protoreflect.Message + +func (f thunk) ProtoReflect() protoreflect.Message { return f() } + +func decode(raw []byte, claims *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, thunk(claims.ProtoReflect)) +}`, + says: "hand-writes ProtoReflect", + }, + // The same, lazily, through a struct whose FIELD is a func — + // carriesAnInterface finds no interface in a func-typed field. + "a struct with a func field": { + source: `type lazy struct{ get func() protoreflect.Message } + +func (l lazy) ProtoReflect() protoreflect.Message { return l.get() } + +func decode(raw []byte, claims *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, lazy{get: claims.ProtoReflect}) +}`, + says: "hand-writes ProtoReflect", + }, + // And the v1 contract, which proto.Unmarshal still accepts through + // protoimpl — so the rule covers both or it covers one spelling. + "the v1 message contract": { + source: `type legacy struct{ inner *basev0.WorkContextV1 } + +func (legacy) Reset() {} +func (legacy) String() string { return "" } +func (legacy) ProtoMessage() {}`, + says: "hand-writes ProtoMessage", + }, // ROUND SEVEN'S BLOCKER: a CONCRETE wrapper around an embedded // interface. No handwritten anything — the promoted ProtoReflect // delegates to the capability, so this is the ordinary protobuf diff --git a/workcontext/import_allowlist_test.go b/workcontext/import_allowlist_test.go index 8b3eaa3..2172560 100644 --- a/workcontext/import_allowlist_test.go +++ b/workcontext/import_allowlist_test.go @@ -572,6 +572,25 @@ func TestNoSourceNoGateCanReadIsShipped(t *testing.T) { shipped) } +// buildConstraintsIn is the constraint lines in a file's header, or none. +// +// Only the header: the block ends at the first blank line before `package`, so +// a `//go:build` in a comment further down is not a constraint and is not +// reported as one. +func buildConstraintsIn(source string) []string { + var found []string + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + break + } + if strings.HasPrefix(trimmed, "//go:build") || strings.HasPrefix(trimmed, "// +build") { + found = append(found, trimmed) + } + } + return found +} + // trackedGoFiles is every non-test Go file this repository ships, from git. func trackedGoFiles(t *testing.T) []string { t.Helper() @@ -590,3 +609,60 @@ func trackedGoFiles(t *testing.T) []string { } return files } + +// NO BUILD CONSTRAINT IN A HAND-WRITTEN NON-TEST FILE. +// +// A constrained file is not built in the current configuration, so +// `packages.Load` never type-checks it and every semantic rule here — the codec +// rule, the capability walk, the hand-written-message rule — reads nothing. +// Executed: Codex's `envelopeTarget` probe inside a `//go:build r8hidden` root +// file passed all three Go gates and the sweep. +// +// TestTheGateReadsEveryShippedFile detects this generically, because such a +// file is tracked and unread. This refuses it by name as well, for two reasons: +// the diagnosis is the actionable part ("unread" sends a reader looking for a +// loader bug), and it says what the answer is — this repository has no use for +// one. Zero in the tree today, and one would be a design change rather than a +// detail: the SDK resolves values and compiles the same everywhere. +// +// Test files are exempt. A `_test.go` cannot be imported by shipped code, and +// nothing in a test binary reaches a consumer. +func TestNoBuildConstraintHidesAFileFromTheGates(t *testing.T) { + // THE DETECTION FIRST, over source written here. A mutation that disabled + // the rule entirely left this test green, because the tree has no + // constrained file and an empty answer was right either way — the same + // assert-the-tree-is-clean shape a review struck elsewhere in this + // repository. The rule is driven over an input before it is run over the + // tree. + for name, source := range map[string]string{ + "the modern form": "//go:build r8hidden\n\npackage x\n", + "the legacy form": "// +build r8hidden\n\npackage x\n", + "a negated constraint": "//go:build !r8hidden\n\npackage x\n", + "one after a doc line": "// Package x does things.\n//go:build r8hidden\n\npackage x\n", + } { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, buildConstraintsIn(source), + "a constrained file is invisible to every semantic rule here") + }) + } + // And ordinary source is not flagged, so the rule is not simply refusing + // everything — the mutation guard for the cases above. + require.Empty(t, buildConstraintsIn("// Package x does things.\npackage x\n\nimport \"fmt\"\n")) + + constrained := map[string][]string{} + for _, path := range trackedGoFiles(t) { + content, err := os.ReadFile(filepath.Join("..", path)) + require.NoError(t, err) + if found := buildConstraintsIn(string(content)); len(found) > 0 { + constrained[path] = found + } + } + require.Empty(t, constrained, + "these shipped files carry a build constraint: %v.\n"+ + "A constrained file is not built in the current configuration, so packages.Load "+ + "never type-checks it and every semantic rule here reads nothing — a reviewer put "+ + "a working decoder behind `//go:build r8hidden` and it passed all three Go gates "+ + "and the sweep. The import sweep still reads it, so an import ban holds and "+ + "nothing else does. This repository compiles the same everywhere and has no use "+ + "for one; adding the first is a design change.", constrained) +} From 151e1036f2e9285ece7ffb4e8a21cf7a8446836b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:09:02 -0400 Subject: [PATCH 31/35] fix(workcontext)!: the validation is mandatory, and an unreachable branch is not a fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's confirming round eight (static, at cdc33fb). MAJOR 4 (fixed) VALIDATION WAS CORRECT AND OPTIONAL, and optional is the whole of it: the README recipe did not call ValidateMethodSet for several revisions, so a consumer following the recipe kept the fail-open the validation exists to close, and nothing said so. A safety check a caller may skip is a safety check for whoever remembers it. GuardStreams makes it mandatory. Intercept REFUSES EVERY STREAM until Validate has been called AND passed — a failed validation does not arm it either. grpc forces the ordering (the interceptor is built before registration, the served method set exists only after), so the one thing a library can do is make the gap loud: a server that forgets refuses every stream on its first request, which is a failure the first test finds rather than a method that was never guarded. StreamServerInterceptor stays for a server with its own reason to wire the two separately; the README recipe is GuardStreams now. MAJOR 2 (not fixed, and the attempt is reported) THE STALE-PROJECTION TRANSFER. I could not construct one from the code, so I added a second check — the answer's AGE AT THE POINT OF USE — on the grounds that an enforced invariant beats my reasoning being right. Then I tried to test it and could not: `sampled` is never before `started` and `done` closes at `sampled`, so the branch is unreachable. A rule no test can reach is a rule nobody has tested, which is the standard everything else here is held to — so it is removed, and the argument is written at the check that does the work: - `done` closes immediately after the answer is stored; - an attempt joins a read only if it began within one RequestTimeout; - every attempt's context is RequestTimeout-bounded before this is entered. So the value an attempt receives was sampled inside its own budget, whichever attempt started the read. If that chain is wrong, the counterexample is what settles it, and I would rather be shown one than ship a branch nobody checks. THE DELEGATION CLASS, confirmed refused at this head with the committed probes (fixed at 7ea600f, after this review's head): PASS …/a_concrete_func_type_implementing_proto.Message PASS …/a_struct_with_a_func_field PASS …/the_v1_message_contract PASS TestNoBuildConstraintHidesAFileFromTheGates Majors 3, 5, 6, 7 and 8 are carried as drafted follow-ups in the PR body. THE SINGLE OPEN BLOCKER is stated in the body in the reviewer's own terms: the published dependabot branch still carries the deleted implementation, which violates "nothing legacy left". It is not characterised as an acceptable external step — it is the one thing standing between this PR and green, and the deletion is the owner's. Verified at this head: root suite ok (47s), leaf ok, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (65 Go files). Three mutations, three killed. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/README.md | 50 ++++++------ workcontext/grpctransport/guarded_stream.go | 77 +++++++++++++++++++ .../grpctransport/round6_reproducers_test.go | 65 ++++++++++++++++ workcontext/mint.go | 18 +++++ 4 files changed, 187 insertions(+), 23 deletions(-) diff --git a/workcontext/README.md b/workcontext/README.md index b9e0c67..2a3ba25 100644 --- a/workcontext/README.md +++ b/workcontext/README.md @@ -614,35 +614,39 @@ guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ // DECLARED at construction: every method whose streams carry a capability. A // method in this list is guarded or its stream is refused; a method outside it // reaches the handler UNGUARDED, which is why the validation below is not -// optional. +// optional and is not skippable. bearing := []string{"/codefly.example.Streamer/Emit"} -server := grpc.NewServer(grpc.StreamInterceptor( - grpctransport.StreamServerInterceptor(bearing, - func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { - verified := verifiedFor(ctx) // from the server's own stream setup - return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ - Recheck: workcontext.RecheckWith(verifier, verified), - }) - }), -)) +streams := grpctransport.GuardStreams(bearing, + func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + verified := verifiedFor(ctx) // from the server's own stream setup + return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: workcontext.RecheckWith(verifier, verified), + }) + }) + +server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) pb.RegisterStreamerServer(server, impl) -// REQUIRED, after registering and before Serve. The interceptor is handed no -// server, so it cannot see that "/codefly.example.Streamer/Emmit" matches -// nothing, or that a second streaming method exists that nobody named — and -// either one reaches the handler with no guard and no complaint. This asks the -// server, once: +// REQUIRED, after registering and before Serve. Until this passes, Intercept +// REFUSES EVERY STREAM — because an earlier revision of this recipe left the +// call out, and a consumer following it kept the fail-open the call exists to +// close. grpc forces the ordering (the interceptor is built before +// registration, the served set exists only after), so the gap is made loud +// instead of silent. +// +// It asks the server three things the interceptor cannot see from inside one +// request: // -// - a declared name the server does not serve is a typo; -// - a declared name that is unary guards nothing; -// - a SERVED stream that is neither declared nor named as deliberately -// unguarded is a decision nobody made. +// - a declared name the server does not serve is a typo, and guards nothing; +// - a declared name that is unary guards nothing and makes the set non-empty; +// - a SERVED stream that is neither declared nor named below is a decision +// nobody made. // -// A stream that genuinely carries no authority is passed as a third argument, -// so the decision exists in the source rather than in somebody's memory. -if err := grpctransport.ValidateMethodSet( - server.GetServiceInfo(), bearing, +// A stream that genuinely carries no authority is named here, so the decision +// lives in the source rather than in somebody's memory. +if err := streams.Validate( + server.GetServiceInfo(), "/codefly.example.Streamer/Health", // deliberately unguarded ); err != nil { return err diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index eb5b76e..e4ccf99 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -744,3 +745,79 @@ func ValidateMethodSet( } return nil } + +// GuardedStreams is the stream interceptor with its validation MADE MANDATORY. +// +// StreamServerInterceptor plus ValidateMethodSet was correct and optional, and +// optional is the whole of the finding: the README recipe did not call the +// validation for several revisions, so a consumer following it kept the +// fail-open the validation exists to close, and nothing anywhere said so. A +// safety check a caller may skip is a safety check for whoever remembers it. +// +// So it is not skippable here. Intercept REFUSES EVERY STREAM until Validate +// has been called and passed: +// +// streams := grpctransport.GuardStreams(bearing, guardFor) +// server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) +// pb.RegisterStreamerServer(server, impl) +// if err := streams.Validate(server.GetServiceInfo(), unguarded...); err != nil { +// return err +// } +// // only now does the server serve +// +// The ordering is forced by grpc itself — the interceptor is built before +// registration and the served method set exists only after it — so the one +// thing a library can do is make the gap LOUD instead of silent. A server that +// forgets refuses every stream on the first request, which is a failure found +// in the first test rather than a method that was never guarded. +// +// StreamServerInterceptor stays, for a server that has its own reason to wire +// the two separately. This is what the README recommends. +type GuardedStreams struct { + bearing []string + intercept grpc.StreamServerInterceptor + validated atomic.Bool +} + +// GuardStreams builds the interceptor and the validation together. +func GuardStreams( + capabilityBearing []string, + guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), +) *GuardedStreams { + return &GuardedStreams{ + bearing: capabilityBearing, + intercept: StreamServerInterceptor(capabilityBearing, guardFor), + } +} + +// Validate asks the server what it serves and records that the answer held. +func (g *GuardedStreams) Validate( + served map[string]grpc.ServiceInfo, + deliberatelyUnguarded ...string, +) error { + if err := ValidateMethodSet(served, g.bearing, deliberatelyUnguarded...); err != nil { + return err + } + g.validated.Store(true) + return nil +} + +// Intercept is the interceptor, and it refuses every stream until Validate has +// passed. +func (g *GuardedStreams) Intercept( + server any, + stream grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, +) error { + if !g.validated.Load() { + return statusFor(fmt.Errorf( + "%w: this stream interceptor has not been validated against the server's method "+ + "set, so it does not know whether any stream it is not guarding was meant to "+ + "be unguarded. Call Validate(server.GetServiceInfo(), …) after registering "+ + "services and before Serve", + errStreamMisuse, + )) + } + return g.intercept(server, stream, info, handler) +} diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go index e34310c..de61fa5 100644 --- a/workcontext/grpctransport/round6_reproducers_test.go +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -253,3 +253,68 @@ func TestEveryServedStreamIsEitherGuardedOrDeliberatelyNot(t *testing.T) { require.Contains(t, both.Error(), "unary on this server", "the unary name") require.Contains(t, both.Error(), "UNGUARDED", "and the streams nothing names") } + +// THE VALIDATION IS MANDATORY, not merely available. +// +// StreamServerInterceptor plus ValidateMethodSet was correct and OPTIONAL, and +// optional is the finding: the README recipe did not call the validation for +// several revisions, so a consumer following it kept the fail-open the +// validation exists to close. A safety check a caller may skip is a safety +// check for whoever remembers it. +// +// grpc forces the ordering — the interceptor is built before registration and +// the served set exists only after — so the one thing a library can do is make +// the gap LOUD. An unvalidated interceptor refuses every stream, which is a +// failure found by the first test rather than a method that was never guarded. +func TestAnUnvalidatedInterceptorRefusesEveryStream(t *testing.T) { + authority := &revocableGuard{} + streams := GuardStreams([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + + // Not validated: every stream is refused, and the message says what to do. + connection := serveWith(t, streams.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("served without validation")) + }) + received, _, _, err := clientSaw(t, connection) + require.Error(t, err) + require.Zero(t, received, "a stream was served by an interceptor that validated nothing") + require.Equal(t, codes.Internal, status.Code(err), + "an unvalidated interceptor is this server's own misconfiguration") + require.Contains(t, status.Convert(err).Message(), "has not been validated") + require.Contains(t, status.Convert(err).Message(), "GetServiceInfo", + "and names the call, because a refusal with no way forward gets worked around") + + // Validated against a server that really serves it: streams flow. + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{{Name: "Emit", IsServerStream: true}}}, + } + require.NoError(t, streams.Validate(served)) + + ok := GuardStreams([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + require.NoError(t, ok.Validate(served)) + flowing := serveWith(t, ok.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("payload")) + }) + count, _, _, err := clientSaw(t, flowing) + require.NoError(t, err) + require.Equal(t, 1, count, "a validated interceptor serves the stream it guards") + + // And a validation that FAILS does not arm it: the typo case, where the + // declared method matches nothing the server serves. + wrong := GuardStreams([]string{"/" + streamService + "/Emmit"}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + require.Error(t, wrong.Validate(served)) + refused := serveWith(t, wrong.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("served after a failed validation")) + }) + after, _, _, err := clientSaw(t, refused) + require.Error(t, err, "a FAILED validation must not arm the interceptor") + require.Zero(t, after) +} diff --git a/workcontext/mint.go b/workcontext/mint.go index 9171f9a..b968edd 100644 --- a/workcontext/mint.go +++ b/workcontext/mint.go @@ -1179,6 +1179,24 @@ func (c *MintClient) projectedToken(ctx context.Context) (string, error) { ErrMintUnavailable, started.Sub(pending.started).Round(time.Millisecond), ) } + // WHY THE CHECK ABOVE IS THE WHOLE OF IT, since a review held that a + // transfer between attempts was still possible and I could not construct + // one. The chain is short enough to state: + // + // - `done` is closed IMMEDIATELY after the answer is stored, so an + // attempt waiting on it takes the value as soon as the source returns; + // - an attempt joins a read only if that read began within one + // RequestTimeout, which is the check above; + // - every attempt's own context is RequestTimeout-bounded from before + // this function is entered, by mintInto. + // + // So the value an attempt receives was sampled inside its own budget, + // whichever attempt started the read. I added a second check of the + // answer's age at the point of USE as belt and braces, and then removed + // it: `sampled` is never before `started`, and `done` closes at `sampled`, + // so no test could reach the branch. A rule no test can reach is a rule + // nobody has tested, which is the standard the rest of this file is held + // to — so the argument is written here instead of a branch nobody checks. if pending == nil { // ONE OUTSTANDING READ, NOT ONE PER ATTEMPT. ProjectedTokenSource takes // no context, so a read cannot be cancelled and a blocked source leaves From e99df342591200435248d8254ac7abd7fe3a555d Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:50:43 -0400 Subject: [PATCH 32/35] fix(workcontext)!: the batched audit fails closed per ref, and there is one way to install the stream guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round nine and ten. First, the record: round ten says the batched tag-audit false-green was UNCHANGED at 151e103, and it was. My fix for it was sitting in the working tree, uncommitted, while I answered round nine — so nothing I had pushed addressed it and the review is right. That is the fourth time in this PR I have described work ahead of pushing it. ROUND NINE #2 / ROUND TEN #3 THE BATCHED TAG AUDIT TURNED A FAILED SCAN INTO A CLEAN TAG. Measured: ok v0.0.1 ok v0.0.2 no such ref: no-such-ref <- the batch ABORTS here <- v0.1.51 carries it and is never scanned Batching for speed took the audit from 333s to 22s and made one unreadable ref silence every ref after it. The guard I first wrote was a COUNT (`reported >= len(tags)`) — the same defect this repository fixed in the ref sweep two rounds earlier, reproduced in my own new code, and a count is satisfied by the wrong set. It is by NAME now: every tag must appear explicitly as `ok` or as carrying, and the exit status is read. Two tests, because a parser test is not an audit test and the review was right to ask for the second: one drives the parse with the transcript above, one builds a repository whose batch contains an unresolvable ref with a CARRYING tag behind it and requires the audit to refuse. And the per-ref scan is one process instead of ~50. `git cat-file blob` per file was ~4,000 spawns for 83 tags and 256 seconds of a five-minute job; `git archive` writes each tree once — 19 seconds, and the files land at their real paths, which also deletes an index-to-path mapping that existed to undo temporary names. The extraction is compared against `ls-tree` BY NAME, because `git archive` omits what it cannot write (a submodule gitlink) and omitting is how the old `2>/dev/null` turned an unreadable entry into a file with no imports. ROUND TEN #1 THE VALIDATE/ENFORCE MISMATCH, closed by removing the choice. GuardStreams made validation mandatory for anyone who used GuardStreams, and StreamServerInterceptor was still exported beside it — so the correct wiring took two calls, only one was reachable from the type system, and a consumer could install the enforcement and skip the check. Which is what the README recipe itself did for several revisions. The constructor is unexported. GuardStreams is the only way in, its Intercept refuses every stream until Validate has passed, and a FAILED validation does not arm it either. No consumer code used the old symbol — only tests and the wrapper — so nothing outside this repository breaks. MUTATIONS: four run, three killed, one EQUIVALENT and reported as such rather than counted. Making the parser treat `no such ref:` as an `ok` line records a garbage key in the scanned set; the audit checks membership by real tag name, so a garbage key cannot make a real tag read as scanned. Same accounting a review asked for when my "11 killed" was 11 killed, 2 survived, 1 equivalent. Also: the coverage, race and one-implementation jobs get ten minutes instead of five, with the measurement in the comment. The gates genuinely cost more now and the honest answer to that is to say what they cost, not to trim what they check. Carried as drafted follow-ups: #4 the young-read transfer (the argument is at the check; I removed an unreachable belt-and-braces branch rather than ship it — a counterexample settles it), #5 the retraction's measured carrying set, #6 erased arguments and function values, #7 per-gate completeness versus loader completeness, #8 published refs get the import policy only. Verified at this head: root suite ok (113s), leaf ok, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (65 Go files). Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 18 +- published_tags_test.go | 203 ++++++++++++++++-- scripts/check-one-implementation.sh | 51 +++-- workcontext/AGENTS.md | 10 +- workcontext/grpctransport/guarded_stream.go | 37 ++-- .../grpctransport/guarded_stream_test.go | 10 +- .../grpctransport/real_grpc_stream_test.go | 4 +- .../grpctransport/round4_reproducers_test.go | 4 +- .../grpctransport/round6_reproducers_test.go | 8 +- 9 files changed, 279 insertions(+), 66 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 734baec..f2b7643 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -70,7 +70,13 @@ jobs: one-implementation: name: no second Work Context implementation runs-on: ubuntu-latest - timeout-minutes: 5 + # Ten, not five. This job sweeps the working tree and every published ref, + # reading imports with go/parser — one `git cat-file` per Go file per ref. + # Measured locally between 22s and 131s depending only on the OS file + # cache, and a CI runner is slower than that. A gate that times out is a + # gate somebody deletes, and the honest answer to "the gates cost more now" + # is to say what they cost. + timeout-minutes: 10 steps: - name: Checkout # fetch-depth 0 so every published ref is reachable: the second step @@ -149,7 +155,10 @@ jobs: coverage: name: coverage runs-on: ubuntu-latest - timeout-minutes: 5 + # Ten: the root suite now includes the published-tag audit, which + # sweeps 83 tags through the import reader. Measured 22s-131s on a warm + # versus cold file cache. + timeout-minutes: 10 permissions: contents: write # The receipts store is written in the caller's transaction, admits on a @@ -210,7 +219,10 @@ jobs: race: name: race runs-on: ubuntu-latest - timeout-minutes: 5 + # Ten: the root suite now includes the published-tag audit, which + # sweeps 83 tags through the import reader. Measured 22s-131s on a warm + # versus cold file cache. + timeout-minutes: 10 strategy: matrix: module: [ ".", "workcontext" ] diff --git a/published_tags_test.go b/published_tags_test.go index 6f76ed2..1032da2 100644 --- a/published_tags_test.go +++ b/published_tags_test.go @@ -121,21 +121,25 @@ func tagHasLeafModule(t *testing.T, tag string) bool { // cannot finish inside the job is a gate somebody deletes. The script already // loops over its arguments and reports `ok ` or `FAIL …`, so this is // a parse of what it was always printing. -func offendingPathsByRef(t *testing.T, reader string, tags []string) map[string][]string { - t.Helper() - run := exec.Command("bash", append([]string{"scripts/check-one-implementation.sh"}, tags...)...) - run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) - output, _ := run.CombinedOutput() - +// parseSweepReport reads the sweep's per-ref report: the findings by ref, and +// the set of refs it explicitly reported on at all. +// +// Separated from the invocation so a probe can drive it with real sweep output, +// including the transcript of a batch that ABORTED — which is the case the +// guard exists for and which an end-to-end test cannot produce on demand. +func parseSweepReport(output string) (map[string][]string, map[string]bool) { byRef := map[string][]string{} + scanned := map[string]bool{} current := "" - for _, line := range strings.Split(string(output), "\n") { + for _, line := range strings.Split(output, "\n") { switch { case strings.HasPrefix(line, "FAIL ") && strings.Contains(line, "carries a Work Context"): current = strings.TrimSpace(strings.TrimSuffix( strings.TrimPrefix(line, "FAIL "), " carries a Work Context implementation:")) byRef[current] = nil + scanned[current] = true case strings.HasPrefix(line, "ok "): + scanned[strings.TrimSpace(strings.TrimPrefix(line, "ok "))] = true current = "" case current != "" && strings.HasPrefix(line, " "): path, _, found := strings.Cut(strings.TrimSpace(line), " imports ") @@ -144,17 +148,49 @@ func offendingPathsByRef(t *testing.T, reader string, tags []string) map[string] } } } - // EVERY TAG MUST HAVE BEEN REPORTED ON, or the parse is reading less than - // the sweep said and a tag could be silently clean. - reported := len(byRef) - for _, line := range strings.Split(string(output), "\n") { - if strings.HasPrefix(line, "ok ") { - reported++ + return byRef, scanned +} + +func offendingPathsByRef(t *testing.T, reader string, tags []string) map[string][]string { + t.Helper() + run := exec.Command("bash", append([]string{"scripts/check-one-implementation.sh"}, tags...)...) + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, runErr := run.CombinedOutput() + + byRef, scanned := parseSweepReport(string(output)) + + // EVERY TAG EXPLICITLY REPORTED ON, BY NAME. This was a COUNT — + // `reported >= len(tags)` — which is the same defect this repository fixed + // in the ref sweep two rounds earlier and which I then reproduced in my own + // batching: a count can be satisfied while the SET is wrong, by a repeated + // `ok` or an `ok` for a ref nobody asked about, and a tag that went + // unreported then reads as clean because "absent from byRef" means "no + // findings". + // + // It matters because the sweep ABORTS the whole batch on a scan error — + // measured: `ok v0.0.1`, `ok v0.0.2`, `no such ref: …`, and v0.1.51, which + // carries the implementation, never looked at. Batching for speed turned a + // per-ref failure into a silence about every ref after it. + var unscanned []string + for _, tag := range tags { + if !scanned[tag] { + unscanned = append(unscanned, tag) } } - require.GreaterOrEqual(t, reported, len(tags), - "the sweep reported on %d of %d tags; a tag it did not reach is a tag this gate "+ - "cleared without looking:\n%s", reported, len(tags), output) + require.Empty(t, unscanned, + "the sweep did not report on these tags, so this gate cleared them without looking: "+ + "%v.\nThe batch aborts on the first scan error, so one unreadable ref silences "+ + "every ref after it. Sweep output:\n%s", unscanned, output) + + // AND THE EXIT STATUS IS READ. A non-zero exit with every tag reported is + // the ordinary case — findings exit 1 — but a non-zero exit with anything + // unexplained is a scan that did not finish, and the count above was the + // only thing standing between that and a clean answer. + if runErr != nil { + require.Contains(t, string(output), "carries a Work Context implementation", + "the sweep exited non-zero and reported no findings, so it failed rather than "+ + "found something:\n%s", output) + } return byRef } @@ -370,3 +406,138 @@ func TestTheVersionComparisonRefusesWhatItCannotOrder(t *testing.T) { require.True(t, semverAtMost(t, "v0.1.65", "v0.1.65")) require.False(t, semverAtMost(t, "v0.2.0", "v0.1.65")) } + +// THE BATCHED AUDIT MUST FAIL CLOSED PER REF. +// +// Batching the tag sweep took it from 333 seconds to 22, and introduced a +// false-green shape: the script ABORTS the whole batch on a scan error, so one +// unreadable ref silences every ref after it. Measured, with real tags: +// +// ok v0.0.1 +// ok v0.0.2 +// no such ref: no-such-ref +// +// and v0.1.51 — which carries the implementation — was never looked at. +// +// The guard I first wrote was a COUNT (`reported >= len(tags)`), which is the +// same defect this repository fixed in the ref sweep two rounds earlier and +// which I then reproduced in my own batching. A count is satisfied by the wrong +// set: a repeated `ok`, or an `ok` for a ref nobody asked about. It is by NAME +// now, and this drives the parse with the transcript above. +func TestTheBatchedTagAuditFailsClosedPerRef(t *testing.T) { + aborted := "ok v0.0.1\nok v0.0.2\nno such ref: no-such-ref\n" + _, scanned := parseSweepReport(aborted) + + require.True(t, scanned["v0.0.1"]) + require.True(t, scanned["v0.0.2"]) + require.False(t, scanned["no-such-ref"], "a ref the sweep could not resolve was not scanned") + require.False(t, scanned["v0.1.51"], + "a ref AFTER the abort was never reached, and must not read as scanned — it carries "+ + "the implementation") + + // A count would have been satisfied here and the set is not: two `ok` lines + // for a four-ref batch. + require.Len(t, scanned, 2, "the batch reported on two of four refs") + + // Findings are attributed to the right ref, with their paths. + report := "ok v0.0.1\n" + + "FAIL v0.1.51 carries a Work Context implementation:\n" + + " work_context.go imports crypto/ed25519\n" + + " work_context_jwks.go imports mime\n" + + "ok v0.1.50\n" + byRef, seen := parseSweepReport(report) + require.ElementsMatch(t, []string{"work_context.go", "work_context_jwks.go"}, byRef["v0.1.51"]) + require.Empty(t, byRef["v0.0.1"], "a clean ref carries nothing") + require.True(t, seen["v0.0.1"] && seen["v0.1.51"] && seen["v0.1.50"]) + + // And an `ok` line STOPS attribution. The sweep does not emit an indented + // line after an `ok`, so this is a malformed transcript on purpose: a + // parser's job is to not invent an answer from input it did not expect, + // and without the reset the path below would be filed under the previous + // ref — a clean tag's report silently acquiring another tag's findings. + malformed := "FAIL v0.1.51 carries a Work Context implementation:\n" + + " work_context.go imports crypto/ed25519\n" + + "ok v0.1.50\n" + + " stray.go imports crypto/ed25519\n" + strayed, _ := parseSweepReport(malformed) + require.Equal(t, []string{"work_context.go"}, strayed["v0.1.51"], + "a line after an `ok` must not be filed under the ref before it") + require.NotContains(t, strayed, "v0.1.50", "and a clean ref gains nothing") +} + +// AND THE SAME THING END TO END, with a scan failure injected into a REAL +// batch — which is what a review asked for after the transcript-driven test +// above, and rightly: a parser test establishes the parse, not the audit. +// +// The script aborts the whole batch on an unresolvable ref, so the tags AFTER +// it are never scanned. This builds a throwaway repository whose tags include +// one the sweep cannot resolve, with a CARRYING tag behind it, and requires the +// audit to refuse rather than report the carrying tag clean. +func TestAScanFailureInABatchIsNotACleanTag(t *testing.T) { + script, err := filepath.Abs(filepath.Join("scripts", "check-one-implementation.sh")) + require.NoError(t, err) + reader := importReader(t) + + repository := t.TempDir() + runIn(t, repository, "git", "init", "--quiet", "-b", "main") + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + + write := func(name string, source string) { + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + runIn(t, repository, "git", "add", ".") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", name) + } + write("clean.go", "package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n") + // -c, because this machine's git config may sign or annotate tags and this + // fixture must not depend on whose machine it runs on — the same reason + // user.email is passed here rather than inherited. + tag := func(name string) { + runIn(t, repository, "git", "-c", "tag.gpgSign=false", "-c", "user.email=t@t", + "-c", "user.name=t", "tag", "-a", "-m", name, name) + } + tag("v0.0.1") + write("carries.go", "package x\n\nimport \"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n") + tag("v0.0.2") + + // The batch: a clean tag, a ref that cannot be resolved, then the carrying + // tag. The script stops at the middle one. + run := exec.Command("bash", "scripts/check-one-implementation.sh", + "v0.0.1", "no-such-ref", "v0.0.2") + run.Dir = repository + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, runErr := run.CombinedOutput() + require.Error(t, runErr, "an unresolvable ref must fail the sweep:\n%s", output) + + byRef, scanned := parseSweepReport(string(output)) + + require.True(t, scanned["v0.0.1"], "the tag before the failure was scanned") + require.False(t, scanned["v0.0.2"], + "v0.0.2 carries the implementation and the batch aborted before reaching it; "+ + "reporting it scanned is the false green this test exists for.\nOutput:\n%s", output) + require.NotContains(t, byRef, "v0.0.2") + + // And the audit's own guard refuses, rather than treating "absent from the + // findings" as clean. Driven through the same predicate the audit uses. + var unscanned []string + for _, tag := range []string{"v0.0.1", "no-such-ref", "v0.0.2"} { + if !scanned[tag] { + unscanned = append(unscanned, tag) + } + } + require.ElementsMatch(t, []string{"no-such-ref", "v0.0.2"}, unscanned, + "the audit must see BOTH the failed ref and everything after it as unscanned") + + // The control: without the bad ref in the batch, the carrying tag is found. + good := exec.Command("bash", "scripts/check-one-implementation.sh", "v0.0.1", "v0.0.2") + good.Dir = repository + good.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + goodOutput, goodErr := good.CombinedOutput() + require.Error(t, goodErr) + foundByRef, foundScanned := parseSweepReport(string(goodOutput)) + require.True(t, foundScanned["v0.0.1"] && foundScanned["v0.0.2"]) + require.Contains(t, foundByRef["v0.0.2"], "carries.go", + "the carrying tag is found when the batch is not aborted:\n%s", goodOutput) +} diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh index 3745e22..a3f8a60 100755 --- a/scripts/check-one-implementation.sh +++ b/scripts/check-one-implementation.sh @@ -225,7 +225,7 @@ carrying_in_tree() { } carrying_in_ref() { - local ref="$1" found="" entries list imports work path imported unreadable index=0 + local ref="$1" found="" entries list imports work path imported unreadable if ! entries=$(git ls-tree -r --name-only "$ref"); then echo "FAIL cannot enumerate $ref, so it has not been swept." >&2 exit 1 @@ -237,35 +237,48 @@ carrying_in_ref() { done <<< "$unreadable" fi work=$(mktemp -d); list=$(mktemp); imports=$(mktemp) - # Each blob's path in the REF is recorded beside its temporary copy, so a - # finding names that path and not something under /tmp. - local -a origin=() + + # ONE PROCESS PER REF, not one per file. This ran `git cat-file blob` for + # every Go file at every ref — about 4,000 spawns for 83 tags — and the tag + # audit took four minutes against a five-minute CI job. `git archive` writes + # the whole tree once, and the files land at their real paths, which also + # removes an index-to-path mapping that existed only to undo the temporary + # names. + if ! git archive --format=tar "$ref" | tar -x -C "$work" 2>/dev/null; then + echo "FAIL cannot extract $ref, so it has not been swept." >&2 + exit 1 + fi + + # AND THE EXTRACTION IS COMPARED AGAINST THE LISTING, by name. `git archive` + # omits what it cannot write — a submodule's gitlink, which `ls-tree -r` + # still names — and omitting is exactly how the previous `2>/dev/null` on + # cat-file turned an unreadable entry into a file with no imports. A ref this + # sweep could not fully extract is a ref it has not swept. + local missing="" while IFS= read -r path; do case "$path" in *_test.go) continue ;; *.go) ;; *) continue ;; esac - # THE SAME FAIL-OPEN AS THE TREE'S, and this one needed no error at all: - # `git ls-tree -r` names gitlinks as well as blobs, so a submodule whose - # path ends in `.go` is listed and `git cat-file blob` cannot read it. - # Measured: `fatal: bad file` swallowed by 2>/dev/null, the source emptied, - # and the ref reported `ok` with exit 0. - if ! git cat-file blob "$ref:$path" > "$work/$index.go" 2>"$work/err"; then - echo "FAIL cannot read $ref:$path, so this ref has not been swept. git said:" >&2 - sed 's/^/ /' "$work/err" >&2 - exit 1 + if [ ! -f "$work/$path" ]; then + missing="$missing $path" + continue fi - origin[index]="$path" - printf '%s\n' "$work/$index.go" >> "$list" - index=$((index + 1)) + printf '%s\n' "$work/$path" >> "$list" done <<< "$entries" - if [ "$index" -gt 0 ]; then + if [ -n "$missing" ]; then + echo "FAIL $ref lists Go files that could not be extracted, so it has not been swept:$missing" >&2 + rm -rf "$work"; rm -f "$list" "$imports" + exit 1 + fi + + if [ -s "$list" ]; then imports_of_files "$list" > "$imports" while IFS=$'\t' read -r path imported; do [ -n "$imported" ] || continue - local base="${path##*/}" - path="${origin[${base%.go}]}" + # Back to the path IN THE REF: strip the extraction directory. + path="${path#"$work"/}" if offending_import "$path" "$imported"; then found="$found$path imports $imported"$'\n' fi diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md index 17970c0..5c8d763 100644 --- a/workcontext/AGENTS.md +++ b/workcontext/AGENTS.md @@ -188,8 +188,14 @@ and never asked. So `GuardedServerStream` keeps the stream private and forwards every interface method by hand, HOLDS headers and trailers until `Finish` re-checks and releases them, and owns the transport stream in the context it hands out. -`StreamServerInterceptor` — not `Guard` — is the recipe, because it does the -wrapping, the context and the `Finish`. Whether a method is guarded is a +`GuardStreams` — not `Guard`, and not a bare interceptor — is the recipe, +because it does the +wrapping, the context and the `Finish`, and because its `Intercept` REFUSES +EVERY STREAM until `Validate(server.GetServiceInfo(), …)` has passed. The +interceptor constructor is unexported for that reason: exporting it beside the +validation meant the correct wiring took two calls and only one was reachable +from the type system, so a consumer could install the enforcement and skip the +check — which the README recipe itself did for several revisions. Whether a method is guarded is a property of the METHOD and the first answer binds; a per-request `(nil, nil)` is the optional-carrier shape this module deleted. diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go index e4ccf99..1ef6e01 100644 --- a/workcontext/grpctransport/guarded_stream.go +++ b/workcontext/grpctransport/guarded_stream.go @@ -24,7 +24,7 @@ import ( // it is called before each emission depends on each author remembering — the // same shape as the optional carrier this module deleted: a rule that holds // wherever somebody thought of it. Wrapping the stream makes the check -// structural, and StreamServerInterceptor makes the wrapping structural too. +// structural, and streamServerInterceptor makes the wrapping structural too. // // # Three routes out of a stream, not one // @@ -94,7 +94,7 @@ type GuardedServerStream struct { // // A caller using Guard directly MUST call Finish when its handler is done, or // the headers and trailers it set are never sent, and MUST pass this wrapper's -// Context to anything that writes metadata. StreamServerInterceptor does both, +// Context to anything that writes metadata. streamServerInterceptor does both, // which is the reason to prefer it — and the reason the README's recipe is the // interceptor and not this. func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { @@ -246,7 +246,7 @@ func (s *GuardedServerStream) setTrailer(trailer metadata.MD) error { // Finish performs the LAST re-check and releases the headers and trailers the // handler set, or discards them. It is called once, after the handler returns, -// and StreamServerInterceptor calls it. +// and streamServerInterceptor calls it. // // It returns the handler's own error unchanged when authority still holds, and // a gRPC status when it does not — so a handler that completed under authority @@ -503,9 +503,18 @@ func (e statusError) GRPCStatus() *status.Status { return status.New(e.code, e.err.Error()) } -// StreamServerInterceptor guards every stream a server opens, so enforcement is +// streamServerInterceptor guards every stream a server opens, so enforcement is // WIRING rather than something each handler author remembers. // +// UNEXPORTED, and that is the last of the validate/enforce mismatch. It was +// public beside ValidateMethodSet, so the correct way to install this took two +// calls and only one of them was reachable from the type system: a consumer +// wiring the interceptor alone got a server where an undeclared stream reaches +// the handler unguarded, which is exactly what the README recipe did for +// several revisions. GuardStreams is the only way in now, and its Intercept +// refuses every stream until Validate has passed, so there is no public path +// that skips the check. +// // Guard on its own leaves the original stream in the caller's scope, so the // rule held wherever somebody thought of it. An interceptor is installed once, // at the server, and the handler receives a stream it cannot write around: the @@ -542,7 +551,7 @@ func (e statusError) GRPCStatus() *status.Status { // Declaring the set removes the learning. A server that cannot enumerate its // capability-bearing methods does not know which of its streams carry // authority, which is the thing to fix before installing an interceptor. -func StreamServerInterceptor( +func streamServerInterceptor( capabilityBearing []string, guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), ) grpc.StreamServerInterceptor { @@ -565,7 +574,7 @@ func StreamServerInterceptor( if len(guarded) == 0 { // AN EMPTY SET GUARDS NOTHING, so it refuses everything instead. // - // StreamServerInterceptor(nil, guardFor) read as "no method carries + // streamServerInterceptor(nil, guardFor) read as "no method carries // a capability" and passed every stream through unguarded — // measured, a message delivered under revoked authority with zero // re-checks. An interceptor installed to enforce something, that @@ -634,10 +643,10 @@ func StreamServerInterceptor( // same observation there. The server knows, so the server is asked, once, at // startup: // -// interceptor := grpctransport.StreamServerInterceptor(bearing, guardFor) -// server := grpc.NewServer(grpc.StreamInterceptor(interceptor)) +// streams := grpctransport.GuardStreams(bearing, guardFor) +// server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) // pb.RegisterStreamerServer(server, impl) -// if err := grpctransport.ValidateMethodSet(server.GetServiceInfo(), bearing); err != nil { +// if err := streams.Validate(server.GetServiceInfo(), unguarded...); err != nil { // return err // } // @@ -748,7 +757,7 @@ func ValidateMethodSet( // GuardedStreams is the stream interceptor with its validation MADE MANDATORY. // -// StreamServerInterceptor plus ValidateMethodSet was correct and optional, and +// streamServerInterceptor plus ValidateMethodSet was correct and optional, and // optional is the whole of the finding: the README recipe did not call the // validation for several revisions, so a consumer following it kept the // fail-open the validation exists to close, and nothing anywhere said so. A @@ -771,8 +780,10 @@ func ValidateMethodSet( // forgets refuses every stream on the first request, which is a failure found // in the first test rather than a method that was never guarded. // -// StreamServerInterceptor stays, for a server that has its own reason to wire -// the two separately. This is what the README recommends. +// There is no longer a public way to build the interceptor WITHOUT this: the +// previous revision exported both and a consumer could wire the interceptor +// alone, which is the mismatch a review kept naming. One entry point, and the +// validation is part of it. type GuardedStreams struct { bearing []string intercept grpc.StreamServerInterceptor @@ -786,7 +797,7 @@ func GuardStreams( ) *GuardedStreams { return &GuardedStreams{ bearing: capabilityBearing, - intercept: StreamServerInterceptor(capabilityBearing, guardFor), + intercept: streamServerInterceptor(capabilityBearing, guardFor), } } diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go index 7076f82..94b5ac4 100644 --- a/workcontext/grpctransport/guarded_stream_test.go +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -251,7 +251,7 @@ func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { require.NoError(t, err) underlying := &recordingStream{ctx: context.Background()} - interceptor := StreamServerInterceptor( + interceptor := streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return guard, nil @@ -285,7 +285,7 @@ func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { refused := errors.New("this capability did not verify") bearing := &grpc.StreamServerInfo{FullMethod: streamMethod} - err := StreamServerInterceptor( + err := streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return nil, refused @@ -297,7 +297,7 @@ func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { // A DECLARED capability-bearing method whose guard could not be built is // REFUSED. This used to be the passthrough that handed out the raw stream. - err = StreamServerInterceptor( + err = streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return nil, nil @@ -310,7 +310,7 @@ func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { // A method OUTSIDE the declared set is not guarded and never asks: the // decision is in the construction, where a reviewer reads it. var handed grpc.ServerStream - err = StreamServerInterceptor( + err = streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { t.Fatal("guardFor must not be asked about a method outside the declared set") @@ -324,7 +324,7 @@ func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { require.Same(t, underlying, handed) // And no guard-builder at all is a configuration error, not a pass. - err = StreamServerInterceptor([]string{streamMethod}, nil)( + err = streamServerInterceptor([]string{streamMethod}, nil)( nil, underlying, bearing, func(any, grpc.ServerStream) error { return nil }) require.ErrorIs(t, err, workcontext.ErrInvalid) } diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go index 026a0de..ec00aa5 100644 --- a/workcontext/grpctransport/real_grpc_stream_test.go +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -53,14 +53,14 @@ func (g *revocableGuard) guard(t *testing.T) *workcontext.StreamGuard { } // serveGuarded stands up a real gRPC server whose one streaming method runs -// handler behind StreamServerInterceptor, and returns a client connection. +// handler behind streamServerInterceptor, and returns a client connection. func serveGuarded( t *testing.T, guard *workcontext.StreamGuard, handler func(stream grpc.ServerStream) error, ) *grpc.ClientConn { t.Helper() - return serveWith(t, StreamServerInterceptor( + return serveWith(t, streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return guard, nil diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go index f996dcd..7a6ba8a 100644 --- a/workcontext/grpctransport/round4_reproducers_test.go +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -241,7 +241,7 @@ func TestAFirstRequestWithoutACapabilityNeitherBypassesNorDisablesTheMethod(t *t var asked int var mu sync.Mutex var handedRaw bool - interceptor := StreamServerInterceptor( + interceptor := streamServerInterceptor( // DECLARED: this method carries a capability, whatever any one request // looks like. []string{streamMethod}, @@ -434,7 +434,7 @@ func TestARecheckSourceFailureReachesTheClientAsUnavailable(t *testing.T) { }) require.NoError(t, err) - connection := serveWith(t, StreamServerInterceptor( + connection := serveWith(t, streamServerInterceptor( []string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return guard, nil diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go index de61fa5..aa2e122 100644 --- a/workcontext/grpctransport/round6_reproducers_test.go +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -98,14 +98,14 @@ func TestAHeaderSetAfterTheFirstMessageIsNotSilentlyDropped(t *testing.T) { // // A method missing from the declared set, and an EMPTY set, each delivered a // message under revoked authority with ZERO re-checks. The second is the worse -// one: StreamServerInterceptor(nil, guardFor) read as "no method carries a +// one: streamServerInterceptor(nil, guardFor) read as "no method carries a // capability", so the wiring was present and enforced nothing — and wiring // that is present is wiring nobody looks at again. func TestAnEmptyMethodSetGuardsNothingAndSoRefusesEverything(t *testing.T) { authority := &revocableGuard{} authority.revoked.Store(true) - connection := serveWith(t, StreamServerInterceptor(nil, + connection := serveWith(t, streamServerInterceptor(nil, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { return authority.guard(t), nil }), func(stream grpc.ServerStream) error { @@ -150,7 +150,7 @@ func TestACapabilityThatExpiresMidStreamTellsTheClientToMintAgain(t *testing.T) expired := fmt.Errorf("stream terminated: %w: expired at 2026-10-02T12:15:00Z", workcontext.ErrInvalid) - connection := serveWith(t, StreamServerInterceptor([]string{streamMethod}, + connection := serveWith(t, streamServerInterceptor([]string{streamMethod}, func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { // A guard whose re-check answers exactly what core's Verifier // answers for a capability whose window has passed: ErrInvalid, @@ -256,7 +256,7 @@ func TestEveryServedStreamIsEitherGuardedOrDeliberatelyNot(t *testing.T) { // THE VALIDATION IS MANDATORY, not merely available. // -// StreamServerInterceptor plus ValidateMethodSet was correct and OPTIONAL, and +// streamServerInterceptor plus ValidateMethodSet was correct and OPTIONAL, and // optional is the finding: the README recipe did not call the validation for // several revisions, so a consumer following it kept the fail-open the // validation exists to close. A safety check a caller may skip is a safety From d7876d5c295c5a4ffd5dbc922463e05eb3cfe63f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:15:52 -0400 Subject: [PATCH 33/35] =?UTF-8?q?refactor(workcontext)!:=20re-pin=20to=20c?= =?UTF-8?q?ore=20db9197f7=20=E2=80=94=20the=20execution=20is=20per=20hop,?= =?UTF-8?q?=20and=20"bears=20none"=20must=20be=20said?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core `v0.8.0` and `v0.8.1` are published and are the WRONG tags: core's main cut both from its own release commits while #692 sat unmerged, so neither carries #692. The tag to wait for is `v0.9.0`, from #692's merge commit, cut by core's version-tag.yml and never by hand. One correction to how that was relayed, because it changes the diagnosis a reader would reach. `v0.8.0` is not missing `workcontext` — the package is there, at its PRE-#692 revision, which is a sharper trap than absence. Verified by pinning it: the build fails on corework.Seal, SealSource, OperationBinding, ErrNotACoreToken and more, so it reads like a bad pin rather than a stale one. RE-PINNING TO db9197f7 WAS NOT BOOKKEEPING. I was told `git diff 55be17f5 db9197f7` touches no workcontext/, proto/ or generated/ file and that the eight green jobs would still describe the API tested. The first is true of 55be17f5 and NOT of the pin this PR was on, 4051e438 — so the delegation-execution work landed between them. Measured against the module zips, db9197f7 changes workcontext/{inspect,mint,fixtures}.go, conformance/conformance.go and the generated work_context.pb.go, and re-pinning broke two tests. Both were mine to adapt, and both core changes are improvements: - ApprovedBuild now separates KNOWN TO BEAR NO EXECUTION from an UNKNOWN principal. Absence used to mean "bears none", which is the most permissive answer the method has — so a principal the issuer had never heard of got a capability sealing no execution, which nothing can revoke by replacing its build. It is asserted now, with PutBearsNoExecution, and this harness was relying on the default. The human-session principal declares it. - A new conformance fixture, `seal-half-execution`: the execution is a PAIR, set together or absent together, and a digest with no incarnation is a seal naming a build nothing can supersede. The suite caught this because TestTheSDKParsePathsAgreeWithCore compares against core's OWN fixture list rather than a constant here — which is exactly why it is written that way, and the third time that choice has paid. This is the fourth SealSource change after the third was described as final. No complaint: each has been right, and this one closes a real hole. But the PR body claimed the pin was content-identical and that is now corrected there too, with the measurement. Verified at db9197f7: leaf suite and -race clean, golangci-lint 0 issues, go mod tidy clean, working-tree sweep ok (65 Go files). The root module is untouched — it pins core separately and uses no workcontext package. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- workcontext/go.mod | 2 +- workcontext/go.sum | 4 ++-- workcontext/minting_test.go | 13 +++++++++++-- workcontext/parse_agreement_test.go | 11 +++++++++-- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/workcontext/go.mod b/workcontext/go.mod index 4942669..69f319b 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 + github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b github.com/stretchr/testify v1.12.1 golang.org/x/mod v0.41.0 golang.org/x/tools v0.51.0 diff --git a/workcontext/go.sum b/workcontext/go.sum index e730e35..56850b2 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635 h1:pUDBWRHLw3AEE2EJ8r1CtLKZNd5SvLuSoC0a5O3p2E4= -github.com/codefly-dev/core v0.7.2-0.20261003161926-4051e4383635/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b h1:lunAXuEiNcp+OhRO69+S+2bD6EQY74iHtJqCnhIbRHU= +github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go index 107303f..b7933a9 100644 --- a/workcontext/minting_test.go +++ b/workcontext/minting_test.go @@ -83,10 +83,19 @@ func newAuthority(t *testing.T) *authority { // installation seal. require.NoError(t, seals.PutApprovedBuild( testPrincipal, testApprovedBuild.digest, testApprovedBuild.incarnation)) - // The human principal gets a seal and an epoch and DELIBERATELY NO - // approved build, which is what ErrNoApprovedBuild answers. + // The human principal gets a seal, an epoch, and an EXPLICIT declaration + // that it bears no execution. + // + // Declared rather than left absent, because core stopped treating those as + // the same answer and was right to: "bears none" mints a capability + // carrying no execution that every verifier accepts, so answering it for a + // principal the issuer simply has no record of hands a service principal a + // credential nothing can revoke by replacing its build. Nothing recorded is + // the most permissive answer ApprovedBuild can give, so it is asserted + // instead of defaulted — and this harness relied on the default. require.NoError(t, seals.Put(testHumanPrincipal, testSeal)) require.NoError(t, seals.PutEpoch(testHumanPrincipal, testPrincipalEpoch)) + require.NoError(t, seals.PutBearsNoExecution(testHumanPrincipal)) // The owner's epoch is recorded through PutEpoch like any other // principal's. Seal no longer carries it: it had two sources, and // revocation could be UNDONE — advancing the epoch left stored seals diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go index 2edeb86..d6e0f52 100644 --- a/workcontext/parse_agreement_test.go +++ b/workcontext/parse_agreement_test.go @@ -33,8 +33,15 @@ var structurallyRefused = map[string]bool{ "zero-principal-epoch": true, // ErrInvalid: zero is not an epoch "zero-installation-revision": true, // ErrInvalid: zero is not a revision "zero-build-incarnation": true, // ErrInvalid: zero is not an incarnation - "partial-operation-binding": true, // ErrInvalid: an id at no revision - "actor-without-epoch": true, // ErrInvalid: a principal nobody can revoke + // The execution is a PAIR, set together or absent together: a digest with + // no incarnation, or an incarnation with no digest, is a seal that names a + // build nothing can supersede. This fixture arrived with core's per-hop + // execution change and the suite caught it by comparing against core's own + // list rather than against a constant here — which is the whole reason the + // comparison is written that way. + "seal-half-execution": true, // ErrInvalid: half an execution is not one + "partial-operation-binding": true, // ErrInvalid: an id at no revision + "actor-without-epoch": true, // ErrInvalid: a principal nobody can revoke // The encoding itself. Core's Verify now refuses unknown fields // recursively and non-canonical encodings, and Inspect reaches the same From b1b10cb5169310e2047c6b7f4b6b9074a189a937 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:29:10 -0400 Subject: [PATCH 34/35] fix(ci): the required check answered differently depending on which event ran it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner deleted the dependabot branch and the check stayed red. It was not a cache and not a stale glob: the sweep was correct and its BASE was wrong. Run 37208615216 is a PUSH build of this branch, and the log says it plainly: the remote publishes 4 ref(s); sweeping 3 of them ok origin/badges ok origin/connectory/welcome FAIL origin/main carries a Work Context implementation: workcontext/work_context.go imports crypto/ed25519 … `origin/main` is the ref this change merges into, and it carries the deleted implementation until it does. The script excludes the base for exactly that reason — sweeping it is circular — but resolved the base as SWEEP_BASE -> GITHUB_BASE_REF -> GITHUB_REF_NAME -> main and GITHUB_BASE_REF is EMPTY on a push. So the base became the pushed branch, main was swept, and the check failed. On a pull_request build GITHUB_BASE_REF is `main`, main was excluded, and the same check passed. Both triggers report under one required check name, so the answer depended on which event landed last. A check that is right half the time is worse than one that is wrong, because the green is what somebody acts on — and this PR has twice now been held up by a red that described the merge target rather than the change. Two changes, belt and braces: - The script's fallback is the DEFAULT BRANCH, not the ref being built. A push build can no longer sweep its own merge target by accident. - The workflow passes SWEEP_BASE explicitly from `github.event.repository.default_branch`, and SWEEP_HEAD from `github.head_ref || github.ref_name`, so the sweep does not guess at all. Guessing was the mechanism. And a test, because nothing covered this: every existing test set SWEEP_BASE explicitly, which is precisely the variable CI was not setting on a push. TestTheBaseRefIsTheDefaultBranchWhicheverEventRan drives both event shapes and requires them to sweep THE SAME SET — the property, rather than the two exclusions that reach it. Both mutations killed. Verified: root suite ok, working-tree sweep ok (65 Go files), golangci-lint 0 issues. Expected at this head: eight green, since main was the only remaining finding and it is the merge target. Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/go.yml | 10 +++++ scripts/sweep-published-refs.sh | 22 +++++++++- sweep_bypasses_test.go | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f2b7643..743aa9e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -114,6 +114,16 @@ jobs: # The script lists by prefix and asserts SWEPT AGAINST WHAT THE REMOTE # PUBLISHES, because a gate that sweeps a subset and reports success is # worse than no gate — the green is the evidence somebody acts on. + # SWEEP_BASE is passed EXPLICITLY, from the repository's own default + # branch, so the sweep does not have to guess which ref this change + # merges into. Guessing is what made this check answer differently on a + # push build than on a pull_request build: GITHUB_BASE_REF is empty on + # a push, the base fell through to the pushed branch, and the default + # branch — which carries the implementation until this merges — was + # swept and failed. + env: + SWEEP_BASE: ${{ github.event.repository.default_branch }} + SWEEP_HEAD: ${{ github.head_ref || github.ref_name }} run: | git fetch --no-tags --prune --quiet origin \ '+refs/heads/*:refs/remotes/origin/*' diff --git a/scripts/sweep-published-refs.sh b/scripts/sweep-published-refs.sh index 8172f6f..143339b 100755 --- a/scripts/sweep-published-refs.sh +++ b/scripts/sweep-published-refs.sh @@ -33,8 +33,26 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The ref this change is becoming, and the ref it merges into, are both # excluded: the working-tree sweep covers the former, and sweeping the latter -# is circular — main carries the implementation until this change deletes it. -base="${SWEEP_BASE:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" +# is circular — the default branch carries the implementation until this change +# deletes it. +# +# THE BASE IS THE DEFAULT BRANCH, NOT THE REF BEING BUILT. It used to fall +# through to GITHUB_REF_NAME, and that made the same required check answer +# differently depending on which EVENT ran it: +# +# pull_request GITHUB_BASE_REF=main -> main excluded, check green +# push GITHUB_BASE_REF is empty -> base became the pushed branch, +# main swept, check RED +# +# Measured: run 37208615216, a push build, `FAIL origin/main carries a Work +# Context implementation` listing the very files this PR deletes. Both builds +# report under one check name, so the result depended on which event landed +# last — a check whose answer depends on its trigger is worse than a check that +# is wrong, because it is right half the time. +# +# The workflow passes SWEEP_BASE explicitly from the repository's own default +# branch, so this fallback is a safety net rather than the mechanism. +base="${SWEEP_BASE:-${GITHUB_BASE_REF:-main}}" head_ref="${SWEEP_HEAD:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}}" # Every remote-tracking ref, by PREFIX so namespaced refs are not dropped. diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 7d30331..788c018 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -711,3 +711,79 @@ func TestTheSweepRefusesSourcesNoGateCanRead(t *testing.T) { "package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n") require.NoError(t, err, out) } + +// THE SAME CHECK MUST ANSWER THE SAME WAY WHICHEVER EVENT RAN IT. +// +// The base ref — the one this change merges into, excluded because sweeping it +// is circular — fell through to GITHUB_REF_NAME when GITHUB_BASE_REF was empty. +// That is empty on a PUSH build, so: +// +// pull_request GITHUB_BASE_REF=main -> main excluded, check green +// push GITHUB_BASE_REF empty -> base became the pushed branch, +// main swept, check RED +// +// Measured in CI, run 37208615216: `FAIL origin/main carries a Work Context +// implementation`, listing the files this PR deletes. Both triggers report +// under one required check name, so the answer depended on which event landed +// last — and a check that is right half the time is worse than one that is +// wrong, because the green is what somebody acts on. +// +// Nothing covered this: every test here set SWEEP_BASE explicitly, which is +// precisely the variable CI was not setting on a push. +func TestTheBaseRefIsTheDefaultBranchWhicheverEventRan(t *testing.T) { + sweep, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + clone := clonedRemote(t, sweep, checker, "feature/work") + // A third published ref, so "everything else is still swept" is an + // assertion rather than an empty list: excluding the base and the head + // from a two-branch remote leaves nothing, and nil contains nothing. + runIn(t, clone, "git", "push", "--quiet", "origin", + "refs/remotes/origin/main:refs/heads/badges") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", + "+refs/heads/*:refs/remotes/origin/*") + + listWith := func(environment ...string) []string { + t.Helper() + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = clone + run.Env = append(sweepEnv(t), append([]string{"SWEEP_LIST_ONLY=1"}, environment...)...) + output, err := run.Output() + require.NoError(t, err, "%s", output) + var refs []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + refs = append(refs, line) + } + } + return refs + } + + // A PUSH build: no GITHUB_BASE_REF, GITHUB_REF_NAME is the pushed branch. + // This is the shape that was red. + push := listWith("GITHUB_BASE_REF=", "GITHUB_REF_NAME=feature/work", "GITHUB_HEAD_REF=") + require.NotContains(t, push, "origin/main", + "a push build swept the default branch, which carries the implementation until "+ + "this change merges — the circular case the exclusion exists for") + require.NotContains(t, push, "origin/feature/work", + "and the ref being built is covered by the working-tree sweep") + require.Contains(t, push, "origin/badges", "while every other published ref is still swept") + + // A PULL_REQUEST build: GITHUB_BASE_REF names the base. + pull := listWith("GITHUB_BASE_REF=main", "GITHUB_REF_NAME=feature/work", + "GITHUB_HEAD_REF=feature/work") + require.NotContains(t, pull, "origin/main") + require.NotContains(t, pull, "origin/feature/work") + + // THE TWO MUST AGREE. That is the property; the individual exclusions are + // how it is reached. + require.ElementsMatch(t, push, pull, + "the same required check swept a different set depending on which event ran it") + + // And an explicit SWEEP_BASE still wins, which is what the workflow passes. + explicit := listWith("SWEEP_BASE=badges", "GITHUB_BASE_REF=", "GITHUB_REF_NAME=feature/work") + require.NotContains(t, explicit, "origin/badges") + require.Contains(t, explicit, "origin/main", + "with badges named as the base, main is an ordinary published ref again") +} From ae32f9b695c77a166543b75ff6d9a46467ea68dc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:04:38 -0400 Subject: [PATCH 35/35] =?UTF-8?q?refactor(workcontext)!:=20pin=20core=20v0?= =?UTF-8?q?.9.0=20=E2=80=94=20the=20tag,=20not=20a=20sha=20that=20leaves?= =?UTF-8?q?=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core#692 merged as squash `dc6f7389d` and core's version-tag.yml cut `v0.9.0` from it. The pseudo-version this PR carried, `db9197f7`, is a PR-head sha that is not on main's history after the squash, so it would have become unfetchable directly once the branch was deleted. CONTENT-IDENTICAL, AND MEASURED RATHER THAN RELAYED. `workcontext/` and `generated/go/codefly/base/v0` are byte-identical across `db9197f7`, the squash `dc6f7389d` and `v0.9.0`, diffed in the module cache. The previous re-pin was relayed as content-identical and was not — it moved inspect.go, mint.go, fixtures.go, the generated wire type and two of this module's tests — so this one was diffed before being taken, and the prepared script re-diffs as step 3 and aborts on any difference. Also fixed in my own guard: it tested `go list -m -versions`, which reads the proxy's cached version LIST, and that list lagged the tag by minutes — so the guard reported "not published" about a tag that existed and that the same proxy served on demand. It fetches the exact version now, which is what CI does. THE ROOT MODULE STAYS ON ITS OWN PIN, deliberately, and the instruction's condition is met without its reason applying: the root imports no `core/workcontext` package and its pin is fetchable. Re-pinning it to v0.9.0 is a cross-package upgrade from v0.5.11, not bookkeeping — measured rather than assumed: it BUILDS, and then seven receipts tests fail (TestReplayAnswersFromTheReceiptWithoutCallingTheHandler, TestEffectIDReusedForADifferentRequestIsRefused and five more). That is a separate change and does not belong in a PR one approval from merge. Named in the body so it is a decision on the record rather than an omission. ROUND TWELVE'S MINOR, fixed rather than carried because it is three lines and it is the "passes for the wrong reason" class: the base-ref test built its environment with append(sweepEnv(t), …), which inherits os.Environ(), and an ambient SWEEP_BASE takes precedence in the script over GITHUB_BASE_REF. So a case clearing GITHUB_BASE_REF to model a push build would have passed on an inherited SWEEP_BASE, concealing the fallback it exists to test. Every variable the script reads is cleared first now, and the old-fallback mutation still dies with the environment cleared — which is what proves the test is testing the script rather than the shell it ran in. ROUND TWELVE'S MAJOR is carried as follow-up 1 with what it would take: go.yml:125 passes the default branch as SWEEP_BASE, which is right for every PR this repository has had and wrong for one targeting a release branch. The fix has to keep both event shapes agreeing, which is what the new test asserts. Verified at this head: both suites clean, golangci-lint 0 issues in both, go mod tidy clean, working-tree sweep ok (65 Go files). Refs #41 Refs #47 Co-Authored-By: Claude Opus 5 (1M context) --- sweep_bypasses_test.go | 14 +++++++++++++- workcontext/go.mod | 2 +- workcontext/go.sum | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go index 788c018..5c9e1ae 100644 --- a/sweep_bypasses_test.go +++ b/sweep_bypasses_test.go @@ -748,7 +748,19 @@ func TestTheBaseRefIsTheDefaultBranchWhicheverEventRan(t *testing.T) { t.Helper() run := exec.Command("bash", "scripts/sweep-published-refs.sh") run.Dir = clone - run.Env = append(sweepEnv(t), append([]string{"SWEEP_LIST_ONLY=1"}, environment...)...) + // EVERY VARIABLE THE SCRIPT READS IS CLEARED FIRST, then the case sets + // what it means. sweepEnv inherits os.Environ(), and an ambient + // SWEEP_BASE takes precedence in the script over GITHUB_BASE_REF — so + // a case that clears GITHUB_BASE_REF to model a push build would pass + // on an inherited SWEEP_BASE, concealing the exact fallback it exists + // to test. A reviewer caught that, and it is the "passes for the wrong + // reason" shape this whole file is about. + cleared := append(sweepEnv(t), + "SWEEP_LIST_ONLY=1", + "SWEEP_BASE=", "SWEEP_HEAD=", + "GITHUB_BASE_REF=", "GITHUB_HEAD_REF=", "GITHUB_REF_NAME=", + ) + run.Env = append(cleared, environment...) output, err := run.Output() require.NoError(t, err, "%s", output) var refs []string diff --git a/workcontext/go.mod b/workcontext/go.mod index 69f319b..621c2c4 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b + github.com/codefly-dev/core v0.9.0 github.com/stretchr/testify v1.12.1 golang.org/x/mod v0.41.0 golang.org/x/tools v0.51.0 diff --git a/workcontext/go.sum b/workcontext/go.sum index 56850b2..081cc45 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -12,8 +12,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b h1:lunAXuEiNcp+OhRO69+S+2bD6EQY74iHtJqCnhIbRHU= -github.com/codefly-dev/core v0.8.2-0.20261004140635-db9197f7fe2b/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=