~bigbes/sr-ht-ecore

ref: 4d7f4c4a405728f5211b17884366523df40e284e sr-ht-ecore/bearer/bearer.go -rw-r--r-- 23.3 KiB
4d7f4c4a — Eugene Blikh assets: the shared hashed-asset discovery and cache policy 10 days 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
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
// 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 }

// Authorize is step 3 of SPEC ch. 6, split out so that it can be asked where the
// action is known — in the handler that implements it — rather than in the
// middleware that resolved the identity. See Inspect.
//
// It reports ErrForbidden and not ErrInvalid: the credential is good and the
// caller is who they say they are, and what is missing is a permission. That
// distinction is what stops an agent retrying forever with a token that will
// never grow the grant it needs.
func (t *Token) Authorize(action string) error {
	if !t.Grants.Has(action) {
		return fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, t.Grants.String())
	}
	return nil
}

// 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) {
	tok, err := decodeOurs(presented)
	if err != nil {
		return tok, err
	}
	// Step 3 before step 4, deliberately: a token that does not carry the grant
	// is refused without a round trip to the daemon. Inspect cannot keep this
	// ordering — it has no action to check — which is the one cost of the split
	// and the reason this method still exists for callers that know both.
	if err := tok.Authorize(action); err != nil {
		return nil, err
	}
	if err := v.revoked(ctx, tok); err != nil {
		return nil, err
	}
	return tok, nil
}

// Inspect is Validate without step 3: it answers who a token belongs to, what it
// may do, and whether it is still live — and leaves the question of whether that
// covers *this* action to the caller.
//
// It exists because of where the two questions get answered in a sourcehut
// service. Identity is resolved once per request in middleware, upstream of the
// router: that is where the cookie plane and the bearer plane meet and where a
// principal is put on the context, and at that point nothing knows yet which
// route will run, so nothing knows the action. The action is known one layer
// down, in the handler that implements it. A validator that insisted on both at
// once would force every service either to invent an action before it has one,
// or to lift its bearer plane out of the middleware every other plane goes
// through — and the second is how a surface ends up with two different ideas of
// who is calling.
//
// So: call Inspect in the resolver and carry the Grants on the principal, then
// call Token.Authorize in the handler. Validate stays for callers that know both
// at one point, and is exactly those two calls.
//
// Everything Validate's doc comment says — about the sentinels, about step 2
// being per-service policy, and about the returned token being nil for every
// failure except ErrNotOurs — applies here unchanged.
func (v *Validator) Inspect(ctx context.Context, presented string) (*Token, error) {
	tok, err := decodeOurs(presented)
	if err != nil {
		return tok, err
	}
	if err := v.revoked(ctx, tok); err != nil {
		return nil, err
	}
	return tok, nil
}

// revoked is step 4: a registered token has a revocation to ask about, a
// stateless one has no row and so completes without any network at all — which
// is the common case under the default configuration.
func (v *Validator) revoked(ctx context.Context, tok *Token) error {
	if tok.TokenID == 0 {
		return nil
	}
	return v.checkRevocation(ctx, tok.TokenID)
}

// decodeOurs is steps 1 and 2 plus the grant parse: everything that can be
// decided from the token itself, with no clock but the real one and no network
// at all.
func decodeOurs(presented 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(),
	}

	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)}
}