Files
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

277 lines
9.4 KiB
Go

// Package store holds the server's authoritative metadata: users, nested
// groups, databases and the grants that connect them.
//
// Glossary:
//
// User an account, authenticated by password+TOTP or by OIDC.
// Group a node in a single tree of groups. Grants hang off groups.
// Membership a user's place in one group, as member or admin.
// Database one KDBX file tracked by the server. Owned by one group.
// Grant a (group, database, permission) triple.
// Invite a single- or multi-use registration token.
// Session a bearer token held by a client device.
package store
import "time"
// Perm is a permission on a database, ordered read < write < admin.
type Perm string
const (
PermNone Perm = ""
PermRead Perm = "read"
PermWrite Perm = "write"
PermAdmin Perm = "admin"
)
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
// Rank orders permissions so the strongest of several grants can be picked.
func (p Perm) Rank() int { return permRank[p] }
// AtLeast reports whether p is as strong as want.
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
// Valid reports whether p is a permission that may be stored.
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
// Role is a user's role inside a group.
type Role string
const (
RoleMember Role = "member"
RoleAdmin Role = "admin"
)
// Valid reports whether r is a role that may be stored.
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
// Strategy decides what happens when a client pushes a database version that
// is no longer current.
type Strategy string
const (
// StrategyServerWins rejects the stale push; the client takes the server
// copy and keeps its own as a conflict file. This is the default.
StrategyServerWins Strategy = "server-wins"
// StrategyClientWins lets the client overwrite the server copy.
StrategyClientWins Strategy = "client-wins"
// StrategyManual rejects the stale push and leaves both copies in place
// for a human to reconcile.
StrategyManual Strategy = "manual"
// StrategyMerge decrypts both copies in memory and folds them together,
// so neither edit is lost. It is the only strategy that requires the
// server to hold the vault's key.
StrategyMerge Strategy = "merge"
)
// Valid reports whether s is a strategy that may be stored.
func (s Strategy) Valid() bool {
return s == StrategyServerWins || s == StrategyClientWins ||
s == StrategyManual || s == StrategyMerge
}
// NeedsKey reports whether this strategy can only run when the server holds
// the vault's key.
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"`
// 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 {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Membership struct {
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
Role Role `json:"role"`
}
type Database struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy Strategy `json:"merge_strategy"`
// WrappedKey is the vault's own password, encrypted under the server's
// master key. It is present only for vaults that are merged here, and it
// is never returned by the API.
WrappedKey string `json:"wrapped_key,omitempty"`
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
}
type Grant struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm Perm `json:"perm"`
}
type Invite struct {
Token string `json:"token"`
GroupID string `json:"group_id,omitempty"`
Role Role `json:"role"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
}
// Usable reports whether the invite may still be redeemed at time now.
func (i *Invite) Usable(now time.Time) bool {
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
Label string `json:"label,omitempty"`
// CSRF guards the browser forms of a session held in a cookie.
CSRF string `json:"csrf,omitempty"`
// Browser marks a session held in a web cookie rather than by a client
// application. Sessions from before the field have the label "web".
Browser bool `json:"browser,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
// LastSeenAt is when the session last made a request, to a resolution
// of a few minutes: recording every request would rewrite the state
// document on each one.
LastSeenAt time.Time `json:"last_seen_at,omitempty"`
}
// IsBrowser reports whether the session belongs to a web browser.
func (s *Session) IsBrowser() bool { return s.Browser || s.Label == "web" }
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
// Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears.
type DeviceAuth struct {
DeviceCode string `json:"device_code"`
// UserCode is the short code a person reads out to approve a client.
// A browser signing in to the web interface has none: there is no
// second device to approve.
UserCode string `json:"user_code"`
// Web marks a sign-in that ends in a cookie rather than in a token a
// waiting client polls for.
Web bool `json:"web,omitempty"`
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.
ReturnURL string `json:"return_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// State is the whole metadata set, serialized as one JSON document.
type State struct {
Users map[string]*User `json:"users"`
Groups map[string]*Group `json:"groups"`
Databases map[string]*Database `json:"databases"`
Invites map[string]*Invite `json:"invites"`
Sessions map[string]*Session `json:"sessions"`
Devices map[string]*DeviceAuth `json:"devices"`
Memberships []Membership `json:"memberships"`
Grants []Grant `json:"grants"`
}
func newState() *State {
return &State{
Users: map[string]*User{},
Groups: map[string]*Group{},
Databases: map[string]*Database{},
Invites: map[string]*Invite{},
Sessions: map[string]*Session{},
Devices: map[string]*DeviceAuth{},
}
}
func (s *State) init() {
if s.Users == nil {
s.Users = map[string]*User{}
}
if s.Groups == nil {
s.Groups = map[string]*Group{}
}
if s.Databases == nil {
s.Databases = map[string]*Database{}
}
if s.Invites == nil {
s.Invites = map[string]*Invite{}
}
if s.Sessions == nil {
s.Sessions = map[string]*Session{}
}
if s.Devices == nil {
s.Devices = map[string]*DeviceAuth{}
}
}
// UserByName finds a user by exact username.
func (s *State) UserByName(name string) *User {
for _, u := range s.Users {
if u.Username == name {
return u
}
}
return nil
}
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
func (s *State) UserByOIDC(issuer, subject string) *User {
for _, u := range s.Users {
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
return u
}
}
return nil
}
// DeviceByUserCode finds a pending device login by the code shown to the
// user. An empty code matches nothing, so a web sign-in, which has no user
// code, is never mistaken for a device waiting to be approved.
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
if code == "" {
return nil
}
for _, d := range s.Devices {
if d.UserCode == code {
return d
}
}
return nil
}