// Package token issues and checks the hub's access tokens: JWTs signed with Ed25519 ("EdDSA"), short-lived. The // public keys are published (JWKS), so catalogs can check tokens without asking the hub. package token import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" ) // Claims are what a token says. type Claims struct { Issuer string `json:"iss"` Subject string `json:"sub"` // "person:" for the hub's own API Audience string `json:"aud"` IssuedAt int64 `json:"iat"` Expires int64 `json:"exp"` Scope string `json:"scope,omitempty"` } // Keys signs and checks tokens. type Keys struct { id string private ed25519.PrivateKey issuer string } // LoadOrCreate reads the signing key from path, making one if there's none. func LoadOrCreate(path, issuer string) (*Keys, error) { seed, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { seed = make([]byte, ed25519.SeedSize) if _, err := rand.Read(seed); err != nil { return nil, err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } if err := os.WriteFile(path, seed, 0o600); err != nil { return nil, err } } else if err != nil { return nil, err } if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("%s: not an Ed25519 seed", path) } key := ed25519.NewKeyFromSeed(seed) pub := key.Public().(ed25519.PublicKey) return &Keys{id: b64(pub[:8]), private: key, issuer: issuer}, nil } func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } // Issue signs a token for subject and audience, valid for ttl. func (k *Keys) Issue(subject, audience, scope string, ttl time.Duration) (string, error) { now := time.Now() header, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT", "kid": k.id}) claims, _ := json.Marshal(Claims{Issuer: k.issuer, Subject: subject, Audience: audience, IssuedAt: now.Unix(), Expires: now.Add(ttl).Unix(), Scope: scope}) signing := b64(header) + "." + b64(claims) return signing + "." + b64(ed25519.Sign(k.private, []byte(signing))), nil } // ErrInvalid means a token is malformed, forged, for someone else, or expired. var ErrInvalid = errors.New("invalid token") // Check verifies a token for audience and returns its claims. func (k *Keys) Check(token, audience string) (*Claims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, ErrInvalid } headerJSON, err1 := base64.RawURLEncoding.DecodeString(parts[0]) claimsJSON, err2 := base64.RawURLEncoding.DecodeString(parts[1]) sig, err3 := base64.RawURLEncoding.DecodeString(parts[2]) if err1 != nil || err2 != nil || err3 != nil { return nil, ErrInvalid } var header struct{ Alg, Kid string } if json.Unmarshal(headerJSON, &header) != nil || header.Alg != "EdDSA" || header.Kid != k.id { return nil, ErrInvalid } if !ed25519.Verify(k.private.Public().(ed25519.PublicKey), []byte(parts[0]+"."+parts[1]), sig) { return nil, ErrInvalid } var c Claims if json.Unmarshal(claimsJSON, &c) != nil || c.Issuer != k.issuer || c.Audience != audience || time.Now().Unix() >= c.Expires { return nil, ErrInvalid } return &c, nil } // JWKS is the public key set, as JSON (RFC 8037 OKP keys). func (k *Keys) JWKS() []byte { pub := k.private.Public().(ed25519.PublicKey) b, _ := json.Marshal(map[string]any{"keys": []map[string]string{{ "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": k.id, "x": b64(pub), }}}) return b } // Random is a random URL-safe string of n bytes' entropy, for session and refresh tokens. func Random(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) } return b64(b) }