~bigbes/sr-ht-spec

ref: c7e4f9f91da3c012cd45e4b95a559c53874b5be3 sr-ht-spec/db/token.go -rw-r--r-- 7.1 KiB
c7e4f9f9 — Eugene Blikh feat: hooks — the receive path, its daemon RPC, and the specsrht daemon 27 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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
package db

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"encoding/base64"
	"errors"
	"fmt"
	"time"

	"github.com/lib/pq"
)

// TokenBytes is the entropy of a minted agent token before encoding. 32 bytes
// is well past any brute-force concern and keeps the encoded form short enough
// to paste into an agent's environment.
const TokenBytes = 32

// AgentToken is a credential an agent presents on the write plane. The token
// value itself is never stored — only Hash — so a database dump cannot be
// replayed against the service.
//
// v1 ships one token plus mandatory provenance rather than per-agent scopes:
// the refs rule (an agent credential can only move refs under BranchPrefix) is
// the boundary that actually bounds the damage, and it holds with a single
// shared token. Per-space scoping is a later column on this row plus a filter
// clause, not an architectural change.
type AgentToken struct {
	ID      int
	Name    string
	Hash    []byte
	Created time.Time
	Revoked *time.Time
}

// Active reports whether the token may still authenticate.
func (t *AgentToken) Active() bool { return t.Revoked == nil }

// GenerateToken mints a fresh token value: TokenBytes of crypto/rand, URL-safe
// base64 without padding. This is the only moment the plaintext exists; the
// caller shows it to the operator once and stores only HashToken(it).
func GenerateToken() (string, error) {
	b := make([]byte, TokenBytes)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("generate agent token: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// HashToken is the one-way function between a presented token and the stored
// agent_token.token_hash. SHA-256 is the right tool here rather than a password
// KDF: the input is 256 bits of uniform randomness we generated, not a
// human-chosen secret, so there is no dictionary to stretch against.
func HashToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

// TokenMatches compares a stored hash with the hash of a presented token in
// constant time. Byte-wise early exit on a hash comparison leaks how many
// leading bytes an attacker guessed right, which is enough to walk a forged
// value into place one byte at a time; subtle.ConstantTimeCompare does not.
func TokenMatches(stored []byte, token string) bool {
	return subtle.ConstantTimeCompare(stored, HashToken(token)) == 1
}

// CreateAgentToken stores a token by hash and returns the row. The plaintext is
// never passed to this function and never reaches SQL — callers hash with
// HashToken and keep the value only long enough to show it once.
//
// A token_hash UNIQUE violation means the same token was registered twice —
// for 32 random bytes, that is a caller re-registering a value it already had,
// not a collision — and is mapped to ErrTokenExists.
func (s *Store) CreateAgentToken(ctx context.Context, name string, hash []byte) (*AgentToken, error) {
	if name == "" {
		return nil, fmt.Errorf("create agent token: name is required")
	}
	if len(hash) != sha256.Size {
		return nil, fmt.Errorf("create agent token: hash must be %d bytes, got %d",
			sha256.Size, len(hash))
	}
	const q = `
INSERT INTO agent_token (name, token_hash, created)
VALUES ($1, $2, $3)
RETURNING id, created`
	t := AgentToken{Name: name, Hash: hash}
	err := s.q.QueryRowContext(ctx, q, name, hash, time.Now().UTC()).Scan(&t.ID, &t.Created)
	if err != nil {
		var pqErr *pq.Error
		if errors.As(err, &pqErr) && pqErr.Code == "23505" {
			return nil, ErrTokenExists
		}
		return nil, fmt.Errorf("create agent token %q: %w", name, err)
	}
	return &t, nil
}

// AgentTokenByHash looks a token up by its stored hash. It does not consider
// revocation — use AuthenticateAgentToken for the authorization decision.
// Returns ErrNotFound if no such token is registered.
func (s *Store) AgentTokenByHash(ctx context.Context, hash []byte) (*AgentToken, error) {
	const q = `
SELECT id, name, token_hash, created, revoked
FROM agent_token
WHERE token_hash = $1`
	var (
		t       AgentToken
		revoked sql.NullTime
	)
	err := s.q.QueryRowContext(ctx, q, hash).Scan(&t.ID, &t.Name, &t.Hash, &t.Created, &revoked)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("agent token by hash: %w", err)
	}
	if revoked.Valid {
		r := revoked.Time
		t.Revoked = &r
	}
	return &t, nil
}

// AuthenticateAgentToken is the authorization boundary: it hashes the presented
// token, looks the row up by that hash, re-verifies the stored hash against the
// presentation in constant time, and rejects a revoked token.
//
// The re-verification is not redundant with the SQL equality. The index lookup
// is what finds the row; TokenMatches is what decides, and it is the one
// comparison an attacker can time. Returns ErrNotFound for an unknown token and
// ErrTokenRevoked for a known but revoked one; both are 401 at the API edge.
func (s *Store) AuthenticateAgentToken(ctx context.Context, token string) (*AgentToken, error) {
	if token == "" {
		return nil, ErrNotFound
	}
	t, err := s.AgentTokenByHash(ctx, HashToken(token))
	if err != nil {
		return nil, err
	}
	if !TokenMatches(t.Hash, token) {
		// The row was found by hash equality, so a mismatch here means the
		// stored hash is not what the index matched on — corruption, not a bad
		// credential.
		return nil, fmt.Errorf("agent token %d: stored hash does not verify", t.ID)
	}
	if !t.Active() {
		return nil, fmt.Errorf("%w: token %q revoked at %s", ErrTokenRevoked, t.Name, t.Revoked)
	}
	return t, nil
}

// RevokeAgentToken stamps a token revoked. Revocation is a stamp rather than a
// delete so the audit trail keeps naming the token that made past proposals.
// Revoking an already-revoked token is a no-op that returns nil; re-revoking is
// not an error worth failing an operator over. Returns ErrNotFound if id does
// not exist.
func (s *Store) RevokeAgentToken(ctx context.Context, id int) error {
	const q = `UPDATE agent_token SET revoked = COALESCE(revoked, $2) WHERE id = $1`
	res, err := s.q.ExecContext(ctx, q, id, time.Now().UTC())
	if err != nil {
		return fmt.Errorf("revoke agent token %d: %w", id, err)
	}
	return requireOne(res, "revoke agent token")
}

// ListAgentTokens returns every token, newest first, so the operator can see
// what exists and pick one to revoke. Hashes are included; there is nothing
// secret about them and the reconciler-style tooling compares by them.
func (s *Store) ListAgentTokens(ctx context.Context) ([]*AgentToken, error) {
	const q = `
SELECT id, name, token_hash, created, revoked
FROM agent_token
ORDER BY created DESC, id DESC`
	rows, err := s.q.QueryContext(ctx, q)
	if err != nil {
		return nil, fmt.Errorf("list agent tokens: %w", err)
	}
	defer rows.Close()
	var out []*AgentToken
	for rows.Next() {
		var (
			t       AgentToken
			revoked sql.NullTime
		)
		if err := rows.Scan(&t.ID, &t.Name, &t.Hash, &t.Created, &revoked); err != nil {
			return nil, fmt.Errorf("scan agent token: %w", err)
		}
		if revoked.Valid {
			r := revoked.Time
			t.Revoked = &r
		}
		out = append(out, &t)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate agent tokens: %w", err)
	}
	return out, nil
}