From 86de8532f48731960a4595b12a92de1ecf3f1dc6 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 17:21:40 +0300 Subject: [PATCH] bearer: the shared working-token validator of SPEC ch. 6 --- README.md | 40 +++ bearer/bearer.go | 504 +++++++++++++++++++++++++++++++++++ bearer/bearer_test.go | 599 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 8 + go.sum | 44 +++- 5 files changed, 1194 insertions(+), 1 deletion(-) create mode 100644 bearer/bearer.go create mode 100644 bearer/bearer_test.go diff --git a/README.md b/README.md index 9f23f3ef93e72878a3a8e33524d882f25c9deec9..9fca5337acce86f26ab0435485e287fb4996991a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ copies of the same code. mints and every service that validates have to read one grammar — two parsers that disagree about what counts as a permission is a hole on the security path, not a cosmetic divergence. +- `bearer` — the shared working-token validator (SPEC ch. 6), one copy for + every service that accepts a tokens.sr.ht token: verify the signature, + decide whether the token is ours, check the grant, and — only for a + registered token — ask tokens.sr.ht whether it is still live, behind a 60s + cache. Every step that can refuse locally runs before the one that cannot, + so a short token never touches the network at all. ## Usage: chrome @@ -47,6 +53,40 @@ The template dot must expose the `chrome.Page` fields — either a `Page` itself, or a service view struct that embeds one (promoted fields resolve in templates). +## Usage: bearer + +```go +v, err := bearer.New(bearer.Options{ + Origin: conf.Get("tokens.sr.ht", "origin"), // https://tokens.srht.bigb.es + ClientID: "bench.sr.ht", // the CALLING service + NodeID: hostname, +}) + +tok, err := v.Validate(r.Context(), presented, "bench:upload") +switch { +case err == nil: + // tok.Username, tok.Grants, tok.TokenID +case errors.Is(err, bearer.ErrNotOurs): + // service policy: accept as a meta.sr.ht PAT, or refuse +case errors.Is(err, bearer.ErrForbidden): + http.Error(w, "insufficient grants", http.StatusForbidden) +case errors.Is(err, bearer.ErrUnavailable): + http.Error(w, "token service unavailable", http.StatusServiceUnavailable) +default: // ErrInvalid, ErrRevoked + http.Error(w, "invalid token", http.StatusUnauthorized) +} +``` + +`crypto.InitCrypto` must have run first — the signing key and the network key +both live in that package's globals. + +Two of those arms are the ones to get right. `ErrNotOurs` is deliberately not +decided by the validator: SPEC ch. 6 step 2 leaves it to each service whether a +foreign bearer token is a meta PAT to accept (dolt) or something to refuse +(bench, cover). And `ErrUnavailable` is **503, never 401** — reading an +unreachable daemon as "revoked" would refuse live tokens across the instance +for the length of a tokens.sr.ht restart. + ## Policy: chrome The chrome bakes in the instance-wide decisions instead of parameterizing diff --git a/bearer/bearer.go b/bearer/bearer.go new file mode 100644 index 0000000000000000000000000000000000000000..fd811a2cd1ef34ab32b094a58c808cee349f2635 --- /dev/null +++ b/bearer/bearer.go @@ -0,0 +1,504 @@ +// Package bearer validates a tokens.sr.ht working token, and is the one copy of +// that check for every service on the instance (SPEC ch. 6). +// +// A working token is an auth.BearerToken: the version, expiry, grants, client id +// and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and +// the same key meta.sr.ht stamps its own personal access tokens with, so a +// service needs no new cryptographic stack to accept one — and, for the same +// reason, the signature alone does not say who issued the token. Only the client +// id does, which is why step 2 below exists at all. +// +// The four steps, in order, are SPEC ch. 6: +// +// 1. decode and verify (local, no network); +// 2. is this token ours? — if not, the *service's* policy decides; +// 3. does it grant the action being attempted? +// 4. if it is a registered token, is it still live? — the one step that talks +// to tokens.sr.ht, cached, and skipped entirely by a short token. +// +// The order is not an implementation detail. Every step that can refuse locally +// runs before the one that cannot, so the network is touched only for a token +// that has already proved it is well-formed, ours, unexpired and sufficient for +// what its holder is doing. A forged or expired credential never reaches the +// daemon, and an instance under a flood of junk tokens does not turn that flood +// into traffic against tokens.sr.ht. +// +// Usage: +// +// v, err := bearer.New(bearer.Options{ +// Origin: conf.Get("tokens.sr.ht", "origin"), +// ClientID: "bench.sr.ht", +// NodeID: hostname, +// }) +// ... +// tok, err := v.Validate(r.Context(), presented, "bench:upload") +// +// The process must have run crypto.InitCrypto before any of this: the signing +// key of step 1 and the network key that seals the internal authorization of +// step 4 both live in that package's globals. This is the same precondition +// every core-go authentication path carries and it is not checked here, because +// there is nothing this package could usefully do about it at request time. +// +// Resolving the owner is not this package's job. The token names a meta.sr.ht +// username; turning that into a local row is auth.LookupUser plus whatever the +// service's own plan does with a user it has never seen (SPEC ch. 6), and that +// differs per service in ways a shared validator should not have opinions about. +package bearer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-ecore/grants" +) + +// TokensClientID is the ClientID tokens.sr.ht stamps into every working token it +// seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the +// two share a signing key, so a valid signature says the instance made the +// token and not which part of it did (SPEC ch. 1). +const TokensClientID = "tokens.sr.ht" + +// DefaultCacheTTL is how long a revocation answer is reused when Options leaves +// CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names. +const DefaultCacheTTL = 60 * time.Second + +// defaultHTTPTimeout bounds a revocation check when the caller supplies no +// client of its own. +// +// It has to be short. This request sits on the hot path of an upload, behind a +// context the caller may not have given a deadline to, and the endpoint it calls +// answers from one indexed row — a healthy daemon replies in single-digit +// milliseconds. Five seconds is generous for that and still short enough that a +// hung tokens.sr.ht turns into 503s rather than into request-handler goroutines +// piling up across every service on the instance. +const defaultHTTPTimeout = 5 * time.Second + +// revocationPath is the endpoint of SPEC ch. 5, joined to Origin. +const revocationPath = "/api/v1/revocations/" + +// maxCacheEntries bounds the revocation cache. See (*Validator).remember for +// what happens at the bound and why that is the right thing to happen. +const maxCacheEntries = 4096 + +// The refusals of SPEC ch. 6, and the status each one is for a service. +// +// They are separate sentinels rather than one error with a code because the +// mapping is not uniform, and the interesting cases are the two that are not +// 401: +// +// - ErrInvalid — 401. The signature did not verify, the version is foreign, or +// the token has expired. +// - ErrNotOurs — the service's own policy, not a status. See Validate. +// - ErrForbidden — 403. The credential is good; it does not cover this action. +// Distinct from 401 because retrying with the same token is pointless and +// the holder needs to be told to ask for a wider grant, not to log in again. +// - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the +// token is no longer a credential at all, and a client that sees 403 will +// keep presenting it. +// - ErrUnavailable — 503. tokens.sr.ht could not be asked. +// +// The 503 is the one that has to be defended, because "I could not check" reads +// so naturally as "so I will not accept it". Reading an unreachable daemon as a +// revocation would refuse every registered token on the instance for as long as +// tokens.sr.ht is down — turning a restart of a service that is deliberately off +// the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says +// 503 for exactly that reason, following core-go, which answers the same way +// when meta.sr.ht cannot be reached. 503 also says the true thing to a client: +// come back, this is us and it is temporary. +// +// This is not a decision to fail open. A short token was never checked against +// the daemon in the first place, and a registered one whose revocation cannot be +// confirmed is refused — with a status that keeps the operator's attention on +// the daemon instead of on a thousand clients being told their credentials are +// bad. +var ( + // ErrInvalid: the presented string is not a token this instance sealed, or + // no longer is one. 401. + ErrInvalid = errors.New("bearer: token does not verify") + + // ErrNotOurs: a well-formed token from another issuer, almost certainly a + // meta.sr.ht PAT. Returned together with the decoded token; the status is + // the service's to choose. + ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht") + + // ErrForbidden: our token, valid, but it does not carry the action. 403. + ErrForbidden = errors.New("bearer: token does not grant this action") + + // ErrRevoked: a registered token whose row is gone, revoked or expired. 401. + ErrRevoked = errors.New("bearer: token has been revoked") + + // ErrUnavailable: the revocation check could not be completed. 503, never + // 401 — see above. + ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached") +) + +// Options configures a Validator. +type Options struct { + // Origin is where tokens.sr.ht answers, scheme and host, e.g. + // "https://tokens.srht.bigb.es". Required. + Origin string + + // ClientID identifies the *calling* service in the internal authorization of + // step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob + // that names neither this nor NodeID. + // + // It is a label, not a credential: the guard admits every internal service + // equally and this only decides what its log line says. Which is precisely + // why it should be right; it is what an operator has to go on when the + // revocation cache misbehaves. + ClientID string + + // NodeID identifies the calling process or host, e.g. "bench-1". Required, + // for the same reason and with the same weight as ClientID. + NodeID string + + // HTTPClient performs the revocation check. Nil means a client with + // defaultHTTPTimeout. + HTTPClient *http.Client + + // CacheTTL is how long one revocation answer is reused. Zero means + // DefaultCacheTTL; negative is refused. + CacheTTL time.Duration + + // Now is the clock the cache ages entries against. Nil means time.Now. + // + // It does not move the expiry check of step 1: auth.DecodeBearerToken reads + // the real clock itself and this package cannot reach inside it. A test that + // wants an expired token has to mint one that is genuinely in the past. + Now func() time.Time +} + +// Validator runs the check of SPEC ch. 6 for one calling service. It is safe for +// concurrent use, which it has to be: a service holds exactly one and every +// request handler goes through it. +type Validator struct { + origin string + clientID string + nodeID string + client *http.Client + ttl time.Duration + now func() time.Time + + mu sync.Mutex + cache map[int]verdict +} + +// verdict is one cached answer from the revocation endpoint: whether the row was +// live, and when that answer stops being reusable. +// +// ErrUnavailable never becomes a verdict — a failure to ask is not an answer, +// and caching it would let one blip, one timeout, one restart pin every token +// that happened to be checked during it to failure for the whole TTL. That turns +// a moment of unavailability into a minute of it, and it does so silently, +// because the daemon is healthy again while the services are still refusing. +type verdict struct { + alive bool + until time.Time +} + +// Token is what validation yields: who the holder is, what the token permits, +// and how long it lasts. +type Token struct { + // Username is the meta.sr.ht account the token was issued to. Resolving it + // to a local row is the service's job (auth.LookupUser). + Username string + + // Grants is the parsed grant set. + // + // It is the zero value — which admits nothing — when the error is + // ErrNotOurs, and that is not an oversight. A foreign token's grant string is + // in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's + // auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one + // would reject as malformed. A service that accepts meta PATs must decode + // that string with auth.DecodeGrants; it can get at it by calling + // auth.DecodeBearerToken on the presented string, which is local and cheap. + Grants grants.Grants + + // TokenID is the row id from the grant string's id: member, or 0 for a + // stateless token — one short enough that tokens.sr.ht never wrote it down + // and that therefore has no revocation to check (SPEC ch. 2). + TokenID int + + // Expires is when the signature stops being honoured. + Expires time.Time +} + +// Registered reports whether this token has a row at tokens.sr.ht — the +// difference between a credential its owner can revoke and one that can only be +// waited out. +func (t *Token) Registered() bool { return t.TokenID != 0 } + +// New builds a Validator, refusing options that would only fail later, one +// request at a time, as an error about the network. +func New(opts Options) (*Validator, error) { + if opts.Origin == "" { + return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es") + } + u, err := url.Parse(opts.Origin) + if err != nil { + return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err) + } + // An origin without a scheme and host is not one, and the failure it causes + // otherwise is a request error on the first registered token some service + // sees — days after the config was written, and reported as ErrUnavailable, + // which points the operator at the daemon rather than at the typo. + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf( + "bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es", + opts.Origin) + } + if opts.ClientID == "" { + return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one") + } + if opts.NodeID == "" { + return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one") + } + if opts.CacheTTL < 0 { + return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL) + } + + v := &Validator{ + origin: strings.TrimSuffix(opts.Origin, "/"), + clientID: opts.ClientID, + nodeID: opts.NodeID, + client: opts.HTTPClient, + ttl: opts.CacheTTL, + now: opts.Now, + cache: make(map[int]verdict), + } + if v.client == nil { + v.client = &http.Client{Timeout: defaultHTTPTimeout} + } + if v.ttl == 0 { + v.ttl = DefaultCacheTTL + } + if v.now == nil { + v.now = time.Now + } + return v, nil +} + +// Validate runs the four steps of SPEC ch. 6 against one presented token for one +// action, e.g. "bench:upload". +// +// presented is the bare credential, with any "Bearer " scheme already stripped. +// +// On success it returns the token and a nil error. On failure it returns one of +// this package's sentinels, wrapped with detail — test with errors.Is, and map +// it to a status with the table on those sentinels. +// +// The returned token is nil for every failure except ErrNotOurs. A token that +// failed validation is not a token, and handing one back invites a caller to use +// what it was just told not to; the exception is the whole point of step 2, +// where the token *did* validate and only the question of whose it is remains. +// +// # Step 2 is not decided here +// +// A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded +// token, and this package takes it no further. That is deliberate and SPEC ch. 6 +// step 2 requires it: what to do with a foreign bearer token is per-service +// policy, not a property of the format. dolt accepts meta.sr.ht PATs today and +// must keep accepting them; bench and cover have no reason to. A validator that +// refused on its own behalf would break the first and look correct doing it, +// because the token really is not one of ours — it just is not this package's +// call. +// +// So a service that also accepts meta PATs writes: +// +// tok, err := v.Validate(ctx, presented, "dolt:push") +// if errors.Is(err, bearer.ErrNotOurs) { +// // ... its own meta-PAT path ... +// } +// +// and one that does not turns ErrNotOurs into 401 alongside ErrInvalid. +func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) { + // Step 1. Signature, version and expiry, all of it local. + // + // DecodeBearerToken returns nil for all three and distinguishes none of + // them, which is the right amount of detail to give a client anyway. It + // checks the expiry itself, against the real clock — so an expired token + // costs one HMAC and never becomes a request to anybody. That property is + // what makes it safe for this step to run before every other. + bt := auth.DecodeBearerToken(presented) + if bt == nil { + return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid) + } + + // Step 2. Ours, or somebody else's? Not our decision — see the doc comment. + if bt.ClientID != TokensClientID { + return &Token{ + Username: bt.Username, + Expires: bt.Expires.Time(), + }, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID) + } + + g, err := grants.Parse(bt.Grants) + if err != nil { + // Only tokens.sr.ht seals a token with our ClientID, and it writes the + // grant string with the same parser that is failing here, so this is + // either a version skew between daemon and service or a bug in one of + // them. Either way it is not a credential this service can act on. + return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err) + } + + tok := &Token{ + Username: bt.Username, + Grants: g, + TokenID: g.TokenID(), + Expires: bt.Expires.Time(), + } + + // Step 3. Does it cover what is being attempted? + if !g.Has(action) { + return nil, fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, g.String()) + } + + // Step 4. Only a registered token has a revocation to check. A stateless one + // has no row, so there is nothing to ask about and nothing to reach for: + // this is the common case and it completes without any network at all. + if tok.TokenID == 0 { + return tok, nil + } + if err := v.checkRevocation(ctx, tok.TokenID); err != nil { + return nil, err + } + return tok, nil +} + +// Forget drops the cached revocation answer for one token id, so that the next +// validation asks the daemon again. +// +// It is for the case where a service learns out of band that an answer is stale +// — a webhook, an operator, a test — and wants the revocation to take effect now +// rather than at the end of the TTL. Forgetting an id that is not cached is a +// no-op, and forgetting one that is only costs a round trip. +func (v *Validator) Forget(id int) { + v.mu.Lock() + delete(v.cache, id) + v.mu.Unlock() +} + +// checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through +// the cache. +// +// 204 is live, 404 is not, and everything else — a 500, a timeout, a refused +// connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has +// exactly two answers by design (SPEC ch. 5), so anything that is neither is not +// a third answer; it is the absence of one. +// +// Two concurrent validations of the same id will both issue a request when the +// entry is cold. That is a duplicated round trip and nothing worse: the answers +// agree, the second write to the cache is idempotent, and collapsing them would +// buy one saved request in exchange for a dependency and a shared failure mode +// where a single slow call holds up every goroutine waiting behind it. +func (v *Validator) checkRevocation(ctx context.Context, id int) error { + if alive, ok := v.cached(id); ok { + if alive { + return nil + } + return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id) + } + + // The internal authorization is minted per request and cannot be cached: it + // is a fernet blob the daemon accepts only for thirty seconds, which is what + // stops a captured one being replayed for a week. + blob, err := json.Marshal(auth.InternalAuth{ClientID: v.clientID, NodeID: v.nodeID}) + if err != nil { + return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err) + } + + url := v.origin + revocationPath + strconv.Itoa(id) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err) + } + req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(blob))) + + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err) + } + defer resp.Body.Close() + // Both answers are empty bodies, but read to the end anyway so the + // connection goes back to the pool instead of being dropped and redialled on + // every upload. + _, _ = io.Copy(io.Discard, resp.Body) + + switch resp.StatusCode { + case http.StatusNoContent: + v.remember(id, true) + return nil + case http.StatusNotFound: + // 404 covers revoked, expired and unknown alike, and all three are + // permanent: no id ever goes back to being live. Caching it is therefore + // not a staleness risk in the way caching "live" is. + v.remember(id, false) + return fmt.Errorf("%w: token %d", ErrRevoked, id) + default: + return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status) + } +} + +// cached returns a still-valid answer for id, if there is one. +func (v *Validator) cached(id int) (alive, ok bool) { + now := v.now() + + v.mu.Lock() + defer v.mu.Unlock() + + e, ok := v.cache[id] + if !ok || !now.Before(e.until) { + return false, false + } + return e.alive, true +} + +// remember stores an answer for CacheTTL, and keeps the cache bounded. +// +// The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated +// plainly: a revocation takes up to CacheTTL to take effect across the instance. +// The alternative is asking the daemon on every request, which puts tokens.sr.ht +// back on the hot path of every upload and makes its availability the +// instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked +// token still working is the price of that, and the operator revoking it should +// be told to expect it. +// +// The bound is a sweep, then a drop. At maxCacheEntries the expired entries go +// first; if that does not get under the bound, the whole map goes. No LRU, no +// eviction list — every entry here is worth exactly one HTTP round trip to +// rebuild and they all expire within CacheTTL anyway, so the cost of throwing +// away a full cache is bounded and small, while the cost of a map that only ever +// grows is a leak in a process meant to run for months. In practice the bound +// never fires: an entry can only be created by a token that already passed an +// HMAC check, so the id space here is the daemon's real rows and not something a +// caller can inflate. +func (v *Validator) remember(id int, alive bool) { + now := v.now() + + v.mu.Lock() + defer v.mu.Unlock() + + if len(v.cache) >= maxCacheEntries { + for k, e := range v.cache { + if !now.Before(e.until) { + delete(v.cache, k) + } + } + if len(v.cache) >= maxCacheEntries { + v.cache = make(map[int]verdict, maxCacheEntries) + } + } + v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)} +} diff --git a/bearer/bearer_test.go b/bearer/bearer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9520a19e0999d0c0d3cb8039ae2da760e9d3faae --- /dev/null +++ b/bearer/bearer_test.go @@ -0,0 +1,599 @@ +package bearer + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +// The identity this validator claims when it calls the daemon — the calling +// service, not tokens.sr.ht. +const ( + callerClientID = "bench.sr.ht" + callerNodeID = "bench-1" +) + +// TestMain initialises the process-global core-go crypto state this package +// depends on and never sets up itself. +// +// Both keys are load-bearing here and for different steps. [webhooks]private-key +// is what auth.BearerToken.Encode signs with and what step 1 verifies against — +// it is derived into the HMAC key, not used directly. [sr.ht]network-key is the +// fernet key that seals the Internal authorization of step 4, so without it the +// revocation tests would not get past the request. The values are the ones +// core-go's own tests use. +func TestMain(m *testing.M) { + config.FS = fstest.MapFS{ + "config.ini": &fstest.MapFile{Data: []byte(` +[webhooks] +private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc= + +[sr.ht] +network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk= +`)}, + } + crypto.InitCrypto(config.LoadConfig()) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// seal mints a token exactly the way tokens.sr.ht's service.seal does, so that +// what these tests present is byte-for-byte the shape a real one has. +func seal(username, clientID, grantString string, expires time.Time) string { + bt := &auth.BearerToken{ + Version: auth.TokenVersion, + Expires: auth.ToTimestamp(expires), + Grants: grantString, + ClientID: clientID, + Username: username, + } + return bt.Encode() +} + +// ourToken is a live working token from this instance's daemon. +func ourToken(grantString string) string { + return seal("bigbes", TokensClientID, grantString, time.Now().Add(time.Hour)) +} + +// daemon is a stand-in for tokens.sr.ht's revocation endpoint. It records every +// request so a test can assert not only what came back but whether anything was +// asked at all — which for step 4 is half of what there is to check. +// +// statuses are consumed one per request, and the last one repeats forever, so +// "fail once then recover" is written as newDaemon(t, 500, 204). +type daemon struct { + server *httptest.Server + + mu sync.Mutex + statuses []int + hits int + paths []string + auths []string +} + +func newDaemon(t *testing.T, statuses ...int) *daemon { + t.Helper() + require.NotEmpty(t, statuses, "a daemon must be told what to answer") + + d := &daemon{statuses: statuses} + d.server = httptest.NewServer(http.HandlerFunc(d.serve)) + t.Cleanup(d.server.Close) + return d +} + +func (d *daemon) serve(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + defer d.mu.Unlock() + + d.paths = append(d.paths, r.URL.Path) + d.auths = append(d.auths, r.Header.Get("Authorization")) + + status := d.statuses[len(d.statuses)-1] + if d.hits < len(d.statuses) { + status = d.statuses[d.hits] + } + d.hits++ + w.WriteHeader(status) +} + +func (d *daemon) count() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.hits +} + +func (d *daemon) lastPath(t *testing.T) string { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + require.NotEmpty(t, d.paths, "the daemon was never asked anything") + return d.paths[len(d.paths)-1] +} + +func (d *daemon) lastAuth(t *testing.T) string { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + require.NotEmpty(t, d.auths, "the daemon was never asked anything") + return d.auths[len(d.auths)-1] +} + +// fakeClock drives the cache's ageing. It does not drive step 1's expiry check: +// auth.DecodeBearerToken reads the real clock and nothing here can reach it. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newClock() *fakeClock { + return &fakeClock{t: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)} +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func newValidator(t *testing.T, origin string, now func() time.Time) *Validator { + t.Helper() + v, err := New(Options{ + Origin: origin, + ClientID: callerClientID, + NodeID: callerNodeID, + Now: now, + }) + require.NoError(t, err) + return v +} + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +func TestNewRefusesOptionsThatWouldOnlyFailLater(t *testing.T) { + good := Options{Origin: "https://tokens.srht.bigb.es", ClientID: callerClientID, NodeID: callerNodeID} + + for name, mutate := range map[string]func(*Options){ + "no origin": func(o *Options) { o.Origin = "" }, + "no scheme": func(o *Options) { o.Origin = "tokens.srht.bigb.es" }, + "wrong scheme": func(o *Options) { o.Origin = "ftp://tokens.srht.bigb.es" }, + "no host": func(o *Options) { o.Origin = "https://" }, + "no client id": func(o *Options) { o.ClientID = "" }, + "no node id": func(o *Options) { o.NodeID = "" }, + "negative ttl": func(o *Options) { o.CacheTTL = -time.Second }, + "unparseable url": func(o *Options) { o.Origin = "https://%zz" }, + } { + t.Run(name, func(t *testing.T) { + opts := good + mutate(&opts) + v, err := New(opts) + require.Error(t, err, "a config mistake must not become a request-time 503") + assert.Nil(t, v) + }) + } +} + +func TestNewFillsTheDefaults(t *testing.T) { + v, err := New(Options{Origin: "https://tokens.srht.bigb.es/", ClientID: callerClientID, NodeID: callerNodeID}) + require.NoError(t, err) + + assert.Equal(t, DefaultCacheTTL, v.ttl, "a zero TTL means the 60s of SPEC ch. 6") + assert.NotNil(t, v.client) + assert.Equal(t, defaultHTTPTimeout, v.client.Timeout, "the default client must not be able to hang") + assert.NotNil(t, v.now) + assert.Equal(t, "https://tokens.srht.bigb.es", v.origin, + "a trailing slash must not become a double slash in the path") +} + +// --------------------------------------------------------------------------- +// Step 1: decode and verify +// --------------------------------------------------------------------------- + +func TestTamperedTokenIsInvalid(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + good := ourToken("bench:upload id:42") + + // Flip one character of the base64. The payload still decodes; the HMAC does + // not verify, which is the whole of what makes the format worth anything. + tampered := []byte(good) + if tampered[3] == 'A' { + tampered[3] = 'B' + } else { + tampered[3] = 'A' + } + + for name, presented := range map[string]string{ + "tampered": string(tampered), + "empty": "", + "not base64": "!!!not-a-token!!!", + "too short": "aGVsbG8", + "grants only": "bench:upload", + } { + t.Run(name, func(t *testing.T) { + tok, err := v.Validate(context.Background(), presented, "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err) + assert.Nil(t, tok, "a token that failed validation must not be handed back") + }) + } + assert.Zero(t, d.count(), "nothing that fails to verify may become a request to the daemon") +} + +// The expiry is checked by auth.DecodeBearerToken against the real clock, which +// is why step 1 needs no network and no clock of ours. +func TestExpiredTokenIsInvalidWithoutAskingAnybody(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + expired := seal("bigbes", TokensClientID, "bench:upload id:42", time.Now().Add(-time.Minute)) + + tok, err := v.Validate(context.Background(), expired, "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err) + assert.Nil(t, tok) + assert.Zero(t, d.count(), "an expired token is refused locally, before any network") +} + +// --------------------------------------------------------------------------- +// Step 2: is it ours? +// --------------------------------------------------------------------------- + +// A meta.sr.ht PAT is signed with the same key and verifies perfectly. Only the +// ClientID tells the two apart, and what to do about it is the service's policy +// — so this returns the token along with the refusal. +func TestForeignClientIDIsNotOursAndComesBackWithTheToken(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + expires := time.Now().Add(time.Hour) + // Meta's grant vocabulary, not ours: this string does not parse under + // grants.Parse, so a validator that checked ClientID after parsing would + // answer ErrInvalid here and a service like dolt would lose the ability to + // tell a foreign token from a broken one. + pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", expires) + + tok, err := v.Validate(context.Background(), pat, "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err) + + require.NotNil(t, tok, "the caller has to be able to look at the token it may still accept") + assert.Equal(t, "bigbes", tok.Username) + assert.Equal(t, expires.UTC().Truncate(time.Second), tok.Expires) + assert.True(t, tok.Grants.Empty(), + "a foreign grant string is in a foreign vocabulary; this one must admit nothing") + assert.Zero(t, tok.TokenID) + assert.Zero(t, d.count(), "a token that is not ours has no revocation of ours to check") +} + +// --------------------------------------------------------------------------- +// Step 3: the grant +// --------------------------------------------------------------------------- + +func TestMissingGrantIsForbiddenAndStopsBeforeTheNetwork(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + // Registered, so step 4 would have work to do — and must not get the chance. + tok, err := v.Validate(context.Background(), ourToken("bench:read id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err) + assert.Nil(t, tok) + assert.Zero(t, d.count(), + "step 3 refuses locally; a token that cannot do the thing must not cost a round trip") +} + +func TestUniversalGrantAdmitsTheAction(t *testing.T) { + v := newValidator(t, "https://tokens.srht.bigb.es", nil) + + tok, err := v.Validate(context.Background(), ourToken("*"), "bench:upload") + require.NoError(t, err) + assert.True(t, tok.Grants.All()) +} + +// --------------------------------------------------------------------------- +// Step 4: revocation +// --------------------------------------------------------------------------- + +// The common case: a short token was never written down, so there is nothing to +// ask and nobody to ask it of. This is the property that keeps tokens.sr.ht off +// the hot path (SPEC ch. 1), so the assertion that matters is the request count. +func TestStatelessTokenNeverTouchesTheNetwork(t *testing.T) { + d := newDaemon(t, http.StatusNotFound) // would refuse, if it were ever asked + v := newValidator(t, d.server.URL, nil) + + tok, err := v.Validate(context.Background(), ourToken("bench:upload cover:read"), "bench:upload") + require.NoError(t, err) + + assert.Equal(t, "bigbes", tok.Username) + assert.Zero(t, tok.TokenID) + assert.False(t, tok.Registered()) + assert.True(t, tok.Grants.Has("cover:read")) + assert.Zero(t, d.count(), "a token with no id: must not reach the daemon at all") +} + +func TestRegisteredTokenValidatesAgainstA204(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.NoError(t, err) + + assert.Equal(t, 42, tok.TokenID) + assert.True(t, tok.Registered()) + assert.Equal(t, 1, d.count()) + assert.Equal(t, "/api/v1/revocations/42", d.lastPath(t)) +} + +func TestRevokedTokenIsRefused(t *testing.T) { + d := newDaemon(t, http.StatusNotFound) + v := newValidator(t, d.server.URL, nil) + + tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRevoked), "want ErrRevoked, got %v", err) + assert.Nil(t, tok) + assert.Equal(t, 1, d.count()) +} + +// A daemon that cannot answer is not a daemon that answered "revoked". Getting +// this wrong would refuse every registered token on the instance for the length +// of a tokens.sr.ht restart, which is why each case asserts what the error is +// *not* as well as what it is. +func TestADaemonThatCannotAnswerIsUnavailableAndNeverRevoked(t *testing.T) { + t.Run("500", func(t *testing.T) { + d := newDaemon(t, http.StatusInternalServerError) + v := newValidator(t, d.server.URL, nil) + + tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) + assert.False(t, errors.Is(err, ErrRevoked), "unknown is not revoked") + assert.Nil(t, tok) + }) + + t.Run("401 from the internal guard", func(t *testing.T) { + // A misconfigured internal-ipnet, or a network key that does not match. + // It is a deployment fault and the operator has to see it as one, not as + // every user's token suddenly being revoked. + d := newDaemon(t, http.StatusUnauthorized) + v := newValidator(t, d.server.URL, nil) + + _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) + assert.False(t, errors.Is(err, ErrRevoked)) + }) + + t.Run("nothing listening", func(t *testing.T) { + dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + origin := dead.URL + dead.Close() // the port is now refusing connections + + v := newValidator(t, origin, nil) + + tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) + assert.False(t, errors.Is(err, ErrRevoked), "a transport failure is not an answer") + assert.Nil(t, tok) + }) + + t.Run("context cancelled", func(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := v.Validate(ctx, ourToken("bench:upload id:42"), "bench:upload") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) + assert.False(t, errors.Is(err, ErrRevoked)) + }) +} + +// The internal authorization is the credential that gets past the daemon's +// guard, and it has to name the *calling* service. If it named something else +// the guard would still admit it — it admits every internal service equally — +// and the daemon's log would quietly attribute every check to the wrong caller. +func TestTheInternalBlobNamesTheConfiguredCaller(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") + require.NoError(t, err) + + scheme, blob, found := strings.Cut(d.lastAuth(t), " ") + require.True(t, found, "the header must be %q, got %q", "Internal ", d.lastAuth(t)) + assert.Equal(t, "Internal", scheme) + + // Decrypted the way authn.InternalGuard does it, expiry window included: the + // blob is minted per request precisely so that this window is meaningful. + payload := crypto.DecryptWithExpiration([]byte(blob), 30*time.Second) + require.NotNil(t, payload, "the blob must verify under the instance's network key, and be fresh") + + var ia auth.InternalAuth + require.NoError(t, json.Unmarshal(payload, &ia)) + assert.Equal(t, callerClientID, ia.ClientID) + assert.Equal(t, callerNodeID, ia.NodeID) +} + +// --------------------------------------------------------------------------- +// The cache +// --------------------------------------------------------------------------- + +func TestARevocationAnswerIsCachedWithinTheTTL(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + clock := newClock() + v := newValidator(t, d.server.URL, clock.now) + + token := ourToken("bench:upload id:42") + for i := 0; i < 5; i++ { + _, err := v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err) + } + assert.Equal(t, 1, d.count(), "five validations inside the TTL are one question") + + clock.advance(DefaultCacheTTL - time.Second) + _, err := v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err) + assert.Equal(t, 1, d.count(), "still inside the TTL") + + clock.advance(2 * time.Second) // now past it + _, err = v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err) + assert.Equal(t, 2, d.count(), "past the TTL the daemon must be asked again") +} + +// The trade SPEC ch. 6 makes on purpose, written down as a test so that nobody +// has to rediscover it during an incident: a revocation takes up to CacheTTL to +// be honoured by a service that already asked. +func TestARevocationTakesUpToTheTTLToTakeEffect(t *testing.T) { + d := newDaemon(t, http.StatusNoContent, http.StatusNotFound) + clock := newClock() + v := newValidator(t, d.server.URL, clock.now) + + token := ourToken("bench:upload id:42") + + _, err := v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err) + + // The owner revokes it here. The daemon would now answer 404 — but it is not + // being asked. + clock.advance(DefaultCacheTTL / 2) + _, err = v.Validate(context.Background(), token, "bench:upload") + assert.NoError(t, err, "inside the TTL the cached 'live' still stands") + assert.Equal(t, 1, d.count()) + + clock.advance(DefaultCacheTTL) + _, err = v.Validate(context.Background(), token, "bench:upload") + assert.True(t, errors.Is(err, ErrRevoked), "past the TTL it must be refused; got %v", err) + assert.Equal(t, 2, d.count()) +} + +// ErrUnavailable must never be cached. If it were, a single blip would pin every +// token checked during it to failure for a full TTL — turning a moment of +// unavailability into a minute of it, with the daemon healthy the whole time. +func TestUnavailableIsNotCachedWhileALiveAnswerIs(t *testing.T) { + d := newDaemon(t, http.StatusInternalServerError, http.StatusNoContent) + clock := newClock() + v := newValidator(t, d.server.URL, clock.now) + + token := ourToken("bench:upload id:42") + + _, err := v.Validate(context.Background(), token, "bench:upload") + require.True(t, errors.Is(err, ErrUnavailable), "got %v", err) + assert.Equal(t, 1, d.count()) + + // Immediately afterwards, no clock movement at all: the failure left nothing + // behind, so the daemon is asked again and the recovery is picked up at once. + _, err = v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err, "a failure to ask must not be remembered as an answer") + assert.Equal(t, 2, d.count()) + + // And the answer that *is* an answer is cached. + _, err = v.Validate(context.Background(), token, "bench:upload") + require.NoError(t, err) + assert.Equal(t, 2, d.count(), "the 204 must be cached") +} + +func TestForgetDropsOneCachedAnswer(t *testing.T) { + d := newDaemon(t, http.StatusNoContent, http.StatusNoContent, http.StatusNotFound) + clock := newClock() + v := newValidator(t, d.server.URL, clock.now) + + first := ourToken("bench:upload id:42") + second := ourToken("bench:upload id:43") + + _, err := v.Validate(context.Background(), first, "bench:upload") + require.NoError(t, err) + _, err = v.Validate(context.Background(), second, "bench:upload") + require.NoError(t, err) + require.Equal(t, 2, d.count()) + + v.Forget(42) + + // 42 is asked again — and the daemon has moved on to answering 404. + _, err = v.Validate(context.Background(), first, "bench:upload") + assert.True(t, errors.Is(err, ErrRevoked), "got %v", err) + assert.Equal(t, 3, d.count()) + + // 43 was not forgotten and is still answered from the cache. + _, err = v.Validate(context.Background(), second, "bench:upload") + assert.NoError(t, err) + assert.Equal(t, 3, d.count()) + + v.Forget(9999) // forgetting what was never cached is a no-op +} + +// The cache must not be a leak in a process that runs for months. The bound is +// a sweep of the expired entries, then — if that was not enough — dropping the +// lot, because every entry costs exactly one round trip to rebuild. +func TestTheCacheIsBounded(t *testing.T) { + clock := newClock() + v := newValidator(t, "https://tokens.srht.bigb.es", clock.now) + + for id := 1; id <= maxCacheEntries*2; id++ { + v.remember(id, true) + } + + v.mu.Lock() + size := len(v.cache) + v.mu.Unlock() + assert.LessOrEqual(t, size, maxCacheEntries, "the cache must not grow without limit") + assert.NotZero(t, size, "and it must still be a cache afterwards") +} + +// A service holds one Validator and every request handler goes through it, so +// "safe for concurrent use" is a requirement rather than a nicety. Worth running +// under -race. +func TestValidatorIsSafeForConcurrentUse(t *testing.T) { + d := newDaemon(t, http.StatusNoContent) + v := newValidator(t, d.server.URL, nil) + + tokens := []string{ + ourToken("bench:upload id:42"), + ourToken("bench:upload id:43"), + ourToken("bench:upload"), + } + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 8; j++ { + _, err := v.Validate(context.Background(), tokens[(i+j)%len(tokens)], "bench:upload") + assert.NoError(t, err) + } + v.Forget(42) + }(i) + } + wg.Wait() +} diff --git a/go.mod b/go.mod index 8ccad81a636a9b8cb06ece36a6e8c50e6cbc9067..a50bea008f9760f315c2f8e79a1ec889a5c829cc 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,15 @@ require ( ) require ( + git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect + github.com/99designs/gqlgen v0.17.36 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 255263e19494a5b2d9a2d68719beda976b7d0f0e..fe75dbaeae628f65195582b8f15fc3af59ca60fe 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,55 @@ +git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw= +git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE= +git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA= +github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ9go= +github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee h1:v6Eju/FhxsACGNipFEPBZZAzGr1F/jlRQr1qiBw2nEE= +github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks= github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4= +github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=