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:
Reactorcoremeltdown
2026-11-02 15:38:32 +01:00
co-authored by Claude Opus 5.5
parent f8f634fbf7
commit 4ffc434b1b
77 changed files with 4192 additions and 776 deletions
+82 -58
View File
@@ -23,14 +23,19 @@ var errForbidden = errors.New("forbidden")
// cases stay distinct so a client knows to prompt for the factor it is
// missing rather than reporting a flat refusal.
func denyAuth(w http.ResponseWriter, err error) {
if writeThrottled(w, err) {
return
}
switch {
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this")
case errors.Is(err, errReauth):
fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials",
"your own account password is required for this")
"your own account password or one-time code is wrong")
default:
deny(w, err)
}
@@ -41,6 +46,8 @@ func deny(w http.ResponseWriter, err error) {
switch {
case err == nil:
return
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errForbidden):
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
default:
@@ -215,16 +222,27 @@ func removeMember(st *store.State, u *store.User, groupID string, refs ...string
// ---- grants ----
// adminsDatabase checks that u administers a database. One without any
// access cannot tell a database that is not theirs from one that does not
// exist; one who can see it is told it is not theirs to change.
func adminsDatabase(st *store.State, u *store.User, dbID string) error {
perm := st.DatabasePerm(u.ID, dbID)
switch {
case st.Databases[dbID] == nil || perm == store.PermNone:
return errNoDatabase
case perm != store.PermAdmin:
return errForbidden
}
return nil
}
func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error {
if st.Databases[dbID] == nil {
return errors.New("no such database")
if err := adminsDatabase(st, u, dbID); err != nil {
return err
}
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
}
if !perm.Valid() {
return errors.New("perm must be read, write or admin")
}
@@ -239,11 +257,8 @@ func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.P
}
func removeGrant(st *store.State, u *store.User, groupID, dbID string) error {
if st.Databases[dbID] == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
if err := adminsDatabase(st, u, dbID); err != nil {
return err
}
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
return g.GroupID != groupID || g.DatabaseID != dbID
@@ -445,13 +460,10 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
}
var out dbView
err := s.st.Update(func(st *store.State) error {
if err := adminsDatabase(st, u, id); err != nil {
return err
}
db := st.Databases[id]
if db == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return errors.New("name may not be empty")
@@ -483,49 +495,48 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
// did not type the name of the thing it is about to destroy.
var errConfirmName = errors.New("type the name exactly to confirm")
// confirmDeletion re-proves who the caller is before something irreversible.
// Holding a session is enough to read and write a vault; it is not enough to
// destroy every version of one, because a session is what an unlocked laptop
// leaves lying around.
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
target := st.Users[u.ID]
if target == nil {
return errBadCredentials
}
if target.PassHash == "" {
if strings.TrimSpace(typed) != name {
return errConfirmName
}
return nil
}
_, err := checkPassword(st, target.Username, password, totpCode)
return err
}
// errNoDatabase is a database that does not exist or that the caller does
// not administer; the two are not told apart, so neither answer says that a
// database exists.
var errNoDatabase = errors.New("no such database")
// deleteDatabase removes a database, its grants and every stored version of
// the file. Clients keep whatever they already have on disk: the server can
// stop tracking a vault, but it has no business reaching into somebody's
// home directory.
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) {
//
// It re-proves who the caller is first. Holding a session is enough to read
// and write a vault; it is not enough to destroy every version of one,
// because a session is what an unlocked laptop leaves lying around. The
// password is checked like a sign-in — outside the store's lock and against
// the attempt limits — and only for a caller who administers the database,
// so it is not a password oracle for anyone else.
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) {
var (
name string
watchers []string
name string
hasPassword bool
)
err := s.st.Update(func(st *store.State) error {
s.st.Read(func(st *store.State) {
if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin {
name = db.Name
}
if cur := st.Users[u.ID]; cur != nil {
hasPassword = cur.PassHash != ""
}
})
if name == "" {
return "", errNoDatabase
}
var watchers []string
remove := func(st *store.State, _ *store.User) error {
db := st.Databases[id]
if db == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
if cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
return cerr
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errNoDatabase
}
name = db.Name
// Taken before the delete: the people to tell are the ones who are
@@ -534,7 +545,16 @@ func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed str
delete(st.Databases, id)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
return nil
})
}
var err error
if !hasPassword {
if strings.TrimSpace(typed) != name {
return "", errConfirmName
}
err = s.st.Update(func(st *store.State) error { return remove(st, u) })
} else {
err = s.reconfirm(r, u, password, totpCode, remove)
}
if err != nil {
return "", err
}
@@ -555,7 +575,7 @@ func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
denyAuth(w, err)
return
}
@@ -777,7 +797,11 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *stor
out := []userView{}
s.st.Read(func(st *store.State) {
for _, target := range visibleUsers(st, u) {
out = append(out, view(target))
v := view(target)
if !u.ServerAdmin && target.ID != u.ID {
v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false
}
out = append(out, v)
}
})
writeJSON(w, http.StatusOK, map[string]any{"users": out})