~bigbes/sr-ht-ecore

ref: 89fa694cbf548ecd642b8101825b7a49b48f975a sr-ht-ecore/metapat/metapat.go -rw-r--r-- 22.6 KiB
89fa694c — Eugene Blikh metapat: the meta.sr.ht PAT plane a federated endpoint needs a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
// Package metapat validates a meta.sr.ht personal access token, and is the one
// copy of that check for every service on the instance that accepts one beside
// a tokens.sr.ht working token.
//
// # Why a service needs both planes
//
// The instance seals two bearer shapes with the same key, and only the ClientID
// tells them apart (bearer.TokensClientID). Which one a surface accepts is not a
// matter of taste:
//
//   - api.sr.ht forwards ONE client "Authorization" header to every service a
//     federated query touches — its AuthMiddleware copies the header verbatim
//     into the request context, and the Internal credential it can mint is used
//     only to fetch schemas at startup. So a federated query carries whatever
//     credential the client had, to all of its services at once. A GraphQL
//     endpoint that refuses meta PATs therefore cannot be federated: the first
//     authenticated query that reaches it answers 401.
//   - Every upstream service on the instance authenticates machine callers with
//     a meta PAT, so a meta PAT is the only credential a client can hold that
//     works instance-wide.
//
// A tokens.sr.ht working token remains the credential of the surfaces that are
// not federated — the REST uploads and the MCP endpoints — because those are
// where a narrow, short-lived, revocable grant is worth its cost. This package
// is what lets one service hold both without writing the PAT path four times.
//
// # What this package does and does not decide
//
// It answers exactly one question: is this presented string a live meta.sr.ht
// personal access token, and whose? Resolving that into the service's own notion
// of a caller, choosing an HTTP status for each refusal, and deciding what the
// caller may then see are all the service's, as they are for bearer.
//
// The steps, in order, and the order is the point — everything that can refuse
// locally runs before anything that touches the network:
//
//  1. decode and verify the signature and expiry (local, no network);
//  2. is this a PAT at all, or a working token wearing the same envelope?
//  3. mirror the owner's profile from meta.sr.ht;
//  4. ask meta.sr.ht whether the token has been revoked.
//
// Steps 3 and 4 are cached together for CacheTTL, so a burst of federated
// queries carrying one PAT costs one pair of lookups rather than one per field
// resolver.
//
// Scope enforcement is deliberately not part of resolution. A PAT carries
// core-go's OAuth grant vocabulary ("cov.sr.ht/REPORTS:RO"), the surface knows
// which scope and which mode it is about to exercise, and Allows is where the
// two meet.
//
// Usage:
//
//	v, err := metapat.New(metapat.Options{Service: "cov.sr.ht"})
//	...
//	switch metapat.PlaneOf(presented) {
//	case metapat.PlaneWorking:
//	        tok, err := workingTokens.Inspect(ctx, presented)
//	        ...
//	case metapat.PlaneMeta:
//	        ac, err := v.Resolve(ctx, presented)
//	        if err == nil && !metapat.Allows(ac, "cov.sr.ht/REPORTS", auth.RO) {
//	                // 403
//	        }
//	}
//
// The process must have run crypto.InitCrypto before any of this: the signing
// key step 1 verifies against lives 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.
package metapat

import (
	"context"
	"crypto/sha512"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)

// DefaultCacheTTL is how long one resolution is reused when Options leaves
// CacheTTL at zero.
//
// Sixty seconds, matching the figure the tokens SPEC names for the working-token
// revocation check, and for the same trade: it bounds how long a revoked
// credential keeps working, against how hard a single agent's request loop hits
// meta.sr.ht.
const DefaultCacheTTL = 60 * time.Second

// maxCacheEntries bounds the resolution 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 this package, 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 they are spelled to mirror bearer's, so that a
// service holding both planes writes one classification table and not two:
//
//   - ErrInvalid — 401. The signature did not verify, the token has expired, or
//     it names an account meta.sr.ht will not resolve.
//   - ErrNotOurs — the service's own policy, not a status. See Resolve.
//   - ErrForbidden — 403. The credential is good; its OAuth grants do not cover
//     what is being attempted. Returned by callers of Allows, never by Resolve.
//   - ErrRevoked — 401. The token was withdrawn by its owner.
//   - ErrUnavailable — 503. meta.sr.ht could not be asked.
//
// The 503 is the one that has to be defended, for the reason bearer's own
// sentinels give at length: "I could not check" is not "your credential is bad",
// and answering 401 to a meta.sr.ht outage tells every client on the instance to
// go and re-mint credentials that were never broken. It is also what core-go
// itself answers when meta cannot be reached, so a service that classifies this
// way stays consistent with the upstream services beside it.
var (
	// ErrInvalid: the presented string is not a personal access token this
	// instance sealed, or no longer is one. 401.
	ErrInvalid = errors.New("metapat: token does not verify")

	// ErrNotOurs: a well-formed token sealed by tokens.sr.ht rather than by
	// meta.sr.ht. Returned so that a service which routes on failure rather than
	// on PlaneOf still gets a total answer; the status is the service's to
	// choose, and for a service holding both planes it is not a refusal at all.
	ErrNotOurs = errors.New("metapat: token was issued by tokens.sr.ht, not meta.sr.ht")

	// ErrForbidden: the token is good and does not carry the scope. 403. This
	// package returns it from no function — Allows answers a bool — and it is
	// exported so that the service's refusal has a sentinel to wrap that belongs
	// to the same table as the rest.
	ErrForbidden = errors.New("metapat: token does not carry the required OAuth scope")

	// ErrRevoked: meta.sr.ht reports the token as revoked. 401 and not 403: it
	// is no longer a credential at all, and a client shown 403 will keep
	// presenting it.
	ErrRevoked = errors.New("metapat: token has been revoked")

	// ErrUnavailable: the profile mirror or the revocation check could not be
	// completed. 503, never 401 — see above.
	ErrUnavailable = errors.New("metapat: meta.sr.ht could not be reached")
)

// Plane says which of the instance's two bearer planes sealed a credential.
type Plane int

const (
	// PlaneUnknown: the string does not decode as a bearer token this instance
	// sealed at all — forged, corrupted, expired, or simply not a token.
	PlaneUnknown Plane = iota

	// PlaneWorking: a tokens.sr.ht working token.
	PlaneWorking

	// PlaneMeta: a meta.sr.ht personal access token.
	PlaneMeta
)

// String names the plane, for a log line.
func (p Plane) String() string {
	switch p {
	case PlaneWorking:
		return "tokens.sr.ht working token"
	case PlaneMeta:
		return "meta.sr.ht personal access token"
	default:
		return "unrecognised credential"
	}
}

// PlaneOf reports which plane a presented credential belongs to, without
// resolving it — one local HMAC and no network.
//
// This is how a service routes, and routing here rather than on the failure of
// one plane matters for a reason that is easy to miss: an instance whose config
// has no [tokens.sr.ht] section holds no working-token validator at all, and its
// meta PAT plane must keep working anyway. A service that routed by calling the
// working-token validator first and catching bearer.ErrNotOurs would have
// nothing to call.
//
// PlaneUnknown is not a verdict about the credential's issuer, only about this
// process's ability to read it. An expired token of either plane lands here,
// because auth.DecodeBearerToken checks expiry before it reports anything — so a
// service should answer PlaneUnknown with the same 401 it gives ErrInvalid,
// rather than treating it as "no credential presented".
func PlaneOf(presented string) Plane {
	bt := auth.DecodeBearerToken(presented)
	if bt == nil {
		return PlaneUnknown
	}
	if bt.ClientID == bearer.TokensClientID {
		return PlaneWorking
	}
	return PlaneMeta
}

// Backend is the meta.sr.ht half of the check, declared here as an interface so
// that every arm of Resolve is testable without a meta.sr.ht, without a network
// and without a database.
//
// Both methods are core-go calls in production (CoreBackend), and both may hit
// the network: LookupUser falls back to an internal GraphQL query when the local
// mirror misses, and IsRevoked always asks.
type Backend interface {
	// LookupUser mirrors a meta.sr.ht profile into out, filling in at least
	// UserID and Username. An error is transient by contract — the account may
	// exist and meta may simply be unreachable.
	LookupUser(ctx context.Context, username string, out *auth.AuthContext) error

	// IsRevoked reports whether the personal access token with this sha512 has
	// been revoked by its owner. clientID is the token's own, which is what
	// scopes the revocation row.
	IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error)
}

// coreBackend is the production Backend: core-go, unadorned.
type coreBackend struct{}

// Compile-time proof that the production backend satisfies the port. Its two
// methods are the only lines in this package a test cannot reach — they need a
// meta.sr.ht — so this is what stands between them and a signature drift.
var _ Backend = coreBackend{}

func (coreBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
	return auth.LookupUser(ctx, username, out)
}

func (coreBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) {
	return auth.LookupTokenRevocation(ctx, username, hash, clientID)
}

// CoreBackend returns the production backend, the one Options selects when
// Backend is nil. It is exported so that a service wrapping it — to add a metric
// or a log line — has something to embed.
func CoreBackend() Backend { return coreBackend{} }

// Options configures a Validator.
type Options struct {
	// Service is this service's name as meta.sr.ht spells it in a grant,
	// e.g. "cov.sr.ht". Required.
	//
	// It is required for a reason that is invisible until it is not:
	// auth.DecodeGrants reads the *calling* service's name off the context, to
	// expand a grant written without one, and config.ServiceName PANICS rather
	// than returning "" when nothing put it there. In production nothing puts it
	// there except core-go's config.Middleware, so a validator that relied on the
	// ambient context would work behind an HTTP router and take the process down
	// anywhere else — a background job, a CLI, a test. Naming the service here
	// makes this package answerable to its own caller instead.
	Service string

	// Backend performs the meta.sr.ht lookups. Nil means CoreBackend().
	Backend Backend

	// CacheTTL is how long one resolution 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 resolves meta.sr.ht personal access tokens for one 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 {
	service string
	backend Backend
	ttl     time.Duration
	now     func() time.Time

	mu    sync.Mutex
	cache map[[64]byte]entry
}

// entry is one cached resolution: the caller it produced, and when that stops
// being reusable.
//
// Only successes are cached. A failure to reach meta is not an answer, and
// caching it would let one blip pin every token checked during it to failure for
// the whole TTL — turning a moment of unavailability into a minute of it, while
// meta is already healthy again. A genuine refusal is not cached either: it
// costs one local HMAC to reproduce, and the alternative is a data structure
// that an attacker can grow by presenting garbage.
type entry struct {
	ac    *auth.AuthContext
	until time.Time
}

// New builds a Validator, refusing options that would only fail later.
func New(opts Options) (*Validator, error) {
	if opts.Service == "" {
		return nil, errors.New(
			"metapat: Service is required, e.g. cov.sr.ht: decoding a grant string needs it")
	}
	if opts.CacheTTL < 0 {
		return nil, fmt.Errorf("metapat: CacheTTL %s is negative; zero means %s",
			opts.CacheTTL, DefaultCacheTTL)
	}

	v := &Validator{
		service: opts.Service,
		backend: opts.Backend,
		ttl:     opts.CacheTTL,
		now:     opts.Now,
		cache:   make(map[[64]byte]entry),
	}
	if v.backend == nil {
		v.backend = CoreBackend()
	}
	if v.ttl == 0 {
		v.ttl = DefaultCacheTTL
	}
	if v.now == nil {
		v.now = time.Now
	}
	return v, nil
}

// Resolve runs the four steps against one presented personal access token.
//
// presented is the bare credential, with any "Bearer " scheme already stripped.
//
// On success it returns an *auth.AuthContext with AuthMethod, BearerToken,
// TokenHash and Grants filled in — the same shape core-go's own OAuth2
// middleware produces, so that everything downstream which already understands
// an OAuth2 caller keeps working, Allows included.
//
// On failure it returns one of this package's sentinels, wrapped with detail:
// test with errors.Is and map to a status with the table on those sentinels. The
// returned context is nil for every failure, including ErrNotOurs — a service
// that meant to accept a working token must route with PlaneOf and call its
// working-token validator, which is the only thing that can check one.
//
// # What is not checked here
//
// The token's own username is taken as the identity. There is no second name in
// a bearer header to compare it against — that check belongs to the Basic-auth
// flows, where a token is presented as somebody's password and the point is to
// stop it being presented as somebody else's.
//
// The OAuth scope is not checked either. See Allows.
func (v *Validator) Resolve(ctx context.Context, presented string) (*auth.AuthContext, error) {
	if presented == "" {
		return nil, fmt.Errorf("%w: no token presented", ErrInvalid)
	}

	hash := sha512.Sum512([]byte(presented))
	if ac, ok := v.cached(hash); ok {
		return ac, nil
	}

	// Step 1, local: signature and expiry. A forged or expired credential costs
	// one HMAC and never becomes a request to meta.sr.ht.
	bt := auth.DecodeBearerToken(presented)
	if bt == nil {
		return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalid)
	}

	// Step 2. Refused rather than attempted: a working token's grant string is
	// in tokens.sr.ht's vocabulary, which auth.DecodeGrants would reject as
	// malformed, and its revocation row lives at a different daemon entirely.
	if bt.ClientID == bearer.TokensClientID {
		return nil, fmt.Errorf("%w: ClientID is %q", ErrNotOurs, bt.ClientID)
	}

	// Step 3. The token names a meta.sr.ht account; turning that into a local
	// row is core-go's job, through the same call every other plane makes.
	var ac auth.AuthContext
	if err := v.backend.LookupUser(ctx, bt.Username, &ac); err != nil {
		// Transient. The credential is good, and telling an agent to re-mint
		// over a lookup outage is the wrong instruction twice: it does not help,
		// and it destroys a working credential.
		return nil, fmt.Errorf("%w: looking up user %q: %w", ErrUnavailable, bt.Username, err)
	}
	if ac.UserID == 0 {
		// LookupUser answered without filling in an id. Nothing downstream can
		// use that: every ownership row keys on the user id, and a zero would
		// match whichever row has an unset owner. Permanent rather than
		// transient — retrying will not conjure the account back.
		return nil, fmt.Errorf("%w: token names %q, for whom no meta id was mirrored",
			ErrInvalid, bt.Username)
	}

	// Step 4.
	revoked, err := v.backend.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
	if err != nil {
		return nil, fmt.Errorf("%w: checking revocation for %q: %w", ErrUnavailable, bt.Username, err)
	}
	if revoked {
		return nil, fmt.Errorf("%w: token of %q", ErrRevoked, bt.Username)
	}

	grants, err := auth.DecodeGrants(v.grantContext(ctx), bt.Grants)
	if err != nil {
		return nil, fmt.Errorf("%w: decoding token grants: %w", ErrInvalid, err)
	}

	ac.AuthMethod = auth.AUTH_OAUTH2
	ac.BearerToken = bt
	ac.TokenHash = hash
	ac.Grants = grants

	v.remember(hash, &ac)
	return copyOf(&ac), nil
}

// grantContext derives the context auth.DecodeGrants insists on: one naming the
// calling service, which it uses to expand a grant written without a service
// prefix, and which config.ServiceName panics for the absence of.
//
// The config half is deliberately empty. DecodeGrants reads only the name, the
// derived context never leaves this call, and carrying a real ini.File through
// Options just to satisfy a field nothing reads would make every caller supply
// one. If core-go ever starts reading the config here, this is where it stops
// being enough — which is why it is one named function and not an inline
// expression.
//
// Overwriting rather than inspecting is forced: both context keys are
// unexported, so there is no way to ask whether a name is already present that
// does not go through the function that panics. Overwriting is also correct —
// what would already be there is this same service's name, put there by
// config.Middleware on the request path.
func (v *Validator) grantContext(ctx context.Context) context.Context {
	return config.Context(ctx, ini.File{}, v.service)
}

// Forget drops any cached resolution of this token, so that the next Resolve
// asks meta.sr.ht again.
//
// It exists for the service that learns out of band — from a webhook, from its
// own revocation UI — that a credential has changed, and would otherwise keep
// honouring it for up to CacheTTL. Forgetting a token that was never cached is a
// no-op rather than an error.
func (v *Validator) Forget(presented string) {
	hash := sha512.Sum512([]byte(presented))
	v.mu.Lock()
	defer v.mu.Unlock()
	delete(v.cache, hash)
}

// cached returns a live cached resolution, if there is one.
func (v *Validator) cached(hash [64]byte) (*auth.AuthContext, bool) {
	v.mu.Lock()
	defer v.mu.Unlock()

	e, ok := v.cache[hash]
	if !ok {
		return nil, false
	}
	if !v.now().Before(e.until) {
		delete(v.cache, hash)
		return nil, false
	}
	return copyOf(e.ac), true
}

// remember caches one successful resolution.
//
// At maxCacheEntries the cache is dropped whole rather than evicted by age. The
// bound is not a tuning knob and reaching it is not the steady state: a service
// sees a handful of distinct credentials, and four thousand of them means either
// an instance far larger than this one or a caller minting a token per request.
// Dropping everything costs one round of re-resolution and cannot degrade into
// the thing an LRU can — a cache that spends more time evicting than answering,
// under exactly the load that filled it.
func (v *Validator) remember(hash [64]byte, ac *auth.AuthContext) {
	v.mu.Lock()
	defer v.mu.Unlock()

	if len(v.cache) >= maxCacheEntries {
		v.cache = make(map[[64]byte]entry, maxCacheEntries)
	}
	v.cache[hash] = entry{ac: copyOf(ac), until: v.now().Add(v.ttl)}
}

// copyOf returns a shallow copy, so that a caller which annotates the context it
// was handed — core-go's own middleware sets IPAddress on one — does not write
// through into the cache and hand the next caller somebody else's address.
//
// Shallow is enough and deep would be wrong. The pointer fields are the mirrored
// profile and the decoded token, which are read-only facts about the account and
// the credential; auth.Grants holds a map, and its only methods read it.
func copyOf(ac *auth.AuthContext) *auth.AuthContext {
	if ac == nil {
		return nil
	}
	c := *ac
	return &c
}

// Allows reports whether a resolved caller's OAuth grants permit acting on scope
// at mode — the gate that complements whatever the service's own access matrix
// decides. A caller must pass both.
//
// scope is the full grant name as meta.sr.ht spells it, service included:
// "cov.sr.ht/REPORTS". The service part is not optional in practice even though
// core-go will fill it in from the ambient config when it is missing, because
// what it fills in is the *calling* service's name read off a context — which is
// right in a service talking about itself and silently wrong everywhere else,
// including in a test. Spell it out.
//
// mode is auth.RO or auth.RW; core-go panics on anything else.
//
// A caller carrying no OAuth grants at all passes unconditionally, and that is
// not a hole in either of the two ways it happens:
//
//   - A cookie session, an anonymous request, or a tokens.sr.ht working token
//     resolved by the other plane has no BearerToken. It was never scoped by
//     meta's vocabulary and cannot be judged in it; a working token is scoped by
//     its own grants, asked for separately.
//   - A personal access token minted with no grants selected is universal by
//     core-go's definition (auth.Grants.HasAll), exactly as it is for every
//     upstream service on the instance.
func Allows(ac *auth.AuthContext, scope, mode string) bool {
	if ac == nil || ac.BearerToken == nil {
		return true
	}
	return ac.Grants.Has(scope, mode)
}

// Scope assembles the grant name of one scope on one service — Scope("cov.sr.ht",
// "REPORTS") is "cov.sr.ht/REPORTS".
//
// It exists so that the two spellings a service must keep in agreement are built
// from the same halves: the scope it publishes in api-meta.json, which meta.sr.ht
// turns into a checkbox by prefixing the service name itself, and the grant name
// it checks here. A service should assert them equal in a test rather than hope.
func Scope(service, scope string) string {
	return service + "/" + scope
}

// ScopeName returns the bare scope of a full grant name — the half a service
// publishes in api-meta.json. ScopeName("cov.sr.ht/REPORTS") is "REPORTS".
//
// A name with no service prefix is returned unchanged, which is what makes this
// safe to apply to a value that may already be bare.
func ScopeName(scope string) string {
	if _, after, ok := strings.Cut(scope, "/"); ok {
		return after
	}
	return scope
}