// Package sqldb is the server's connection to its database: SQLite for a // single node with nothing to set up, PostgreSQL for a deployment that has a // database service. Both are reached through database/sql with pure-Go // drivers, so the server still builds without cgo. // // The schema is three tables and deliberately says nothing about the data: // // keeporg_meta name → value: schema version, write generation, key check // keeporg_records id → sealed record (one user, group, session, …) // keeporg_blobs (ref, version) → sealed KDBX payload // // Every row is encrypted by the caller before it gets here, and named by a // keyed digest rather than by anything readable. What the database, its // backups and its administrators can see is how many rows there are and how // big they are. package sqldb import ( "context" "database/sql" "encoding/base64" "errors" "fmt" "net/url" "os" "path/filepath" "strconv" "strings" _ "github.com/jackc/pgx/v5/stdlib" // registers "pgx" _ "modernc.org/sqlite" // registers "sqlite" "cc.tiredsysadmin.keeporg/internal/secret" ) // Dialect is the SQL engine behind a DB. type Dialect string const ( SQLite Dialect = "sqlite" Postgres Dialect = "postgres" ) // schemaVersion is the layout this code reads and writes. const schemaVersion = 1 // DB is an open, migrated database. type DB struct { *sql.DB Dialect Dialect // Where is the database's location with any password removed, for logs. Where string } // Open connects to a database and brings its schema up to date. The URL is // postgres://… or postgresql://… for PostgreSQL, and sqlite: or a bare // path for SQLite, whose file is created owner-only if it does not exist. func Open(ctx context.Context, dbURL string) (*DB, error) { var ( db *DB err error ) switch { case strings.HasPrefix(dbURL, "postgres://"), strings.HasPrefix(dbURL, "postgresql://"): db, err = openPostgres(dbURL) default: db, err = openSQLite(strings.TrimPrefix(dbURL, "sqlite:")) } if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("database %s: %w", db.Where, err) } if err := db.migrate(ctx); err != nil { db.Close() return nil, fmt.Errorf("database %s: %w", db.Where, err) } return db, nil } func openPostgres(dbURL string) (*DB, error) { sqldb, err := sql.Open("pgx", dbURL) if err != nil { return nil, err } where := dbURL if u, err := url.Parse(dbURL); err == nil { where = u.Redacted() } return &DB{DB: sqldb, Dialect: Postgres, Where: where}, nil } func openSQLite(path string) (*DB, error) { if path == "" { return nil, errors.New("sqlite: no database path") } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } // Created here so the file is owner-only from its first byte; SQLite // would otherwise create it with the process umask. if f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600); err != nil { return nil, err } else { f.Close() } // WAL lets the blob reads of synchronization run beside a write. FULL // synchronous is the durable setting, and the right one for passwords. // An immediate transaction takes the write lock at BEGIN, which is what // makes the generation check in a write transaction meaningful. dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)" + "&_txlock=immediate" sqldb, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } return &DB{DB: sqldb, Dialect: SQLite, Where: path}, nil } func (db *DB) blobType() string { if db.Dialect == Postgres { return "BYTEA" } return "BLOB" } func (db *DB) migrate(ctx context.Context) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS keeporg_meta ( name TEXT PRIMARY KEY, value TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS keeporg_records ( id TEXT PRIMARY KEY, body ` + db.blobType() + ` NOT NULL)`, `CREATE TABLE IF NOT EXISTS keeporg_blobs ( ref TEXT NOT NULL, version BIGINT NOT NULL, body ` + db.blobType() + ` NOT NULL, PRIMARY KEY (ref, version))`, } return db.Tx(ctx, func(tx *sql.Tx) error { for _, s := range stmts { if _, err := tx.ExecContext(ctx, s); err != nil { return err } } v, err := db.meta(ctx, tx, "schema") if err != nil { return err } switch { case v == "": if err := db.setMeta(ctx, tx, "schema", strconv.Itoa(schemaVersion)); err != nil { return err } return db.setMeta(ctx, tx, "generation", "0") case v != strconv.Itoa(schemaVersion): return fmt.Errorf("schema version %s is not one this server knows (%d); "+ "it was written by a newer keeporgd", v, schemaVersion) } return nil }) } // Q rewrites ?-placeholders as $1, $2, … for PostgreSQL. Statements in this // code base are written once, with ?, and pass through here. func (db *DB) Q(q string) string { if db.Dialect != Postgres { return q } var b strings.Builder n := 0 for _, r := range q { if r == '?' { n++ b.WriteString("$" + strconv.Itoa(n)) continue } b.WriteRune(r) } return b.String() } // Tx runs fn in a transaction, committing when it returns nil. func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } if err := fn(tx); err != nil { tx.Rollback() return err } return tx.Commit() } // Querier is what both *sql.DB and *sql.Tx offer. type Querier interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } func (db *DB) meta(ctx context.Context, q Querier, name string) (string, error) { var v string err := q.QueryRowContext(ctx, db.Q(`SELECT value FROM keeporg_meta WHERE name = ?`), name).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return "", nil } return v, err } func (db *DB) setMeta(ctx context.Context, q Querier, name, value string) error { _, err := q.ExecContext(ctx, db.Q(`INSERT INTO keeporg_meta (name, value) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET value = excluded.value`), name, value) return err } // Generation is the number of committed writes to the records. A process // that holds the records in memory compares it with its own to tell whether // another process has written since. func (db *DB) Generation(ctx context.Context, q Querier) (int64, error) { v, err := db.meta(ctx, q, "generation") if err != nil { return 0, err } return strconv.ParseInt(v, 10, 64) } // LockGeneration reads the generation inside a write transaction and holds // it, so no other writer can commit in between. On PostgreSQL that is a row // lock; a SQLite write transaction already holds the whole database. func (db *DB) LockGeneration(ctx context.Context, tx *sql.Tx) (int64, error) { q := `SELECT value FROM keeporg_meta WHERE name = 'generation'` if db.Dialect == Postgres { q += ` FOR UPDATE` } var v string if err := tx.QueryRowContext(ctx, q).Scan(&v); err != nil { return 0, err } return strconv.ParseInt(v, 10, 64) } // SetGeneration records a new generation inside the write that made it. func (db *DB) SetGeneration(ctx context.Context, tx *sql.Tx, gen int64) error { return db.setMeta(ctx, tx, "generation", strconv.FormatInt(gen, 10)) } // ErrWrongKey means the database was encrypted under a different master // key. Carrying on would write rows nobody could read alongside rows this // key cannot, so it is fatal. var ErrWrongKey = errors.New("this master key does not open this database; " + "start with the key it was created with") // CheckKey proves that k is the key the database was written with, by // opening a sealed marker stored at its first start. A new database gets the // marker now. func (db *DB) CheckKey(ctx context.Context, k *secret.Keyring) error { const aad = "keeporg key check" v, err := db.meta(ctx, db, "key_check") if err != nil { return err } if v == "" { sealed := k.Seal([]byte(aad), []byte("keeporg")) return db.setMeta(ctx, db, "key_check", base64.StdEncoding.EncodeToString(sealed)) } raw, err := base64.StdEncoding.DecodeString(v) if err != nil { return ErrWrongKey } if _, err := k.Open([]byte(aad), raw); err != nil { return ErrWrongKey } return nil }