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
@@ -124,8 +124,8 @@ func (s *State) Assignments(userID string) []Assignment {
|
||||
// Watchers lists the user IDs that must be told when a database changes.
|
||||
func (s *State) Watchers(dbID string) []string {
|
||||
out := []string{}
|
||||
for id := range s.Users {
|
||||
if s.DatabasePerm(id, dbID) != PermNone {
|
||||
for id, u := range s.Users {
|
||||
if !u.Disabled && s.DatabasePerm(id, dbID) != PermNone {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-11
@@ -76,17 +76,20 @@ func (s Strategy) Valid() bool {
|
||||
func (s Strategy) NeedsKey() bool { return s == StrategyMerge }
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PassHash string `json:"pass_hash,omitempty"`
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||
OIDCSubject string `json:"oidc_subject,omitempty"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||
ServerAdmin bool `json:"server_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PassHash string `json:"pass_hash,omitempty"`
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||
OIDCSubject string `json:"oidc_subject,omitempty"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||
// TOTPLastStep is the time step of the last one-time code accepted, so
|
||||
// the same code cannot be used twice.
|
||||
TOTPLastStep int64 `json:"totp_last_step,omitempty"`
|
||||
ServerAdmin bool `json:"server_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
@@ -175,6 +178,16 @@ type DeviceAuth struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
OIDCState string `json:"oidc_state,omitempty"`
|
||||
// PKCEVerifier proves to the provider that this server started the
|
||||
// round trip that produced a code.
|
||||
PKCEVerifier string `json:"pkce_verifier,omitempty"`
|
||||
// LinkUserID marks a round trip that links an identity to this account
|
||||
// rather than signing in.
|
||||
LinkUserID string `json:"link_user_id,omitempty"`
|
||||
// PendingUserID and ApprovalNonce hold a single sign-on approval that
|
||||
// identified its person but awaits their explicit yes.
|
||||
PendingUserID string `json:"pending_user_id,omitempty"`
|
||||
ApprovalNonce string `json:"approval_nonce,omitempty"`
|
||||
// ReturnURL is where the approval page sends the person back to: the
|
||||
// client's own URL scheme, for a phone whose browser would otherwise be
|
||||
// a dead end. Empty for clients that need no hand-back.
|
||||
|
||||
+367
-57
@@ -1,46 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
)
|
||||
|
||||
// Store is the single writer of the metadata document. Every mutation runs on
|
||||
// a private copy of the state and is swapped in only once it has reached the
|
||||
// disk, so a reader never observes a half-applied update and a crash never
|
||||
// leaves a partial file.
|
||||
// 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 {
|
||||
path string
|
||||
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{}
|
||||
}
|
||||
|
||||
// Open loads the state document at path, creating an empty one if absent.
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
// 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
|
||||
}
|
||||
st := &Store{path: path, s: newState()}
|
||||
b, err := os.ReadFile(path)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
return st, st.persist(st.s)
|
||||
case 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()
|
||||
st.s = s
|
||||
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)) {
|
||||
@@ -49,23 +76,104 @@ func (st *Store) Read(fn func(*State)) {
|
||||
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.
|
||||
// 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()
|
||||
next, err := clone(st.s)
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
if err := fn(next); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.persist(next); err != nil {
|
||||
return err
|
||||
}
|
||||
st.s = next
|
||||
return nil
|
||||
return errors.New("store: the database kept changing under this update; try again")
|
||||
}
|
||||
|
||||
// GC drops expired sessions, device logins and invites.
|
||||
@@ -90,12 +198,214 @@ func (st *Store) GC(now time.Time) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) persist(s *State) error {
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
// 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
|
||||
}
|
||||
return writeFileAtomic(st.path, b, 0o600)
|
||||
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) {
|
||||
@@ -111,26 +421,26 @@ func clone(s *State) (*State, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces path with data, surviving a crash mid-write.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
// 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 err
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
s := newState()
|
||||
if err := json.Unmarshal(b, s); err != nil {
|
||||
return nil, fmt.Errorf("store: parse %s: %w", path, err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
+145
-17
@@ -1,44 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
)
|
||||
|
||||
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "state.json")
|
||||
st, err := Open(p)
|
||||
var testKey, _ = secret.NewKeyring(bytes.Repeat([]byte{7}, 32))
|
||||
|
||||
// open opens a store on url, closing it when the test ends.
|
||||
func open(t *testing.T, url string) (*Store, *sqldb.DB) {
|
||||
t.Helper()
|
||||
db, err := sqldb.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := Open(context.Background(), db, testKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close(); db.Close() })
|
||||
return st, db
|
||||
}
|
||||
|
||||
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
st, _ := open(t, url)
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||
s.Sessions["tok"] = &Session{Token: "tok", UserID: "u1"}
|
||||
s.Memberships = append(s.Memberships, Membership{UserID: "u1", GroupID: "g", Role: RoleAdmin})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Open(p)
|
||||
again, _ := open(t, url)
|
||||
again.Read(func(s *State) {
|
||||
if s.Users["u1"] == nil || s.Users["u1"].Username != "alice" || s.Sessions["tok"] == nil ||
|
||||
len(s.Memberships) != 1 || s.Memberships[0].Role != RoleAdmin {
|
||||
t.Fatalf("reopened state = %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRowsAreSealedAndNamedOpaquely(t *testing.T) {
|
||||
st, db := open(t, sqldbtest.URL(t))
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice", Email: "alice@example.com"}
|
||||
s.Sessions["secret-session-token"] = &Session{Token: "secret-session-token", UserID: "u1"}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := db.Query(`SELECT id, body FROM keeporg_records`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var name string
|
||||
again.Read(func(s *State) { name = s.Users["u1"].Username })
|
||||
if name != "alice" {
|
||||
t.Fatalf("reopened username = %q", name)
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var body []byte
|
||||
if err := rows.Scan(&id, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plain := range []string{"alice", "example.com", "secret-session-token", "u1", "user"} {
|
||||
if bytes.Contains([]byte(id), []byte(plain)) || bytes.Contains(body, []byte(plain)) {
|
||||
t.Fatalf("row %s carries %q in the clear", id, plain)
|
||||
}
|
||||
}
|
||||
n++
|
||||
}
|
||||
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("state file mode = %v", fi.Mode().Perm())
|
||||
if n != 2 {
|
||||
t.Fatalf("rows = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyChangedRowsAreWritten(t *testing.T) {
|
||||
st, db := open(t, sqldbtest.URL(t))
|
||||
_ = st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||
s.Users["u2"] = &User{ID: "u2", Username: "bob"}
|
||||
return nil
|
||||
})
|
||||
body := func() map[string]string {
|
||||
out := map[string]string{}
|
||||
rows, _ := db.Query(`SELECT id, body FROM keeporg_records`)
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var b []byte
|
||||
rows.Scan(&id, &b)
|
||||
out[id] = string(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := body()
|
||||
gen, _ := db.Generation(context.Background(), db)
|
||||
_ = st.Update(func(s *State) error { s.Users["u2"].Email = "bob@example.com"; return nil })
|
||||
after := body()
|
||||
changed := 0
|
||||
for id := range before {
|
||||
if before[id] != after[id] {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed != 1 {
|
||||
t.Fatalf("%d rows rewritten for one changed user", changed)
|
||||
}
|
||||
// A no-op update writes nothing and does not move the generation.
|
||||
_ = st.Update(func(s *State) error { return nil })
|
||||
if g, _ := db.Generation(context.Background(), db); g != gen+1 {
|
||||
t.Fatalf("generation %d after one write from %d", g, gen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := open(t, sqldbtest.URL(t))
|
||||
boom := errors.New("boom")
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1"}
|
||||
@@ -53,8 +137,52 @@ func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two processes on one database: a write by one is seen by the other, and a
|
||||
// write based on stale state is re-applied on top of the newer one instead
|
||||
// of overwriting it.
|
||||
func TestTwoStoresShareOneDatabase(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
a, _ := open(t, url)
|
||||
b, _ := open(t, url)
|
||||
|
||||
_ = a.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"}; return nil })
|
||||
if err := b.Update(func(s *State) error { s.Users["u2"] = &User{ID: "u2", Username: "bob"}; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Read(func(s *State) {
|
||||
if s.Users["u1"] == nil || s.Users["u2"] == nil {
|
||||
t.Fatalf("b lost a's write: %v", s.Users)
|
||||
}
|
||||
})
|
||||
// a learns of b's write without writing itself.
|
||||
deadline := time.Now().Add(3 * watchInterval)
|
||||
for {
|
||||
var ok bool
|
||||
a.Read(func(s *State) { ok = s.Users["u2"] != nil })
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a never saw b's write")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyIsRefused(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
_, db := open(t, url)
|
||||
if err := db.CheckKey(context.Background(), testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := secret.NewKeyring(bytes.Repeat([]byte{8}, 32))
|
||||
if err := db.CheckKey(context.Background(), other); !errors.Is(err, sqldb.ErrWrongKey) {
|
||||
t.Fatalf("a different key was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCDropsExpired(t *testing.T) {
|
||||
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
st, _ := open(t, sqldbtest.URL(t))
|
||||
now := time.Now()
|
||||
_ = st.Update(func(s *State) error {
|
||||
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
|
||||
|
||||
Reference in New Issue
Block a user