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
}