mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
fix(auth): reject non-canonical base64 in bearer tokens
Go's base64 decoders are lenient in two independent ways, and both JWT verifiers used them raw, so one credential validated under several spellings. The unused low bits of the final quantum are ignored: an HMAC-SHA256 signature is 32 bytes in 43 base64url characters, leaving 2 slack bits and so 4 accepted strings per admin token; an RSA-2048 signature is 256 bytes in 342 characters, leaving 4 slack bits and so 16 per IAM token. Separately, \r and \n are skipped wherever they appear, which Encoding.Strict() does not close -- so Strict alone would not have been a fix. Every string-keyed structure over a credential silently splits under that: a denylist misses the spellings it was not handed, an audit log reads one actor as several, a rate limiter multiplies the budget by the class size. pkg/token holds the one canonical encoding. Decode round-trips rather than enumerating failure modes -- a segment is canonical exactly when it is the encoding of the bytes it decodes to -- and rejects rather than normalizes. token.ID is the hash to key revocation, audit and rate limiting on, so the identity is never the submitted string. Both verifiers now decode every segment through it. JWKS n/e stays lenient on purpose: public key material from the trusted IAM endpoint, where every spelling is the same key. Also in pkg/admin: sign returns raw MAC bytes instead of text, dropping an encode-then-decode round trip in the verify path; the header is now decoded and required to declare HS256, closing alg confusion under future refactor; and a comment referencing a long-deleted hashPassword is gone. Tests: exactly one spelling authenticates on both paths; revocation keyed on token.ID holds across all four; CR/LF, padding, standard alphabet and empty segments rejected. TestValidateToken_TamperedSignature substituted a fixed 'X' that was a no-op whenever the token already ended in one -- a 1-in-64 flake that this bug made a 1-in-16 one -- now derived from the token. LLM.md: admin auth section corrected (bcrypt cost 12, not SHA-256+salt; no ADMIN_* env vars exist; middleware strips those headers rather than setting them; package is not mounted). Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -77,11 +77,41 @@ if am, ok := p.(provider.AccountManager); ok { ... }
|
|||||||
- `handlers_extended.go` -- All capability-based route handlers
|
- `handlers_extended.go` -- All capability-based route handlers
|
||||||
- `handlers_funding.go` -- Payment processor deposit/withdraw
|
- `handlers_funding.go` -- Payment processor deposit/withdraw
|
||||||
|
|
||||||
|
### Bearer token encoding (pkg/token/)
|
||||||
|
One canonical text encoding for every bearer credential: unpadded base64url,
|
||||||
|
and nothing else. `token.Decode` rejects any other spelling of the same bytes;
|
||||||
|
`token.ID` is the hash to key revocation, audit, dedup and rate limiting on.
|
||||||
|
|
||||||
|
The rule exists because Go's base64 decoders are lenient in two independent
|
||||||
|
ways, and a lenient decoder hands out one credential under several names:
|
||||||
|
|
||||||
|
- the unused low bits of the final quantum are ignored, so an HS256 signature
|
||||||
|
(32 bytes in 43 chars, 2 slack bits) has 4 spellings and an RS256 signature
|
||||||
|
(256 bytes in 342 chars, 4 slack bits) has 16;
|
||||||
|
- `\r` and `\n` are skipped wherever they appear, which `Encoding.Strict()`
|
||||||
|
does **not** close — so Strict alone is not a fix.
|
||||||
|
|
||||||
|
`Decode` round-trips instead: a segment is canonical exactly when it is the
|
||||||
|
encoding of the bytes it decodes to. One predicate, nothing to enumerate.
|
||||||
|
Non-canonical input is rejected, never normalized.
|
||||||
|
|
||||||
|
Both JWT verifiers decode every segment through it. The one deliberate
|
||||||
|
exception is the JWKS `n`/`e` in `pkg/auth` — public key material from the
|
||||||
|
trusted IAM endpoint, where every spelling yields the same key and rejecting
|
||||||
|
one would take the whole JWKS offline.
|
||||||
|
|
||||||
|
Never key a denylist, audit record, dedup cache or rate-limit bucket on a
|
||||||
|
submitted token string, and never write the token itself to a log or a column.
|
||||||
|
|
||||||
### Admin Auth (pkg/admin/)
|
### Admin Auth (pkg/admin/)
|
||||||
- `admin.go` -- JWT auth (HMAC-SHA256), password hashing (SHA-256 + salt, never plaintext)
|
- `admin.go` -- HS256 JWT; passwords bcrypt cost 12, never plaintext
|
||||||
- Admin users configured via ADMIN_USERNAME + ADMIN_PASSWORD env vars
|
- `NewStore(jwtSecret)` takes the signing secret from the caller; source it
|
||||||
- JWT secret from ADMIN_SECRET env var (required for production)
|
from KMS. No env var, no default, no fallback.
|
||||||
- Middleware validates Bearer tokens, sets X-Admin-User/X-Admin-Role headers
|
- The verifier requires `alg: HS256` and never dispatches on the token header
|
||||||
|
- Middleware **strips** X-Admin-User/X-Admin-Role and passes claims through
|
||||||
|
the request context — claims never travel as headers
|
||||||
|
- **Not mounted.** Nothing imports this package; `cmd/brokerd` does not serve
|
||||||
|
an admin surface. Wire it up and the whole path goes live at once.
|
||||||
|
|
||||||
### Provider Registration from Env (pkg/provider/envconfig/)
|
### Provider Registration from Env (pkg/provider/envconfig/)
|
||||||
`envconfig.RegisterFromEnv(registry)` reads all 16 provider env vars and registers
|
`envconfig.RegisterFromEnv(registry)` reads all 16 provider env vars and registers
|
||||||
|
|||||||
+32
-22
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,8 +14,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/luxfi/broker/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// alg is the only signature algorithm this package issues or accepts. The
|
||||||
|
// verifier never dispatches on the token's own header — it requires this.
|
||||||
|
const alg = "HS256"
|
||||||
|
|
||||||
// contextKey is an unexported type for context keys in this package.
|
// contextKey is an unexported type for context keys in this package.
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
@@ -99,29 +104,37 @@ func (s *Store) Authenticate(username, password string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateToken validates a JWT and returns the claims.
|
// ValidateToken validates a JWT and returns the claims.
|
||||||
|
//
|
||||||
|
// Every segment is decoded canonically: a token is its bytes, and exactly one
|
||||||
|
// string encodes those bytes. Accepting a second spelling would hand out one
|
||||||
|
// credential under several identities, which is what breaks a denylist.
|
||||||
func (s *Store) ValidateToken(tokenStr string) (*Claims, error) {
|
func (s *Store) ValidateToken(tokenStr string) (*Claims, error) {
|
||||||
parts := strings.Split(tokenStr, ".")
|
parts := strings.Split(tokenStr, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid token format")
|
return nil, fmt.Errorf("invalid token format")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature using constant-time comparison to prevent timing attacks
|
headerJSON, err := token.Decode(parts[0])
|
||||||
signingInput := parts[0] + "." + parts[1]
|
if err != nil {
|
||||||
expectedSig := sign([]byte(signingInput), s.secret)
|
return nil, fmt.Errorf("invalid token header")
|
||||||
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
}
|
||||||
|
var header struct {
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(headerJSON, &header); err != nil || header.Alg != alg {
|
||||||
|
return nil, fmt.Errorf("invalid token header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time compare of raw MAC bytes — never of their text.
|
||||||
|
sig, err := token.Decode(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid token signature")
|
return nil, fmt.Errorf("invalid token signature")
|
||||||
}
|
}
|
||||||
expectedBytes, err := base64.RawURLEncoding.DecodeString(expectedSig)
|
if !hmac.Equal(sig, sign([]byte(parts[0]+"."+parts[1]), s.secret)) {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid token signature")
|
|
||||||
}
|
|
||||||
if !hmac.Equal(sigBytes, expectedBytes) {
|
|
||||||
return nil, fmt.Errorf("invalid token signature")
|
return nil, fmt.Errorf("invalid token signature")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode claims
|
claimsJSON, err := token.Decode(parts[1])
|
||||||
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid token payload")
|
return nil, fmt.Errorf("invalid token payload")
|
||||||
}
|
}
|
||||||
@@ -179,7 +192,7 @@ func Middleware(store *Store) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) generateJWT(admin *Admin) (string, error) {
|
func (s *Store) generateJWT(admin *Admin) (string, error) {
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
header := token.Encode([]byte(`{"alg":"` + alg + `","typ":"JWT"}`))
|
||||||
|
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
Sub: admin.Username,
|
Sub: admin.Username,
|
||||||
@@ -188,23 +201,20 @@ func (s *Store) generateJWT(admin *Admin) (string, error) {
|
|||||||
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
claimsJSON, _ := json.Marshal(claims)
|
claimsJSON, _ := json.Marshal(claims)
|
||||||
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
payload := token.Encode(claimsJSON)
|
||||||
|
|
||||||
signingInput := header + "." + payload
|
signingInput := header + "." + payload
|
||||||
signature := sign([]byte(signingInput), s.secret)
|
return signingInput + "." + token.Encode(sign([]byte(signingInput), s.secret)), nil
|
||||||
|
|
||||||
return signingInput + "." + signature, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(data, secret []byte) string {
|
// sign returns the raw MAC. Bytes are the value; encoding happens only at the
|
||||||
|
// wire boundary, so nothing downstream can compare or key on the text.
|
||||||
|
func sign(data, secret []byte) []byte {
|
||||||
mac := hmac.New(sha256.New, secret)
|
mac := hmac.New(sha256.New, secret)
|
||||||
mac.Write(data)
|
mac.Write(data)
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return mac.Sum(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashPassword is kept only for backward-compatible token validation.
|
|
||||||
// New passwords are always stored as bcrypt hashes via AddAdmin.
|
|
||||||
|
|
||||||
// LoginHandler returns an http.HandlerFunc that accepts POST {username, password}
|
// LoginHandler returns an http.HandlerFunc that accepts POST {username, password}
|
||||||
// and returns {token: "jwt..."} on success.
|
// and returns {token: "jwt..."} on success.
|
||||||
func LoginHandler(store *Store) http.HandlerFunc {
|
func LoginHandler(store *Store) http.HandlerFunc {
|
||||||
|
|||||||
+165
-2
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/luxfi/broker/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testSecret = "test-jwt-secret-32bytes-minimum!"
|
const testSecret = "test-jwt-secret-32bytes-minimum!"
|
||||||
@@ -174,8 +176,14 @@ func TestValidateToken_TamperedSignature(t *testing.T) {
|
|||||||
|
|
||||||
token, _ := s.Authenticate("alice", "pass123")
|
token, _ := s.Authenticate("alice", "pass123")
|
||||||
|
|
||||||
// Replace last character of signature to tamper with it
|
// Replace the last character of the signature with a different one.
|
||||||
tampered := token[:len(token)-1] + "X"
|
// Picking a fixed letter would silently be a no-op whenever the token
|
||||||
|
// already ends in it, so derive the substitute from what is there.
|
||||||
|
sub := byte('A')
|
||||||
|
if token[len(token)-1] == sub {
|
||||||
|
sub = 'B'
|
||||||
|
}
|
||||||
|
tampered := token[:len(token)-1] + string(sub)
|
||||||
|
|
||||||
_, err := s.ValidateToken(tampered)
|
_, err := s.ValidateToken(tampered)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -502,3 +510,158 @@ func signForTest(data, secret []byte) string {
|
|||||||
mac.Write(data)
|
mac.Write(data)
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Token canonicality ---
|
||||||
|
//
|
||||||
|
// An HMAC-SHA256 signature is 32 bytes carried in 43 base64url characters:
|
||||||
|
// 258 bits of room for 256 bits of MAC. The 2 spare bits in the final
|
||||||
|
// character mean four distinct strings decode to the identical MAC. A lenient
|
||||||
|
// decoder accepts all four, so one credential arrives wearing four names and
|
||||||
|
// every string-keyed denylist, audit record and rate-limit bucket splits.
|
||||||
|
|
||||||
|
// respellSig returns the four token strings whose signature segments decode
|
||||||
|
// to the same MAC bytes, canonical first.
|
||||||
|
func respellSig(t *testing.T, tok string) []string {
|
||||||
|
t.Helper()
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
|
dot := strings.LastIndex(tok, ".")
|
||||||
|
sig := tok[dot+1:]
|
||||||
|
if len(sig) != 43 {
|
||||||
|
t.Fatalf("signature segment is %d chars, want 43 for a 32-byte MAC", len(sig))
|
||||||
|
}
|
||||||
|
want, err := base64.RawURLEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode signature: %v", err)
|
||||||
|
}
|
||||||
|
last := strings.IndexByte(alphabet, sig[len(sig)-1])
|
||||||
|
out := []string{tok}
|
||||||
|
for b := 0; b < 4; b++ {
|
||||||
|
v := tok[:dot+1] + sig[:len(sig)-1] + string(alphabet[(last&^3)|b])
|
||||||
|
if v == tok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, err := base64.RawURLEncoding.DecodeString(v[dot+1:])
|
||||||
|
if err != nil || !hmac.Equal(got, want) {
|
||||||
|
t.Fatalf("test premise: respelling %q does not decode to the same MAC", v[len(v)-4:])
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if len(out) != 4 {
|
||||||
|
t.Fatalf("built %d spellings, want 4", len(out))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateToken_AcceptsExactlyOneSpelling(t *testing.T) {
|
||||||
|
s := NewStore(testSecret)
|
||||||
|
if err := s.AddAdmin("root", "hunter2hunter2", "super_admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, err := s.Authenticate("root", "hunter2hunter2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted := 0
|
||||||
|
for i, v := range respellSig(t, tok) {
|
||||||
|
_, err := s.ValidateToken(v)
|
||||||
|
if err == nil {
|
||||||
|
accepted++
|
||||||
|
if i != 0 {
|
||||||
|
t.Errorf("accepted non-canonical spelling ...%q", v[len(v)-4:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accepted != 1 {
|
||||||
|
t.Fatalf("%d of 4 spellings authenticate as the same credential, want exactly 1", accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateToken_RevocationHoldsAcrossSpellings is the operational failure
|
||||||
|
// the malleability caused: an operator revokes the token they were handed and
|
||||||
|
// the holder keeps using the other three. Revocation is keyed on token.ID —
|
||||||
|
// the hash of the bytes — never on the submitted string.
|
||||||
|
func TestValidateToken_RevocationHoldsAcrossSpellings(t *testing.T) {
|
||||||
|
s := NewStore(testSecret)
|
||||||
|
if err := s.AddAdmin("root", "hunter2hunter2", "super_admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, err := s.Authenticate("root", "hunter2hunter2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked := map[string]bool{token.ID(tok): true}
|
||||||
|
authenticates := func(submitted string) bool {
|
||||||
|
if _, err := s.ValidateToken(submitted); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !revoked[token.ID(submitted)]
|
||||||
|
}
|
||||||
|
|
||||||
|
if authenticates(tok) {
|
||||||
|
t.Fatal("revoked token still authenticates in its canonical spelling")
|
||||||
|
}
|
||||||
|
for _, v := range respellSig(t, tok)[1:] {
|
||||||
|
if authenticates(v) {
|
||||||
|
t.Errorf("revoked token still authenticates as ...%q", v[len(v)-4:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateToken_RejectsWhitespaceInSegments(t *testing.T) {
|
||||||
|
s := NewStore(testSecret)
|
||||||
|
if err := s.AddAdmin("root", "hunter2hunter2", "super_admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, _ := s.Authenticate("root", "hunter2hunter2")
|
||||||
|
dot := strings.LastIndex(tok, ".")
|
||||||
|
|
||||||
|
// Go's base64 decoder skips \r and \n wherever they appear, so these
|
||||||
|
// carry the same MAC bytes. Reachable over any carrier that permits them
|
||||||
|
// (query string, JSON body, cookie) even though HTTP headers do not.
|
||||||
|
for _, v := range []string{
|
||||||
|
tok[:dot+1] + "\n" + tok[dot+1:],
|
||||||
|
tok + "\n",
|
||||||
|
tok[:dot+10] + "\r\n" + tok[dot+10:],
|
||||||
|
} {
|
||||||
|
if _, err := s.ValidateToken(v); err == nil {
|
||||||
|
t.Errorf("accepted a signature segment containing CR/LF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verifier is HMAC-SHA256 and never dispatches on the token's own header,
|
||||||
|
// so a foreign alg is rejected outright rather than honoured.
|
||||||
|
func TestValidateToken_RejectsForeignAlg(t *testing.T) {
|
||||||
|
s := NewStore(testSecret)
|
||||||
|
if err := s.AddAdmin("root", "hunter2hunter2", "super_admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
header := token.Encode([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||||
|
claimsJSON, _ := json.Marshal(Claims{Sub: "root", Role: "super_admin", Exp: time.Now().Add(time.Hour).Unix()})
|
||||||
|
signingInput := header + "." + token.Encode(claimsJSON)
|
||||||
|
forged := signingInput + "." + token.Encode(sign([]byte(signingInput), s.secret))
|
||||||
|
|
||||||
|
if _, err := s.ValidateToken(forged); err == nil {
|
||||||
|
t.Fatal("accepted a correctly-signed token declaring alg=none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateToken_RejectsEmptySegments(t *testing.T) {
|
||||||
|
s := NewStore(testSecret)
|
||||||
|
if err := s.AddAdmin("root", "hunter2hunter2", "super_admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, _ := s.Authenticate("root", "hunter2hunter2")
|
||||||
|
parts := strings.Split(tok, ".")
|
||||||
|
|
||||||
|
for i := range parts {
|
||||||
|
blanked := append([]string(nil), parts...)
|
||||||
|
blanked[i] = ""
|
||||||
|
if _, err := s.ValidateToken(strings.Join(blanked, ".")); err == nil {
|
||||||
|
t.Errorf("accepted a token with segment %d empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-3
@@ -13,6 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/broker/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jwksCache caches RSA public keys from IAM JWKS, keyed by kid.
|
// jwksCache caches RSA public keys from IAM JWKS, keyed by kid.
|
||||||
@@ -113,7 +115,7 @@ func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
|
|||||||
return nil, fmt.Errorf("malformed token")
|
return nil, fmt.Errorf("malformed token")
|
||||||
}
|
}
|
||||||
|
|
||||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
headerBytes, err := token.Decode(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bad header")
|
return nil, fmt.Errorf("bad header")
|
||||||
}
|
}
|
||||||
@@ -133,7 +135,7 @@ func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
sig, err := token.Decode(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bad signature")
|
return nil, fmt.Errorf("bad signature")
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@ func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
|
|||||||
return nil, fmt.Errorf("signature invalid")
|
return nil, fmt.Errorf("signature invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
claimBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
claimBytes, err := token.Decode(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bad claims")
|
return nil, fmt.Errorf("bad claims")
|
||||||
}
|
}
|
||||||
@@ -210,6 +212,10 @@ func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
|
|||||||
if k.Kty != "RSA" {
|
if k.Kty != "RSA" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Deliberately the lenient decoder, not token.Decode: n and e are
|
||||||
|
// public key material from the trusted IAM endpoint, so every
|
||||||
|
// spelling yields the same key and canonicality buys nothing —
|
||||||
|
// while rejecting one would take the whole JWKS offline.
|
||||||
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
|
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/broker/pkg/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
func okHandler(w http.ResponseWriter, r *http.Request) {
|
func okHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -156,3 +168,108 @@ func searchString(s, substr string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- IAM JWT canonicality (the mounted path: pkg/api and pkg/compliance) ---
|
||||||
|
|
||||||
|
// newJWKS starts a JWKS endpoint for a fresh RSA key and returns its URL
|
||||||
|
// alongside a signer that mints canonical RS256 tokens against it.
|
||||||
|
func newJWKS(t *testing.T) (jwksURL string, mint func(claims string) string, key *rsa.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The JWKS cache is package-level and keyed on kid alone, so tests that
|
||||||
|
// reuse a kid would serve each other's keys. (Operationally this also
|
||||||
|
// means a key rotated in place under an unchanged kid is not picked up
|
||||||
|
// until the one-hour cache expiry.)
|
||||||
|
kid := t.Name()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
|
||||||
|
"kty": "RSA", "kid": kid,
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
|
||||||
|
}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
mint = func(claims string) string {
|
||||||
|
hdr := token.Encode([]byte(`{"alg":"RS256","typ":"JWT","kid":"` + kid + `"}`))
|
||||||
|
body := hdr + "." + token.Encode([]byte(claims))
|
||||||
|
digest := sha256.Sum256([]byte(body))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return body + "." + token.Encode(sig)
|
||||||
|
}
|
||||||
|
return srv.URL, mint, key
|
||||||
|
}
|
||||||
|
|
||||||
|
// An RSA-2048 signature is 256 bytes carried in 342 base64url characters:
|
||||||
|
// 2052 bits of room for 2048 bits of signature. The 4 spare bits in the final
|
||||||
|
// character mean sixteen distinct strings decode to the identical signature.
|
||||||
|
func TestValidateJWT_AcceptsExactlyOneSpelling(t *testing.T) {
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
|
|
||||||
|
jwksURL, mint, _ := newJWKS(t)
|
||||||
|
tok := mint(`{"sub":"u1","iss":"i","exp":` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `}`)
|
||||||
|
|
||||||
|
dot := strings.LastIndex(tok, ".")
|
||||||
|
sig := tok[dot+1:]
|
||||||
|
if len(sig) != 342 {
|
||||||
|
t.Fatalf("signature segment is %d chars, want 342 for a 256-byte signature", len(sig))
|
||||||
|
}
|
||||||
|
want, err := base64.RawURLEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
last := strings.IndexByte(alphabet, sig[len(sig)-1])
|
||||||
|
|
||||||
|
accepted, ids := 0, map[string]bool{}
|
||||||
|
for b := 0; b < 16; b++ {
|
||||||
|
v := tok[:dot+1] + sig[:len(sig)-1] + string(alphabet[(last&^15)|b])
|
||||||
|
got, err := base64.RawURLEncoding.DecodeString(v[dot+1:])
|
||||||
|
if err != nil || string(got) != string(want) {
|
||||||
|
t.Fatalf("test premise: respelling %d does not decode to the same signature", b)
|
||||||
|
}
|
||||||
|
ids[v] = true
|
||||||
|
if _, err := ValidateJWT(v, jwksURL); err == nil {
|
||||||
|
accepted++
|
||||||
|
if v != tok {
|
||||||
|
t.Errorf("accepted non-canonical spelling ...%q", v[len(v)-4:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) != 16 {
|
||||||
|
t.Fatalf("built %d distinct strings, want 16", len(ids))
|
||||||
|
}
|
||||||
|
if accepted != 1 {
|
||||||
|
t.Fatalf("%d of 16 spellings authenticate as the same credential, want exactly 1", accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJWT_RejectsWhitespaceInSegments(t *testing.T) {
|
||||||
|
jwksURL, mint, _ := newJWKS(t)
|
||||||
|
tok := mint(`{"sub":"u1","exp":` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `}`)
|
||||||
|
dot := strings.LastIndex(tok, ".")
|
||||||
|
|
||||||
|
for _, v := range []string{tok + "\n", tok[:dot+1] + "\n" + tok[dot+1:], "\n" + tok} {
|
||||||
|
if _, err := ValidateJWT(v, jwksURL); err == nil {
|
||||||
|
t.Errorf("accepted a token segment containing CR/LF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJWT_AcceptsCanonicalToken(t *testing.T) {
|
||||||
|
jwksURL, mint, _ := newJWKS(t)
|
||||||
|
tok := mint(`{"sub":"u1","exp":` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `}`)
|
||||||
|
|
||||||
|
claims, err := ValidateJWT(tok, jwksURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical token rejected: %v", err)
|
||||||
|
}
|
||||||
|
if ClaimStr(claims, "sub") != "u1" {
|
||||||
|
t.Fatalf("sub = %q, want u1", ClaimStr(claims, "sub"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Package token defines the one canonical text encoding of a bearer
|
||||||
|
// credential: unpadded base64url, and nothing else.
|
||||||
|
//
|
||||||
|
// A bearer token is a byte string; its text is transport. Whatever stores,
|
||||||
|
// compares, revokes, audits, dedups or rate-limits a credential must key on
|
||||||
|
// the bytes — or on ID — never on the string a client submitted. When one
|
||||||
|
// credential can wear several spellings, every string-keyed structure
|
||||||
|
// silently splits it into several principals: a denylist misses the
|
||||||
|
// spellings it was not given, an audit log reads one actor as many, and a
|
||||||
|
// rate limiter multiplies the budget by the size of the equivalence class.
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEncoding reports text that is not the canonical encoding of its bytes.
|
||||||
|
var ErrEncoding = errors.New("token: non-canonical encoding")
|
||||||
|
|
||||||
|
var enc = base64.RawURLEncoding
|
||||||
|
|
||||||
|
// Decode returns the bytes of a canonically encoded segment and rejects
|
||||||
|
// every other spelling of those same bytes.
|
||||||
|
//
|
||||||
|
// Go's base64 decoders are lenient in two independent ways. The unused low
|
||||||
|
// bits of the final quantum are ignored — an HS256 signature is 32 bytes in
|
||||||
|
// 43 characters, leaving 2 slack bits and so 4 spellings; an RS256 signature
|
||||||
|
// is 256 bytes in 342 characters, leaving 4 slack bits and so 16 spellings.
|
||||||
|
// And \r and \n are skipped wherever they appear, which admits unboundedly
|
||||||
|
// many more over any carrier that permits them. Encoding.Strict() closes
|
||||||
|
// only the first of those, so it is not a fix on its own.
|
||||||
|
//
|
||||||
|
// Round-tripping closes both, and anything else of the same shape, with a
|
||||||
|
// single predicate: a segment is canonical exactly when it is the encoding
|
||||||
|
// of the bytes it decodes to. There is nothing to enumerate and nothing to
|
||||||
|
// keep in sync as the standard library's leniency changes.
|
||||||
|
//
|
||||||
|
// Non-canonical input is rejected, never normalized. A client that sends one
|
||||||
|
// is buggy or probing; accepting it quietly is how the malleability survives
|
||||||
|
// the next refactor.
|
||||||
|
func Decode(seg string) ([]byte, error) {
|
||||||
|
b, err := enc.DecodeString(seg)
|
||||||
|
if err != nil || enc.EncodeToString(b) != seg {
|
||||||
|
return nil, ErrEncoding
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode is the inverse of Decode and the only encoder for token text.
|
||||||
|
func Encode(b []byte) string { return enc.EncodeToString(b) }
|
||||||
|
|
||||||
|
// ID is the identity of a token: hex SHA-256 over its bytes. Key revocation
|
||||||
|
// lists, audit records, dedup caches and rate limiters on this. Never on the
|
||||||
|
// token itself — that is the bearer secret, and it must not reach a log line
|
||||||
|
// or a database column in the clear.
|
||||||
|
//
|
||||||
|
// Pass only a token that validation has already accepted: validation is what
|
||||||
|
// establishes that the text is the unique encoding of its bytes, and hence
|
||||||
|
// that equal credentials have equal IDs.
|
||||||
|
func ID(tok string) string {
|
||||||
|
sum := sha256.Sum256([]byte(tok))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
|
|
||||||
|
// slackBits is the number of unused low bits in the final base64url character
|
||||||
|
// for a payload of n bytes: 6*ceil(8n/6) - 8n. Each slack bit doubles the
|
||||||
|
// number of strings that decode to the same bytes.
|
||||||
|
func slackBits(n int) int { return 6*base64.RawURLEncoding.EncodedLen(n) - 8*n }
|
||||||
|
|
||||||
|
// respell returns every string that decodes to the same bytes as seg by
|
||||||
|
// varying only the non-significant low bits of its final character.
|
||||||
|
func respell(seg string, slack int) []string {
|
||||||
|
last := strings.IndexByte(alphabet, seg[len(seg)-1])
|
||||||
|
mask := 1<<slack - 1
|
||||||
|
out := make([]string, 0, mask+1)
|
||||||
|
for b := 0; b <= mask; b++ {
|
||||||
|
out = append(out, seg[:len(seg)-1]+string(alphabet[(last&^mask)|b]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackBitsMatchRealSignatureSizes(t *testing.T) {
|
||||||
|
// The two signature sizes this repo verifies, and how many spellings a
|
||||||
|
// lenient decoder would accept for each.
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
bytes, slack, spellings int
|
||||||
|
}{
|
||||||
|
{"HMAC-SHA256", 32, 2, 4},
|
||||||
|
{"RSA-2048 PKCS#1v1.5", 256, 4, 16},
|
||||||
|
} {
|
||||||
|
if got := slackBits(c.bytes); got != c.slack {
|
||||||
|
t.Fatalf("%s: slack bits = %d, want %d", c.name, got, c.slack)
|
||||||
|
}
|
||||||
|
if 1<<c.slack != c.spellings {
|
||||||
|
t.Fatalf("%s: spellings = %d, want %d", c.name, 1<<c.slack, c.spellings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeAcceptsExactlyOneSpelling(t *testing.T) {
|
||||||
|
for _, n := range []int{32, 256} {
|
||||||
|
raw := make([]byte, n)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(i * 7)
|
||||||
|
}
|
||||||
|
canonical := Encode(raw)
|
||||||
|
|
||||||
|
accepted := 0
|
||||||
|
for _, s := range respell(canonical, slackBits(n)) {
|
||||||
|
// Every spelling decodes to the same bytes under the lenient
|
||||||
|
// decoder — that is the whole problem.
|
||||||
|
lenient, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil || string(lenient) != string(raw) {
|
||||||
|
t.Fatalf("%d-byte: spelling %q does not decode to the same bytes; test premise is wrong", n, s[len(s)-4:])
|
||||||
|
}
|
||||||
|
if b, err := Decode(s); err == nil {
|
||||||
|
accepted++
|
||||||
|
if s != canonical {
|
||||||
|
t.Errorf("%d-byte: accepted non-canonical spelling ...%q", n, s[len(s)-4:])
|
||||||
|
}
|
||||||
|
if string(b) != string(raw) {
|
||||||
|
t.Errorf("%d-byte: canonical decode returned wrong bytes", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accepted != 1 {
|
||||||
|
t.Fatalf("%d-byte payload: %d spellings accepted, want exactly 1", n, accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeRejectsNonCanonical(t *testing.T) {
|
||||||
|
// 0xFB repeats as "-_" under base64url, so the URL alphabet is genuinely
|
||||||
|
// exercised and the standard-alphabet vector below is not a no-op.
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0xFB
|
||||||
|
}
|
||||||
|
canonical := Encode(raw)
|
||||||
|
if !strings.ContainsRune(canonical, '-') || !strings.ContainsRune(canonical, '_') {
|
||||||
|
t.Fatalf("test premise: %q lacks URL-alphabet characters", canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every case below decodes to raw under some standard-library decoder.
|
||||||
|
// Strict() alone catches only the trailing-bits family; the whitespace
|
||||||
|
// family is why Decode round-trips instead.
|
||||||
|
for _, c := range []struct{ name, seg string }{
|
||||||
|
{"trailing bits set", respell(canonical, 2)[1]},
|
||||||
|
{"embedded LF", canonical[:10] + "\n" + canonical[10:]},
|
||||||
|
{"embedded CRLF", canonical[:10] + "\r\n" + canonical[10:]},
|
||||||
|
{"trailing LF", canonical + "\n"},
|
||||||
|
{"leading LF", "\n" + canonical},
|
||||||
|
{"padded", canonical + "="},
|
||||||
|
{"standard alphabet", strings.NewReplacer("-", "+", "_", "/").Replace(canonical)},
|
||||||
|
} {
|
||||||
|
if _, err := Decode(c.seg); !errors.Is(err, ErrEncoding) {
|
||||||
|
t.Errorf("%s: Decode accepted or returned %v, want ErrEncoding", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b, err := Decode(canonical); err != nil || string(b) != string(raw) {
|
||||||
|
t.Fatalf("canonical segment rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The empty string is the canonical encoding of zero bytes, so the codec
|
||||||
|
// accepts it. Minimum length is a policy concern belonging to the caller —
|
||||||
|
// keeping it out of the codec is what stops the two from braiding. The JWT
|
||||||
|
// verifiers reject empty segments on their own terms; see the admin and auth
|
||||||
|
// packages.
|
||||||
|
func TestDecodeEmptyIsCanonicalZeroBytes(t *testing.T) {
|
||||||
|
b, err := Decode("")
|
||||||
|
if err != nil || len(b) != 0 {
|
||||||
|
t.Fatalf("Decode(\"\") = %v, %v; want zero bytes and no error", b, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||||
|
for n := 0; n < 64; n++ {
|
||||||
|
raw := make([]byte, n)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(255 - i)
|
||||||
|
}
|
||||||
|
b, err := Decode(Encode(raw))
|
||||||
|
if err != nil || string(b) != string(raw) {
|
||||||
|
t.Fatalf("n=%d: round trip failed: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDIsStableAndNotTheToken(t *testing.T) {
|
||||||
|
tok := Encode([]byte("a bearer secret"))
|
||||||
|
id := ID(tok)
|
||||||
|
if id != ID(tok) {
|
||||||
|
t.Fatal("ID is not deterministic")
|
||||||
|
}
|
||||||
|
if len(id) != 64 {
|
||||||
|
t.Fatalf("ID length = %d, want 64 hex chars", len(id))
|
||||||
|
}
|
||||||
|
if strings.Contains(id, tok) {
|
||||||
|
t.Fatal("ID leaks the token it identifies")
|
||||||
|
}
|
||||||
|
if ID(tok) == ID(tok+"x") {
|
||||||
|
t.Fatal("distinct tokens share an ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user