A Go server that takes product uploads with hub upload tokens, checks them by t5de-api's pack rules, stores files by sha256, registers revisions with the hub (POST /api/v1/catalog/revisions), and serves files with immutable caching; files used only by adult revisions need an adult access token. Contract: t5de-hub's docs/products.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
8742829e77
20 files changed
+2348
No files matched your search
@@ -0,0 +1,257 @@
|
||||
// Package token checks the hub's tokens: EdDSA (Ed25519) JWTs in compact form, signed with a key from the hub's
|
||||
// JWKS (GET <hub>/.well-known/t5de-keys.json). See t5de-hub's docs/products.md, "Tokens".
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token types.
|
||||
const (
|
||||
Upload = "t5de-upload"
|
||||
Access = "t5de-access"
|
||||
Revision = "t5de-revision"
|
||||
)
|
||||
|
||||
// KeysPath is where the hub publishes its keys.
|
||||
const KeysPath = "/.well-known/t5de-keys.json"
|
||||
|
||||
// Leeway allows for clocks that disagree a little.
|
||||
const Leeway = time.Minute
|
||||
|
||||
// RefetchInterval is the least time between fetches of the hub's keys, however many unknown key IDs show up.
|
||||
const RefetchInterval = 30 * time.Second
|
||||
|
||||
const maxToken = 16 << 10
|
||||
const maxKeys = 1 << 20
|
||||
|
||||
// ErrInvalid wraps every reason a token is refused.
|
||||
var ErrInvalid = errors.New("invalid token")
|
||||
|
||||
// Claims are the claims T5DE's tokens carry.
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Type string `json:"typ"`
|
||||
Subject string `json:"sub"`
|
||||
IssuedAt *int64 `json:"iat"`
|
||||
Expires *int64 `json:"exp"`
|
||||
NotYet *int64 `json:"nbf"`
|
||||
Catalog string `json:"catalog"` // t5de-upload
|
||||
Product string `json:"product"` // t5de-upload (optional), t5de-revision
|
||||
Adult bool `json:"adult"` // t5de-access
|
||||
Revision int `json:"revision"` // t5de-revision
|
||||
Rating string `json:"rating"` // t5de-revision
|
||||
}
|
||||
|
||||
// Verifier checks tokens signed by one hub.
|
||||
type Verifier struct {
|
||||
issuer string
|
||||
keyURL string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
|
||||
fetch sync.Mutex // held while fetching, so one fetch serves everyone waiting
|
||||
mu sync.Mutex
|
||||
keys map[string]ed25519.PublicKey
|
||||
fetched time.Time // when the keys were last fetched (or tried), zero if never
|
||||
}
|
||||
|
||||
// NewVerifier returns a verifier for tokens from the hub at hubURL (its API's address, which is also the tokens'
|
||||
// issuer). client may be nil.
|
||||
func NewVerifier(hubURL string, client *http.Client) *Verifier {
|
||||
hubURL = strings.TrimRight(hubURL, "/")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &Verifier{issuer: hubURL, keyURL: hubURL + KeysPath, client: client, now: time.Now}
|
||||
}
|
||||
|
||||
// SetClock replaces the verifier's clock, for tests.
|
||||
func (v *Verifier) SetClock(now func() time.Time) { v.now = now }
|
||||
|
||||
type header struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
Crit any `json:"crit"`
|
||||
}
|
||||
|
||||
// Verify checks a token of type typ: its signature, issuer, type and times. Tokens of other types than
|
||||
// t5de-revision must have an expiry.
|
||||
func (v *Verifier) Verify(ctx context.Context, token, typ string) (*Claims, error) {
|
||||
c, err := v.verify(ctx, token, typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) verify(ctx context.Context, token, typ string) (*Claims, error) {
|
||||
if len(token) > maxToken {
|
||||
return nil, errors.New("too long")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("not a compact JWT")
|
||||
}
|
||||
var h header
|
||||
if err := decodePart(parts[0], &h); err != nil {
|
||||
return nil, fmt.Errorf("header: %v", err)
|
||||
}
|
||||
if h.Alg != "EdDSA" {
|
||||
return nil, fmt.Errorf("algorithm %q isn't EdDSA", h.Alg)
|
||||
}
|
||||
if h.Crit != nil {
|
||||
return nil, errors.New("unsupported critical header")
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.Strict().DecodeString(parts[2])
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return nil, errors.New("bad signature encoding")
|
||||
}
|
||||
key, err := v.key(ctx, h.Kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ed25519.Verify(key, []byte(parts[0]+"."+parts[1]), sig) {
|
||||
return nil, errors.New("bad signature")
|
||||
}
|
||||
|
||||
var c Claims
|
||||
if err := decodePart(parts[1], &c); err != nil {
|
||||
return nil, fmt.Errorf("claims: %v", err)
|
||||
}
|
||||
if c.Issuer != v.issuer {
|
||||
return nil, fmt.Errorf("issuer %q isn't the hub", c.Issuer)
|
||||
}
|
||||
if c.Type != typ {
|
||||
return nil, fmt.Errorf("type %q isn't %q", c.Type, typ)
|
||||
}
|
||||
now := v.now()
|
||||
if c.Expires == nil {
|
||||
if typ != Revision {
|
||||
return nil, errors.New("no expiry")
|
||||
}
|
||||
} else if !now.Before(time.Unix(*c.Expires, 0).Add(Leeway)) {
|
||||
return nil, errors.New("expired")
|
||||
}
|
||||
if c.NotYet != nil && now.Add(Leeway).Before(time.Unix(*c.NotYet, 0)) {
|
||||
return nil, errors.New("not valid yet")
|
||||
}
|
||||
if c.IssuedAt != nil && now.Add(Leeway).Before(time.Unix(*c.IssuedAt, 0)) {
|
||||
return nil, errors.New("issued in the future")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func decodePart(s string, v any) error {
|
||||
data, err := base64.RawURLEncoding.Strict().DecodeString(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
// key returns the key with ID kid, fetching the hub's keys if it isn't known and they weren't fetched lately.
|
||||
func (v *Verifier) key(ctx context.Context, kid string) (ed25519.PublicKey, error) {
|
||||
if k, ok := v.cached(kid); ok {
|
||||
return k, nil
|
||||
}
|
||||
v.fetch.Lock()
|
||||
defer v.fetch.Unlock()
|
||||
if k, ok := v.cached(kid); ok { // fetched while waiting
|
||||
return k, nil
|
||||
}
|
||||
v.mu.Lock()
|
||||
recent := !v.fetched.IsZero() && v.now().Sub(v.fetched) < RefetchInterval
|
||||
v.mu.Unlock()
|
||||
if recent {
|
||||
return nil, fmt.Errorf("unknown key %q", kid)
|
||||
}
|
||||
keys, err := v.fetchKeys(ctx)
|
||||
v.mu.Lock()
|
||||
v.fetched = v.now()
|
||||
if err == nil {
|
||||
v.keys = keys
|
||||
}
|
||||
v.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching the hub's keys: %v", err)
|
||||
}
|
||||
if k, ok := keys[kid]; ok {
|
||||
return k, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown key %q", kid)
|
||||
}
|
||||
|
||||
func (v *Verifier) cached(kid string) (ed25519.PublicKey, bool) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
k, ok := v.keys[kid]
|
||||
return k, ok
|
||||
}
|
||||
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
Use string `json:"use"`
|
||||
}
|
||||
|
||||
func (v *Verifier) fetchKeys(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.keyURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", v.keyURL, resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxKeys+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxKeys {
|
||||
return nil, errors.New("key set too large")
|
||||
}
|
||||
return ParseKeys(data)
|
||||
}
|
||||
|
||||
// ParseKeys reads the Ed25519 signing keys from a JWKS. Other keys are skipped.
|
||||
func ParseKeys(data []byte) (map[string]ed25519.PublicKey, error) {
|
||||
var set struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
for _, k := range set.Keys {
|
||||
if k.Kty != "OKP" || k.Crv != "Ed25519" || k.Kid == "" {
|
||||
continue
|
||||
}
|
||||
if (k.Alg != "" && k.Alg != "EdDSA") || (k.Use != "" && k.Use != "sig") {
|
||||
continue
|
||||
}
|
||||
x, err := base64.RawURLEncoding.Strict().DecodeString(k.X)
|
||||
if err != nil || len(x) != ed25519.PublicKeySize {
|
||||
continue
|
||||
}
|
||||
keys[k.Kid] = ed25519.PublicKey(x)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-catalog/internal/token"
|
||||
"git.t5de.dev/t5de/t5de-catalog/internal/token/tokentest"
|
||||
)
|
||||
|
||||
type hub struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
keys []*tokentest.Key
|
||||
fetches atomic.Int32
|
||||
}
|
||||
|
||||
func newHub(t *testing.T, keys ...*tokentest.Key) *hub {
|
||||
h := &hub{keys: keys}
|
||||
h.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != token.KeysPath {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.fetches.Add(1)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
w.Write(tokentest.JWKS(h.keys...))
|
||||
}))
|
||||
t.Cleanup(h.Close)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *hub) setKeys(keys ...*tokentest.Key) {
|
||||
h.mu.Lock()
|
||||
h.keys = keys
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
var now = time.Unix(1_800_000_000, 0)
|
||||
|
||||
func claims(h *hub, typ string, extra map[string]any) map[string]any {
|
||||
c := map[string]any{"iss": h.URL, "typ": typ, "sub": "person1", "iat": now.Unix(), "exp": now.Add(30 * time.Minute).Unix()}
|
||||
for k, v := range extra {
|
||||
if v == nil {
|
||||
delete(c, k)
|
||||
} else {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func verifier(h *hub, url string) *token.Verifier {
|
||||
v := token.NewVerifier(url, h.Client())
|
||||
v.SetClock(func() time.Time { return now })
|
||||
return v
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
key := tokentest.NewKey("k1")
|
||||
other := tokentest.NewKey("k1") // same ID, different key
|
||||
h := newHub(t, key)
|
||||
v := verifier(h, h.URL+"/") // a trailing slash in HUB_URL is fine
|
||||
|
||||
c, err := v.Verify(context.Background(), key.Sign(claims(h, token.Upload, map[string]any{"catalog": "cat1", "product": "abcdefghij"})), token.Upload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Subject != "person1" || c.Catalog != "cat1" || c.Product != "abcdefghij" {
|
||||
t.Fatalf("claims: %+v", c)
|
||||
}
|
||||
c, err = v.Verify(context.Background(), key.Sign(claims(h, token.Access, map[string]any{"adult": true})), token.Access)
|
||||
if err != nil || !c.Adult {
|
||||
t.Fatalf("access: %+v, %v", c, err)
|
||||
}
|
||||
// Revision tokens don't expire.
|
||||
if _, err := v.Verify(context.Background(), key.Sign(claims(h, token.Revision, map[string]any{"exp": nil})), token.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bad := map[string]string{
|
||||
"wrong type": key.Sign(claims(h, token.Access, nil)),
|
||||
"wrong issuer": key.Sign(claims(h, token.Upload, map[string]any{"iss": "https://evil.example"})),
|
||||
"expired": key.Sign(claims(h, token.Upload, map[string]any{"exp": now.Add(-2 * time.Minute).Unix()})),
|
||||
"no expiry": key.Sign(claims(h, token.Upload, map[string]any{"exp": nil})),
|
||||
"not yet": key.Sign(claims(h, token.Upload, map[string]any{"nbf": now.Add(time.Hour).Unix()})),
|
||||
"future iat": key.Sign(claims(h, token.Upload, map[string]any{"iat": now.Add(time.Hour).Unix()})),
|
||||
"other key": other.Sign(claims(h, token.Upload, nil)),
|
||||
"alg none": key.SignWithHeader(map[string]any{"alg": "none", "kid": "k1"}, claims(h, token.Upload, nil)),
|
||||
"alg HS256": key.SignWithHeader(map[string]any{"alg": "HS256", "kid": "k1"}, claims(h, token.Upload, nil)),
|
||||
"crit": key.SignWithHeader(map[string]any{"alg": "EdDSA", "kid": "k1", "crit": []string{"x"}}, claims(h, token.Upload, nil)),
|
||||
"two parts": "a.b",
|
||||
"garbage": "not a token at all",
|
||||
"empty signature": strings.Join(strings.Split(key.Sign(claims(h, token.Upload, nil)), ".")[:2], ".") + ".",
|
||||
}
|
||||
for name, tok := range bad {
|
||||
if _, err := v.Verify(context.Background(), tok, token.Upload); !errors.Is(err, token.ErrInvalid) {
|
||||
t.Errorf("%s: got %v, want ErrInvalid", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A tampered payload fails the signature.
|
||||
parts := strings.Split(key.Sign(claims(h, token.Access, map[string]any{"adult": false})), ".")
|
||||
forged := key.Sign(claims(h, token.Access, map[string]any{"adult": true}))
|
||||
tampered := parts[0] + "." + strings.Split(forged, ".")[1] + "." + parts[2]
|
||||
if _, err := v.Verify(context.Background(), tampered, token.Access); !errors.Is(err, token.ErrInvalid) {
|
||||
t.Errorf("tampered: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryLeeway(t *testing.T) {
|
||||
key := tokentest.NewKey("k1")
|
||||
h := newHub(t, key)
|
||||
v := verifier(h, h.URL)
|
||||
tok := key.Sign(claims(h, token.Upload, map[string]any{"exp": now.Add(-30 * time.Second).Unix()}))
|
||||
if _, err := v.Verify(context.Background(), tok, token.Upload); err != nil {
|
||||
t.Fatalf("within leeway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRotation(t *testing.T) {
|
||||
k1, k2 := tokentest.NewKey("k1"), tokentest.NewKey("k2")
|
||||
h := newHub(t, k1)
|
||||
clock := now
|
||||
v := token.NewVerifier(h.URL, h.Client())
|
||||
v.SetClock(func() time.Time { return clock })
|
||||
|
||||
for range 3 {
|
||||
if _, err := v.Verify(context.Background(), k1.Sign(claims(h, token.Upload, nil)), token.Upload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := h.fetches.Load(); n != 1 {
|
||||
t.Fatalf("keys fetched %d times, want 1 (cached)", n)
|
||||
}
|
||||
|
||||
// An unknown kid right after a fetch isn't fetched again (rate limit).
|
||||
h.setKeys(k1, k2)
|
||||
for range 5 {
|
||||
if _, err := v.Verify(context.Background(), k2.Sign(claims(h, token.Upload, nil)), token.Upload); err == nil {
|
||||
t.Fatal("unknown key accepted before refetch")
|
||||
}
|
||||
}
|
||||
if n := h.fetches.Load(); n != 1 {
|
||||
t.Fatalf("keys fetched %d times, want 1 (rate-limited)", n)
|
||||
}
|
||||
|
||||
// Later, an unknown kid fetches the keys again.
|
||||
clock = clock.Add(token.RefetchInterval)
|
||||
if _, err := v.Verify(context.Background(), k2.Sign(claims(h, token.Upload, nil)), token.Upload); err != nil {
|
||||
t.Fatalf("rotated key: %v", err)
|
||||
}
|
||||
if n := h.fetches.Load(); n != 2 {
|
||||
t.Fatalf("keys fetched %d times, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeys(t *testing.T) {
|
||||
k := tokentest.NewKey("good")
|
||||
data := []byte(`{"keys":[
|
||||
{"kty":"RSA","kid":"rsa","n":"x","e":"AQAB"},
|
||||
{"kty":"OKP","crv":"X25519","kid":"x","x":"` + strings.Repeat("A", 43) + `"},
|
||||
{"kty":"OKP","crv":"Ed25519","kid":"short","x":"AAAA"},
|
||||
{"kty":"OKP","crv":"Ed25519","kid":"enc","use":"enc","x":"` + strings.Repeat("A", 43) + `"}
|
||||
]}`)
|
||||
keys, err := token.ParseKeys(data)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("got %v, %v; want no keys", keys, err)
|
||||
}
|
||||
keys, err = token.ParseKeys(tokentest.JWKS(k))
|
||||
if err != nil || !keys["good"].Equal(k.Public) {
|
||||
t.Fatalf("got %v, %v", keys, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package tokentest signs tokens the way the hub does, for tests.
|
||||
package tokentest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Key is a test signing key.
|
||||
type Key struct {
|
||||
ID string
|
||||
Private ed25519.PrivateKey
|
||||
Public ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewKey makes a key with ID kid.
|
||||
func NewKey(kid string) *Key {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &Key{ID: kid, Private: priv, Public: pub}
|
||||
}
|
||||
|
||||
// Sign returns a compact EdDSA JWT with these claims, with the header the hub sends.
|
||||
func (k *Key) Sign(claims map[string]any) string {
|
||||
return k.SignWithHeader(map[string]any{"alg": "EdDSA", "kid": k.ID, "typ": "JWT"}, claims)
|
||||
}
|
||||
|
||||
// SignWithHeader signs claims with any header.
|
||||
func (k *Key) SignWithHeader(header, claims map[string]any) string {
|
||||
input := part(header) + "." + part(claims)
|
||||
return input + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(k.Private, []byte(input)))
|
||||
}
|
||||
|
||||
func part(v any) string {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// JWKS returns a key set holding these keys, as the hub serves it.
|
||||
func JWKS(keys ...*Key) []byte {
|
||||
var set struct {
|
||||
Keys []map[string]string `json:"keys"`
|
||||
}
|
||||
set.Keys = []map[string]string{}
|
||||
for _, k := range keys {
|
||||
set.Keys = append(set.Keys, map[string]string{
|
||||
"kty": "OKP", "crv": "Ed25519", "x": base64.RawURLEncoding.EncodeToString(k.Public),
|
||||
"kid": k.ID, "alg": "EdDSA", "use": "sig",
|
||||
})
|
||||
}
|
||||
data, _ := json.Marshal(set)
|
||||
return data
|
||||
}
|
||||
Reference in new issue
Block a user