package authn import ( "context" "crypto/ed25519" "crypto/subtle" "fmt" "log" "time" "git.sr.ht/~sircmpwn/core-go/auth" "github.com/dolthub/dolt/go/libraries/doltcore/creds" jose "gopkg.in/go-jose/go-jose.v2" "gopkg.in/go-jose/go-jose.v2/jwt" ) // jwtSubjectPrefix is the fixed prefix dolt puts in the JWT "sub" claim, // followed by the base32 key id: "doltClientCredentials/". See dolt's // creds.DoltCreds.RPCCreds. const jwtSubjectPrefix = "doltClientCredentials/" // jwtLeeway is the clock-skew tolerance applied to the exp claim. dolt mints // 30-second tokens; a minute of leeway absorbs modest clock drift between the // client and this server without meaningfully extending a token's usefulness. const jwtLeeway = 1 * time.Minute // KeyStore looks up dolt Ed25519 public keys by their key id and records key // usage. It is implemented by the db package (concurrently); authn defines it // here to stay independent of the database. A key id is // creds.PubKeyToKIDStr(pubkey): base32(SHA-512/224(pubkey)) over dolt's custom // alphabet. type KeyStore interface { // ByKID returns the 32-byte Ed25519 public key registered under kid and the // username of its owner. It returns an error if no such key exists. ByKID(ctx context.Context, kid string) (pubkey []byte, username string, err error) // TouchLastUsed records that the key identified by kid was just used. TouchLastUsed(ctx context.Context, kid string) error } // ResolveDoltJWT resolves the caller for a dolt keypair Bearer token: an EdDSA // JWS minted by `dolt clone/push` from a `dolt creds`/`dolt login` keypair. It: // // - parses the JWS and requires alg == EdDSA (defeats alg-confusion / "none"); // - reads the kid header and looks the public key up via keys.ByKID; // - cross-checks kid == creds.PubKeyToKIDStr(pubkey) — the same derivation // dolt uses — so a corrupt (kid, pubkey) pairing cannot be trusted; // - verifies the EdDSA signature with the stored public key; // - requires aud == expectedAud and a valid exp (with jwtLeeway), and // sub == "doltClientCredentials/"; the iss claim is deliberately // IGNORED (dolt hardcodes iss = "dolt-client.dolthub.com"); // - resolves the owning user via the meta backend and records key usage. // // Permanent rejections wrap ErrInvalidToken; a backend failure (user lookup) // is returned unwrapped so callers treat it as transient. A TouchLastUsed // failure is non-fatal (bookkeeping only) and is logged, not returned. func ResolveDoltJWT(ctx context.Context, rawJWT, expectedAud string, keys KeyStore) (*auth.AuthContext, error) { tok, err := jwt.ParseSigned(rawJWT) if err != nil { return nil, fmt.Errorf("%w: parsing JWS: %v", ErrInvalidToken, err) } if len(tok.Headers) != 1 { return nil, fmt.Errorf("%w: expected exactly one signature, got %d", ErrInvalidToken, len(tok.Headers)) } hdr := tok.Headers[0] if hdr.Algorithm != string(jose.EdDSA) { return nil, fmt.Errorf("%w: unexpected signature algorithm %q (want EdDSA)", ErrInvalidToken, hdr.Algorithm) } kid := hdr.KeyID if kid == "" { return nil, fmt.Errorf("%w: missing kid header", ErrInvalidToken) } pubkey, username, err := keys.ByKID(ctx, kid) if err != nil { return nil, fmt.Errorf("%w: unknown key id %q: %v", ErrInvalidToken, kid, err) } if len(pubkey) != ed25519.PublicKeySize { return nil, fmt.Errorf("%w: stored public key for %q has wrong size %d", ErrInvalidToken, kid, len(pubkey)) } // Integrity: the key id must be the canonical derivation of this public // key, computed exactly as dolt does. Guards against a mismatched DB row. if subtle.ConstantTimeCompare([]byte(creds.PubKeyToKIDStr(pubkey)), []byte(kid)) != 1 { return nil, fmt.Errorf("%w: key id %q does not match its stored public key", ErrInvalidToken, kid) } var claims jwt.Claims if err := tok.Claims(ed25519.PublicKey(pubkey), &claims); err != nil { return nil, fmt.Errorf("%w: signature verification failed: %v", ErrInvalidToken, err) } // Validate aud + exp/nbf (with leeway). Issuer and Subject are intentionally // left unset in Expected: iss is ignored, and sub is checked separately // below against the exact "doltClientCredentials/" form. if err := claims.ValidateWithLeeway(jwt.Expected{ Audience: jwt.Audience{expectedAud}, Time: nowFn(), }, jwtLeeway); err != nil { return nil, fmt.Errorf("%w: claim validation failed: %v", ErrInvalidToken, err) } if claims.Subject != jwtSubjectPrefix+kid { return nil, fmt.Errorf("%w: subject %q is not %s%s", ErrInvalidToken, claims.Subject, jwtSubjectPrefix, kid) } var ac auth.AuthContext if err := meta.LookupUser(ctx, username, &ac); err != nil { return nil, fmt.Errorf("looking up user %q: %w", username, err) } ac.AuthMethod = AuthMethodDoltKey if err := keys.TouchLastUsed(ctx, kid); err != nil { // Non-fatal: the caller is already authenticated; last_used is display // metadata. Log and continue rather than failing the clone/push. log.Printf("authn: TouchLastUsed(%q): %v", kid, err) } return &ac, nil }