upaas/internal/database/database.go
clawbot 72786a9feb fix: use hashed webhook secrets for constant-time comparison
Store a SHA-256 hash of the webhook secret in a new webhook_secret_hash
column. FindAppByWebhookSecret now hashes the incoming secret and queries
by hash, eliminating the SQL string comparison timing side-channel.

- Add migration 005_add_webhook_secret_hash.sql
- Add database.HashWebhookSecret() helper
- Backfill existing secrets on startup
- Update App model to include WebhookSecretHash in all queries
- Update app creation to compute hash at insert time
- Add TestHashWebhookSecret unit test
- Update all test fixtures to set WebhookSecretHash

Closes #13
2026-02-15 14:06:53 -08:00

232 lines
5.3 KiB
Go

// Package database provides SQLite database access with logging.
package database
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// dataDirPermissions is the file permission for the data directory.
const dataDirPermissions = 0o750
// Params contains dependencies for Database.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
}
// Database wraps sql.DB with logging and helper methods.
type Database struct {
database *sql.DB
log *slog.Logger
params *Params
}
// New creates a new Database instance.
func New(lifecycle fx.Lifecycle, params Params) (*Database, error) {
database := &Database{
log: params.Logger.Get(),
params: &params,
}
// For testing, if lifecycle is nil, connect immediately
if lifecycle == nil {
err := database.connect(context.Background())
if err != nil {
return nil, err
}
return database, nil
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return database.connect(ctx)
},
OnStop: func(_ context.Context) error {
return database.close()
},
})
return database, nil
}
// DB returns the underlying sql.DB for direct access.
func (d *Database) DB() *sql.DB {
return d.database
}
// Exec executes a query with logging.
func (d *Database) Exec(
ctx context.Context,
query string,
args ...any,
) (sql.Result, error) {
d.log.Debug("database exec", "query", query, "args", args)
result, err := d.database.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("exec failed: %w", err)
}
return result, nil
}
// QueryRow executes a query that returns a single row.
func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row {
d.log.Debug("database query row", "query", query, "args", args)
return d.database.QueryRowContext(ctx, query, args...)
}
// Query executes a query that returns multiple rows.
func (d *Database) Query(
ctx context.Context,
query string,
args ...any,
) (*sql.Rows, error) {
d.log.Debug("database query", "query", query, "args", args)
rows, err := d.database.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
return rows, nil
}
// BeginTx starts a new transaction.
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
d.log.Debug("database begin transaction")
transaction, err := d.database.BeginTx(ctx, opts)
if err != nil {
return nil, fmt.Errorf("begin transaction failed: %w", err)
}
return transaction, nil
}
// Path returns the database file path.
func (d *Database) Path() string {
return d.params.Config.DatabasePath()
}
func (d *Database) connect(ctx context.Context) error {
dbPath := d.params.Config.DatabasePath()
// Ensure data directory exists
dir := filepath.Dir(dbPath)
err := os.MkdirAll(dir, dataDirPermissions)
if err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Open database with WAL mode and foreign keys
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on"
database, err := sql.Open("sqlite3", dsn)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// Test connection
err = database.PingContext(ctx)
if err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
d.database = database
d.log.Info("database connected", "path", dbPath)
// Run migrations
err = d.migrate(ctx)
if err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
// Backfill webhook_secret_hash for any rows that have a secret but no hash
err = d.backfillWebhookSecretHashes(ctx)
if err != nil {
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
}
return nil
}
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
func HashWebhookSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
if err != nil {
return fmt.Errorf("querying apps for backfill: %w", err)
}
defer func() { _ = rows.Close() }()
type row struct {
id, secret string
}
var toUpdate []row
for rows.Next() {
var r row
if scanErr := rows.Scan(&r.id, &r.secret); scanErr != nil {
return fmt.Errorf("scanning app for backfill: %w", scanErr)
}
toUpdate = append(toUpdate, r)
}
if rowsErr := rows.Err(); rowsErr != nil {
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
}
for _, r := range toUpdate {
hash := HashWebhookSecret(r.secret)
_, updateErr := d.database.ExecContext(ctx,
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
if updateErr != nil {
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
}
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
}
return nil
}
func (d *Database) close() error {
if d.database != nil {
d.log.Info("closing database connection")
err := d.database.Close()
if err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
}
return nil
}