135 lines
4.3 KiB
Go
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[:])
|
|
}
|