tenseleyflow/shithub / ef73d0c

Browse files

actions/runnerjwt: add single-use job token foundation (S41c)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ef73d0c2b4950bd5f0c08f84e3585e71561cf699
Parents
9ecd8ed
Tree
d7d146b

23 changed files

StatusFile+-
A internal/actions/queries/runner_jwt_used.sql 10 0
M internal/actions/sqlc/models.go 10 0
M internal/actions/sqlc/querier.go 3 0
A internal/actions/sqlc/runner_jwt_used.sql.go 61 0
M internal/admin/sqlc/models.go 10 0
M internal/auth/policy/sqlc/models.go 10 0
A internal/auth/runnerjwt/jwt.go 298 0
A internal/auth/runnerjwt/jwt_test.go 164 0
A internal/auth/runnerjwt/replay.go 40 0
A internal/auth/runnerjwt/replay_test.go 118 0
M internal/checks/sqlc/models.go 10 0
M internal/issues/sqlc/models.go 10 0
M internal/meta/sqlc/models.go 10 0
A internal/migrationsfs/migrations/0052_runner_jwt_used.sql 35 0
M internal/notif/sqlc/models.go 10 0
M internal/orgs/sqlc/models.go 10 0
M internal/pulls/sqlc/models.go 10 0
M internal/ratelimit/sqlc/models.go 10 0
M internal/repos/sqlc/models.go 10 0
M internal/social/sqlc/models.go 10 0
M internal/users/sqlc/models.go 10 0
M internal/webhook/sqlc/models.go 10 0
M internal/worker/sqlc/models.go 10 0
internal/actions/queries/runner_jwt_used.sqladded
@@ -0,0 +1,10 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: MarkRunnerJWTUsed :one
4
+INSERT INTO runner_jwt_used (jti, runner_id, job_id, run_id, repo_id, expires_at)
5
+VALUES ($1, $2, $3, $4, $5, $6)
6
+ON CONFLICT (jti) DO NOTHING
7
+RETURNING jti, runner_id, job_id, run_id, repo_id, expires_at, used_at;
8
+
9
+-- name: DeleteExpiredRunnerJWTUses :exec
10
+DELETE FROM runner_jwt_used WHERE expires_at < now();
internal/actions/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/actions/sqlc/querier.gomodified
@@ -14,6 +14,7 @@ type Querier interface {
1414
 	// SPDX-License-Identifier: AGPL-3.0-or-later
1515
 	AppendStepLogChunk(ctx context.Context, db DBTX, arg AppendStepLogChunkParams) (AppendStepLogChunkRow, error)
1616
 	DeleteExpiredArtifacts(ctx context.Context, db DBTX) ([]DeleteExpiredArtifactsRow, error)
17
+	DeleteExpiredRunnerJWTUses(ctx context.Context, db DBTX) error
1718
 	DeleteOrgSecret(ctx context.Context, db DBTX, arg DeleteOrgSecretParams) error
1819
 	DeleteOrgVariable(ctx context.Context, db DBTX, arg DeleteOrgVariableParams) error
1920
 	DeleteRepoSecret(ctx context.Context, db DBTX, arg DeleteRepoSecretParams) error
@@ -66,6 +67,8 @@ type Querier interface {
6667
 	// handler uses this to find the existing row so it can surface a
6768
 	// stable RunID. Matches the partial-unique index from migration 0051.
6869
 	LookupWorkflowRunByTriggerEvent(ctx context.Context, db DBTX, arg LookupWorkflowRunByTriggerEventParams) (WorkflowRun, error)
70
+	// SPDX-License-Identifier: AGPL-3.0-or-later
71
+	MarkRunnerJWTUsed(ctx context.Context, db DBTX, arg MarkRunnerJWTUsedParams) (RunnerJwtUsed, error)
6972
 	// Atomic next-index emitter: take the max + 1 for this repo. Pairs
7073
 	// with the (repo_id, run_index) UNIQUE so concurrent inserts that
7174
 	// race here will catch a unique-violation and the caller retries.
internal/actions/sqlc/runner_jwt_used.sql.goadded
@@ -0,0 +1,61 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: runner_jwt_used.sql
5
+
6
+package actionsdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const deleteExpiredRunnerJWTUses = `-- name: DeleteExpiredRunnerJWTUses :exec
15
+DELETE FROM runner_jwt_used WHERE expires_at < now()
16
+`
17
+
18
+func (q *Queries) DeleteExpiredRunnerJWTUses(ctx context.Context, db DBTX) error {
19
+	_, err := db.Exec(ctx, deleteExpiredRunnerJWTUses)
20
+	return err
21
+}
22
+
23
+const markRunnerJWTUsed = `-- name: MarkRunnerJWTUsed :one
24
+
25
+INSERT INTO runner_jwt_used (jti, runner_id, job_id, run_id, repo_id, expires_at)
26
+VALUES ($1, $2, $3, $4, $5, $6)
27
+ON CONFLICT (jti) DO NOTHING
28
+RETURNING jti, runner_id, job_id, run_id, repo_id, expires_at, used_at
29
+`
30
+
31
+type MarkRunnerJWTUsedParams struct {
32
+	Jti       string
33
+	RunnerID  int64
34
+	JobID     int64
35
+	RunID     int64
36
+	RepoID    int64
37
+	ExpiresAt pgtype.Timestamptz
38
+}
39
+
40
+// SPDX-License-Identifier: AGPL-3.0-or-later
41
+func (q *Queries) MarkRunnerJWTUsed(ctx context.Context, db DBTX, arg MarkRunnerJWTUsedParams) (RunnerJwtUsed, error) {
42
+	row := db.QueryRow(ctx, markRunnerJWTUsed,
43
+		arg.Jti,
44
+		arg.RunnerID,
45
+		arg.JobID,
46
+		arg.RunID,
47
+		arg.RepoID,
48
+		arg.ExpiresAt,
49
+	)
50
+	var i RunnerJwtUsed
51
+	err := row.Scan(
52
+		&i.Jti,
53
+		&i.RunnerID,
54
+		&i.JobID,
55
+		&i.RunID,
56
+		&i.RepoID,
57
+		&i.ExpiresAt,
58
+		&i.UsedAt,
59
+	)
60
+	return i, err
61
+}
internal/admin/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/auth/policy/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/auth/runnerjwt/jwt.goadded
@@ -0,0 +1,298 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package runnerjwt signs and verifies the short-lived job tokens used by
4
+// shithub Actions runners.
5
+//
6
+// Registration tokens authenticate a runner to the heartbeat endpoint. A
7
+// successful claim receives one JWT scoped to one workflow_jobs row; job
8
+// endpoints verify the signature, expiry, path/job match, and then consume
9
+// the jti through runner_jwt_used so the token is single-use.
10
+package runnerjwt
11
+
12
+import (
13
+	"crypto/hkdf"
14
+	"crypto/hmac"
15
+	"crypto/rand"
16
+	"crypto/sha256"
17
+	"encoding/base64"
18
+	"encoding/json"
19
+	"errors"
20
+	"fmt"
21
+	"io"
22
+	"strconv"
23
+	"strings"
24
+	"time"
25
+)
26
+
27
+const (
28
+	// DefaultTTL is the runner job-token lifetime from the S41c contract.
29
+	DefaultTTL = 15 * time.Minute
30
+
31
+	signingKeySize = 32
32
+	hkdfInfo       = "actions-runner-jwt-v1"
33
+	jtiBytes       = 32
34
+)
35
+
36
+var (
37
+	ErrEmptyKey          = errors.New("runnerjwt: empty key")
38
+	ErrInvalidKey        = errors.New("runnerjwt: key must be 32 bytes")
39
+	ErrMalformed         = errors.New("runnerjwt: malformed token")
40
+	ErrInvalidSignature  = errors.New("runnerjwt: invalid signature")
41
+	ErrExpired           = errors.New("runnerjwt: expired token")
42
+	ErrInvalidClaims     = errors.New("runnerjwt: invalid claims")
43
+	ErrUnsupportedHeader = errors.New("runnerjwt: unsupported header")
44
+)
45
+
46
+// Claims are the JWT payload fields accepted by runner job endpoints.
47
+type Claims struct {
48
+	Sub    string `json:"sub"`
49
+	JobID  int64  `json:"job_id"`
50
+	RunID  int64  `json:"run_id"`
51
+	RepoID int64  `json:"repo_id"`
52
+	Exp    int64  `json:"exp"`
53
+	JTI    string `json:"jti"`
54
+}
55
+
56
+// RunnerID extracts the runner id encoded in sub="runner:<id>".
57
+func (c Claims) RunnerID() (int64, error) {
58
+	const prefix = "runner:"
59
+	if !strings.HasPrefix(c.Sub, prefix) {
60
+		return 0, ErrInvalidClaims
61
+	}
62
+	id, err := strconv.ParseInt(strings.TrimPrefix(c.Sub, prefix), 10, 64)
63
+	if err != nil || id <= 0 {
64
+		return 0, ErrInvalidClaims
65
+	}
66
+	return id, nil
67
+}
68
+
69
+// MintParams describes a job token to issue.
70
+type MintParams struct {
71
+	RunnerID int64
72
+	JobID    int64
73
+	RunID    int64
74
+	RepoID   int64
75
+	TTL      time.Duration
76
+}
77
+
78
+// Signer signs and verifies HS256 runner JWTs.
79
+type Signer struct {
80
+	key []byte
81
+	now func() time.Time
82
+	rng io.Reader
83
+}
84
+
85
+// Option customizes a Signer. Tests use these for deterministic time/randomness.
86
+type Option func(*Signer)
87
+
88
+// WithClock overrides the clock used for exp validation and issuance.
89
+func WithClock(now func() time.Time) Option {
90
+	return func(s *Signer) {
91
+		if now != nil {
92
+			s.now = now
93
+		}
94
+	}
95
+}
96
+
97
+// WithRand overrides the random source used for jti generation.
98
+func WithRand(r io.Reader) Option {
99
+	return func(s *Signer) {
100
+		if r != nil {
101
+			s.rng = r
102
+		}
103
+	}
104
+}
105
+
106
+// NewFromTOTPKeyB64 decodes cfg.Auth.TOTPKeyB64 and derives an isolated
107
+// runner-JWT signing key via HKDF. The raw TOTP/secretbox key is never used
108
+// directly for JWT signatures.
109
+func NewFromTOTPKeyB64(totpKeyB64 string, opts ...Option) (*Signer, error) {
110
+	key, err := DeriveKeyFromTOTPKeyB64(totpKeyB64)
111
+	if err != nil {
112
+		return nil, err
113
+	}
114
+	return NewFromKey(key, opts...)
115
+}
116
+
117
+// DeriveKeyFromTOTPKeyB64 returns the HS256 key derived from the configured
118
+// 32-byte TOTP/secretbox key.
119
+func DeriveKeyFromTOTPKeyB64(totpKeyB64 string) ([]byte, error) {
120
+	if totpKeyB64 == "" {
121
+		return nil, ErrEmptyKey
122
+	}
123
+	raw, err := decodeKey(totpKeyB64)
124
+	if err != nil {
125
+		return nil, fmt.Errorf("runnerjwt: decode key: %w", err)
126
+	}
127
+	if len(raw) != signingKeySize {
128
+		return nil, ErrInvalidKey
129
+	}
130
+	key, err := hkdf.Key(sha256.New, raw, nil, hkdfInfo, signingKeySize)
131
+	if err != nil {
132
+		return nil, fmt.Errorf("runnerjwt: derive key: %w", err)
133
+	}
134
+	return key, nil
135
+}
136
+
137
+// NewFromKey constructs a Signer from an already-derived 32-byte HS256 key.
138
+func NewFromKey(key []byte, opts ...Option) (*Signer, error) {
139
+	if len(key) != signingKeySize {
140
+		return nil, ErrInvalidKey
141
+	}
142
+	copied := make([]byte, len(key))
143
+	copy(copied, key)
144
+	s := &Signer{
145
+		key: copied,
146
+		now: time.Now,
147
+		rng: rand.Reader,
148
+	}
149
+	for _, opt := range opts {
150
+		opt(s)
151
+	}
152
+	return s, nil
153
+}
154
+
155
+// Mint signs a new job token and returns the token plus the exact claims.
156
+func (s *Signer) Mint(p MintParams) (string, Claims, error) {
157
+	ttl := p.TTL
158
+	if ttl == 0 {
159
+		ttl = DefaultTTL
160
+	}
161
+	if p.RunnerID <= 0 || p.JobID <= 0 || p.RunID <= 0 || p.RepoID <= 0 || ttl <= 0 {
162
+		return "", Claims{}, ErrInvalidClaims
163
+	}
164
+	jti, err := newJTI(s.rng)
165
+	if err != nil {
166
+		return "", Claims{}, err
167
+	}
168
+	claims := Claims{
169
+		Sub:    fmt.Sprintf("runner:%d", p.RunnerID),
170
+		JobID:  p.JobID,
171
+		RunID:  p.RunID,
172
+		RepoID: p.RepoID,
173
+		Exp:    s.now().Add(ttl).Unix(),
174
+		JTI:    jti,
175
+	}
176
+	if err := validateClaims(claims); err != nil {
177
+		return "", Claims{}, err
178
+	}
179
+	token, err := s.sign(claims)
180
+	if err != nil {
181
+		return "", Claims{}, err
182
+	}
183
+	return token, claims, nil
184
+}
185
+
186
+// Verify checks token shape, HS256 signature, registered claims, and expiry.
187
+// It does not consume jti; callers perform that DB operation after verifying
188
+// path/job ownership.
189
+func (s *Signer) Verify(token string) (Claims, error) {
190
+	parts := strings.Split(token, ".")
191
+	if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
192
+		return Claims{}, ErrMalformed
193
+	}
194
+	headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
195
+	if err != nil {
196
+		return Claims{}, ErrMalformed
197
+	}
198
+	var header struct {
199
+		Alg string `json:"alg"`
200
+		Typ string `json:"typ"`
201
+	}
202
+	if err := json.Unmarshal(headerBytes, &header); err != nil {
203
+		return Claims{}, ErrMalformed
204
+	}
205
+	if header.Alg != "HS256" || header.Typ != "JWT" {
206
+		return Claims{}, ErrUnsupportedHeader
207
+	}
208
+
209
+	signingInput := parts[0] + "." + parts[1]
210
+	gotSig, err := base64.RawURLEncoding.DecodeString(parts[2])
211
+	if err != nil {
212
+		return Claims{}, ErrMalformed
213
+	}
214
+	wantSig := signHS256(s.key, signingInput)
215
+	if !hmac.Equal(gotSig, wantSig) {
216
+		return Claims{}, ErrInvalidSignature
217
+	}
218
+
219
+	payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
220
+	if err != nil {
221
+		return Claims{}, ErrMalformed
222
+	}
223
+	var claims Claims
224
+	if err := json.Unmarshal(payloadBytes, &claims); err != nil {
225
+		return Claims{}, ErrMalformed
226
+	}
227
+	if err := validateClaims(claims); err != nil {
228
+		return Claims{}, err
229
+	}
230
+	if !s.now().Before(time.Unix(claims.Exp, 0)) {
231
+		return Claims{}, ErrExpired
232
+	}
233
+	return claims, nil
234
+}
235
+
236
+func (s *Signer) sign(claims Claims) (string, error) {
237
+	headerJSON, err := json.Marshal(struct {
238
+		Alg string `json:"alg"`
239
+		Typ string `json:"typ"`
240
+	}{Alg: "HS256", Typ: "JWT"})
241
+	if err != nil {
242
+		return "", err
243
+	}
244
+	payloadJSON, err := json.Marshal(claims)
245
+	if err != nil {
246
+		return "", err
247
+	}
248
+	header := base64.RawURLEncoding.EncodeToString(headerJSON)
249
+	payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
250
+	signingInput := header + "." + payload
251
+	sig := base64.RawURLEncoding.EncodeToString(signHS256(s.key, signingInput))
252
+	return signingInput + "." + sig, nil
253
+}
254
+
255
+func signHS256(key []byte, signingInput string) []byte {
256
+	mac := hmac.New(sha256.New, key)
257
+	_, _ = mac.Write([]byte(signingInput))
258
+	return mac.Sum(nil)
259
+}
260
+
261
+func newJTI(r io.Reader) (string, error) {
262
+	buf := make([]byte, jtiBytes)
263
+	if _, err := io.ReadFull(r, buf); err != nil {
264
+		return "", fmt.Errorf("runnerjwt: jti: %w", err)
265
+	}
266
+	return base64.RawURLEncoding.EncodeToString(buf), nil
267
+}
268
+
269
+func validateClaims(c Claims) error {
270
+	if _, err := c.RunnerID(); err != nil {
271
+		return err
272
+	}
273
+	if c.JobID <= 0 || c.RunID <= 0 || c.RepoID <= 0 || c.Exp <= 0 {
274
+		return ErrInvalidClaims
275
+	}
276
+	if len(c.JTI) < 16 || len(c.JTI) > 128 {
277
+		return ErrInvalidClaims
278
+	}
279
+	return nil
280
+}
281
+
282
+func decodeKey(s string) ([]byte, error) {
283
+	encodings := []*base64.Encoding{
284
+		base64.StdEncoding,
285
+		base64.RawStdEncoding,
286
+		base64.URLEncoding,
287
+		base64.RawURLEncoding,
288
+	}
289
+	var lastErr error
290
+	for _, enc := range encodings {
291
+		raw, err := enc.DecodeString(s)
292
+		if err == nil {
293
+			return raw, nil
294
+		}
295
+		lastErr = err
296
+	}
297
+	return nil, lastErr
298
+}
internal/auth/runnerjwt/jwt_test.goadded
@@ -0,0 +1,164 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package runnerjwt_test
4
+
5
+import (
6
+	"encoding/base64"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
13
+)
14
+
15
+func TestDeriveKeyFromTOTPKeyB64_UsesHKDFLabel(t *testing.T) {
16
+	raw := bytesOf(0x42, 32)
17
+	derived, err := runnerjwt.DeriveKeyFromTOTPKeyB64(base64.StdEncoding.EncodeToString(raw))
18
+	if err != nil {
19
+		t.Fatalf("DeriveKeyFromTOTPKeyB64: %v", err)
20
+	}
21
+	if len(derived) != 32 {
22
+		t.Fatalf("derived length: got %d, want 32", len(derived))
23
+	}
24
+	if string(derived) == string(raw) {
25
+		t.Fatal("derived key matched raw TOTP key; want HKDF isolation")
26
+	}
27
+}
28
+
29
+func TestMintVerifyRoundTrip(t *testing.T) {
30
+	now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
31
+	signer := newTestSigner(t, now, bytesOf(0x11, 32))
32
+
33
+	token, claims, err := signer.Mint(runnerjwt.MintParams{
34
+		RunnerID: 7,
35
+		JobID:    11,
36
+		RunID:    13,
37
+		RepoID:   17,
38
+	})
39
+	if err != nil {
40
+		t.Fatalf("Mint: %v", err)
41
+	}
42
+	if got := len(strings.Split(token, ".")); got != 3 {
43
+		t.Fatalf("token parts: got %d, want 3", got)
44
+	}
45
+	if claims.Exp != now.Add(runnerjwt.DefaultTTL).Unix() {
46
+		t.Fatalf("exp: got %d, want %d", claims.Exp, now.Add(runnerjwt.DefaultTTL).Unix())
47
+	}
48
+	runnerID, err := claims.RunnerID()
49
+	if err != nil {
50
+		t.Fatalf("RunnerID: %v", err)
51
+	}
52
+	if runnerID != 7 {
53
+		t.Fatalf("RunnerID: got %d, want 7", runnerID)
54
+	}
55
+
56
+	got, err := signer.Verify(token)
57
+	if err != nil {
58
+		t.Fatalf("Verify: %v", err)
59
+	}
60
+	if got != claims {
61
+		t.Fatalf("claims mismatch:\n got %#v\nwant %#v", got, claims)
62
+	}
63
+}
64
+
65
+func TestVerifyRejectsTamperedPayload(t *testing.T) {
66
+	signer := newTestSigner(t, time.Unix(100, 0), bytesOf(0x22, 32))
67
+	token, _, err := signer.Mint(runnerjwt.MintParams{RunnerID: 1, JobID: 2, RunID: 3, RepoID: 4})
68
+	if err != nil {
69
+		t.Fatalf("Mint: %v", err)
70
+	}
71
+	parts := strings.Split(token, ".")
72
+	replacement := "A"
73
+	if parts[1][len(parts[1])-1] == 'A' {
74
+		replacement = "B"
75
+	}
76
+	parts[1] = parts[1][:len(parts[1])-1] + replacement
77
+
78
+	if _, err := signer.Verify(strings.Join(parts, ".")); !errors.Is(err, runnerjwt.ErrInvalidSignature) {
79
+		t.Fatalf("Verify tampered payload: got %v, want ErrInvalidSignature", err)
80
+	}
81
+}
82
+
83
+func TestVerifyRejectsExpiredToken(t *testing.T) {
84
+	issuedAt := time.Unix(100, 0)
85
+	key, err := runnerjwt.DeriveKeyFromTOTPKeyB64(base64.StdEncoding.EncodeToString(bytesOf(0x99, 32)))
86
+	if err != nil {
87
+		t.Fatalf("derive: %v", err)
88
+	}
89
+	signer, err := runnerjwt.NewFromKey(
90
+		key,
91
+		runnerjwt.WithClock(func() time.Time { return issuedAt }),
92
+		runnerjwt.WithRand(strings.NewReader(string(bytesOf(0x55, 32)))),
93
+	)
94
+	if err != nil {
95
+		t.Fatalf("NewFromKey signer: %v", err)
96
+	}
97
+	token, _, err := signer.Mint(runnerjwt.MintParams{RunnerID: 1, JobID: 2, RunID: 3, RepoID: 4, TTL: time.Second})
98
+	if err != nil {
99
+		t.Fatalf("Mint: %v", err)
100
+	}
101
+	verifier, err := runnerjwt.NewFromKey(key, runnerjwt.WithClock(func() time.Time { return issuedAt.Add(time.Second) }))
102
+	if err != nil {
103
+		t.Fatalf("NewFromKey verifier: %v", err)
104
+	}
105
+	if _, err := verifier.Verify(token); !errors.Is(err, runnerjwt.ErrExpired) {
106
+		t.Fatalf("Verify expired: got %v, want ErrExpired", err)
107
+	}
108
+}
109
+
110
+func TestVerifyRejectsUnsupportedHeader(t *testing.T) {
111
+	signer := newTestSigner(t, time.Unix(100, 0), bytesOf(0x44, 32))
112
+	if _, err := signer.Verify("eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.sig"); !errors.Is(err, runnerjwt.ErrUnsupportedHeader) {
113
+		t.Fatalf("Verify unsupported header: got %v, want ErrUnsupportedHeader", err)
114
+	}
115
+}
116
+
117
+func TestMintGeneratesDistinctJTI(t *testing.T) {
118
+	now := time.Unix(100, 0)
119
+	rng := strings.NewReader(string(append(bytesOf(0x01, 32), bytesOf(0x02, 32)...)))
120
+	key, err := runnerjwt.DeriveKeyFromTOTPKeyB64(base64.StdEncoding.EncodeToString(bytesOf(0x77, 32)))
121
+	if err != nil {
122
+		t.Fatalf("derive: %v", err)
123
+	}
124
+	signer, err := runnerjwt.NewFromKey(key, runnerjwt.WithClock(func() time.Time { return now }), runnerjwt.WithRand(rng))
125
+	if err != nil {
126
+		t.Fatalf("NewFromKey: %v", err)
127
+	}
128
+	_, first, err := signer.Mint(runnerjwt.MintParams{RunnerID: 1, JobID: 2, RunID: 3, RepoID: 4})
129
+	if err != nil {
130
+		t.Fatalf("Mint first: %v", err)
131
+	}
132
+	_, second, err := signer.Mint(runnerjwt.MintParams{RunnerID: 1, JobID: 2, RunID: 3, RepoID: 4})
133
+	if err != nil {
134
+		t.Fatalf("Mint second: %v", err)
135
+	}
136
+	if first.JTI == second.JTI {
137
+		t.Fatalf("JTI reused: %s", first.JTI)
138
+	}
139
+}
140
+
141
+func newTestSigner(t *testing.T, now time.Time, jtiBytes []byte) *runnerjwt.Signer {
142
+	t.Helper()
143
+	key, err := runnerjwt.DeriveKeyFromTOTPKeyB64(base64.StdEncoding.EncodeToString(bytesOf(0x99, 32)))
144
+	if err != nil {
145
+		t.Fatalf("derive: %v", err)
146
+	}
147
+	signer, err := runnerjwt.NewFromKey(
148
+		key,
149
+		runnerjwt.WithClock(func() time.Time { return now }),
150
+		runnerjwt.WithRand(strings.NewReader(string(jtiBytes))),
151
+	)
152
+	if err != nil {
153
+		t.Fatalf("NewFromKey: %v", err)
154
+	}
155
+	return signer
156
+}
157
+
158
+func bytesOf(b byte, n int) []byte {
159
+	out := make([]byte, n)
160
+	for i := range out {
161
+		out[i] = b
162
+	}
163
+	return out
164
+}
internal/auth/runnerjwt/replay.goadded
@@ -0,0 +1,40 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package runnerjwt
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"time"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
14
+)
15
+
16
+var ErrReplay = errors.New("runnerjwt: token replay")
17
+
18
+// Consume records claims.JTI as used. It returns ErrReplay when the jti was
19
+// already consumed, which callers translate to 401.
20
+func Consume(ctx context.Context, db actionsdb.DBTX, claims Claims) error {
21
+	runnerID, err := claims.RunnerID()
22
+	if err != nil {
23
+		return err
24
+	}
25
+	if err := validateClaims(claims); err != nil {
26
+		return err
27
+	}
28
+	_, err = actionsdb.New().MarkRunnerJWTUsed(ctx, db, actionsdb.MarkRunnerJWTUsedParams{
29
+		Jti:       claims.JTI,
30
+		RunnerID:  runnerID,
31
+		JobID:     claims.JobID,
32
+		RunID:     claims.RunID,
33
+		RepoID:    claims.RepoID,
34
+		ExpiresAt: pgtype.Timestamptz{Time: time.Unix(claims.Exp, 0), Valid: true},
35
+	})
36
+	if errors.Is(err, pgx.ErrNoRows) {
37
+		return ErrReplay
38
+	}
39
+	return err
40
+}
internal/auth/runnerjwt/replay_test.goadded
@@ -0,0 +1,118 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package runnerjwt_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"testing"
9
+	"time"
10
+
11
+	"github.com/jackc/pgx/v5"
12
+	"github.com/jackc/pgx/v5/pgconn"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
16
+)
17
+
18
+func TestConsumeRecordsClaims(t *testing.T) {
19
+	claims := runnerjwt.Claims{
20
+		Sub:    "runner:7",
21
+		JobID:  11,
22
+		RunID:  13,
23
+		RepoID: 17,
24
+		Exp:    time.Unix(1000, 0).Unix(),
25
+		JTI:    "0123456789abcdef",
26
+	}
27
+	db := &fakeReplayDB{row: fakeReplayRow{}}
28
+
29
+	if err := runnerjwt.Consume(context.Background(), db, claims); err != nil {
30
+		t.Fatalf("Consume: %v", err)
31
+	}
32
+	if len(db.args) != 6 {
33
+		t.Fatalf("args length: got %d, want 6", len(db.args))
34
+	}
35
+	if db.args[0] != claims.JTI || db.args[1] != int64(7) ||
36
+		db.args[2] != claims.JobID || db.args[3] != claims.RunID || db.args[4] != claims.RepoID {
37
+		t.Fatalf("unexpected args: %#v", db.args)
38
+	}
39
+	expiresAt, ok := db.args[5].(pgtype.Timestamptz)
40
+	if !ok {
41
+		t.Fatalf("expires_at arg type: got %T", db.args[5])
42
+	}
43
+	if !expiresAt.Valid || expiresAt.Time.Unix() != claims.Exp {
44
+		t.Fatalf("expires_at: got %#v, want unix %d", expiresAt, claims.Exp)
45
+	}
46
+}
47
+
48
+func TestConsumeMapsNoRowsToReplay(t *testing.T) {
49
+	claims := runnerjwt.Claims{
50
+		Sub:    "runner:7",
51
+		JobID:  11,
52
+		RunID:  13,
53
+		RepoID: 17,
54
+		Exp:    time.Unix(1000, 0).Unix(),
55
+		JTI:    "0123456789abcdef",
56
+	}
57
+	db := &fakeReplayDB{row: fakeReplayRow{err: pgx.ErrNoRows}}
58
+
59
+	if err := runnerjwt.Consume(context.Background(), db, claims); !errors.Is(err, runnerjwt.ErrReplay) {
60
+		t.Fatalf("Consume replay: got %v, want ErrReplay", err)
61
+	}
62
+}
63
+
64
+func TestConsumeRejectsInvalidClaimsBeforeDB(t *testing.T) {
65
+	db := &fakeReplayDB{row: fakeReplayRow{}}
66
+	err := runnerjwt.Consume(context.Background(), db, runnerjwt.Claims{
67
+		Sub:    "user:7",
68
+		JobID:  11,
69
+		RunID:  13,
70
+		RepoID: 17,
71
+		Exp:    time.Unix(1000, 0).Unix(),
72
+		JTI:    "0123456789abcdef",
73
+	})
74
+	if !errors.Is(err, runnerjwt.ErrInvalidClaims) {
75
+		t.Fatalf("Consume invalid claims: got %v, want ErrInvalidClaims", err)
76
+	}
77
+	if db.calls != 0 {
78
+		t.Fatalf("DB called for invalid claims: %d", db.calls)
79
+	}
80
+}
81
+
82
+type fakeReplayDB struct {
83
+	row   pgx.Row
84
+	args  []any
85
+	calls int
86
+}
87
+
88
+func (f *fakeReplayDB) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
89
+	return pgconn.CommandTag{}, nil
90
+}
91
+
92
+func (f *fakeReplayDB) Query(context.Context, string, ...any) (pgx.Rows, error) {
93
+	return nil, nil
94
+}
95
+
96
+func (f *fakeReplayDB) QueryRow(_ context.Context, _ string, args ...any) pgx.Row {
97
+	f.calls++
98
+	f.args = args
99
+	return f.row
100
+}
101
+
102
+type fakeReplayRow struct {
103
+	err error
104
+}
105
+
106
+func (r fakeReplayRow) Scan(dest ...any) error {
107
+	if r.err != nil {
108
+		return r.err
109
+	}
110
+	*(dest[0].(*string)) = "0123456789abcdef"
111
+	*(dest[1].(*int64)) = 7
112
+	*(dest[2].(*int64)) = 11
113
+	*(dest[3].(*int64)) = 13
114
+	*(dest[4].(*int64)) = 17
115
+	*(dest[5].(*pgtype.Timestamptz)) = pgtype.Timestamptz{Time: time.Unix(1000, 0), Valid: true}
116
+	*(dest[6].(*pgtype.Timestamptz)) = pgtype.Timestamptz{Time: time.Unix(900, 0), Valid: true}
117
+	return nil
118
+}
internal/checks/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/issues/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/meta/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/migrationsfs/migrations/0052_runner_jwt_used.sqladded
@@ -0,0 +1,35 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S41c runner JWT replay protection.
4
+--
5
+-- The runner heartbeat endpoint mints short-lived, per-job JWTs after a
6
+-- registration-token-authenticated runner claims a workflow_jobs row. Job
7
+-- endpoints require that JWT and consume its jti exactly once. INSERT ...
8
+-- ON CONFLICT DO NOTHING against this table is the replay gate: one affected
9
+-- row means "first use"; zero rows means "replay" and the API returns 401.
10
+--
11
+-- We keep the workflow references here for auditability and cleanup. jti is
12
+-- the hot lookup path and is enforced by the PRIMARY KEY.
13
+
14
+-- +goose Up
15
+
16
+CREATE TABLE runner_jwt_used (
17
+    jti         text         PRIMARY KEY,
18
+    runner_id   bigint       NOT NULL REFERENCES workflow_runners(id) ON DELETE CASCADE,
19
+    job_id      bigint       NOT NULL REFERENCES workflow_jobs(id) ON DELETE CASCADE,
20
+    run_id      bigint       NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
21
+    repo_id     bigint       NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
22
+    expires_at  timestamptz  NOT NULL,
23
+    used_at     timestamptz  NOT NULL DEFAULT now(),
24
+
25
+    CONSTRAINT runner_jwt_used_jti_length CHECK (char_length(jti) BETWEEN 16 AND 128)
26
+);
27
+
28
+CREATE INDEX runner_jwt_used_expires_idx
29
+    ON runner_jwt_used (expires_at);
30
+CREATE INDEX runner_jwt_used_runner_used_idx
31
+    ON runner_jwt_used (runner_id, used_at DESC);
32
+
33
+
34
+-- +goose Down
35
+DROP TABLE IF EXISTS runner_jwt_used;
internal/notif/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/orgs/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/pulls/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/ratelimit/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/repos/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/social/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/users/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/webhook/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64
internal/worker/sqlc/models.gomodified
@@ -2025,6 +2025,16 @@ type ReposSearch struct {
20252025
 	Tsv    interface{}
20262026
 }
20272027
 
2028
+type RunnerJwtUsed struct {
2029
+	Jti       string
2030
+	RunnerID  int64
2031
+	JobID     int64
2032
+	RunID     int64
2033
+	RepoID    int64
2034
+	ExpiresAt pgtype.Timestamptz
2035
+	UsedAt    pgtype.Timestamptz
2036
+}
2037
+
20282038
 type RunnerToken struct {
20292039
 	ID        int64
20302040
 	RunnerID  int64