diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 10:15:56 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 10:15:56 +0300 |
| commit | 7374be02a89cad4d0df4460a98b42764d191ad92 (patch) | |
| tree | 880889fffc6681925536d354cba206a52cb22175 /internal/authkeys | |
| parent | c053f1e04ffb0fb89743cc7bc5154efaf6e8a0bf (diff) | |
task 13: upload API keys in SQLite, Bearer auth, create-client-key CLI
- Add internal/authkeys with SHA-256 hashed tokens and KeyCount gate
- PUT /upload/{host}/{kind} for uptimed files; auth when any key exists
- Daemon -auth-db and Config.AuthDB; plain HTTP unchanged
- CLI: goprecords --create-client-key HOSTNAME (-stats-dir|-auth-db)
Made-with: Cursor
Diffstat (limited to 'internal/authkeys')
| -rw-r--r-- | internal/authkeys/store.go | 112 | ||||
| -rw-r--r-- | internal/authkeys/store_test.go | 65 |
2 files changed, 177 insertions, 0 deletions
diff --git a/internal/authkeys/store.go b/internal/authkeys/store.go new file mode 100644 index 0000000..9f0a8b2 --- /dev/null +++ b/internal/authkeys/store.go @@ -0,0 +1,112 @@ +package authkeys + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "fmt" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS client_key ( + hostname TEXT NOT NULL PRIMARY KEY, + key_hash BLOB NOT NULL, + created_at INTEGER NOT NULL +); +` + +// Store holds per-client API key hashes in SQLite. +type Store struct { + db *sql.DB +} + +// DefaultPath returns the default auth database path under statsDir. +func DefaultPath(statsDir string) string { + return filepath.Join(statsDir, "goprecords-auth.db") +} + +// OpenStore opens or creates the SQLite auth database at path. +func OpenStore(ctx context.Context, path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open auth db: %w", err) + } + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { + db.Close() + return nil, fmt.Errorf("pragma: %w", err) + } + return &Store{db: db}, nil +} + +// Close releases the database handle. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// EnsureSchema creates the client_key table if missing. +func (s *Store) EnsureSchema(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, schemaSQL) + if err != nil { + return fmt.Errorf("auth schema: %w", err) + } + return nil +} + +// KeyCount returns how many client keys are stored. When zero, upload auth is not enforced. +func (s *Store) KeyCount(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM client_key").Scan(&n) + if err != nil { + return 0, fmt.Errorf("count keys: %w", err) + } + return n, nil +} + +// CreateKey inserts or replaces the key for hostname and returns the plaintext token once. +func (s *Store) CreateKey(ctx context.Context, hostname string) (token string, err error) { + if hostname == "" { + return "", fmt.Errorf("empty hostname") + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("random token: %w", err) + } + tok := base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(tok)) + _, err = s.db.ExecContext(ctx, + `INSERT INTO client_key (hostname, key_hash, created_at) VALUES (?, ?, ?) + ON CONFLICT(hostname) DO UPDATE SET key_hash = excluded.key_hash, created_at = excluded.created_at`, + hostname, sum[:], time.Now().Unix(), + ) + if err != nil { + return "", fmt.Errorf("insert key: %w", err) + } + return tok, nil +} + +// Verify checks that token matches the stored hash for hostname. +func (s *Store) Verify(ctx context.Context, hostname, token string) (bool, error) { + var stored []byte + err := s.db.QueryRowContext(ctx, "SELECT key_hash FROM client_key WHERE hostname = ?", hostname).Scan(&stored) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("lookup key: %w", err) + } + sum := sha256.Sum256([]byte(token)) + if len(stored) != len(sum) { + return false, nil + } + return subtle.ConstantTimeCompare(stored, sum[:]) == 1, nil +} diff --git a/internal/authkeys/store_test.go b/internal/authkeys/store_test.go new file mode 100644 index 0000000..8331a5b --- /dev/null +++ b/internal/authkeys/store_test.go @@ -0,0 +1,65 @@ +package authkeys + +import ( + "context" + "path/filepath" + "testing" +) + +func TestCreateVerifyReplace(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "auth.db") + s, err := OpenStore(ctx, path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.EnsureSchema(ctx); err != nil { + t.Fatal(err) + } + n, err := s.KeyCount(ctx) + if err != nil || n != 0 { + t.Fatalf("KeyCount got %d err %v", n, err) + } + tok1, err := s.CreateKey(ctx, "host-a") + if err != nil { + t.Fatal(err) + } + if tok1 == "" { + t.Fatal("empty token") + } + n, err = s.KeyCount(ctx) + if err != nil || n != 1 { + t.Fatalf("KeyCount after create got %d err %v", n, err) + } + ok, err := s.Verify(ctx, "host-a", tok1) + if err != nil || !ok { + t.Fatalf("Verify tok1 got %v ok=%v", err, ok) + } + ok, err = s.Verify(ctx, "host-a", "wrong") + if err != nil || ok { + t.Fatalf("Verify wrong got %v ok=%v", err, ok) + } + tok2, err := s.CreateKey(ctx, "host-a") + if err != nil { + t.Fatal(err) + } + if tok2 == tok1 { + t.Fatal("expected new token after replace") + } + ok, err = s.Verify(ctx, "host-a", tok1) + if err != nil || ok { + t.Fatalf("old token should fail got %v ok=%v", err, ok) + } + ok, err = s.Verify(ctx, "host-a", tok2) + if err != nil || !ok { + t.Fatalf("new token should work got %v ok=%v", err, ok) + } +} + +func TestDefaultPath(t *testing.T) { + p := DefaultPath("/var/stats") + if filepath.Base(p) != "goprecords-auth.db" { + t.Fatalf("got %q", p) + } +} |
