| 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 |
|
} |