Files
keeporg/internal/blob/blob.go
T
ReactorcoremeltdownandClaude Opus 5.5 4ffc434b1b 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>
2026-11-02 15:38:32 +01:00

180 lines
5.3 KiB
Go

// Package blob stores the KDBX payloads. Each database keeps a numbered
// version history, which makes a rejected push recoverable and a bad save
// reversible.
//
// The payloads live in the server's database next to the metadata, sealed
// under a key derived from the master key and named by a keyed digest of
// the database's id. KDBX is already encrypted by KeePass; sealing it again
// means the database holds no readable KeePass headers either, and that one
// backup of the database is the whole server.
package blob
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
)
// KeepVersions is how many historical versions of each database are retained.
const KeepVersions = 20
// timeout bounds one blob operation. A payload is at most tens of megabytes.
const timeout = time.Minute
type Store struct {
db *sqldb.DB
key *secret.Keyring
}
// Open returns the blob store in db, sealing with key.
func Open(db *sqldb.DB, key *secret.Keyring) *Store { return &Store{db: db, key: key} }
// SHA256 is the lowercase hex digest used everywhere as a content fingerprint.
func SHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (s *Store) ref(dbID string) string { return s.key.MAC("blob\x00" + dbID) }
// aad binds a sealed payload to its database and version, so a row copied
// over another one fails to open instead of serving the wrong file.
func aad(ref string, version int64) []byte {
return []byte(ref + "/" + strconv.FormatInt(version, 10))
}
// Put writes one version of a database. Writing a version again replaces it,
// so a retried request is harmless.
func (s *Store) Put(dbID string, version int64, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.put(ctx, s.db, dbID, version, data)
}
// PutTx is Put inside a transaction, for a version written together with
// the metadata that names it: a version a concurrent writer committed can
// then never be overwritten by a write whose metadata turns out stale.
func (s *Store) PutTx(ctx context.Context, tx *sql.Tx, dbID string, version int64, data []byte) error {
return s.put(ctx, tx, dbID, version, data)
}
func (s *Store) put(ctx context.Context, q sqldb.Querier, dbID string, version int64, data []byte) error {
ref := s.ref(dbID)
_, err := q.ExecContext(ctx, s.db.Q(`INSERT INTO keeporg_blobs (ref, version, body)
VALUES (?, ?, ?) ON CONFLICT (ref, version) DO UPDATE SET body = excluded.body`),
ref, version, s.key.Seal(aad(ref, version), data))
return err
}
// Get reads one version of a database. A version that is not stored is
// fs.ErrNotExist.
func (s *Store) Get(dbID string, version int64) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ref := s.ref(dbID)
var body []byte
err := s.db.QueryRowContext(ctx, s.db.Q(`SELECT body FROM keeporg_blobs
WHERE ref = ? AND version = ?`), ref, version).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("blob %s v%d: %w", dbID, version, fs.ErrNotExist)
}
if err != nil {
return nil, err
}
return s.key.Open(aad(ref, version), body)
}
// Versions lists the stored versions of a database, oldest first.
func (s *Store) Versions(dbID string) []int64 {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, s.db.Q(`SELECT version FROM keeporg_blobs
WHERE ref = ? ORDER BY version`), s.ref(dbID))
if err != nil {
return nil
}
defer rows.Close()
out := []int64{}
for rows.Next() {
var v int64
if rows.Scan(&v) == nil {
out = append(out, v)
}
}
return out
}
// Prune removes all but the newest KeepVersions versions.
func (s *Store) Prune(dbID string) error {
vs := s.Versions(dbID)
if len(vs) <= KeepVersions {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ? AND version < ?`),
s.ref(dbID), vs[len(vs)-KeepVersions])
return err
}
// Forget deletes every version of a database.
func (s *Store) Forget(dbID string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ?`), s.ref(dbID))
return err
}
// ImportDir copies the blobs/<database id>/<version>.kdbx tree that servers
// before 0.11 kept on disk into the store, and says how many it copied.
func (s *Store) ImportDir(dir string) (int, error) {
dbs, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
n := 0
for _, d := range dbs {
if !d.IsDir() {
continue
}
files, err := os.ReadDir(filepath.Join(dir, d.Name()))
if err != nil {
return n, err
}
for _, f := range files {
name := f.Name()
if !strings.HasSuffix(name, ".kdbx") {
continue
}
v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64)
if err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(dir, d.Name(), name))
if err != nil {
return n, err
}
if err := s.Put(d.Name(), v, data); err != nil {
return n, err
}
n++
}
}
return n, nil
}