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>
329 lines
9.8 KiB
Go
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
|
|
}
|