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) } 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[:]) }