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 }