coverage~bigbes/sr-ht-doltede3b0bbauthn/jwt.go

Coverage
84.4% 27/32 statements
Δ
Blob
6513cd4
1 package authn
2
3 import (
4 "context"
5 "crypto/ed25519"
6 "crypto/subtle"
7 "fmt"
8 "log/slog"
9 "time"
10
11 "github.com/dolthub/dolt/go/libraries/doltcore/creds"
12 jose "gopkg.in/go-jose/go-jose.v2"
13 "gopkg.in/go-jose/go-jose.v2/jwt"
14
15 "go.bigb.es/auxilia/scribe"
16
17 "sourcecraft.dev/bigbes/sr-ht-core/auth"
18 )
19
20 // jwtSubjectPrefix is the fixed prefix dolt puts in the JWT "sub" claim,
21 // followed by the base32 key id: "doltClientCredentials/<kid>". See dolt's
22 // creds.DoltCreds.RPCCreds.
23 const jwtSubjectPrefix = "doltClientCredentials/"
24
25 // jwtLeeway is the clock-skew tolerance applied to the exp claim. dolt mints
26 // 30-second tokens; a minute of leeway absorbs modest clock drift between the
27 // client and this server without meaningfully extending a token's usefulness.
28 const jwtLeeway = 1 * time.Minute
29
30 // KeyStore looks up dolt Ed25519 public keys by their key id and records key
31 // usage. It is implemented by the db package (concurrently); authn defines it
32 // here to stay independent of the database. A key id is
33 // creds.PubKeyToKIDStr(pubkey): base32(SHA-512/224(pubkey)) over dolt's custom
34 // alphabet.
35 type KeyStore interface {
36 // ByKID returns the 32-byte Ed25519 public key registered under kid and the
37 // username of its owner. It returns an error if no such key exists.
38 ByKID(ctx context.Context, kid string) (pubkey []byte, username string, err error)
39 // TouchLastUsed records that the key identified by kid was just used.
40 TouchLastUsed(ctx context.Context, kid string) error
41 }
42
43 // ResolveDoltJWT resolves the caller for a dolt keypair Bearer token: an EdDSA
44 // JWS minted by `dolt clone/push` from a `dolt creds`/`dolt login` keypair. It:
45 //
46 // - parses the JWS and requires alg == EdDSA (defeats alg-confusion / "none");
47 // - reads the kid header and looks the public key up via keys.ByKID;
48 // - cross-checks kid == creds.PubKeyToKIDStr(pubkey) — the same derivation
49 // dolt uses — so a corrupt (kid, pubkey) pairing cannot be trusted;
50 // - verifies the EdDSA signature with the stored public key;
51 // - requires aud == expectedAud and a valid exp (with jwtLeeway), and
52 // sub == "doltClientCredentials/<kid>"; the iss claim is deliberately
53 // IGNORED (dolt hardcodes iss = "dolt-client.dolthub.com");
54 // - resolves the owning user via the meta backend and records key usage.
55 //
56 // Permanent rejections wrap ErrInvalidToken; a backend failure (user lookup)
57 // is returned unwrapped so callers treat it as transient. A TouchLastUsed
58 // failure is non-fatal (bookkeeping only) and is logged, not returned.
59 11 func ResolveDoltJWT(ctx context.Context, rawJWT, expectedAud string, keys KeyStore) (*auth.AuthContext, error) {
60 11 tok, err := jwt.ParseSigned(rawJWT)
61 11 if err != nil {
62 0 return nil, fmt.Errorf("%w: parsing JWS: %v", ErrInvalidToken, err)
63 0 }
64 11 if len(tok.Headers) != 1 {
65 0 return nil, fmt.Errorf("%w: expected exactly one signature, got %d", ErrInvalidToken, len(tok.Headers))
66 0 }
67 11 hdr := tok.Headers[0]
68 11 if hdr.Algorithm != string(jose.EdDSA) {
69 0 return nil, fmt.Errorf("%w: unexpected signature algorithm %q (want EdDSA)", ErrInvalidToken, hdr.Algorithm)
70 0 }
71 11 kid := hdr.KeyID
72 11 if kid == "" {
73 0 return nil, fmt.Errorf("%w: missing kid header", ErrInvalidToken)
74 0 }
75
76 11 pubkey, username, err := keys.ByKID(ctx, kid)
77 11 if err != nil {
78 1 return nil, fmt.Errorf("%w: unknown key id %q: %v", ErrInvalidToken, kid, err)
79 1 }
80 10 if len(pubkey) != ed25519.PublicKeySize {
81 0 return nil, fmt.Errorf("%w: stored public key for %q has wrong size %d", ErrInvalidToken, kid, len(pubkey))
82 0 }
83 // Integrity: the key id must be the canonical derivation of this public
84 // key, computed exactly as dolt does. Guards against a mismatched DB row.
85 10 if subtle.ConstantTimeCompare([]byte(creds.PubKeyToKIDStr(pubkey)), []byte(kid)) != 1 {
86 1 return nil, fmt.Errorf("%w: key id %q does not match its stored public key", ErrInvalidToken, kid)
87 1 }
88
89 9 var claims jwt.Claims
90 9 if err := tok.Claims(ed25519.PublicKey(pubkey), &claims); err != nil {
91 1 return nil, fmt.Errorf("%w: signature verification failed: %v", ErrInvalidToken, err)
92 1 }
93
94 // Validate aud + exp/nbf (with leeway). Issuer and Subject are intentionally
95 // left unset in Expected: iss is ignored, and sub is checked separately
96 // below against the exact "doltClientCredentials/<kid>" form.
97 8 if err := claims.ValidateWithLeeway(jwt.Expected{
98 8 Audience: jwt.Audience{expectedAud},
99 8 Time: nowFn(),
100 8 }, jwtLeeway); err != nil {
101 2 return nil, fmt.Errorf("%w: claim validation failed: %v", ErrInvalidToken, err)
102 2 }
103 6 if claims.Subject != jwtSubjectPrefix+kid {
104 1 return nil, fmt.Errorf("%w: subject %q is not %s%s", ErrInvalidToken, claims.Subject, jwtSubjectPrefix, kid)
105 1 }
106
107 5 var ac auth.AuthContext
108 5 if err := meta.LookupUser(ctx, username, &ac); err != nil {
109 1 return nil, fmt.Errorf("looking up user %q: %w", username, err)
110 1 }
111 4 ac.AuthMethod = AuthMethodDoltKey
112 4
113 4 if err := keys.TouchLastUsed(ctx, kid); err != nil {
114 1 // Non-fatal: the caller is already authenticated; last_used is display
115 1 // metadata. Log and continue rather than failing the clone/push.
116 1 slog.WarnContext(ctx, "recording the last use of a dolt key failed",
117 1 "component", "authn", "kid", kid, scribe.Err(err))
118 1 }
119 4 return &ac, nil
120 }