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:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user