Files
nadir-agent/internal/auth/tokens.go
T
2026-06-24 17:29:45 +02:00

135 lines
4.3 KiB
Go

package auth
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// tokenPrefix marks a Nadir machine credential, like GitHub's "ghp_". It's
// cosmetic (helps secret scanners and humans recognize a leaked token), not a
// security boundary - the prefix is hashed and stored along with the rest.
const tokenPrefix = "nad_"
// TokenStore persists machine credentials for non-interactive callers (a
// central dashboard managing N nodes), so they authenticate with a static
// Bearer token instead of a per-host PAM session. Only the SHA-256 of each
// token is stored - a leaked DB or backup can't hand out live credentials.
//
// It lives in its own SQLite file because both the server (read, on every
// Bearer request) and the `nadir token` CLI (write, when minting/revoking)
// touch it. WAL + a busy timeout let those two processes share the file without
// "database is locked".
type TokenStore struct {
db *sql.DB
}
// TokenInfo is one stored credential's public metadata (never the secret).
type TokenInfo struct {
Name string
Created time.Time
}
// NewTokenStore opens (creating if needed) the token database at path.
func NewTokenStore(path string) (*TokenStore, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("token db dir: %w", err)
}
// WAL + busy_timeout: the server process and the CLI process both open this
// file, so a plain rollback journal would surface transient lock errors.
dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open token db: %w", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS tokens (
name TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)`); err != nil {
return nil, fmt.Errorf("create tokens table: %w", err)
}
if err := os.Chmod(path, 0600); err != nil {
return nil, fmt.Errorf("chmod token db: %w", err)
}
return &TokenStore{db: db}, nil
}
func (s *TokenStore) Close() error { return s.db.Close() }
// Create mints a new token named name and returns the raw secret. The secret is
// shown only here, at generation time - only its hash is stored. A duplicate
// name is rejected (the caller should revoke and re-mint to rotate).
func (s *TokenStore) Create(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("token name required")
}
raw := tokenPrefix + randomToken()
if _, err := s.db.Exec(
`INSERT INTO tokens (name, token_hash, created_at) VALUES (?, ?, ?)`,
name, hashToken(raw), time.Now().Unix(),
); err != nil {
return "", fmt.Errorf("store token %q (already exists?): %w", name, err)
}
return raw, nil
}
// Lookup returns the token name for a presented raw secret.
//
// No constant-time compare is needed: we look the secret up by its SHA-256, so
// the value compared in the index is already a hash of attacker-controlled
// input. A timing side-channel could at most leak a stored hash, which is
// useless without a SHA-256 preimage (the actual token).
func (s *TokenStore) Lookup(raw string) (string, bool) {
if !strings.HasPrefix(raw, tokenPrefix) {
return "", false
}
var name string
err := s.db.QueryRow(
`SELECT name FROM tokens WHERE token_hash = ?`, hashToken(raw),
).Scan(&name)
if err != nil {
return "", false
}
return name, true
}
// Delete revokes a token by name. Revoking an unknown name is a no-op. The
// change is effective immediately - the server reads this DB live, no restart.
func (s *TokenStore) Delete(name string) error {
_, err := s.db.Exec(`DELETE FROM tokens WHERE name = ?`, name)
return err
}
// List returns all token names with their creation time, newest first.
func (s *TokenStore) List() ([]TokenInfo, error) {
rows, err := s.db.Query(`SELECT name, created_at FROM tokens ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
infos := []TokenInfo{}
for rows.Next() {
var t TokenInfo
var created int64
if err := rows.Scan(&t.Name, &created); err != nil {
return nil, err
}
t.Created = time.Unix(created, 0).UTC()
infos = append(infos, t)
}
return infos, rows.Err()
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}