summaryrefslogtreecommitdiff
path: root/internal/authkeys
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-14 10:15:56 +0300
committerPaul Buetow <paul@buetow.org>2026-04-14 10:15:56 +0300
commit7374be02a89cad4d0df4460a98b42764d191ad92 (patch)
tree880889fffc6681925536d354cba206a52cb22175 /internal/authkeys
parentc053f1e04ffb0fb89743cc7bc5154efaf6e8a0bf (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.go112
-rw-r--r--internal/authkeys/store_test.go65
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)
+ }
+}