| 1 |
|
// Package bearer validates a tokens.sr.ht working token, and is the one copy of |
| 2 |
|
// that check for every service on the instance (SPEC ch. 6). |
| 3 |
|
// |
| 4 |
|
// A working token is an auth.BearerToken: the version, expiry, grants, client id |
| 5 |
|
// and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and |
| 6 |
|
// the same key meta.sr.ht stamps its own personal access tokens with, so a |
| 7 |
|
// service needs no new cryptographic stack to accept one — and, for the same |
| 8 |
|
// reason, the signature alone does not say who issued the token. Only the client |
| 9 |
|
// id does, which is why step 2 below exists at all. |
| 10 |
|
// |
| 11 |
|
// The four steps, in order, are SPEC ch. 6: |
| 12 |
|
// |
| 13 |
|
// 1. decode and verify (local, no network); |
| 14 |
|
// 2. is this token ours? — if not, the *service's* policy decides; |
| 15 |
|
// 3. does it grant the action being attempted? |
| 16 |
|
// 4. if it is a registered token, is it still live? — the one step that talks |
| 17 |
|
// to tokens.sr.ht, cached, and skipped entirely by a short token. |
| 18 |
|
// |
| 19 |
|
// The order is not an implementation detail. Every step that can refuse locally |
| 20 |
|
// runs before the one that cannot, so the network is touched only for a token |
| 21 |
|
// that has already proved it is well-formed, ours, unexpired and sufficient for |
| 22 |
|
// what its holder is doing. A forged or expired credential never reaches the |
| 23 |
|
// daemon, and an instance under a flood of junk tokens does not turn that flood |
| 24 |
|
// into traffic against tokens.sr.ht. |
| 25 |
|
// |
| 26 |
|
// Usage: |
| 27 |
|
// |
| 28 |
|
// v, err := bearer.New(bearer.Options{ |
| 29 |
|
// Origin: conf.Get("tokens.sr.ht", "origin"), |
| 30 |
|
// ClientID: "bench.sr.ht", |
| 31 |
|
// NodeID: hostname, |
| 32 |
|
// }) |
| 33 |
|
// ... |
| 34 |
|
// tok, err := v.Validate(r.Context(), presented, "bench:upload") |
| 35 |
|
// |
| 36 |
|
// The process must have run crypto.InitCrypto before any of this: the signing |
| 37 |
|
// key of step 1 and the network key that seals the internal authorization of |
| 38 |
|
// step 4 both live in that package's globals. This is the same precondition |
| 39 |
|
// every core-go authentication path carries and it is not checked here, because |
| 40 |
|
// there is nothing this package could usefully do about it at request time. |
| 41 |
|
// |
| 42 |
|
// Resolving the owner is not this package's job. The token names a meta.sr.ht |
| 43 |
|
// username; turning that into a local row is auth.LookupUser plus whatever the |
| 44 |
|
// service's own plan does with a user it has never seen (SPEC ch. 6), and that |
| 45 |
|
// differs per service in ways a shared validator should not have opinions about. |
| 46 |
|
package bearer |
| 47 |
|
|
| 48 |
|
import ( |
| 49 |
|
"context" |
| 50 |
|
"errors" |
| 51 |
|
"fmt" |
| 52 |
|
"io" |
| 53 |
|
"net/http" |
| 54 |
|
"net/url" |
| 55 |
|
"strconv" |
| 56 |
|
"strings" |
| 57 |
|
"sync" |
| 58 |
|
"time" |
| 59 |
|
|
| 60 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 61 |
|
|
| 62 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/grants" |
| 63 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/internalauth" |
| 64 |
|
) |
| 65 |
|
|
| 66 |
|
// TokensClientID is the ClientID tokens.sr.ht stamps into every working token it |
| 67 |
|
// seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the |
| 68 |
|
// two share a signing key, so a valid signature says the instance made the |
| 69 |
|
// token and not which part of it did (SPEC ch. 1). |
| 70 |
|
const TokensClientID = "tokens.sr.ht" |
| 71 |
|
|
| 72 |
|
// DefaultCacheTTL is how long a revocation answer is reused when Options leaves |
| 73 |
|
// CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names. |
| 74 |
|
const DefaultCacheTTL = 60 * time.Second |
| 75 |
|
|
| 76 |
|
// defaultHTTPTimeout bounds a revocation check when the caller supplies no |
| 77 |
|
// client of its own. |
| 78 |
|
// |
| 79 |
|
// It has to be short. This request sits on the hot path of an upload, behind a |
| 80 |
|
// context the caller may not have given a deadline to, and the endpoint it calls |
| 81 |
|
// answers from one indexed row — a healthy daemon replies in single-digit |
| 82 |
|
// milliseconds. Five seconds is generous for that and still short enough that a |
| 83 |
|
// hung tokens.sr.ht turns into 503s rather than into request-handler goroutines |
| 84 |
|
// piling up across every service on the instance. |
| 85 |
|
const defaultHTTPTimeout = 5 * time.Second |
| 86 |
|
|
| 87 |
|
// revocationPath is the endpoint of SPEC ch. 5, joined to Origin. |
| 88 |
|
const revocationPath = "/api/v1/revocations/" |
| 89 |
|
|
| 90 |
|
// maxCacheEntries bounds the revocation cache. See (*Validator).remember for |
| 91 |
|
// what happens at the bound and why that is the right thing to happen. |
| 92 |
|
const maxCacheEntries = 4096 |
| 93 |
|
|
| 94 |
|
// The refusals of SPEC ch. 6, and the status each one is for a service. |
| 95 |
|
// |
| 96 |
|
// They are separate sentinels rather than one error with a code because the |
| 97 |
|
// mapping is not uniform, and the interesting cases are the two that are not |
| 98 |
|
// 401: |
| 99 |
|
// |
| 100 |
|
// - ErrInvalid — 401. The signature did not verify, the version is foreign, or |
| 101 |
|
// the token has expired. |
| 102 |
|
// - ErrNotOurs — the service's own policy, not a status. See Validate. |
| 103 |
|
// - ErrForbidden — 403. The credential is good; it does not cover this action. |
| 104 |
|
// Distinct from 401 because retrying with the same token is pointless and |
| 105 |
|
// the holder needs to be told to ask for a wider grant, not to log in again. |
| 106 |
|
// - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the |
| 107 |
|
// token is no longer a credential at all, and a client that sees 403 will |
| 108 |
|
// keep presenting it. |
| 109 |
|
// - ErrUnavailable — 503. tokens.sr.ht could not be asked. |
| 110 |
|
// |
| 111 |
|
// The 503 is the one that has to be defended, because "I could not check" reads |
| 112 |
|
// so naturally as "so I will not accept it". Reading an unreachable daemon as a |
| 113 |
|
// revocation would refuse every registered token on the instance for as long as |
| 114 |
|
// tokens.sr.ht is down — turning a restart of a service that is deliberately off |
| 115 |
|
// the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says |
| 116 |
|
// 503 for exactly that reason, following core-go, which answers the same way |
| 117 |
|
// when meta.sr.ht cannot be reached. 503 also says the true thing to a client: |
| 118 |
|
// come back, this is us and it is temporary. |
| 119 |
|
// |
| 120 |
|
// This is not a decision to fail open. A short token was never checked against |
| 121 |
|
// the daemon in the first place, and a registered one whose revocation cannot be |
| 122 |
|
// confirmed is refused — with a status that keeps the operator's attention on |
| 123 |
|
// the daemon instead of on a thousand clients being told their credentials are |
| 124 |
|
// bad. |
| 125 |
|
var ( |
| 126 |
|
// ErrInvalid: the presented string is not a token this instance sealed, or |
| 127 |
|
// no longer is one. 401. |
| 128 |
|
ErrInvalid = errors.New("bearer: token does not verify") |
| 129 |
|
|
| 130 |
|
// ErrNotOurs: a well-formed token from another issuer, almost certainly a |
| 131 |
|
// meta.sr.ht PAT. Returned together with the decoded token; the status is |
| 132 |
|
// the service's to choose. |
| 133 |
|
ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht") |
| 134 |
|
|
| 135 |
|
// ErrForbidden: our token, valid, but it does not carry the action. 403. |
| 136 |
|
ErrForbidden = errors.New("bearer: token does not grant this action") |
| 137 |
|
|
| 138 |
|
// ErrRevoked: a registered token whose row is gone, revoked or expired. 401. |
| 139 |
|
ErrRevoked = errors.New("bearer: token has been revoked") |
| 140 |
|
|
| 141 |
|
// ErrUnavailable: the revocation check could not be completed. 503, never |
| 142 |
|
// 401 — see above. |
| 143 |
|
ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached") |
| 144 |
|
) |
| 145 |
|
|
| 146 |
|
// Options configures a Validator. |
| 147 |
|
type Options struct { |
| 148 |
|
// Origin is where tokens.sr.ht answers, scheme and host, e.g. |
| 149 |
|
// "https://tokens.srht.bigb.es". Required. |
| 150 |
|
Origin string |
| 151 |
|
|
| 152 |
|
// ClientID identifies the *calling* service in the internal authorization of |
| 153 |
|
// step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob |
| 154 |
|
// that names neither this nor NodeID. |
| 155 |
|
// |
| 156 |
|
// It is a label, not a credential: the guard admits every internal service |
| 157 |
|
// equally and this only decides what its log line says. Which is precisely |
| 158 |
|
// why it should be right; it is what an operator has to go on when the |
| 159 |
|
// revocation cache misbehaves. |
| 160 |
|
ClientID string |
| 161 |
|
|
| 162 |
|
// NodeID identifies the calling process or host, e.g. "bench-1". Required, |
| 163 |
|
// for the same reason and with the same weight as ClientID. |
| 164 |
|
NodeID string |
| 165 |
|
|
| 166 |
|
// HTTPClient performs the revocation check. Nil means a client with |
| 167 |
|
// defaultHTTPTimeout. |
| 168 |
|
HTTPClient *http.Client |
| 169 |
|
|
| 170 |
|
// CacheTTL is how long one revocation answer is reused. Zero means |
| 171 |
|
// DefaultCacheTTL; negative is refused. |
| 172 |
|
CacheTTL time.Duration |
| 173 |
|
|
| 174 |
|
// Now is the clock the cache ages entries against. Nil means time.Now. |
| 175 |
|
// |
| 176 |
|
// It does not move the expiry check of step 1: auth.DecodeBearerToken reads |
| 177 |
|
// the real clock itself and this package cannot reach inside it. A test that |
| 178 |
|
// wants an expired token has to mint one that is genuinely in the past. |
| 179 |
|
Now func() time.Time |
| 180 |
|
} |
| 181 |
|
|
| 182 |
|
// Validator runs the check of SPEC ch. 6 for one calling service. It is safe for |
| 183 |
|
// concurrent use, which it has to be: a service holds exactly one and every |
| 184 |
|
// request handler goes through it. |
| 185 |
|
type Validator struct { |
| 186 |
|
origin string |
| 187 |
|
clientID string |
| 188 |
|
nodeID string |
| 189 |
|
client *http.Client |
| 190 |
|
ttl time.Duration |
| 191 |
|
now func() time.Time |
| 192 |
|
|
| 193 |
|
mu sync.Mutex |
| 194 |
|
cache map[int]verdict |
| 195 |
|
} |
| 196 |
|
|
| 197 |
|
// verdict is one cached answer from the revocation endpoint: whether the row was |
| 198 |
|
// live, and when that answer stops being reusable. |
| 199 |
|
// |
| 200 |
|
// ErrUnavailable never becomes a verdict — a failure to ask is not an answer, |
| 201 |
|
// and caching it would let one blip, one timeout, one restart pin every token |
| 202 |
|
// that happened to be checked during it to failure for the whole TTL. That turns |
| 203 |
|
// a moment of unavailability into a minute of it, and it does so silently, |
| 204 |
|
// because the daemon is healthy again while the services are still refusing. |
| 205 |
|
type verdict struct { |
| 206 |
|
alive bool |
| 207 |
|
until time.Time |
| 208 |
|
} |
| 209 |
|
|
| 210 |
|
// Token is what validation yields: who the holder is, what the token permits, |
| 211 |
|
// and how long it lasts. |
| 212 |
|
type Token struct { |
| 213 |
|
// Username is the meta.sr.ht account the token was issued to. Resolving it |
| 214 |
|
// to a local row is the service's job (auth.LookupUser). |
| 215 |
|
Username string |
| 216 |
|
|
| 217 |
|
// Grants is the parsed grant set. |
| 218 |
|
// |
| 219 |
|
// It is the zero value — which admits nothing — when the error is |
| 220 |
|
// ErrNotOurs, and that is not an oversight. A foreign token's grant string is |
| 221 |
|
// in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's |
| 222 |
|
// auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one |
| 223 |
|
// would reject as malformed. A service that accepts meta PATs must decode |
| 224 |
|
// that string with auth.DecodeGrants; it can get at it by calling |
| 225 |
|
// auth.DecodeBearerToken on the presented string, which is local and cheap. |
| 226 |
|
Grants grants.Grants |
| 227 |
|
|
| 228 |
|
// TokenID is the row id from the grant string's id: member, or 0 for a |
| 229 |
|
// stateless token — one short enough that tokens.sr.ht never wrote it down |
| 230 |
|
// and that therefore has no revocation to check (SPEC ch. 2). |
| 231 |
|
TokenID int |
| 232 |
|
|
| 233 |
|
// Expires is when the signature stops being honoured. |
| 234 |
|
Expires time.Time |
| 235 |
|
} |
| 236 |
|
|
| 237 |
|
// Registered reports whether this token has a row at tokens.sr.ht — the |
| 238 |
|
// difference between a credential its owner can revoke and one that can only be |
| 239 |
|
// waited out. |
| 240 |
3 |
func (t *Token) Registered() bool { return t.TokenID != 0 } |
| 241 |
|
|
| 242 |
|
// Authorize is step 3 of SPEC ch. 6, split out so that it can be asked where the |
| 243 |
|
// action is known — in the handler that implements it — rather than in the |
| 244 |
|
// middleware that resolved the identity. See Inspect. |
| 245 |
|
// |
| 246 |
|
// It reports ErrForbidden and not ErrInvalid: the credential is good and the |
| 247 |
|
// caller is who they say they are, and what is missing is a permission. That |
| 248 |
|
// distinction is what stops an agent retrying forever with a token that will |
| 249 |
|
// never grow the grant it needs. |
| 250 |
285 |
func (t *Token) Authorize(action string) error { |
| 251 |
285 |
if !t.Grants.Has(action) { |
| 252 |
2 |
return fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, t.Grants.String()) |
| 253 |
2 |
} |
| 254 |
283 |
return nil |
| 255 |
|
} |
| 256 |
|
|
| 257 |
|
// New builds a Validator, refusing options that would only fail later, one |
| 258 |
|
// request at a time, as an error about the network. |
| 259 |
31 |
func New(opts Options) (*Validator, error) { |
| 260 |
31 |
if opts.Origin == "" { |
| 261 |
1 |
return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es") |
| 262 |
1 |
} |
| 263 |
30 |
u, err := url.Parse(opts.Origin) |
| 264 |
30 |
if err != nil { |
| 265 |
1 |
return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err) |
| 266 |
1 |
} |
| 267 |
|
// An origin without a scheme and host is not one, and the failure it causes |
| 268 |
|
// otherwise is a request error on the first registered token some service |
| 269 |
|
// sees — days after the config was written, and reported as ErrUnavailable, |
| 270 |
|
// which points the operator at the daemon rather than at the typo. |
| 271 |
29 |
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { |
| 272 |
3 |
return nil, fmt.Errorf( |
| 273 |
3 |
"bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es", |
| 274 |
3 |
opts.Origin) |
| 275 |
3 |
} |
| 276 |
26 |
if opts.ClientID == "" { |
| 277 |
1 |
return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one") |
| 278 |
1 |
} |
| 279 |
25 |
if opts.NodeID == "" { |
| 280 |
1 |
return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one") |
| 281 |
1 |
} |
| 282 |
24 |
if opts.CacheTTL < 0 { |
| 283 |
1 |
return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL) |
| 284 |
1 |
} |
| 285 |
|
|
| 286 |
23 |
v := &Validator{ |
| 287 |
23 |
origin: strings.TrimSuffix(opts.Origin, "/"), |
| 288 |
23 |
clientID: opts.ClientID, |
| 289 |
23 |
nodeID: opts.NodeID, |
| 290 |
23 |
client: opts.HTTPClient, |
| 291 |
23 |
ttl: opts.CacheTTL, |
| 292 |
23 |
now: opts.Now, |
| 293 |
23 |
cache: make(map[int]verdict), |
| 294 |
23 |
} |
| 295 |
23 |
if v.client == nil { |
| 296 |
23 |
v.client = &http.Client{Timeout: defaultHTTPTimeout} |
| 297 |
23 |
} |
| 298 |
23 |
if v.ttl == 0 { |
| 299 |
23 |
v.ttl = DefaultCacheTTL |
| 300 |
23 |
} |
| 301 |
23 |
if v.now == nil { |
| 302 |
18 |
v.now = time.Now |
| 303 |
18 |
} |
| 304 |
23 |
return v, nil |
| 305 |
|
} |
| 306 |
|
|
| 307 |
|
// Validate runs the four steps of SPEC ch. 6 against one presented token for one |
| 308 |
|
// action, e.g. "bench:upload". |
| 309 |
|
// |
| 310 |
|
// presented is the bare credential, with any "Bearer " scheme already stripped. |
| 311 |
|
// |
| 312 |
|
// On success it returns the token and a nil error. On failure it returns one of |
| 313 |
|
// this package's sentinels, wrapped with detail — test with errors.Is, and map |
| 314 |
|
// it to a status with the table on those sentinels. |
| 315 |
|
// |
| 316 |
|
// The returned token is nil for every failure except ErrNotOurs. A token that |
| 317 |
|
// failed validation is not a token, and handing one back invites a caller to use |
| 318 |
|
// what it was just told not to; the exception is the whole point of step 2, |
| 319 |
|
// where the token *did* validate and only the question of whose it is remains. |
| 320 |
|
// |
| 321 |
|
// # Step 2 is not decided here |
| 322 |
|
// |
| 323 |
|
// A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded |
| 324 |
|
// token, and this package takes it no further. That is deliberate and SPEC ch. 6 |
| 325 |
|
// step 2 requires it: what to do with a foreign bearer token is per-service |
| 326 |
|
// policy, not a property of the format. dolt accepts meta.sr.ht PATs today and |
| 327 |
|
// must keep accepting them; bench and cover have no reason to. A validator that |
| 328 |
|
// refused on its own behalf would break the first and look correct doing it, |
| 329 |
|
// because the token really is not one of ours — it just is not this package's |
| 330 |
|
// call. |
| 331 |
|
// |
| 332 |
|
// So a service that also accepts meta PATs writes: |
| 333 |
|
// |
| 334 |
|
// tok, err := v.Validate(ctx, presented, "dolt:push") |
| 335 |
|
// if errors.Is(err, bearer.ErrNotOurs) { |
| 336 |
|
// // ... its own meta-PAT path ... |
| 337 |
|
// } |
| 338 |
|
// |
| 339 |
|
// and one that does not turns ErrNotOurs into 401 alongside ErrInvalid. |
| 340 |
290 |
func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) { |
| 341 |
290 |
tok, err := decodeOurs(presented) |
| 342 |
290 |
if err != nil { |
| 343 |
7 |
return tok, err |
| 344 |
7 |
} |
| 345 |
|
// Step 3 before step 4, deliberately: a token that does not carry the grant |
| 346 |
|
// is refused without a round trip to the daemon. Inspect cannot keep this |
| 347 |
|
// ordering — it has no action to check — which is the one cost of the split |
| 348 |
|
// and the reason this method still exists for callers that know both. |
| 349 |
283 |
if err := tok.Authorize(action); err != nil { |
| 350 |
1 |
return nil, err |
| 351 |
1 |
} |
| 352 |
282 |
if err := v.revoked(ctx, tok); err != nil { |
| 353 |
8 |
return nil, err |
| 354 |
8 |
} |
| 355 |
274 |
return tok, nil |
| 356 |
|
} |
| 357 |
|
|
| 358 |
|
// Inspect is Validate without step 3: it answers who a token belongs to, what it |
| 359 |
|
// may do, and whether it is still live — and leaves the question of whether that |
| 360 |
|
// covers *this* action to the caller. |
| 361 |
|
// |
| 362 |
|
// It exists because of where the two questions get answered in a sourcehut |
| 363 |
|
// service. Identity is resolved once per request in middleware, upstream of the |
| 364 |
|
// router: that is where the cookie plane and the bearer plane meet and where a |
| 365 |
|
// principal is put on the context, and at that point nothing knows yet which |
| 366 |
|
// route will run, so nothing knows the action. The action is known one layer |
| 367 |
|
// down, in the handler that implements it. A validator that insisted on both at |
| 368 |
|
// once would force every service either to invent an action before it has one, |
| 369 |
|
// or to lift its bearer plane out of the middleware every other plane goes |
| 370 |
|
// through — and the second is how a surface ends up with two different ideas of |
| 371 |
|
// who is calling. |
| 372 |
|
// |
| 373 |
|
// So: call Inspect in the resolver and carry the Grants on the principal, then |
| 374 |
|
// call Token.Authorize in the handler. Validate stays for callers that know both |
| 375 |
|
// at one point, and is exactly those two calls. |
| 376 |
|
// |
| 377 |
|
// Everything Validate's doc comment says — about the sentinels, about step 2 |
| 378 |
|
// being per-service policy, and about the returned token being nil for every |
| 379 |
|
// failure except ErrNotOurs — applies here unchanged. |
| 380 |
3 |
func (v *Validator) Inspect(ctx context.Context, presented string) (*Token, error) { |
| 381 |
3 |
tok, err := decodeOurs(presented) |
| 382 |
3 |
if err != nil { |
| 383 |
1 |
return tok, err |
| 384 |
1 |
} |
| 385 |
2 |
if err := v.revoked(ctx, tok); err != nil { |
| 386 |
0 |
return nil, err |
| 387 |
0 |
} |
| 388 |
2 |
return tok, nil |
| 389 |
|
} |
| 390 |
|
|
| 391 |
|
// revoked is step 4: a registered token has a revocation to ask about, a |
| 392 |
|
// stateless one has no row and so completes without any network at all — which |
| 393 |
|
// is the common case under the default configuration. |
| 394 |
284 |
func (v *Validator) revoked(ctx context.Context, tok *Token) error { |
| 395 |
284 |
if tok.TokenID == 0 { |
| 396 |
88 |
return nil |
| 397 |
88 |
} |
| 398 |
196 |
return v.checkRevocation(ctx, tok.TokenID) |
| 399 |
|
} |
| 400 |
|
|
| 401 |
|
// decodeOurs is steps 1 and 2 plus the grant parse: everything that can be |
| 402 |
|
// decided from the token itself, with no clock but the real one and no network |
| 403 |
|
// at all. |
| 404 |
293 |
func decodeOurs(presented string) (*Token, error) { |
| 405 |
293 |
// Step 1. Signature, version and expiry, all of it local. |
| 406 |
293 |
// |
| 407 |
293 |
// DecodeBearerToken returns nil for all three and distinguishes none of |
| 408 |
293 |
// them, which is the right amount of detail to give a client anyway. It |
| 409 |
293 |
// checks the expiry itself, against the real clock — so an expired token |
| 410 |
293 |
// costs one HMAC and never becomes a request to anybody. That property is |
| 411 |
293 |
// what makes it safe for this step to run before every other. |
| 412 |
293 |
bt := auth.DecodeBearerToken(presented) |
| 413 |
293 |
if bt == nil { |
| 414 |
6 |
return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid) |
| 415 |
6 |
} |
| 416 |
|
|
| 417 |
|
// Step 2. Ours, or somebody else's? Not our decision — see the doc comment. |
| 418 |
287 |
if bt.ClientID != TokensClientID { |
| 419 |
2 |
return &Token{ |
| 420 |
2 |
Username: bt.Username, |
| 421 |
2 |
Expires: bt.Expires.Time(), |
| 422 |
2 |
}, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID) |
| 423 |
2 |
} |
| 424 |
|
|
| 425 |
285 |
g, err := grants.Parse(bt.Grants) |
| 426 |
285 |
if err != nil { |
| 427 |
0 |
// Only tokens.sr.ht seals a token with our ClientID, and it writes the |
| 428 |
0 |
// grant string with the same parser that is failing here, so this is |
| 429 |
0 |
// either a version skew between daemon and service or a bug in one of |
| 430 |
0 |
// them. Either way it is not a credential this service can act on. |
| 431 |
0 |
return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err) |
| 432 |
0 |
} |
| 433 |
|
|
| 434 |
285 |
tok := &Token{ |
| 435 |
285 |
Username: bt.Username, |
| 436 |
285 |
Grants: g, |
| 437 |
285 |
TokenID: g.TokenID(), |
| 438 |
285 |
Expires: bt.Expires.Time(), |
| 439 |
285 |
} |
| 440 |
285 |
|
| 441 |
285 |
return tok, nil |
| 442 |
|
} |
| 443 |
|
|
| 444 |
|
// Forget drops the cached revocation answer for one token id, so that the next |
| 445 |
|
// validation asks the daemon again. |
| 446 |
|
// |
| 447 |
|
// It is for the case where a service learns out of band that an answer is stale |
| 448 |
|
// — a webhook, an operator, a test — and wants the revocation to take effect now |
| 449 |
|
// rather than at the end of the TTL. Forgetting an id that is not cached is a |
| 450 |
|
// no-op, and forgetting one that is only costs a round trip. |
| 451 |
34 |
func (v *Validator) Forget(id int) { |
| 452 |
34 |
v.mu.Lock() |
| 453 |
34 |
delete(v.cache, id) |
| 454 |
34 |
v.mu.Unlock() |
| 455 |
34 |
} |
| 456 |
|
|
| 457 |
|
// checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through |
| 458 |
|
// the cache. |
| 459 |
|
// |
| 460 |
|
// 204 is live, 404 is not, and everything else — a 500, a timeout, a refused |
| 461 |
|
// connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has |
| 462 |
|
// exactly two answers by design (SPEC ch. 5), so anything that is neither is not |
| 463 |
|
// a third answer; it is the absence of one. |
| 464 |
|
// |
| 465 |
|
// Two concurrent validations of the same id will both issue a request when the |
| 466 |
|
// entry is cold. That is a duplicated round trip and nothing worse: the answers |
| 467 |
|
// agree, the second write to the cache is idempotent, and collapsing them would |
| 468 |
|
// buy one saved request in exchange for a dependency and a shared failure mode |
| 469 |
|
// where a single slow call holds up every goroutine waiting behind it. |
| 470 |
196 |
func (v *Validator) checkRevocation(ctx context.Context, id int) error { |
| 471 |
196 |
if alive, ok := v.cached(id); ok { |
| 472 |
130 |
if alive { |
| 473 |
130 |
return nil |
| 474 |
130 |
} |
| 475 |
0 |
return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id) |
| 476 |
|
} |
| 477 |
|
|
| 478 |
|
// The internal authorization is minted per request and cannot be cached: it |
| 479 |
|
// is a fernet blob the daemon accepts only for thirty seconds, which is what |
| 480 |
|
// stops a captured one being replayed for a week. |
| 481 |
|
// |
| 482 |
|
// Through internalauth rather than assembled here, so that this caller and |
| 483 |
|
// every Guard on the instance read one definition of the payload. Minting it |
| 484 |
|
// by hand next to a package whose whole purpose is to hold both ends of this |
| 485 |
|
// handshake is the drift that package exists to prevent. |
| 486 |
66 |
authorization, err := internalauth.Authorization(v.clientID, v.nodeID) |
| 487 |
66 |
if err != nil { |
| 488 |
0 |
return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err) |
| 489 |
0 |
} |
| 490 |
|
|
| 491 |
66 |
url := v.origin + revocationPath + strconv.Itoa(id) |
| 492 |
66 |
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| 493 |
66 |
if err != nil { |
| 494 |
0 |
return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err) |
| 495 |
0 |
} |
| 496 |
66 |
req.Header.Set("Authorization", authorization) |
| 497 |
66 |
|
| 498 |
66 |
resp, err := v.client.Do(req) |
| 499 |
66 |
if err != nil { |
| 500 |
2 |
return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err) |
| 501 |
2 |
} |
| 502 |
64 |
defer resp.Body.Close() |
| 503 |
64 |
// Both answers are empty bodies, but read to the end anyway so the |
| 504 |
64 |
// connection goes back to the pool instead of being dropped and redialled on |
| 505 |
64 |
// every upload. |
| 506 |
64 |
_, _ = io.Copy(io.Discard, resp.Body) |
| 507 |
64 |
|
| 508 |
64 |
switch resp.StatusCode { |
| 509 |
58 |
case http.StatusNoContent: |
| 510 |
58 |
v.remember(id, true) |
| 511 |
58 |
return nil |
| 512 |
3 |
case http.StatusNotFound: |
| 513 |
3 |
// 404 covers revoked, expired and unknown alike, and all three are |
| 514 |
3 |
// permanent: no id ever goes back to being live. Caching it is therefore |
| 515 |
3 |
// not a staleness risk in the way caching "live" is. |
| 516 |
3 |
v.remember(id, false) |
| 517 |
3 |
return fmt.Errorf("%w: token %d", ErrRevoked, id) |
| 518 |
3 |
default: |
| 519 |
3 |
return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status) |
| 520 |
|
} |
| 521 |
|
} |
| 522 |
|
|
| 523 |
|
// cached returns a still-valid answer for id, if there is one. |
| 524 |
196 |
func (v *Validator) cached(id int) (alive, ok bool) { |
| 525 |
196 |
now := v.now() |
| 526 |
196 |
|
| 527 |
196 |
v.mu.Lock() |
| 528 |
196 |
defer v.mu.Unlock() |
| 529 |
196 |
|
| 530 |
196 |
e, ok := v.cache[id] |
| 531 |
196 |
if !ok || !now.Before(e.until) { |
| 532 |
66 |
return false, false |
| 533 |
66 |
} |
| 534 |
130 |
return e.alive, true |
| 535 |
|
} |
| 536 |
|
|
| 537 |
|
// remember stores an answer for CacheTTL, and keeps the cache bounded. |
| 538 |
|
// |
| 539 |
|
// The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated |
| 540 |
|
// plainly: a revocation takes up to CacheTTL to take effect across the instance. |
| 541 |
|
// The alternative is asking the daemon on every request, which puts tokens.sr.ht |
| 542 |
|
// back on the hot path of every upload and makes its availability the |
| 543 |
|
// instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked |
| 544 |
|
// token still working is the price of that, and the operator revoking it should |
| 545 |
|
// be told to expect it. |
| 546 |
|
// |
| 547 |
|
// The bound is a sweep, then a drop. At maxCacheEntries the expired entries go |
| 548 |
|
// first; if that does not get under the bound, the whole map goes. No LRU, no |
| 549 |
|
// eviction list — every entry here is worth exactly one HTTP round trip to |
| 550 |
|
// rebuild and they all expire within CacheTTL anyway, so the cost of throwing |
| 551 |
|
// away a full cache is bounded and small, while the cost of a map that only ever |
| 552 |
|
// grows is a leak in a process meant to run for months. In practice the bound |
| 553 |
|
// never fires: an entry can only be created by a token that already passed an |
| 554 |
|
// HMAC check, so the id space here is the daemon's real rows and not something a |
| 555 |
|
// caller can inflate. |
| 556 |
8253 |
func (v *Validator) remember(id int, alive bool) { |
| 557 |
8253 |
now := v.now() |
| 558 |
8253 |
|
| 559 |
8253 |
v.mu.Lock() |
| 560 |
8253 |
defer v.mu.Unlock() |
| 561 |
8253 |
|
| 562 |
8253 |
if len(v.cache) >= maxCacheEntries { |
| 563 |
4096 |
for k, e := range v.cache { |
| 564 |
4096 |
if !now.Before(e.until) { |
| 565 |
0 |
delete(v.cache, k) |
| 566 |
0 |
} |
| 567 |
|
} |
| 568 |
1 |
if len(v.cache) >= maxCacheEntries { |
| 569 |
1 |
v.cache = make(map[int]verdict, maxCacheEntries) |
| 570 |
1 |
} |
| 571 |
|
} |
| 572 |
8253 |
v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)} |
| 573 |
|
} |