Files
keeporg/internal/server/web.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

329 lines
9.8 KiB
Go

package server
import (
"errors"
"net/http"
"sort"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
// The web interface covers what a browser is better at than the command
// line: creating a vault and setting its password, and the administration of
// people — invites, accounts, groups and who reaches which vault. It is not
// a password manager and never shows a vault's contents; the server does not
// open a vault except to merge one.
const webCookie = "keeporg_web"
// webSession is a signed-in browser.
type webSession struct {
user *store.User
csrf string
token string
}
// webUser resolves the session cookie, or nil when the browser is not
// signed in.
func (s *Server) webUser(r *http.Request) *webSession {
c, err := r.Cookie(webCookie)
if err != nil || c.Value == "" {
return nil
}
var (
out *webSession
seen time.Time
)
s.st.Read(func(st *store.State) {
sess := st.Sessions[c.Value]
if sess == nil || time.Now().After(sess.ExpiresAt) {
return
}
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
cp := *u
out = &webSession{user: &cp, csrf: sess.CSRF, token: c.Value}
seen = sess.LastSeenAt
}
})
if out != nil {
s.markSeen(c.Value, seen)
}
return out
}
// webHandler is a handler that needs a signed-in browser.
type webHandler func(http.ResponseWriter, *http.Request, *webSession)
// webPage serves a page to a signed-in browser, and the sign-in form to
// anyone else.
func (s *Server) webPage(fn webHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r)
if sess == nil {
s.renderSignIn(w, r, "")
return
}
fn(w, r, sess)
}
}
// webForm accepts a form from a signed-in browser. Every form on every page
// goes through here, so the CSRF check cannot be forgotten on one of them.
func (s *Server) webForm(fn webHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r)
if sess == nil {
s.renderSignIn(w, r, "")
return
}
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
return
}
fn(w, r, sess)
}
}
// checkCSRF verifies the form token against the session's.
func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool {
return sess.csrf != "" && secret.Equal(sess.csrf, r.FormValue("csrf"))
}
func (s *Server) setWebCookie(w http.ResponseWriter, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: webCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteStrictMode,
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) {
if s.webUser(r) != nil {
http.Redirect(w, r, "/web/vaults", http.StatusFound)
return
}
s.renderSignIn(w, r, "")
}
// preCookie carries a token that the sign-in form must echo. Without it a
// page elsewhere could post the form with its own credentials and sign a
// visitor in to the attacker's account, where whatever they then create
// lands. Strict, so a cross-site post does not carry it.
const preCookie = "keeporg_pre"
// preSessionToken returns this browser's sign-in form token, issuing one if
// it has none.
func (s *Server) preSessionToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(preCookie); err == nil && len(c.Value) == 64 {
return c.Value
}
tok := secret.Token()
http.SetCookie(w, &http.Cookie{
Name: preCookie, Value: tok, Path: "/web", HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteStrictMode,
})
return tok
}
func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if c, err := r.Cookie(preCookie); err != nil || !secret.Equal(c.Value, r.FormValue("pre")) {
s.renderSignIn(w, r, "The sign-in form had expired. Sign in again.")
return
}
var token string
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
func(st *store.State, u *store.User) error {
token = s.newBrowserSession(st, u.ID, r).Token
return nil
})
var throttled *errThrottled
switch {
case errors.As(err, &throttled):
s.renderSignIn(w, r, throttled.Error())
case errors.Is(err, errTOTPRequired):
s.renderSignIn(w, r, "This account needs its one-time code.")
case errors.Is(err, errTOTPInvalid):
s.renderSignIn(w, r, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderSignIn(w, r, "Unknown user or wrong password.")
case err != nil:
s.renderSignIn(w, r, err.Error())
default:
s.setWebCookie(w, token, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound)
}
}
func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request, _ *webSession) {
if c, err := r.Cookie(webCookie); err == nil {
_ = s.st.Update(func(st *store.State) error {
delete(st.Sessions, c.Value)
return nil
})
}
s.setWebCookie(w, "", -time.Second)
http.Redirect(w, r, "/web", http.StatusFound)
}
// webVault is one row of the vault list.
type webVault struct {
ID string
Name string
Perm string
Strategy string
Version int64
Updated string
By string
HasKey bool
}
// webGroup is one option in a group selector.
type webGroup struct {
ID string
Name string
}
func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request, sess *webSession) {
s.renderVaults(w, sess, "", "")
}
func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
password := r.FormValue("password")
if password != r.FormValue("password2") {
s.renderVaults(w, sess, "The two vault passwords do not match.", "")
return
}
strategy := store.Strategy(r.FormValue("strategy"))
if strategy == "" {
strategy = store.StrategyMerge
}
view, err := s.createVault(sess.user, r.FormValue("name"), r.FormValue("group"),
strategy, password)
if err != nil {
s.renderVaults(w, sess, humanError(err), "")
return
}
s.renderVaults(w, sess, "",
"Created "+view.Name+". Open "+view.Name+".kdbx from your KeepOrg client "+
"with the password you just set.")
}
// webDeleteVault is what the confirmation page shows before a vault goes.
type webDeleteVault struct {
ID string
Name string
Group string
Version int64
Updated string
Grants int
// NeedsName marks an account with no password of its own, which confirms
// by typing the vault's name instead; NeedsTOTP marks one with a second
// factor enrolled.
NeedsName bool
NeedsTOTP bool
}
// deleteVaultView gathers what is about to be destroyed, or nil when the
// caller does not administer the vault.
func (s *Server) deleteVaultView(u *store.User, id string) *webDeleteVault {
var out *webDeleteVault
s.st.Read(func(st *store.State) {
db := st.Databases[id]
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
return
}
v := &webDeleteVault{ID: db.ID, Name: db.Name, Version: db.Version, Updated: "never"}
if db.Version > 0 {
v.Updated = db.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC")
}
if g := st.Groups[db.OwnerGroupID]; g != nil {
v.Group = g.Name
}
for _, gr := range st.Grants {
if gr.DatabaseID == id {
v.Grants++
}
}
if target := st.Users[u.ID]; target != nil {
v.NeedsName = target.PassHash == ""
v.NeedsTOTP = target.TOTPConfirmed
}
out = v
})
return out
}
func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request, sess *webSession) {
s.renderDeleteVault(w, sess, r.PathValue("id"), "")
}
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
id := r.PathValue("id")
name, err := s.deleteDatabase(r, sess.user, id, r.FormValue("password"),
r.FormValue("totp"), r.FormValue("confirm_name"))
var throttled *errThrottled
switch {
case errors.As(err, &throttled):
s.renderDeleteVault(w, sess, id, throttled.Error())
case errors.Is(err, errReauth):
s.renderDeleteVault(w, sess, id, "Enter your account password.")
case errors.Is(err, errTOTPRequired):
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
case errors.Is(err, errBadCredentials):
s.renderDeleteVault(w, sess, id, "Your account password or one-time code is wrong.")
case errors.Is(err, errConfirmName):
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
case err != nil:
s.renderDeleteVault(w, sess, id, humanError(err))
default:
s.renderVaults(w, sess, "", "Deleted "+name+" and every version of it. "+
"Copies already on people's computers stay where they are.")
}
}
// humanError keeps internal wording out of the browser.
func humanError(err error) string {
if errors.Is(err, errForbidden) {
return "You do not administer that."
}
return err.Error()
}
// vaultRows collects what the signed-in user may see, and the groups they
// may create a vault in.
func (s *Server) vaultRows(u *store.User) ([]webVault, []webGroup) {
vaults := []webVault{}
groups := []webGroup{}
s.st.Read(func(st *store.State) {
for _, a := range st.Assignments(u.ID) {
updated := "never"
if a.DB.Version > 0 {
updated = a.DB.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC")
}
vaults = append(vaults, webVault{
ID: a.DB.ID, Name: a.DB.Name, Perm: string(a.Perm),
Strategy: string(a.DB.MergeStrategy), Version: a.DB.Version,
Updated: updated, By: a.DB.UpdatedBy, HasKey: a.DB.WrappedKey != "",
})
}
for id, g := range st.Groups {
if st.IsGroupAdmin(u.ID, id) {
groups = append(groups, webGroup{ID: id, Name: g.Name})
}
}
})
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
return vaults, groups
}