Keep server data in an encrypted database, and harden every part

Storage. The server keeps its data in SQLite (a file in the data
directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or
-database). Every row, the KDBX versions included, is sealed with
AES-256-GCM under a key derived from the master key and named by an HMAC.
The master key is required to start and a different one is refused. A data
directory from 0.10 or earlier is imported on first start. Several servers
may share one PostgreSQL database. Go goes to 1.26 for the pure-Go
drivers (modernc.org/sqlite, jackc/pgx).

Hardening, after an audit of the server, both clients and the deployment:
- Single sign-on approves a device only after an explicit Approve in the
  browser that started it, bound by a cookie and PKCE; identities are never
  linked by username, by verified email only when allowed, never to admins.
- Sign-in attempts are limited per address and per account, passwords are
  checked outside the store lock with uniform timing, one-time codes work
  once, and starting two-factor needs the password.
- Merge vaults bound KDF parameters, payload size and XML shape, check
  pushes before storing them, and run at most two merges at once. A version
  is written in the same transaction as its metadata.
- Tokens in URLs only on the event stream, which ends on revocation; CSP,
  frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases
  without access; new trusted_proxies.
- Clients open only the server's approval page, keep case-variant and
  retired file names apart, never overwrite a save made during a pull,
  require https except locally, send the stream token in a header, bound
  their reads and escape notifications and terminal output.
- The .deb creates the master key, systemd is sandboxed further, nginx sets
  modern TLS and HSTS, and CI deploys from main only.

Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:38:32 +01:00
co-authored by Claude Opus 5.5
parent f8f634fbf7
commit 4ffc434b1b
77 changed files with 4192 additions and 776 deletions
+280
View File
@@ -0,0 +1,280 @@
// 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:<path> 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
}
+51
View File
@@ -0,0 +1,51 @@
// Package sqldbtest gives tests a database of their own: a SQLite file in a
// temporary directory, or, when KEEPORG_TEST_POSTGRES names a PostgreSQL
// server, a fresh database on it that is dropped afterwards. Every test that
// goes through here runs against PostgreSQL by setting one variable.
package sqldbtest
import (
"context"
"database/sql"
"net/url"
"os"
"path/filepath"
"testing"
"cc.tiredsysadmin.keeporg/internal/secret"
_ "github.com/jackc/pgx/v5/stdlib"
)
// PostgresEnv names the PostgreSQL server tests use instead of SQLite, as a
// URL whose user may create databases.
const PostgresEnv = "KEEPORG_TEST_POSTGRES"
// URL returns a database URL that is empty and private to t.
func URL(t testing.TB) string {
t.Helper()
admin := os.Getenv(PostgresEnv)
if admin == "" {
return "sqlite:" + filepath.Join(t.TempDir(), "keeporg.db")
}
u, err := url.Parse(admin)
if err != nil {
t.Fatalf("%s: %v", PostgresEnv, err)
}
name := "keeporg_test_" + secret.ID()
db, err := sql.Open("pgx", admin)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := db.ExecContext(ctx, "CREATE DATABASE "+name); err != nil {
db.Close()
t.Fatalf("create test database: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(ctx, "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)")
db.Close()
})
u.Path = "/" + name
return u.String()
}