A authn/authn_test.go => authn/authn_test.go +186 -0
@@ 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
+}
A authn/cookie.go => authn/cookie.go +72 -0
@@ 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
+}
A authn/cookie_test.go => authn/cookie_test.go +92 -0
@@ 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)
+ }
+ })
+ }
+}
A authn/doc.go => authn/doc.go +104 -0
@@ 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) <agent@spec.srht.bigb.es>
+// Committer: bigbes <bigbes@gmail.com>
+//
+// 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)
+}
A authn/principal.go => authn/principal.go +145 -0
@@ 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
+}
A authn/provenance.go => authn/provenance.go +312 -0
@@ 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 <email>" 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@<host of [spec.sr.ht] origin>, 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) <agent@spec.srht.bigb.es>
+// Committer: bigbes <bigbes@gmail.com>
+//
+// 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
+}
A authn/provenance_test.go => authn/provenance_test.go +277 -0
@@ 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 <bigbes@gmail.com>" {
+ 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 <root>@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) <agent@spec.srht.bigb.es>"
+ if got := prov.Author.String(); got != wantAuthor {
+ t.Fatalf("Author = %q, want %q", got, wantAuthor)
+ }
+ wantCommitter := "bigbes <bigbes@gmail.com>"
+ 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 <root@example.com>", Session: "s-1", Base: testBase},
+ "angle in session": {Agent: "a", Session: "<s>", 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)
+ }
+}
A authn/resolver.go => authn/resolver.go +115 -0
@@ 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)))
+ })
+ }
+}
A authn/resolver_test.go => authn/resolver_test.go +330 -0
@@ 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)
+ }
+ }
+}
A authn/token.go => authn/token.go +158 -0
@@ 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 <token>"
+// 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
+}
A authn/token_test.go => authn/token_test.go +161 -0
@@ 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)
+ }
+}