~bigbes/sr-ht-dolt

ref: 93e699101e297244802590365e96da638404b579 sr-ht-dolt/authn/jwt.go -rw-r--r-- 5.1 KiB
93e69910 — Eugene Blikh storage: create databases empty so the first push needs no --force 3 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package authn

import (
	"context"
	"crypto/ed25519"
	"crypto/subtle"
	"fmt"
	"log/slog"
	"time"

	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
	jose "gopkg.in/go-jose/go-jose.v2"
	"gopkg.in/go-jose/go-jose.v2/jwt"

	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
)

// jwtSubjectPrefix is the fixed prefix dolt puts in the JWT "sub" claim,
// followed by the base32 key id: "doltClientCredentials/<kid>". 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/<kid>"; 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/<kid>" 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.
		slog.WarnContext(ctx, "recording the last use of a dolt key failed",
			"component", "authn", "kid", kid, scribe.Err(err))
	}
	return &ac, nil
}