From fe9136564fce231f2b4d9e4c2613856ad7796761 Mon Sep 17 00:00:00 2001 From: bigbes Date: Wed, 22 Jul 2026 11:14:04 +0300 Subject: [PATCH] feat(authn): unified-login identity, agent token validation, provenance trailers Cookie identity is fail-open to anonymous: a missing, tampered or foreign-key cookie yields no user rather than an error, so anonymous browsing never breaks on a bad cookie. Agent tokens are the opposite. A bad bearer is a permanent 401, a store outage a transient 503, and revoked rows are returned so operators can distinguish a killed token from one that never existed. TokenStore is declared here rather than imported from db/, so the two packages can be built in parallel and service/ wires them together. --- authn/authn_test.go | 186 ++++++++++++++++++++++ authn/cookie.go | 72 +++++++++ authn/cookie_test.go | 92 +++++++++++ authn/doc.go | 104 ++++++++++++ authn/principal.go | 145 +++++++++++++++++ authn/provenance.go | 312 ++++++++++++++++++++++++++++++++++++ authn/provenance_test.go | 277 ++++++++++++++++++++++++++++++++ authn/resolver.go | 115 ++++++++++++++ authn/resolver_test.go | 330 +++++++++++++++++++++++++++++++++++++++ authn/token.go | 158 +++++++++++++++++++ authn/token_test.go | 161 +++++++++++++++++++ 11 files changed, 1952 insertions(+) create mode 100644 authn/authn_test.go create mode 100644 authn/cookie.go create mode 100644 authn/cookie_test.go create mode 100644 authn/doc.go create mode 100644 authn/principal.go create mode 100644 authn/provenance.go create mode 100644 authn/provenance_test.go create mode 100644 authn/resolver.go create mode 100644 authn/resolver_test.go create mode 100644 authn/token.go create mode 100644 authn/token_test.go diff --git a/authn/authn_test.go b/authn/authn_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0cbf4907756b365252b8535fd7e41fbbbb5e45f0 --- /dev/null +++ b/authn/authn_test.go @@ -0,0 +1,186 @@ +package authn + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/fernet/fernet-go" + "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +// testConf is the synthesized instance config every test runs against: a fresh +// random Fernet network-key, a random ed25519 webhook seed (crypto.InitCrypto +// fatally requires one even though v1 emits no webhooks), the owner identity +// the provenance builder reads, and our own section's origin. +var testConf ini.File + +// rotatedKey stands in for a Fernet key we no longer hold — a cookie sealed +// with it is indistinguishable from a forgery, which is exactly the point. +var rotatedKey fernet.Key + +// TestMain builds that config in memory and runs crypto.InitCrypto once, so +// crypto.Encrypt / DecryptWithoutExpiration share a keyset across the package. +// No network, no Postgres, no files. +func TestMain(m *testing.M) { + var fk fernet.Key + if err := fk.Generate(); err != nil { + panic("generate fernet network key: " + err.Error()) + } + if err := rotatedKey.Generate(); err != nil { + panic("generate rotated fernet key: " + err.Error()) + } + seed := make([]byte, 32) + if _, err := rand.Read(seed); err != nil { + panic("generate webhook seed: " + err.Error()) + } + + testConf = ini.File{ + "sr.ht": ini.Section{ + "network-key": fk.Encode(), + "owner-name": "bigbes", + "owner-email": "bigbes@gmail.com", + }, + "webhooks": ini.Section{ + "private-key": base64.StdEncoding.EncodeToString(seed), + }, + ConfigSection: ini.Section{ + "origin": "https://spec.srht.bigb.es", + }, + } + crypto.InitCrypto(testConf) + + os.Exit(m.Run()) +} + +// sealCookie forges a valid unified-login cookie value carrying name, sealed +// with the instance network key — byte for byte what meta.sr.ht would set. +func sealCookie(t *testing.T, name string) string { + t.Helper() + payload, err := json.Marshal(auth.AuthCookie{Name: name}) + if err != nil { + t.Fatalf("marshal cookie claims: %v", err) + } + return string(crypto.Encrypt(payload)) +} + +// sealCookieWithKey forges a cookie under an arbitrary Fernet key, used to +// stand in for a rotated key or an attacker's own. +func sealCookieWithKey(t *testing.T, key *fernet.Key, name string) string { + t.Helper() + payload, err := json.Marshal(auth.AuthCookie{Name: name}) + if err != nil { + t.Fatalf("marshal cookie claims: %v", err) + } + tok, err := fernet.EncryptAndSign(payload, key) + if err != nil { + t.Fatalf("seal cookie: %v", err) + } + return string(tok) +} + +// tamper flips one character in the middle of a Fernet token, leaving it +// well-formed base64 so that the HMAC check — not the decoder — is what +// rejects it. +func tamper(t *testing.T, token string) string { + t.Helper() + if len(token) < 8 { + t.Fatalf("token too short to tamper with: %q", token) + } + b := []byte(token) + i := len(b) / 2 + if b[i] == 'A' { + b[i] = 'B' + } else { + b[i] = 'A' + } + if string(b) == token { + t.Fatal("tamper produced an identical token") + } + return string(b) +} + +// testInstance is the provenance identity set built from testConf. +func testInstance(t *testing.T) Instance { + t.Helper() + inst, err := InstanceFromConfig(testConf) + if err != nil { + t.Fatalf("InstanceFromConfig: %v", err) + } + return inst +} + +// stubStore is an in-memory TokenStore keyed by hex(sha256(token)). It can be +// told to fail with an arbitrary error to exercise the transient path, and +// counts calls so tests can assert the cookie path never touches it. +type stubStore struct { + rows map[string]AgentToken + err error + calls int +} + +func newStubStore() *stubStore { return &stubStore{rows: map[string]AgentToken{}} } + +func (s *stubStore) LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error) { + s.calls++ + if s.err != nil { + return AgentToken{}, s.err + } + tok, ok := s.rows[hex.EncodeToString(hash)] + if !ok { + // The contract db/ must honour: no row means ErrUnknownToken. + return AgentToken{}, fmt.Errorf("agent_token: %w", ErrUnknownToken) + } + return tok, nil +} + +// add stores a live token row for the given secret. +func (s *stubStore) add(secret, name string) AgentToken { + hash := HashToken(secret) + tok := AgentToken{ + ID: int64(len(s.rows) + 1), + Name: name, + Hash: hash, + Created: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + } + s.rows[hex.EncodeToString(hash)] = tok + return tok +} + +// revoke stores a revoked token row for the given secret. +func (s *stubStore) revoke(secret, name string) AgentToken { + tok := s.add(secret, name) + when := time.Date(2026, 7, 22, 11, 0, 0, 0, time.UTC) + tok.Revoked = &when + s.rows[hex.EncodeToString(tok.Hash)] = tok + return tok +} + +// put stores an arbitrary row under an arbitrary lookup key, for the case where +// the store returns a row whose hash does not match what was presented. +func (s *stubStore) put(hash []byte, tok AgentToken) { + s.rows[hex.EncodeToString(hash)] = tok +} + +// request builds a GET / carrying the given cookie value and headers. An empty +// cookie value means no cookie at all. +func request(cookie string, headers map[string]string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if cookie != "" { + r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) + } + for k, v := range headers { + r.Header.Set(k, v) + } + return r +} diff --git a/authn/cookie.go b/authn/cookie.go new file mode 100644 index 0000000000000000000000000000000000000000..eed441bfbbdabd4b23423905f9e047bba76a500f --- /dev/null +++ b/authn/cookie.go @@ -0,0 +1,72 @@ +package authn + +import ( + "encoding/json" + "net/http" + "strings" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-spec/core" +) + +// CookieName is the unified-login cookie shared by every service on the +// instance. Its value is a Fernet token sealed by meta.sr.ht with the +// [sr.ht] network-key, which is why spec.srht.bigb.es must live under the +// shared *.srht.bigb.es cookie domain — otherwise the cookie is never sent to +// us and every viewer looks anonymous. +const CookieName = "sr.ht.unified-login.v1" + +// UsernameFromRequest returns the username carried by the unified-login cookie, +// or "" for an anonymous viewer. +// +// Every failure path — no cookie, forged or truncated ciphertext, a payload +// that is not JSON, a payload with no name — returns "" rather than an error. +// This service never serves an error page on identity grounds; it decides what +// an anonymous viewer may see instead. Returning an error here would turn a +// stale cookie from a browser tab left open over a key rotation into a broken +// site rather than a logged-out one. +// +// Requires crypto.InitCrypto to have run (server.New does it at startup). +func UsernameFromRequest(r *http.Request) string { + c, err := r.Cookie(CookieName) + if err != nil { + return "" // no cookie: anonymous + } + return UsernameFromCookie(c.Value) +} + +// UsernameFromCookie is UsernameFromRequest for a cookie value already in hand +// — the form the hooks' RPC path and tests need, where there is no +// *http.Request to read from. +// +// Note the deliberate use of DecryptWithoutExpiration, matching core-go's own +// cookieAuth: the unified-login cookie carries no service-side TTL, and its +// lifetime is the browser cookie's Expires plus meta.sr.ht's ability to rotate +// the network key. Adding a TTL here would log the owner out of this one +// service on a schedule no other service shares. +func UsernameFromCookie(value string) string { + if value == "" { + return "" + } + payload := crypto.DecryptWithoutExpiration([]byte(value)) + if payload == nil { + return "" // forged, tampered, or sealed with a key we no longer hold + } + + var claims auth.AuthCookie + if err := json.Unmarshal(payload, &claims); err != nil { + return "" // well-sealed but malformed payload + } + + // Cookies carry the bare username; strip a leading '~' defensively in case + // something upstream stored the canonical "~user" form. + name := strings.TrimPrefix(claims.Name, "~") + if err := core.ValidateOwner(name); err != nil { + // An unusable username is not an identity. Rejecting here keeps a + // hostile cookie payload out of path construction and log lines. + return "" + } + return name +} diff --git a/authn/cookie_test.go b/authn/cookie_test.go new file mode 100644 index 0000000000000000000000000000000000000000..84f7cc5c3f20103dd6e48f4dd4423555b0761668 --- /dev/null +++ b/authn/cookie_test.go @@ -0,0 +1,92 @@ +package authn + +import ( + "testing" + + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +func TestUsernameFromRequest_ValidCookieRoundTrips(t *testing.T) { + got := UsernameFromRequest(request(sealCookie(t, "bigbes"), nil)) + if got != "bigbes" { + t.Fatalf("username = %q, want %q", got, "bigbes") + } +} + +func TestUsernameFromRequest_StripsTilde(t *testing.T) { + got := UsernameFromRequest(request(sealCookie(t, "~bigbes"), nil)) + if got != "bigbes" { + t.Fatalf("username = %q, want %q", got, "bigbes") + } +} + +// A cookie whose ciphertext has been altered must fail the Fernet HMAC and read +// as anonymous — not as an error, and certainly not as an identity. +func TestUsernameFromRequest_TamperedCookieIsAnonymous(t *testing.T) { + got := UsernameFromRequest(request(tamper(t, sealCookie(t, "bigbes")), nil)) + if got != "" { + t.Fatalf("tampered cookie yielded %q, want anonymous", got) + } +} + +func TestUsernameFromRequest_AbsentCookieIsAnonymous(t *testing.T) { + if got := UsernameFromRequest(request("", nil)); got != "" { + t.Fatalf("absent cookie yielded %q, want anonymous", got) + } +} + +func TestUsernameFromRequest_GarbageIsAnonymous(t *testing.T) { + for name, value := range map[string]string{ + "not base64": "not-a-valid-fernet-token", + "empty": "", + "truncated fernet": sealCookie(t, "bigbes")[:10], + } { + t.Run(name, func(t *testing.T) { + if got := UsernameFromCookie(value); got != "" { + t.Fatalf("garbage cookie yielded %q, want anonymous", got) + } + }) + } +} + +// A cookie sealed under a key we no longer hold — the shape of both a rotated +// network key and an outright forgery. It must expire the session, not the +// request. +func TestUsernameFromCookie_ForeignKeyIsAnonymous(t *testing.T) { + value := sealCookieWithKey(t, &rotatedKey, "bigbes") + if got := UsernameFromCookie(value); got != "" { + t.Fatalf("cookie under a foreign key yielded %q, want anonymous", got) + } + // Sanity: the same payload under the live key does resolve, so the test + // above is proving the key check and not a broken helper. + if got := UsernameFromCookie(sealCookie(t, "bigbes")); got != "bigbes" { + t.Fatalf("control cookie yielded %q, want %q", got, "bigbes") + } +} + +func TestUsernameFromCookie_NonJSONPayloadIsAnonymous(t *testing.T) { + value := string(crypto.Encrypt([]byte("plain text, well sealed"))) + if got := UsernameFromCookie(value); got != "" { + t.Fatalf("non-JSON payload yielded %q, want anonymous", got) + } +} + +// A well-sealed cookie can still carry a name we must refuse to treat as an +// identity: empty, or something that would not survive being used as a path +// segment or a log field. +func TestUsernameFromCookie_UnusableNameIsAnonymous(t *testing.T) { + for _, name := range []string{ + "", + "..", + "../../etc/passwd", + "has space", + "Uppercase", + "-leading-dash", + } { + t.Run(name, func(t *testing.T) { + if got := UsernameFromCookie(sealCookie(t, name)); got != "" { + t.Fatalf("cookie name %q yielded %q, want anonymous", name, got) + } + }) + } +} diff --git a/authn/doc.go b/authn/doc.go new file mode 100644 index 0000000000000000000000000000000000000000..0d2cd365f1a03f09e02fa47fea125e87638bcedd --- /dev/null +++ b/authn/doc.go @@ -0,0 +1,104 @@ +// Package authn answers one question for spec.sr.ht — "who is making this +// request?" — and builds the git provenance that answers "who made this write?" +// forever after. +// +// The design has exactly two principals that carry authority: +// +// - the human owner, recognised by the shared `sr.ht.unified-login.v1` +// cookie carrying the instance's [sr.ht] owner-name; +// - an agent, recognised by an opaque bearer token stored (hashed) in the +// agent_token table. +// +// Everything else is anonymous. Single-user does not mean "no authorization"; +// it relocates it onto agents, which is why Principal distinguishes those two +// and nothing finer. Per the confirmed v1 decision there is one agent token and +// no per-space scoping: the boundary that matters is the refs rule (agents may +// only write proposals/*), and that lives in gitx. +// +// The two credentials are deliberately asymmetric: +// +// - A cookie that is missing, forged, expired or unreadable yields an +// anonymous principal and never an error. Browsing must keep working. +// - A bearer token that is present but unknown, revoked or corrupt is a hard +// failure. An agent that presented an explicit credential must not be +// silently downgraded to a reader; it would then fail confusingly at the +// write instead of clearly at the door. +// +// Provenance is the other half. Agent identity and session ID are mandatory on +// every agent write, and are recorded in the commit itself so that a plain +// `git log` on any clone carries the audit trail: +// +// Author: claude-code/spec-writer (for bigbes) +// Committer: bigbes +// +// Add storage model section +// +// X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921 +// X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809 +// +// A write missing either field is rejected rather than defaulted: a commit +// stamped with a guessed session is worse than no commit, because it launders +// unattributable output as attributed. +// +// This package owns no storage. The agent_token table lives in db/, which is +// injected through the TokenStore interface declared here — authn never imports +// db, so the dependency arrow keeps pointing downward. +package authn + +import "errors" + +// Sentinel errors. Callers compare with errors.Is. The split that matters is +// permanent (the credential is bad — 401/403) versus transient (the store could +// not answer — 503); IsAuthFailure draws it. +var ( + // ErrNoToken is returned when a bearer credential was expected but the + // request carried no Authorization header, or one in another scheme. + ErrNoToken = errors.New("no agent token presented") + + // ErrUnknownToken is the contract a TokenStore must honour: it is what + // LookupAgentToken returns (possibly wrapped) when no row matches the + // presented hash. Any other error is treated as transient, so a Postgres + // outage reads as "try again", never as "your token is bad". + ErrUnknownToken = errors.New("unknown agent token") + + // ErrInvalidToken marks a presented credential that is malformed, or a + // stored row whose hash does not actually match what was presented. + ErrInvalidToken = errors.New("invalid agent token") + + // ErrRevokedToken marks a token that resolved to a real row which has been + // revoked. Distinct from ErrUnknownToken so operators can tell "you are + // using a token I deliberately killed" from "that token never existed". + ErrRevokedToken = errors.New("revoked agent token") + + // ErrNotAgent is returned when agent provenance is demanded of a principal + // that is not an agent — the human push path builds no trailers. + ErrNotAgent = errors.New("principal is not an agent") + + // ErrMissingProvenance marks an agent write that omits the agent identity, + // the session ID, or the base revision. Mandatory on every agent write; the + // design is explicit that these are not defaultable. + ErrMissingProvenance = errors.New("missing agent provenance") + + // ErrInvalidProvenance marks provenance whose values are present but + // unusable: control characters or angle brackets that would forge a git + // signature line or inject an extra trailer, an over-long field, or a base + // revision that is not a hex object name. + ErrInvalidProvenance = errors.New("invalid agent provenance") + + // ErrMissingConfig is returned by InstanceFromConfig when the instance + // config lacks a key the provenance identities are built from. It is a + // startup failure, not a request failure. + ErrMissingConfig = errors.New("missing instance config key") +) + +// IsAuthFailure reports whether err is a permanent credential failure — the +// caller should answer 401/403 — as opposed to a transient backend failure, +// which should answer 503 and be retried. Everything not in this set is +// transient by definition, which is the fail-closed direction: a store outage +// never reads as a valid credential. +func IsAuthFailure(err error) bool { + return errors.Is(err, ErrNoToken) || + errors.Is(err, ErrUnknownToken) || + errors.Is(err, ErrInvalidToken) || + errors.Is(err, ErrRevokedToken) +} diff --git a/authn/principal.go b/authn/principal.go new file mode 100644 index 0000000000000000000000000000000000000000..5c6d954643b989c7471a7cf2ea7967b6619f1e2c --- /dev/null +++ b/authn/principal.go @@ -0,0 +1,145 @@ +package authn + +import ( + "context" + "fmt" +) + +// Kind enumerates the principals spec.sr.ht distinguishes. There are three +// values but only two of them carry authority: the design's "authorization is +// about agents, not people" collapses every human other than the instance owner +// into the anonymous case, because there is no second human in the model to +// grant anything to. +type Kind string + +const ( + // KindAnonymous is an unauthenticated request — no cookie, an unreadable + // cookie, or a cookie belonging to somebody who is not the instance owner. + // It is a normal, expected state: the read plane is anonymous-capable. + KindAnonymous Kind = "anonymous" + + // KindOwner is bigbes: the unified-login cookie resolved to the username in + // [sr.ht] owner-name. This is the principal whose git push *is* the + // approval, and the only one that may approve a proposal. + KindOwner Kind = "owner" + + // KindAgent is a bot holding the agent bearer token. It may propose and it + // may read; the refs rule in gitx is what stops it touching the approved + // branch. + KindAgent Kind = "agent" +) + +// Principal is the resolved identity of a request. It is a value type with no +// pointers into request state, so it can be stashed in a context, logged, and +// passed to service/ without aliasing surprises. +// +// This is what the API layer and gitx's refs rule branch on, and it is +// deliberately the narrowest thing that supports both: which kind, and — for an +// agent — the two provenance fields that every agent write must carry. +type Principal struct { + // Kind is which of the three principals this is. The zero value is the + // anonymous case, so a Principal read out of a context that never had one + // set is safe rather than privileged. + Kind Kind + + // Owner is the instance owner username (no leading '~') this principal acts + // as or on behalf of: itself for KindOwner, the human an agent writes for + // for KindAgent. Empty for KindAnonymous. + Owner string + + // Agent is the agent identity string, e.g. "claude-code/spec-writer". + // KindAgent only. It may be empty on a read — it is demanded at the write, + // which is the only place the design requires it. + Agent string + + // Session is the agent's session ID, e.g. a UUID. KindAgent only, with the + // same read/write asymmetry as Agent. + Session string + + // TokenName is the human-readable name of the agent_token row that + // authenticated this request. KindAgent only, diagnostics only — with one + // token and no scopes it grants nothing. + TokenName string + + // CookieUser is whatever username the unified-login cookie carried, even + // when that user was not the instance owner and Kind is therefore + // KindAnonymous. Display and logging only: never an authorization input. + CookieUser string +} + +// Anonymous returns the principal for an unauthenticated request. +func Anonymous() Principal { return Principal{Kind: KindAnonymous} } + +// IsAnonymous reports whether the principal carries no authority. Written as +// "not one of the two that do" so that an unrecognised or zero Kind is denied +// rather than accidentally admitted. +func (p Principal) IsAnonymous() bool { return p.Kind != KindOwner && p.Kind != KindAgent } + +// IsOwner reports whether this is the human owner — the principal that may +// approve proposals and whose pushes need no review. +func (p Principal) IsOwner() bool { return p.Kind == KindOwner } + +// IsAgent reports whether this is an agent — the principal gitx confines to +// proposals/*. +func (p Principal) IsAgent() bool { return p.Kind == KindAgent } + +// String renders the principal for logs. It never includes the token name's +// secret (there is none — the name is not the token) and never includes the +// cookie value. +func (p Principal) String() string { + switch p.Kind { + case KindOwner: + return "owner ~" + p.Owner + case KindAgent: + agent := p.Agent + if agent == "" { + agent = "(unnamed)" + } + session := p.Session + if session == "" { + session = "(no session)" + } + return fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner) + default: + if p.CookieUser != "" { + return "anonymous (cookie user ~" + p.CookieUser + ")" + } + return "anonymous" + } +} + +// AgentWriteFor builds the provenance inputs for an agent write at the given +// base revision, enforcing that the mandatory fields are present. It fails for +// a non-agent principal: the human write path goes through native +// receive-pack and constructs no commit here. +func (p Principal) AgentWriteFor(base string) (AgentWrite, error) { + if !p.IsAgent() { + return AgentWrite{}, fmt.Errorf("%w: %s", ErrNotAgent, p) + } + w := AgentWrite{Agent: p.Agent, Session: p.Session, Base: base} + if err := w.Validate(); err != nil { + return AgentWrite{}, err + } + return w, nil +} + +type contextKey struct{ name string } + +var principalCtxKey = &contextKey{"authn.principal"} + +// WithPrincipal returns a copy of ctx carrying p. +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalCtxKey, p) +} + +// PrincipalFromContext returns the principal stored by WithPrincipal, or the +// anonymous principal when none was stored. It never panics: an +// unauthenticated request is ordinary here, and a handler reached without the +// middleware must degrade to *less* authority, not more. +func PrincipalFromContext(ctx context.Context) Principal { + p, ok := ctx.Value(principalCtxKey).(Principal) + if !ok { + return Anonymous() + } + return p +} diff --git a/authn/provenance.go b/authn/provenance.go new file mode 100644 index 0000000000000000000000000000000000000000..38735a9b6dfca9ab08b3794c57ceee9f714a40a2 --- /dev/null +++ b/authn/provenance.go @@ -0,0 +1,312 @@ +package authn + +import ( + "fmt" + "net/url" + "strings" + "unicode/utf8" + + "github.com/vaughan0/go-ini" + + "sourcecraft.dev/bigbes/sr-ht-spec/core" +) + +// ConfigSection is our config section. The literal ".sr.ht" suffix is what puts +// us in the nav network list and what other services look us up by, so it is a +// constant rather than a parameter. +const ConfigSection = "spec.sr.ht" + +// Trailer keys recorded on every agent commit. Git trailers rather than a +// Postgres-only audit table, so provenance is visible in a plain `git log` on +// any clone and cannot drift from the content it describes. +// +// Only these two. The agent's own identity rides on the Author line, where git +// already puts "who wrote this", and duplicating it into a third trailer would +// create two spellings that can disagree. +const ( + TrailerAgentSession = "X-Agent-Session" + TrailerAgentBase = "X-Agent-Base" +) + +// agentLocalPart is the mailbox of the synthetic address stamped on agent +// authorship. Agents have no mailbox; the address exists because git demands +// one, and it is made obviously non-human so nobody mails it. +const agentLocalPart = "agent" + +const ( + // MaxAgentLen bounds the agent identity string. It becomes a git author + // name, which is read by humans in a review UI. + MaxAgentLen = 128 + + // MaxSessionLen bounds the session ID. A UUID is 36 bytes; the slack is for + // runners that prefix their own job identifiers. + MaxSessionLen = 128 + + // minRevLen and maxRevLen bound a git object name in the X-Agent-Base + // trailer: an abbreviated sha at the low end, a full sha-256 at the high. + minRevLen = 7 + maxRevLen = 64 +) + +// Signature is a git identity: the name and mailbox halves of an author or +// committer line. +type Signature struct { + Name string + Email string +} + +// String renders the identity in git's own "Name " form. +func (s Signature) String() string { return s.Name + " <" + s.Email + ">" } + +// Instance carries the identity facts an agent commit needs from the instance +// config. It is a value, so service/ can build it once at startup and hand +// copies around; every field is exported so a caller that has these facts from +// somewhere other than an ini file can construct it directly. +type Instance struct { + // OwnerName and OwnerEmail are [sr.ht] owner-name / owner-email — the human + // this instance belongs to. They become the committer of every agent write + // and of every merge, which is what makes "an agent proposed it, bigbes' + // service committed it" legible in `git log`. + OwnerName string + OwnerEmail string + + // AgentEmail is the synthetic mailbox stamped on agent authorship. + AgentEmail string +} + +// InstanceFromConfig reads the provenance identities out of the instance +// config. +// +// It mirrors config.GetOwner without the panic: this is a library, and a +// missing key should fail the daemon's startup validation with a message that +// names the key, not unwind a request. Every failure wraps ErrMissingConfig. +// +// AgentEmail is derived as agent@, so no new +// config key exists to forget or to disagree with the origin. The design's +// worked example shows agent@srht.bigb.es (the bare cookie domain) rather than +// agent@spec.srht.bigb.es; the design never says where that address comes from, +// and deriving it from our own origin is the only rule that needs no operator +// input. A caller that wants the bare domain sets Instance.AgentEmail directly. +func InstanceFromConfig(conf ini.File) (Instance, error) { + ownerName, ok := conf.Get("sr.ht", "owner-name") + if !ok { + return Instance{}, fmt.Errorf("%w: [sr.ht] owner-name", ErrMissingConfig) + } + ownerEmail, ok := conf.Get("sr.ht", "owner-email") + if !ok { + return Instance{}, fmt.Errorf("%w: [sr.ht] owner-email", ErrMissingConfig) + } + origin, ok := conf.Get(ConfigSection, "origin") + if !ok { + return Instance{}, fmt.Errorf("%w: [%s] origin", ErrMissingConfig, ConfigSection) + } + + u, err := url.Parse(origin) + if err != nil { + return Instance{}, fmt.Errorf("%w: [%s] origin %q is not a URL: %v", + ErrMissingConfig, ConfigSection, origin, err) + } + host := u.Hostname() + if host == "" { + return Instance{}, fmt.Errorf("%w: [%s] origin %q has no host", + ErrMissingConfig, ConfigSection, origin) + } + + inst := Instance{ + OwnerName: strings.TrimPrefix(ownerName, "~"), + OwnerEmail: ownerEmail, + AgentEmail: agentLocalPart + "@" + host, + } + if err := inst.Validate(); err != nil { + return Instance{}, err + } + return inst, nil +} + +// Validate reports whether the instance identities are usable in a git +// signature line. +func (i Instance) Validate() error { + if err := core.ValidateOwner(i.OwnerName); err != nil { + return fmt.Errorf("%w: [sr.ht] owner-name: %v", ErrMissingConfig, err) + } + if err := validateSigField("[sr.ht] owner-email", i.OwnerEmail, MaxAgentLen); err != nil { + return fmt.Errorf("%w: %v", ErrMissingConfig, err) + } + if err := validateSigField("agent email", i.AgentEmail, MaxAgentLen); err != nil { + return fmt.Errorf("%w: %v", ErrMissingConfig, err) + } + return nil +} + +// OwnerSignature is the human this instance belongs to: the committer of every +// agent write and of every merge commit. +func (i Instance) OwnerSignature() Signature { + return Signature{Name: i.OwnerName, Email: i.OwnerEmail} +} + +// AgentWrite is the provenance an agent must supply with every write. All three +// fields are mandatory — see Validate. +type AgentWrite struct { + // Agent is the agent identity string, e.g. "claude-code/spec-writer". + Agent string + + // Session is the agent's session ID, e.g. a UUID. + Session string + + // Base is the approved-head revision the agent read the document at — the + // If-Match value, and the same value that becomes the proposal's base B. + // Recorded as X-Agent-Base so the claim is auditable against a pinned + // ?rev= read rather than decorative. + Base string +} + +// Validate enforces that an agent write carries complete, usable provenance. +// +// Missing fields are rejected, never defaulted. The design is explicit that +// agent identity and session ID are mandatory on every write, and a commit +// stamped with a synthesised session is worse than a rejected write: it +// launders unattributable output as attributed, which is the one failure the +// whole provenance mechanism exists to prevent. Base is held to the same +// standard for the same reason — an empty X-Agent-Base trailer is a claim with +// nothing behind it. +// +// The character rules are not cosmetic. A newline in the agent string would +// break the git author line in two; a newline in the session would inject an +// arbitrary extra trailer; angle brackets would forge the mailbox. All three +// are rejected outright rather than escaped, because there is no legitimate +// agent name that needs them. +func (w AgentWrite) Validate() error { + if w.Agent == "" { + return fmt.Errorf("%w: agent identity is required on every agent write", ErrMissingProvenance) + } + if w.Session == "" { + return fmt.Errorf("%w: agent session id is required on every agent write", ErrMissingProvenance) + } + if w.Base == "" { + return fmt.Errorf("%w: base revision is required on every agent write", ErrMissingProvenance) + } + if err := validateSigField("agent identity", w.Agent, MaxAgentLen); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidProvenance, err) + } + if err := validateSigField("agent session id", w.Session, MaxSessionLen); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidProvenance, err) + } + if err := validateRev(w.Base); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidProvenance, err) + } + return nil +} + +// Provenance is the fully-resolved authorship of one agent commit: who git will +// record as author and committer, and the trailers that carry the rest. +type Provenance struct { + Author Signature + Committer Signature + Session string + Base string +} + +// Provenance builds the authorship of an agent commit, per the design: +// +// Author: claude-code/spec-writer (for bigbes) +// Committer: bigbes +// +// The author is the agent, annotated with the human it acted for; the committer +// is the instance owner, because the service — running as bigbes — is what +// actually wrote the object. An invalid or incomplete AgentWrite is an error, +// never a commit with a hole in it. +func (i Instance) Provenance(w AgentWrite) (Provenance, error) { + if err := i.Validate(); err != nil { + return Provenance{}, err + } + if err := w.Validate(); err != nil { + return Provenance{}, err + } + return Provenance{ + Author: Signature{ + Name: w.Agent + " (for " + i.OwnerName + ")", + Email: i.AgentEmail, + }, + Committer: i.OwnerSignature(), + Session: w.Session, + Base: w.Base, + }, nil +} + +// TrailerBlock renders the trailers as their own paragraph, each line +// newline-terminated: +// +// X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921 +// X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809 +func (p Provenance) TrailerBlock() string { + var b strings.Builder + b.WriteString(TrailerAgentSession) + b.WriteString(": ") + b.WriteString(p.Session) + b.WriteByte('\n') + b.WriteString(TrailerAgentBase) + b.WriteString(": ") + b.WriteString(p.Base) + b.WriteByte('\n') + return b.String() +} + +// CommitMessage appends the trailer block to an agent-supplied message, +// separated by a blank line so git parses it as the trailer paragraph — and so +// that anything trailer-shaped inside the agent's own text stays part of the +// body rather than becoming the last block. +// +// An empty message is rejected: a commit whose only content is provenance +// records that something happened without saying what. +func (p Provenance) CommitMessage(message string) (string, error) { + msg := strings.TrimRight(message, " \t\r\n") + if msg == "" { + return "", fmt.Errorf("%w: empty commit message", ErrInvalidProvenance) + } + return msg + "\n\n" + p.TrailerBlock(), nil +} + +// validateSigField holds the rules shared by every string that ends up inside a +// git identity or trailer line: present, trimmed, bounded, valid UTF-8, and +// free of the bytes that would let it escape its line or its field. +func validateSigField(kind, s string, maxLen int) error { + if s == "" { + return fmt.Errorf("%s is empty", kind) + } + if len(s) > maxLen { + return fmt.Errorf("%s is too long (%d > %d)", kind, len(s), maxLen) + } + if !utf8.ValidString(s) { + return fmt.Errorf("%s is not valid UTF-8", kind) + } + if strings.TrimSpace(s) != s { + return fmt.Errorf("%s %q has leading or trailing whitespace", kind, s) + } + for _, r := range s { + switch { + case r < 0x20 || r == 0x7f: + return fmt.Errorf("%s %q contains a control character %U", kind, s, r) + case r == '<' || r == '>': + return fmt.Errorf("%s %q contains %q", kind, s, string(r)) + } + } + return nil +} + +// validateRev reports whether s is a plausible git object name. Strict enough +// that nothing can be smuggled into the trailer line, loose enough to accept +// both an abbreviated name and a full sha-256 one. +func validateRev(s string) error { + if len(s) < minRevLen || len(s) > maxRevLen { + return fmt.Errorf("base revision %q must be %d-%d hex characters, got %d", + s, minRevLen, maxRevLen, len(s)) + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { + continue + } + return fmt.Errorf("base revision %q contains a non-hex byte %q", s, c) + } + return nil +} diff --git a/authn/provenance_test.go b/authn/provenance_test.go new file mode 100644 index 0000000000000000000000000000000000000000..10cde0f89864de1804b07870add6efb10345cfda --- /dev/null +++ b/authn/provenance_test.go @@ -0,0 +1,277 @@ +package authn + +import ( + "errors" + "strings" + "testing" + + "github.com/vaughan0/go-ini" +) + +const testBase = "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809" + +func TestInstanceFromConfig(t *testing.T) { + inst := testInstance(t) + if inst.OwnerName != "bigbes" { + t.Fatalf("OwnerName = %q, want %q", inst.OwnerName, "bigbes") + } + if inst.OwnerEmail != "bigbes@gmail.com" { + t.Fatalf("OwnerEmail = %q, want %q", inst.OwnerEmail, "bigbes@gmail.com") + } + // Derived from [spec.sr.ht] origin, so there is no second key to forget. + if inst.AgentEmail != "agent@spec.srht.bigb.es" { + t.Fatalf("AgentEmail = %q, want %q", inst.AgentEmail, "agent@spec.srht.bigb.es") + } + if got := inst.OwnerSignature().String(); got != "bigbes " { + t.Fatalf("OwnerSignature = %q", got) + } +} + +func TestInstanceFromConfig_MissingKeysAreStartupErrors(t *testing.T) { + full := func() ini.File { + return ini.File{ + "sr.ht": ini.Section{"owner-name": "bigbes", "owner-email": "bigbes@gmail.com"}, + ConfigSection: ini.Section{"origin": "https://spec.srht.bigb.es"}, + } + } + cases := map[string]func(ini.File){ + "no owner-name": func(c ini.File) { delete(c["sr.ht"], "owner-name") }, + "no owner-email": func(c ini.File) { delete(c["sr.ht"], "owner-email") }, + "no origin": func(c ini.File) { delete(c[ConfigSection], "origin") }, + "hostless origin": func(c ini.File) { + c[ConfigSection]["origin"] = "not-a-url" + }, + "unusable owner": func(c ini.File) { c["sr.ht"]["owner-name"] = "Not A Name" }, + "unparseable origin": func(c ini.File) { + c[ConfigSection]["origin"] = "https://[::1" + }, + "unusable owner-email": func(c ini.File) { + c["sr.ht"]["owner-email"] = "bigbes @gmail.com" + }, + "host that cannot be a mailbox": func(c ini.File) { + c[ConfigSection]["origin"] = "https://" + strings.Repeat("h", MaxAgentLen) + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + conf := full() + mutate(conf) + if _, err := InstanceFromConfig(conf); !errors.Is(err, ErrMissingConfig) { + t.Fatalf("error = %v, want ErrMissingConfig", err) + } + }) + } +} + +// An Instance assembled by hand rather than from config is held to the same +// standard, and Provenance refuses to build a commit on top of a broken one. +func TestInstance_ValidateRejectsHandBuiltGarbage(t *testing.T) { + good := AgentWrite{Agent: "a", Session: "s-1", Base: testBase} + cases := map[string]Instance{ + "no owner name": {OwnerName: "", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"}, + "unusable owner name": {OwnerName: "Not A Name", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"}, + "no owner email": {OwnerName: "bigbes", OwnerEmail: "", AgentEmail: "agent@example.com"}, + "owner email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com>\nAuthor: root", AgentEmail: "agent@example.com"}, + "no agent email": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: ""}, + "agent email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com>x"}, + } + for name, inst := range cases { + t.Run(name, func(t *testing.T) { + if err := inst.Validate(); !errors.Is(err, ErrMissingConfig) { + t.Fatalf("Validate error = %v, want ErrMissingConfig", err) + } + if _, err := inst.Provenance(good); !errors.Is(err, ErrMissingConfig) { + t.Fatalf("Provenance error = %v, want ErrMissingConfig", err) + } + }) + } +} + +// The worked example from the design, byte for byte apart from the agent +// mailbox, which is derived from our own origin. +func TestProvenance_MatchesTheDesign(t *testing.T) { + inst := testInstance(t) + prov, err := inst.Provenance(AgentWrite{ + Agent: "claude-code/spec-writer", + Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", + Base: testBase, + }) + if err != nil { + t.Fatalf("Provenance: %v", err) + } + + wantAuthor := "claude-code/spec-writer (for bigbes) " + if got := prov.Author.String(); got != wantAuthor { + t.Fatalf("Author = %q, want %q", got, wantAuthor) + } + wantCommitter := "bigbes " + if got := prov.Committer.String(); got != wantCommitter { + t.Fatalf("Committer = %q, want %q", got, wantCommitter) + } + + wantTrailers := "X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921\n" + + "X-Agent-Base: " + testBase + "\n" + if got := prov.TrailerBlock(); got != wantTrailers { + t.Fatalf("TrailerBlock = %q, want %q", got, wantTrailers) + } + + msg, err := prov.CommitMessage("Add storage model section") + if err != nil { + t.Fatalf("CommitMessage: %v", err) + } + want := "Add storage model section\n\n" + wantTrailers + if msg != want { + t.Fatalf("CommitMessage = %q, want %q", msg, want) + } +} + +// Agent identity and session are mandatory on every agent write. A write that +// omits either is rejected, never defaulted — a commit stamped with a +// synthesised session launders unattributable output as attributed. +func TestProvenance_RejectsMissingAgentIdentity(t *testing.T) { + inst := testInstance(t) + full := AgentWrite{ + Agent: "claude-code/spec-writer", + Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", + Base: testBase, + } + cases := map[string]AgentWrite{ + "no agent": {Agent: "", Session: full.Session, Base: full.Base}, + "no session": {Agent: full.Agent, Session: "", Base: full.Base}, + "no base": {Agent: full.Agent, Session: full.Session, Base: ""}, + "nothing": {}, + } + for name, w := range cases { + t.Run(name, func(t *testing.T) { + if err := w.Validate(); !errors.Is(err, ErrMissingProvenance) { + t.Fatalf("Validate error = %v, want ErrMissingProvenance", err) + } + if _, err := inst.Provenance(w); !errors.Is(err, ErrMissingProvenance) { + t.Fatalf("Provenance error = %v, want ErrMissingProvenance", err) + } + }) + } + // Control: the complete write is accepted, so the cases above are failing + // on the missing field and not on something incidental. + if err := full.Validate(); err != nil { + t.Fatalf("complete write rejected: %v", err) + } +} + +// A newline in the agent identity would split the git author line; a newline in +// the session would inject an arbitrary extra trailer. Both are refused +// outright rather than escaped. +func TestProvenance_RejectsInjection(t *testing.T) { + inst := testInstance(t) + cases := map[string]AgentWrite{ + "newline in agent": {Agent: "evil\nAuthor: root", Session: "s-1", Base: testBase}, + "newline in session": {Agent: "a", Session: "s\nX-Agent-Base: deadbeef", Base: testBase}, + "cr in session": {Agent: "a", Session: "s\rX-Agent-Base: deadbeef", Base: testBase}, + "angle in agent": {Agent: "a ", Session: "s-1", Base: testBase}, + "angle in session": {Agent: "a", Session: "", Base: testBase}, + "padded agent": {Agent: " a ", Session: "s-1", Base: testBase}, + "nul in agent": {Agent: "a\x00b", Session: "s-1", Base: testBase}, + "over-long agent": {Agent: strings.Repeat("a", MaxAgentLen+1), Session: "s-1", Base: testBase}, + "over-long session": {Agent: "a", Session: strings.Repeat("s", MaxSessionLen+1), Base: testBase}, + "base with a space": {Agent: "a", Session: "s-1", Base: "dead beef"}, + "base not hex": {Agent: "a", Session: "s-1", Base: "proposals/42"}, + "base uppercase hex": {Agent: "a", Session: "s-1", Base: strings.ToUpper(testBase)}, + "base too short": {Agent: "a", Session: "s-1", Base: "abc"}, + "base too long": {Agent: "a", Session: "s-1", Base: strings.Repeat("a", maxRevLen+1)}, + "base with a newline": {Agent: "a", Session: "s-1", Base: testBase + "\nX-Agent-Session: forged"}, + "invalid utf-8 agent": {Agent: "a\xff", Session: "s-1", Base: testBase}, + "invalid utf-8 sessid": {Agent: "a", Session: "s\xff", Base: testBase}, + } + for name, w := range cases { + t.Run(name, func(t *testing.T) { + if err := w.Validate(); !errors.Is(err, ErrInvalidProvenance) { + t.Fatalf("Validate error = %v, want ErrInvalidProvenance", err) + } + if _, err := inst.Provenance(w); !errors.Is(err, ErrInvalidProvenance) { + t.Fatalf("Provenance error = %v, want ErrInvalidProvenance", err) + } + }) + } +} + +// An abbreviated object name is accepted; the trailer stays auditable and +// nothing can hide in it. +func TestProvenance_AcceptsAbbreviatedBase(t *testing.T) { + inst := testInstance(t) + for _, base := range []string{"1f0c1d1", testBase, strings.Repeat("a", 64)} { + if _, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: base}); err != nil { + t.Fatalf("base %q rejected: %v", base, err) + } + } +} + +func TestCommitMessage_RejectsEmpty(t *testing.T) { + inst := testInstance(t) + prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: testBase}) + if err != nil { + t.Fatal(err) + } + for _, msg := range []string{"", " ", "\n\n\t"} { + if _, err := prov.CommitMessage(msg); !errors.Is(err, ErrInvalidProvenance) { + t.Fatalf("CommitMessage(%q) error = %v, want ErrInvalidProvenance", msg, err) + } + } +} + +// Trailer-shaped text inside the agent's own message must stay in the body: +// our block is the last paragraph, which is the one git parses as trailers. +func TestCommitMessage_OurTrailersAreTheLastParagraph(t *testing.T) { + inst := testInstance(t) + prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "real-session", Base: testBase}) + if err != nil { + t.Fatal(err) + } + msg, err := prov.CommitMessage("Subject\n\nX-Agent-Session: forged\n") + if err != nil { + t.Fatal(err) + } + paras := strings.Split(strings.TrimRight(msg, "\n"), "\n\n") + last := paras[len(paras)-1] + if !strings.HasPrefix(last, "X-Agent-Session: real-session\n") { + t.Fatalf("last paragraph = %q, want it to start with the real session", last) + } + if strings.Contains(last, "forged") { + t.Fatalf("forged trailer leaked into the trailer paragraph: %q", last) + } +} + +// Provenance for a non-agent principal is an error: the human write path goes +// through native receive-pack and builds no commit here. +func TestAgentWriteFor_RejectsNonAgents(t *testing.T) { + for _, p := range []Principal{ + Anonymous(), + {Kind: KindOwner, Owner: "bigbes"}, + } { + if _, err := p.AgentWriteFor(testBase); !errors.Is(err, ErrNotAgent) { + t.Fatalf("%s: error = %v, want ErrNotAgent", p, err) + } + } +} + +func TestAgentWriteFor_Agent(t *testing.T) { + p := Principal{ + Kind: KindAgent, + Owner: "bigbes", + Agent: "claude-code/spec-writer", + Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", + } + w, err := p.AgentWriteFor(testBase) + if err != nil { + t.Fatalf("AgentWriteFor: %v", err) + } + if w.Agent != p.Agent || w.Session != p.Session || w.Base != testBase { + t.Fatalf("AgentWrite = %+v", w) + } + + // An agent that authenticated but sent no provenance headers reads fine and + // is refused at the write, which is where the design requires the fields. + bare := Principal{Kind: KindAgent, Owner: "bigbes"} + if _, err := bare.AgentWriteFor(testBase); !errors.Is(err, ErrMissingProvenance) { + t.Fatalf("error = %v, want ErrMissingProvenance", err) + } +} diff --git a/authn/resolver.go b/authn/resolver.go new file mode 100644 index 0000000000000000000000000000000000000000..09886491a770bb99c465cf39aa79ee02e5c21d19 --- /dev/null +++ b/authn/resolver.go @@ -0,0 +1,115 @@ +package authn + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + + "sourcecraft.dev/bigbes/sr-ht-spec/core" +) + +// Resolver turns a request into a Principal. It holds the instance owner +// username — the one name a cookie has to match to carry authority — and the +// TokenStore agents are checked against. +type Resolver struct { + owner string + store TokenStore +} + +// NewResolver builds a Resolver for the instance owner named in +// [sr.ht] owner-name. +// +// A nil store is rejected rather than tolerated: with no store every agent +// token would resolve as unknown, which looks exactly like a mass revocation +// and is a miserable thing to debug at 2am. Wire a store or do not build a +// resolver. +func NewResolver(owner string, store TokenStore) (*Resolver, error) { + owner = strings.TrimPrefix(owner, "~") + if err := core.ValidateOwner(owner); err != nil { + return nil, fmt.Errorf("authn: instance owner: %w", err) + } + if store == nil { + return nil, fmt.Errorf("authn: nil TokenStore") + } + return &Resolver{owner: owner, store: store}, nil +} + +// Owner returns the instance owner username this resolver recognises. +func (rs *Resolver) Owner() string { return rs.owner } + +// Resolve determines who is making a request. +// +// A bearer token wins over a cookie when both are present: an agent that went +// to the trouble of presenting a credential is asking to be treated as an +// agent, and letting a stale browser cookie promote it to the owner would hand +// it the approved branch. The two credentials are checked in that order and +// never merged. +// +// The error contract is asymmetric on purpose: +// +// - No bearer token: never an error. The cookie decides between KindOwner and +// KindAnonymous, and any cookie problem is anonymity, not failure. +// - A bearer token that fails: an error. IsAuthFailure separates the 401 case +// (unknown, revoked, malformed) from the 503 case (store unreachable). +// +// The agent identity and session headers are read here but not required: they +// are demanded at the write, by AgentWrite.Validate, which is the only place +// the design requires them and the only place a missing one can do harm. +func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) { + if presented := BearerFromRequest(r); presented != "" { + tok, err := ResolveAgentToken(ctx, rs.store, presented) + if err != nil { + return Anonymous(), err + } + return Principal{ + Kind: KindAgent, + Owner: rs.owner, + Agent: strings.TrimSpace(r.Header.Get(HeaderAgent)), + Session: strings.TrimSpace(r.Header.Get(HeaderAgentSession)), + TokenName: tok.Name, + }, nil + } + + username := UsernameFromRequest(r) + if username == "" { + return Anonymous(), nil + } + if username != rs.owner { + // A real user of the instance who is not bigbes. Single-user means + // there is nothing to grant them, so they read exactly as an anonymous + // viewer does; the name is kept for the log line and the "you are + // signed in as" affordance only. + return Principal{Kind: KindAnonymous, CookieUser: username}, nil + } + return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil +} + +// Middleware attaches the resolved Principal to the request context, where +// PrincipalFromContext reads it. +// +// It rejects only a failed bearer token — 401 for a bad credential, 503 for a +// store that could not answer. Everything else, including every cookie +// problem, flows through as anonymous: the read plane is anonymous-capable and +// must never answer an error page on identity grounds. +func (rs *Resolver) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p, err := rs.Resolve(r.Context(), r) + if err != nil { + if IsAuthFailure(err) { + http.Error(w, "invalid agent token", http.StatusUnauthorized) + return + } + // Fail closed and loudly. The alternative — degrading to + // anonymous — would turn a Postgres blip into agents silently + // losing their write access. + log.Printf("authn: resolving agent token: %v", err) + http.Error(w, "authentication backend unavailable", http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p))) + }) + } +} diff --git a/authn/resolver_test.go b/authn/resolver_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ab676d70d2a94b4d46075f8fbf0e9bd9b219165e --- /dev/null +++ b/authn/resolver_test.go @@ -0,0 +1,330 @@ +package authn + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func newTestResolver(t *testing.T, store TokenStore) *Resolver { + t.Helper() + rs, err := NewResolver("bigbes", store) + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + return rs +} + +func TestNewResolver_RejectsBadWiring(t *testing.T) { + if _, err := NewResolver("bigbes", nil); err == nil { + t.Fatal("nil store must be rejected") + } + if _, err := NewResolver("", newStubStore()); err == nil { + t.Fatal("empty owner must be rejected") + } + if _, err := NewResolver("Not A Name", newStubStore()); err == nil { + t.Fatal("unusable owner must be rejected") + } + // The canonical "~user" spelling is accepted and normalised. + rs, err := NewResolver("~bigbes", newStubStore()) + if err != nil { + t.Fatalf("NewResolver(~bigbes): %v", err) + } + if rs.Owner() != "bigbes" { + t.Fatalf("Owner() = %q, want %q", rs.Owner(), "bigbes") + } +} + +func TestResolve_OwnerCookie(t *testing.T) { + store := newStubStore() + rs := newTestResolver(t, store) + + p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !p.IsOwner() { + t.Fatalf("principal = %+v, want the owner", p) + } + if p.IsAnonymous() || p.IsAgent() { + t.Fatalf("owner classified as anonymous/agent: %+v", p) + } + if p.Owner != "bigbes" { + t.Fatalf("Owner = %q, want %q", p.Owner, "bigbes") + } + if store.calls != 0 { + t.Fatalf("cookie path consulted the token store %d times, want 0", store.calls) + } +} + +func TestResolve_AbsentCookieIsAnonymousNotAnError(t *testing.T) { + rs := newTestResolver(t, newStubStore()) + p, err := rs.Resolve(context.Background(), request("", nil)) + if err != nil { + t.Fatalf("an anonymous request must never error: %v", err) + } + if !p.IsAnonymous() { + t.Fatalf("principal = %+v, want anonymous", p) + } +} + +func TestResolve_BrokenCookiesAreAnonymousNotErrors(t *testing.T) { + rs := newTestResolver(t, newStubStore()) + for name, value := range map[string]string{ + "tampered": tamper(t, sealCookie(t, "bigbes")), + "garbage": "not-a-valid-fernet-token", + "foreign key": sealCookieWithKey(t, &rotatedKey, "bigbes"), + } { + t.Run(name, func(t *testing.T) { + p, err := rs.Resolve(context.Background(), request(value, nil)) + if err != nil { + t.Fatalf("a broken cookie must never error: %v", err) + } + if !p.IsAnonymous() { + t.Fatalf("principal = %+v, want anonymous", p) + } + }) + } +} + +// Single-user: a real user who is not bigbes has nothing granted to them, so +// they read exactly as an anonymous viewer does. The name survives for logs +// only, and must not be mistaken for authority. +func TestResolve_NonOwnerCookieIsAnonymous(t *testing.T) { + rs := newTestResolver(t, newStubStore()) + p, err := rs.Resolve(context.Background(), request(sealCookie(t, "someone"), nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !p.IsAnonymous() { + t.Fatalf("principal = %+v, want anonymous", p) + } + if p.IsOwner() { + t.Fatal("a non-owner cookie must not yield the owner") + } + if p.Owner != "" { + t.Fatalf("Owner = %q, want empty for a non-owner", p.Owner) + } + if p.CookieUser != "someone" { + t.Fatalf("CookieUser = %q, want %q", p.CookieUser, "someone") + } +} + +func TestResolve_AgentTokenAccepted(t *testing.T) { + store := newStubStore() + store.add("live-token", "laptop") + rs := newTestResolver(t, store) + + p, err := rs.Resolve(context.Background(), request("", map[string]string{ + "Authorization": "Bearer live-token", + HeaderAgent: "claude-code/spec-writer", + HeaderAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", + })) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !p.IsAgent() { + t.Fatalf("principal = %+v, want an agent", p) + } + if p.IsOwner() || p.IsAnonymous() { + t.Fatalf("agent classified as owner/anonymous: %+v", p) + } + if p.Agent != "claude-code/spec-writer" || p.Session != "8fb9c9a4-b078-4af1-89eb-d97c522f9921" { + t.Fatalf("provenance not carried onto the principal: %+v", p) + } + if p.Owner != "bigbes" { + t.Fatalf("agent acts for %q, want %q", p.Owner, "bigbes") + } + if p.TokenName != "laptop" { + t.Fatalf("TokenName = %q, want %q", p.TokenName, "laptop") + } +} + +func TestResolve_AgentTokenRevokedAndUnknown(t *testing.T) { + store := newStubStore() + store.add("live-token", "laptop") + store.revoke("dead-token", "cron") + rs := newTestResolver(t, store) + + cases := map[string]struct { + token string + want error + }{ + "revoked": {"dead-token", ErrRevokedToken}, + "unknown": {"never-issued", ErrUnknownToken}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + p, err := rs.Resolve(context.Background(), request("", + map[string]string{"Authorization": "Bearer " + c.token})) + if !errors.Is(err, c.want) { + t.Fatalf("error = %v, want %v", err, c.want) + } + if !p.IsAnonymous() { + t.Fatalf("a refused token must yield no authority: %+v", p) + } + }) + } +} + +// A bearer token wins over a cookie. Letting a stale browser cookie promote a +// token-bearing request to the owner would hand an agent the approved branch. +func TestResolve_BearerBeatsCookie(t *testing.T) { + store := newStubStore() + store.add("live-token", "laptop") + rs := newTestResolver(t, store) + + p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), + map[string]string{"Authorization": "Bearer live-token"})) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !p.IsAgent() { + t.Fatalf("principal = %+v, want an agent", p) + } + if p.IsOwner() { + t.Fatal("an owner cookie must not promote a token-bearing request") + } +} + +// A bad token is a hard failure even when a valid owner cookie is present: an +// agent silently downgraded to a reader fails confusingly at its first write +// instead of clearly at the door. +func TestResolve_BadTokenFailsEvenWithOwnerCookie(t *testing.T) { + store := newStubStore() + rs := newTestResolver(t, store) + + _, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), + map[string]string{"Authorization": "Bearer never-issued"})) + if !errors.Is(err, ErrUnknownToken) { + t.Fatalf("error = %v, want ErrUnknownToken", err) + } +} + +func runMiddleware(t *testing.T, rs *Resolver, r *http.Request) (Principal, int, bool) { + t.Helper() + var got Principal + var reached bool + h := rs.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + got = PrincipalFromContext(r.Context()) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return got, rec.Code, reached +} + +func TestMiddleware_AttachesPrincipal(t *testing.T) { + store := newStubStore() + store.add("live-token", "laptop") + rs := newTestResolver(t, store) + + t.Run("owner", func(t *testing.T) { + p, code, reached := runMiddleware(t, rs, request(sealCookie(t, "bigbes"), nil)) + if !reached || code != http.StatusOK { + t.Fatalf("reached=%v code=%d, want true/200", reached, code) + } + if !p.IsOwner() { + t.Fatalf("principal = %+v, want the owner", p) + } + }) + + t.Run("anonymous", func(t *testing.T) { + p, code, reached := runMiddleware(t, rs, request("", nil)) + if !reached || code != http.StatusOK { + t.Fatalf("reached=%v code=%d, want true/200", reached, code) + } + if !p.IsAnonymous() { + t.Fatalf("principal = %+v, want anonymous", p) + } + }) + + t.Run("agent", func(t *testing.T) { + p, code, reached := runMiddleware(t, rs, request("", map[string]string{ + "Authorization": "Bearer live-token", + HeaderAgent: "claude-code/spec-writer", + HeaderAgentSession: "sess-1", + })) + if !reached || code != http.StatusOK { + t.Fatalf("reached=%v code=%d, want true/200", reached, code) + } + if !p.IsAgent() || p.Agent != "claude-code/spec-writer" { + t.Fatalf("principal = %+v, want the agent", p) + } + }) +} + +func TestMiddleware_RejectsBadToken(t *testing.T) { + rs := newTestResolver(t, newStubStore()) + _, code, reached := runMiddleware(t, rs, request("", + map[string]string{"Authorization": "Bearer never-issued"})) + if reached { + t.Fatal("a refused token must not reach the handler") + } + if code != http.StatusUnauthorized { + t.Fatalf("code = %d, want 401", code) + } +} + +func TestMiddleware_StoreOutageIs503(t *testing.T) { + store := newStubStore() + store.err = errors.New("connection refused") + rs := newTestResolver(t, store) + + _, code, reached := runMiddleware(t, rs, request("", + map[string]string{"Authorization": "Bearer live-token"})) + if reached { + t.Fatal("a store outage must fail closed, not reach the handler") + } + if code != http.StatusServiceUnavailable { + t.Fatalf("code = %d, want 503", code) + } +} + +// A handler reached without the middleware must degrade to less authority, not +// more. +func TestPrincipalFromContext_BareContextIsAnonymous(t *testing.T) { + p := PrincipalFromContext(context.Background()) + if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() { + t.Fatalf("bare context yielded %+v, want anonymous", p) + } + if p.Kind != KindAnonymous { + t.Fatalf("Kind = %q, want %q", p.Kind, KindAnonymous) + } +} + +func TestPrincipalFromContext_RoundTrip(t *testing.T) { + want := Principal{Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s"} + if got := PrincipalFromContext(WithPrincipal(context.Background(), want)); got != want { + t.Fatalf("round trip = %+v, want %+v", got, want) + } +} + +// The zero Principal must be the anonymous one — an unrecognised Kind is denied +// rather than accidentally admitted. +func TestPrincipal_ZeroValueIsAnonymous(t *testing.T) { + var p Principal + if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() { + t.Fatalf("zero Principal = %+v, want anonymous", p) + } + if p2 := (Principal{Kind: Kind("nonsense")}); !p2.IsAnonymous() { + t.Fatal("an unrecognised Kind must be anonymous") + } +} + +func TestPrincipal_String(t *testing.T) { + cases := map[Principal]string{ + Anonymous(): "anonymous", + {Kind: KindAnonymous, CookieUser: "someone"}: "anonymous (cookie user ~someone)", + {Kind: KindOwner, Owner: "bigbes"}: "owner ~bigbes", + {Kind: KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "s-1"}: "agent claude-code/spec-writer session s-1 for ~bigbes", + {Kind: KindAgent, Owner: "bigbes"}: "agent (unnamed) session (no session) for ~bigbes", + } + for p, want := range cases { + if got := p.String(); got != want { + t.Fatalf("String() = %q, want %q", got, want) + } + } +} diff --git a/authn/token.go b/authn/token.go new file mode 100644 index 0000000000000000000000000000000000000000..c638c6f5723fe1813fbd90939c48dab5dbd24784 --- /dev/null +++ b/authn/token.go @@ -0,0 +1,158 @@ +package authn + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +// bearerScheme is the Authorization scheme agents present the token under. +// Matched case-insensitively, as RFC 7235 requires. +const bearerScheme = "bearer" + +// HeaderAgent and HeaderAgentSession carry the mandatory provenance an agent +// must send alongside its token. They are named after the git trailers they end +// up in, so that what an agent sends and what a reviewer reads in `git log` are +// spelled the same way. +// +// There is deliberately no X-Agent-Base header: the base revision is the +// `If-Match` value the write plane already defines, and giving it a second +// spelling is exactly how REST and MCP end up disagreeing about what it means. +const ( + HeaderAgent = "X-Agent" + HeaderAgentSession = "X-Agent-Session" +) + +// AgentToken is one row of the agent_token table, in the shape this package +// needs. It is declared here rather than in db/ so that authn owns its own +// input contract and the dependency arrow keeps pointing downward. +type AgentToken struct { + // ID is the agent_token primary key. Diagnostics and audit only. + ID int64 + + // Name is the operator-facing label of the token ("laptop", "cron"). With + // one token and no scopes it grants nothing; it exists so a revocation can + // be aimed at something a human recognises. + Name string + + // Hash is the stored sha256 of the token as issued. ResolveAgentToken + // re-checks it against the presented token in constant time rather than + // trusting that the store's lookup was an exact match. + Hash []byte + + // Created is when the token was issued. + Created time.Time + + // Revoked is when the token was revoked, or nil while it is live. A + // revoked row still resolves from the store — it is this package that + // turns it into a refusal, so the refusal can say "revoked" rather than + // "unknown". + Revoked *time.Time +} + +// IsRevoked reports whether the token has been revoked. +func (t AgentToken) IsRevoked() bool { return t.Revoked != nil } + +// TokenStore is the sliver of db/ that authn needs: look up an agent token row +// by the hash of the presented secret. service/ wires the real Postgres +// implementation in; tests wire a map. +// +// Contract: +// +// - hash is the value HashToken returned; the implementation must match it +// against agent_token.token_hash exactly, never by prefix. +// - When no row matches, return an error satisfying +// errors.Is(err, ErrUnknownToken). sql.ErrNoRows is accepted as an +// equivalent spelling, since that is what a bare QueryRow().Scan() yields. +// - Any other error is taken to be transient (Postgres down, context +// cancelled) and is surfaced as such, never as a bad credential. +// +// The interface takes no scope or space argument on purpose: v1 has one agent +// token and no per-space scoping, and the boundary that actually bounds damage +// is the refs rule in gitx. Adding scopes later is a column here and a filter +// clause there, not a reshaping of this interface. +type TokenStore interface { + LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error) +} + +// HashToken returns the sha256 of a presented agent token — the value stored in +// agent_token.token_hash and the only form of the secret this service keeps. +// The token itself is opaque and high-entropy, so a plain hash is sufficient: +// there is no low-entropy password here for a KDF to slow down guessing of. +func HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// BearerFromRequest returns the token from an "Authorization: Bearer " +// header, or "" when the header is absent or uses another scheme. Anything +// after the scheme is returned verbatim apart from surrounding whitespace: the +// token is opaque and this package is not the place to guess at its grammar. +func BearerFromRequest(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + return "" + } + scheme, rest, ok := strings.Cut(h, " ") + if !ok || !strings.EqualFold(scheme, bearerScheme) { + return "" + } + return strings.TrimSpace(rest) +} + +// ResolveAgentToken validates a presented agent token against the store. +// +// Unlike the cookie path this fails loudly. An agent that presented a +// credential and got silently downgraded to an anonymous reader would sail +// through its reads and then fail incomprehensibly at its first propose; a 401 +// at the door is the only useful answer. +// +// Permanent refusals (ErrNoToken, ErrUnknownToken, ErrRevokedToken, +// ErrInvalidToken) satisfy IsAuthFailure. A store failure is returned wrapped +// and does not, so callers answer 503 rather than 401 and agents retry rather +// than re-provision. +func ResolveAgentToken(ctx context.Context, store TokenStore, presented string) (AgentToken, error) { + if store == nil { + // A nil store is a wiring bug, not a credential problem. Refusing + // loudly beats resolving every token as unknown, which would look like + // a revocation storm. + return AgentToken{}, errors.New("authn: nil TokenStore") + } + if presented == "" { + return AgentToken{}, ErrNoToken + } + + hash := HashToken(presented) + tok, err := store.LookupAgentToken(ctx, hash) + switch { + case err == nil: + // fall through + case errors.Is(err, ErrUnknownToken), errors.Is(err, sql.ErrNoRows): + // Two spellings of "no such row"; ErrUnknownToken is the contract, + // sql.ErrNoRows is what an unwrapped Scan leaks. + return AgentToken{}, fmt.Errorf("%w: no agent token matches the presented secret", ErrUnknownToken) + default: + return AgentToken{}, fmt.Errorf("looking up agent token: %w", err) + } + + // Re-check the hash ourselves, in constant time. The store's WHERE clause + // already did an equality match, but this is the one comparison that + // decides authentication and it costs a memcmp to not depend on somebody + // else getting it right. + if subtle.ConstantTimeCompare(tok.Hash, hash) != 1 { + return AgentToken{}, fmt.Errorf("%w: store returned a row whose hash does not match the presented token", ErrInvalidToken) + } + + if tok.IsRevoked() { + return AgentToken{}, fmt.Errorf("%w: token %q was revoked at %s", + ErrRevokedToken, tok.Name, tok.Revoked.UTC().Format(time.RFC3339)) + } + + return tok, nil +} diff --git a/authn/token_test.go b/authn/token_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2464278bafd768bfddf5e24f3be74699a1f4db65 --- /dev/null +++ b/authn/token_test.go @@ -0,0 +1,161 @@ +package authn + +import ( + "bytes" + "context" + "database/sql" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHashToken_IsSHA256AndStable(t *testing.T) { + a := HashToken("s3cret") + b := HashToken("s3cret") + if len(a) != 32 { + t.Fatalf("hash length = %d, want 32", len(a)) + } + if !bytes.Equal(a, b) { + t.Fatal("hashing the same token twice produced different values") + } + if bytes.Equal(a, HashToken("s3cres")) { + t.Fatal("distinct tokens hashed to the same value") + } +} + +func TestBearerFromRequest(t *testing.T) { + cases := map[string]struct{ header, want string }{ + "bearer": {"Bearer abc123", "abc123"}, + "lowercase scheme": {"bearer abc123", "abc123"}, + "mixed case scheme": {"BeArEr abc123", "abc123"}, + "padded": {"Bearer abc123 ", "abc123"}, + "basic is not ours": {"Basic dXNlcjpwYXNz", ""}, + "no scheme": {"abc123", ""}, + "absent": {"", ""}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if c.header != "" { + r.Header.Set("Authorization", c.header) + } + if got := BearerFromRequest(r); got != c.want { + t.Fatalf("BearerFromRequest = %q, want %q", got, c.want) + } + }) + } +} + +func TestResolveAgentToken_Accepted(t *testing.T) { + store := newStubStore() + want := store.add("live-token", "laptop") + + got, err := ResolveAgentToken(context.Background(), store, "live-token") + if err != nil { + t.Fatalf("ResolveAgentToken: %v", err) + } + if got.ID != want.ID || got.Name != "laptop" { + t.Fatalf("resolved %+v, want id %d name %q", got, want.ID, "laptop") + } + if got.IsRevoked() { + t.Fatal("live token reported as revoked") + } +} + +func TestResolveAgentToken_Revoked(t *testing.T) { + store := newStubStore() + store.revoke("dead-token", "cron") + + _, err := ResolveAgentToken(context.Background(), store, "dead-token") + if !errors.Is(err, ErrRevokedToken) { + t.Fatalf("error = %v, want ErrRevokedToken", err) + } + // A revoked token must not be reported as unknown: the operator needs to + // tell "I killed this" from "this never existed". + if errors.Is(err, ErrUnknownToken) { + t.Fatalf("revoked token also reported as unknown: %v", err) + } + if !IsAuthFailure(err) { + t.Fatalf("revocation must be a permanent auth failure: %v", err) + } +} + +func TestResolveAgentToken_Unknown(t *testing.T) { + store := newStubStore() + store.add("live-token", "laptop") + + _, err := ResolveAgentToken(context.Background(), store, "never-issued") + if !errors.Is(err, ErrUnknownToken) { + t.Fatalf("error = %v, want ErrUnknownToken", err) + } + if !IsAuthFailure(err) { + t.Fatalf("unknown token must be a permanent auth failure: %v", err) + } +} + +// db/ may hand back a bare sql.ErrNoRows from QueryRow().Scan(); it means the +// same thing as ErrUnknownToken and must not be mistaken for a store outage. +func TestResolveAgentToken_SQLNoRowsIsUnknown(t *testing.T) { + store := newStubStore() + store.err = sql.ErrNoRows + + _, err := ResolveAgentToken(context.Background(), store, "whatever") + if !errors.Is(err, ErrUnknownToken) { + t.Fatalf("error = %v, want ErrUnknownToken", err) + } +} + +func TestResolveAgentToken_EmptyIsNoToken(t *testing.T) { + store := newStubStore() + _, err := ResolveAgentToken(context.Background(), store, "") + if !errors.Is(err, ErrNoToken) { + t.Fatalf("error = %v, want ErrNoToken", err) + } + if store.calls != 0 { + t.Fatalf("store consulted %d times for an absent token, want 0", store.calls) + } +} + +// A store outage must never read as a bad credential: fail closed, but tell the +// caller it is transient so it answers 503 and the agent retries. +func TestResolveAgentToken_StoreFailureIsTransient(t *testing.T) { + boom := errors.New("connection refused") + store := newStubStore() + store.add("live-token", "laptop") + store.err = boom + + _, err := ResolveAgentToken(context.Background(), store, "live-token") + if !errors.Is(err, boom) { + t.Fatalf("error = %v, want it to wrap the store error", err) + } + if IsAuthFailure(err) { + t.Fatalf("store failure must not be a permanent auth failure: %v", err) + } +} + +// The constant-time re-check exists so that a store which matched loosely — by +// prefix, or on the wrong column — cannot authenticate anybody. +func TestResolveAgentToken_HashMismatchRejected(t *testing.T) { + store := newStubStore() + store.put(HashToken("presented"), AgentToken{ + ID: 7, + Name: "sloppy-store", + Hash: HashToken("something-else"), + }) + + _, err := ResolveAgentToken(context.Background(), store, "presented") + if !errors.Is(err, ErrInvalidToken) { + t.Fatalf("error = %v, want ErrInvalidToken", err) + } +} + +func TestResolveAgentToken_NilStoreIsNotAnAuthFailure(t *testing.T) { + _, err := ResolveAgentToken(context.Background(), nil, "live-token") + if err == nil { + t.Fatal("nil store must be an error") + } + if IsAuthFailure(err) { + t.Fatalf("a wiring bug must not read as a bad credential: %v", err) + } +}