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>
447 lines
12 KiB
Go
447 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
|
)
|
|
|
|
// Store is the server's metadata. It is held in memory as one State, which
|
|
// is what every access decision reads, and persisted in the database as one
|
|
// sealed row per object.
|
|
//
|
|
// Every mutation runs on a private copy of the state; the rows that changed
|
|
// are written in one transaction, and the copy is swapped in only once that
|
|
// transaction has committed. A reader never observes a half-applied update,
|
|
// and a failed one changes nothing.
|
|
//
|
|
// Several processes may share one database. Each write first checks the
|
|
// database's generation: if another process has committed since this one
|
|
// last loaded, the state is reloaded and the mutation applied again on top.
|
|
// A background check picks up other processes' writes within a couple of
|
|
// seconds, so a session revoked through one process stops working in all.
|
|
type Store struct {
|
|
db *sqldb.DB
|
|
key *secret.Keyring
|
|
|
|
mu sync.RWMutex
|
|
s *State
|
|
gen int64
|
|
rows map[string][32]byte // row id → digest of the plaintext last stored
|
|
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
}
|
|
|
|
// watchInterval is how often the store checks for writes by other processes.
|
|
const watchInterval = 2 * time.Second
|
|
|
|
// Open loads the state from db. key seals and names the rows; it must be the
|
|
// key the rows were written with, which sqldb.CheckKey establishes first.
|
|
func Open(ctx context.Context, db *sqldb.DB, key *secret.Keyring) (*Store, error) {
|
|
st := &Store{db: db, key: key, stop: make(chan struct{}), done: make(chan struct{})}
|
|
if err := st.reload(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
go st.watch()
|
|
return st, nil
|
|
}
|
|
|
|
// Close stops watching for other processes' writes. The database stays open;
|
|
// it belongs to whoever opened it.
|
|
func (st *Store) Close() {
|
|
select {
|
|
case <-st.stop:
|
|
default:
|
|
close(st.stop)
|
|
<-st.done
|
|
}
|
|
}
|
|
|
|
// Read runs fn against a consistent view of the state. fn must not retain or
|
|
// mutate anything it is given.
|
|
func (st *Store) Read(fn func(*State)) {
|
|
st.mu.RLock()
|
|
defer st.mu.RUnlock()
|
|
fn(st.s)
|
|
}
|
|
|
|
// Empty reports whether nothing has ever been stored.
|
|
func (st *Store) Empty() bool {
|
|
st.mu.RLock()
|
|
defer st.mu.RUnlock()
|
|
return len(st.rows) == 0
|
|
}
|
|
|
|
// errStale means another process committed since this one loaded.
|
|
var errStale = errors.New("store: stale generation")
|
|
|
|
// Update applies fn to a copy of the state and commits it. If fn or the write
|
|
// fails, the stored state is left untouched. fn may run more than once when
|
|
// another process writes at the same moment, so it must only change the
|
|
// state it is given and the variables it reports results through.
|
|
func (st *Store) Update(fn func(*State) error) error {
|
|
return st.UpdateWith(fn, nil)
|
|
}
|
|
|
|
// UpdateWith is Update with more writes in the same transaction: inTx runs
|
|
// after the generation is locked and before the commit, so what it writes
|
|
// lands together with the state or not at all, and no other process can
|
|
// write in between. Like fn, it may run more than once.
|
|
func (st *Store) UpdateWith(fn func(*State) error, inTx func(context.Context, *sql.Tx) error) error {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for attempt := 0; attempt < 5; attempt++ {
|
|
next, err := clone(st.s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(next); err != nil {
|
|
return err
|
|
}
|
|
rows, err := st.flatten(next)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digests := make(map[string][32]byte, len(rows))
|
|
var upserts []string
|
|
for id, plain := range rows {
|
|
d := sha256.Sum256(plain)
|
|
digests[id] = d
|
|
if old, ok := st.rows[id]; !ok || old != d {
|
|
upserts = append(upserts, id)
|
|
}
|
|
}
|
|
var deletes []string
|
|
for id := range st.rows {
|
|
if _, ok := rows[id]; !ok {
|
|
deletes = append(deletes, id)
|
|
}
|
|
}
|
|
if len(upserts) == 0 && len(deletes) == 0 && inTx == nil {
|
|
st.s = next
|
|
return nil
|
|
}
|
|
err = st.db.Tx(ctx, func(tx *sql.Tx) error {
|
|
gen, err := st.db.LockGeneration(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if gen != st.gen {
|
|
return errStale
|
|
}
|
|
if inTx != nil {
|
|
if err := inTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, id := range upserts {
|
|
if _, err := tx.ExecContext(ctx, st.db.Q(`INSERT INTO keeporg_records (id, body)
|
|
VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET body = excluded.body`),
|
|
id, st.key.Seal([]byte(id), rows[id])); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, id := range deletes {
|
|
if _, err := tx.ExecContext(ctx, st.db.Q(`DELETE FROM keeporg_records WHERE id = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return st.db.SetGeneration(ctx, tx, gen+1)
|
|
})
|
|
if errors.Is(err, errStale) {
|
|
if err := st.reloadLocked(ctx); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("store: %w", err)
|
|
}
|
|
st.s, st.rows, st.gen = next, digests, st.gen+1
|
|
return nil
|
|
}
|
|
return errors.New("store: the database kept changing under this update; try again")
|
|
}
|
|
|
|
// GC drops expired sessions, device logins and invites.
|
|
func (st *Store) GC(now time.Time) error {
|
|
return st.Update(func(s *State) error {
|
|
for k, v := range s.Sessions {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(s.Sessions, k)
|
|
}
|
|
}
|
|
for k, v := range s.Devices {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(s.Devices, k)
|
|
}
|
|
}
|
|
for k, v := range s.Invites {
|
|
if !v.Usable(now) {
|
|
delete(s.Invites, k)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// watch reloads the state when another process has written to the database.
|
|
func (st *Store) watch() {
|
|
defer close(st.done)
|
|
t := time.NewTicker(watchInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-st.stop:
|
|
return
|
|
case <-t.C:
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), watchInterval)
|
|
gen, err := st.db.Generation(ctx, st.db)
|
|
st.mu.RLock()
|
|
stale := err == nil && gen != st.gen
|
|
st.mu.RUnlock()
|
|
if stale {
|
|
// A failed reload is retried on the next tick; meanwhile the
|
|
// last good state keeps serving.
|
|
_ = st.reload(ctx)
|
|
}
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (st *Store) reload(ctx context.Context) error {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
return st.reloadLocked(ctx)
|
|
}
|
|
|
|
// reloadLocked reads every row. The generation and the rows are read in one
|
|
// transaction, so they describe the same moment.
|
|
func (st *Store) reloadLocked(ctx context.Context) error {
|
|
s := newState()
|
|
digests := map[string][32]byte{}
|
|
var gen int64
|
|
err := st.db.Tx(ctx, func(tx *sql.Tx) error {
|
|
var err error
|
|
if gen, err = st.db.Generation(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `SELECT id, body FROM keeporg_records`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var (
|
|
id string
|
|
body []byte
|
|
)
|
|
if err := rows.Scan(&id, &body); err != nil {
|
|
return err
|
|
}
|
|
plain, err := st.key.Open([]byte(id), body)
|
|
if err != nil {
|
|
return fmt.Errorf("record %s: %w", id, err)
|
|
}
|
|
if err := s.addRecord(plain); err != nil {
|
|
return fmt.Errorf("record %s: %w", id, err)
|
|
}
|
|
digests[id] = sha256.Sum256(plain)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("store: load: %w", err)
|
|
}
|
|
s.sortLists()
|
|
st.s, st.rows, st.gen = s, digests, gen
|
|
return nil
|
|
}
|
|
|
|
// record is the plaintext of one row: what kind of object, and the object.
|
|
type record struct {
|
|
Kind string `json:"k"`
|
|
Data json.RawMessage `json:"d"`
|
|
}
|
|
|
|
const (
|
|
kindUser = "user"
|
|
kindGroup = "group"
|
|
kindDatabase = "database"
|
|
kindInvite = "invite"
|
|
kindSession = "session"
|
|
kindDevice = "device"
|
|
kindMembership = "membership"
|
|
kindGrant = "grant"
|
|
)
|
|
|
|
// flatten turns a state into rows. A row's id is a keyed digest of its kind
|
|
// and natural key, so ids are stable across writes, and a session token or
|
|
// an invite token never appears in the database except inside a sealed row.
|
|
func (st *Store) flatten(s *State) (map[string][]byte, error) {
|
|
out := map[string][]byte{}
|
|
put := func(kind, key string, v any) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plain, err := json.Marshal(record{Kind: kind, Data: data})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[st.key.MAC(kind+"\x00"+key)] = plain
|
|
return nil
|
|
}
|
|
var err error
|
|
for k, v := range s.Users {
|
|
err = errors.Join(err, put(kindUser, k, v))
|
|
}
|
|
for k, v := range s.Groups {
|
|
err = errors.Join(err, put(kindGroup, k, v))
|
|
}
|
|
for k, v := range s.Databases {
|
|
err = errors.Join(err, put(kindDatabase, k, v))
|
|
}
|
|
for k, v := range s.Invites {
|
|
err = errors.Join(err, put(kindInvite, k, v))
|
|
}
|
|
for k, v := range s.Sessions {
|
|
err = errors.Join(err, put(kindSession, k, v))
|
|
}
|
|
for k, v := range s.Devices {
|
|
err = errors.Join(err, put(kindDevice, k, v))
|
|
}
|
|
for _, m := range s.Memberships {
|
|
err = errors.Join(err, put(kindMembership, m.UserID+"\x00"+m.GroupID, m))
|
|
}
|
|
for _, g := range s.Grants {
|
|
err = errors.Join(err, put(kindGrant, g.GroupID+"\x00"+g.DatabaseID, g))
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// addRecord puts one row's object back where it belongs in the state.
|
|
func (s *State) addRecord(plain []byte) error {
|
|
var r record
|
|
if err := json.Unmarshal(plain, &r); err != nil {
|
|
return err
|
|
}
|
|
switch r.Kind {
|
|
case kindUser:
|
|
v := &User{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Users[v.ID] = v
|
|
case kindGroup:
|
|
v := &Group{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Groups[v.ID] = v
|
|
case kindDatabase:
|
|
v := &Database{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Databases[v.ID] = v
|
|
case kindInvite:
|
|
v := &Invite{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Invites[v.Token] = v
|
|
case kindSession:
|
|
v := &Session{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Sessions[v.Token] = v
|
|
case kindDevice:
|
|
v := &DeviceAuth{}
|
|
if err := json.Unmarshal(r.Data, v); err != nil {
|
|
return err
|
|
}
|
|
s.Devices[v.DeviceCode] = v
|
|
case kindMembership:
|
|
var v Membership
|
|
if err := json.Unmarshal(r.Data, &v); err != nil {
|
|
return err
|
|
}
|
|
s.Memberships = append(s.Memberships, v)
|
|
case kindGrant:
|
|
var v Grant
|
|
if err := json.Unmarshal(r.Data, &v); err != nil {
|
|
return err
|
|
}
|
|
s.Grants = append(s.Grants, v)
|
|
default:
|
|
return fmt.Errorf("unknown record kind %q", r.Kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sortLists gives the two lists a stable order, since rows come back in
|
|
// whatever order the database likes.
|
|
func (s *State) sortLists() {
|
|
sort.Slice(s.Memberships, func(i, j int) bool {
|
|
a, b := s.Memberships[i], s.Memberships[j]
|
|
return a.UserID+"\x00"+a.GroupID < b.UserID+"\x00"+b.GroupID
|
|
})
|
|
sort.Slice(s.Grants, func(i, j int) bool {
|
|
a, b := s.Grants[i], s.Grants[j]
|
|
return a.GroupID+"\x00"+a.DatabaseID < b.GroupID+"\x00"+b.DatabaseID
|
|
})
|
|
}
|
|
|
|
func clone(s *State) (*State, error) {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := newState()
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
return nil, err
|
|
}
|
|
out.init()
|
|
return out, nil
|
|
}
|
|
|
|
// ReadLegacy reads the state.json document that servers before 0.11 kept,
|
|
// for importing it into a database.
|
|
func ReadLegacy(path string) (*State, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := newState()
|
|
if err := json.Unmarshal(b, s); err != nil {
|
|
return nil, fmt.Errorf("store: parse %s: %w", path, err)
|
|
}
|
|
s.init()
|
|
return s, nil
|
|
}
|
|
|
|
// Replace overwrites the whole state with s, for an import.
|
|
func (st *Store) Replace(s *State) error {
|
|
return st.Update(func(next *State) error {
|
|
*next = *s
|
|
next.init()
|
|
return nil
|
|
})
|
|
}
|