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

354 lines
9.4 KiB
Go

package server
import (
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// Proving who someone is: every password and one-time code the server
// checks goes through verify, wherever it was typed — the API, the sign-in
// form, the device page, a password change, a second-factor change or a
// vault deletion. Two things follow from that being one function.
//
// The password hash is checked outside the store's lock. It is deliberately
// slow, and under the write lock it stalled every other request, so a few
// guesses a second were a denial of service. verify reads what it needs,
// checks it, and commit re-checks inside the write that nothing changed in
// between.
//
// Failures are counted per client address and per account, and a client or
// account over its budget is refused before any hash is computed. The
// counts live in memory: a restart forgets them, which costs an attacker a
// restart they cannot cause.
// errThrottled means too many failures; RetryAfter says how long to wait.
type errThrottled struct{ RetryAfter time.Duration }
func (e *errThrottled) Error() string {
return fmt.Sprintf("too many failed attempts; try again in %s",
e.RetryAfter.Round(time.Minute))
}
// credentials is a password, and code, that checked out against an account
// as it stood when verify read it.
type credentials struct {
userID string
hash string
step int64 // the one-time code's time step, when one was needed
totp bool
}
// dummyHash is verified against when there is no real one, so that an
// unknown, disabled or password-less account takes as long to refuse as a
// wrong password: the timing would otherwise list the usernames.
var dummyHash = func() string {
h, err := secret.Hash("keeporg timing equaliser, never a real password")
if err != nil {
panic(err)
}
return h
}()
// verify checks a username, password and one-time code.
func (s *Server) verify(r *http.Request, username, password, code string) (*credentials, error) {
ip := "ip:" + s.clientIP(r)
acct := "user:" + strings.ToLower(strings.TrimSpace(username))
if wait := s.limits.blocked(ip, acct); wait > 0 {
return nil, &errThrottled{RetryAfter: wait}
}
var (
u store.User
found bool
)
s.st.Read(func(st *store.State) {
if p := st.UserByName(username); p != nil {
u, found = *p, true
}
})
if !found || u.Disabled || u.PassHash == "" {
secret.Verify(dummyHash, password)
s.limits.fail(ip, acct)
return nil, errBadCredentials
}
if !secret.Verify(u.PassHash, password) {
s.limits.fail(ip, acct)
return nil, errBadCredentials
}
c := &credentials{userID: u.ID, hash: u.PassHash}
if u.TOTPConfirmed {
if strings.TrimSpace(code) == "" {
return nil, errTOTPRequired
}
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
if !ok || step <= u.TOTPLastStep {
s.limits.fail(ip, acct)
return nil, errTOTPInvalid
}
c.step, c.totp = step, true
}
s.limits.reset(acct)
return c, nil
}
// commit re-checks verified credentials inside a write: the account must
// still exist, be enabled and have the same password, and the one-time code
// is spent. It returns the account to act on.
func (c *credentials) commit(st *store.State) (*store.User, error) {
u := st.Users[c.userID]
if u == nil || u.Disabled || u.PassHash != c.hash {
return nil, errBadCredentials
}
if c.totp && u.TOTPConfirmed {
if c.step <= u.TOTPLastStep {
return nil, errTOTPInvalid
}
u.TOTPLastStep = c.step
}
return u, nil
}
// signIn verifies credentials and, inside one write, re-checks them and runs
// then on the account. It is the shape of every sign-in.
func (s *Server) signIn(r *http.Request, username, password, code string,
then func(st *store.State, u *store.User) error) error {
c, err := s.verify(r, username, password, code)
if err != nil {
return err
}
return s.st.Update(func(st *store.State) error {
u, err := c.commit(st)
if err != nil {
return err
}
return then(st, u)
})
}
// reconfirm proves, for a change that weakens or replaces how an account
// signs in, that the person holding the session also holds the password and
// the current code. Unlike a sign-in it does not say which of the two was
// wrong: a stolen session must not become a password oracle.
func (s *Server) reconfirm(r *http.Request, u *store.User, password, code string,
then func(st *store.State, u *store.User) error) error {
// The account as stored now, not as the session saw it at the start of
// the request.
s.st.Read(func(st *store.State) {
if cur := st.Users[u.ID]; cur != nil {
cp := *cur
u = &cp
}
})
if u.PassHash == "" {
return errNoPassword
}
if password == "" {
return errReauth
}
if u.TOTPConfirmed && strings.TrimSpace(code) == "" {
// Asked before the password is checked, so it says nothing about
// whether the password was right.
return errTOTPRequired
}
err := s.signIn(r, u.Username, password, code, then)
if errors.Is(err, errTOTPInvalid) || errors.Is(err, errTOTPRequired) {
return errBadCredentials
}
return err
}
// writeThrottled answers a refused attempt with 429 and Retry-After.
func writeThrottled(w http.ResponseWriter, err error) bool {
var t *errThrottled
if !errors.As(err, &t) {
return false
}
w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())+1))
fail(w, http.StatusTooManyRequests, "throttled", t.Error())
return true
}
// ---- limits ----
// limitWindow is how long failures are remembered.
const limitWindow = 15 * time.Minute
// Budgets per window. An address may fail more often than an account, since
// several people may share one address; an account fails rarely when its
// owner is the one typing.
var limitBudget = map[string]int{
"ip": 30,
"user": 10,
"start": 30, // unauthenticated device and single sign-on starts, per address
}
type attempts struct {
n int
since time.Time
}
// limiter counts failures per key ("ip:…", "user:…", "start:…").
type limiter struct {
mu sync.Mutex
m map[string]*attempts
now func() time.Time
}
func newLimiter() *limiter { return &limiter{m: map[string]*attempts{}, now: time.Now} }
func budget(key string) int {
kind, _, _ := strings.Cut(key, ":")
return limitBudget[kind]
}
// blocked says how long until every key is under budget again, or 0.
func (l *limiter) blocked(keys ...string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
var wait time.Duration
for _, k := range keys {
a := l.m[k]
if a == nil || now.Sub(a.since) >= limitWindow || a.n < budget(k) {
continue
}
if left := limitWindow - now.Sub(a.since); left > wait {
wait = left
}
}
return wait
}
// fail counts one failure against each key.
func (l *limiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
if len(l.m) > 50_000 {
for k, a := range l.m {
if now.Sub(a.since) >= limitWindow {
delete(l.m, k)
}
}
}
for _, k := range keys {
a := l.m[k]
if a == nil || now.Sub(a.since) >= limitWindow {
a = &attempts{since: now}
l.m[k] = a
}
a.n++
}
}
// take counts one use of key and reports whether it was within budget, for
// endpoints limited by use rather than by failure.
func (l *limiter) take(key string) time.Duration {
if wait := l.blocked(key); wait > 0 {
return wait
}
l.fail(key)
return 0
}
func (l *limiter) reset(key string) {
l.mu.Lock()
delete(l.m, key)
l.mu.Unlock()
}
// ---- client addresses ----
// clientIP is the address a request came from. Behind a reverse proxy every
// request comes from the proxy, so for a peer listed in trusted_proxies the
// address is taken from X-Forwarded-For, right to left, skipping trusted
// hops; nobody else gets to choose their own address with a header.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !s.trusted(host) {
return host
}
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(hops) - 1; i >= 0; i-- {
h := strings.TrimSpace(hops[i])
if h == "" {
continue
}
if !s.trusted(h) {
return h
}
host = h
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" && net.ParseIP(real) != nil {
return real
}
return host
}
func (s *Server) trusted(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
for _, n := range s.proxies {
if n.Contains(ip) {
return true
}
}
return false
}
// parseProxies reads trusted_proxies: addresses or CIDR ranges.
func parseProxies(list []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, p := range list {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !strings.Contains(p, "/") {
if ip := net.ParseIP(p); ip != nil && ip.To4() != nil {
p += "/32"
} else {
p += "/128"
}
}
_, n, err := net.ParseCIDR(p)
if err != nil {
return nil, fmt.Errorf("trusted_proxies: %q: %w", p, err)
}
out = append(out, n)
}
return out, nil
}
// maxLabel bounds a session or device label a client chooses: it is shown
// in lists and stored with every session, and nothing needs more.
const maxLabel = 64
// clampLabel trims a client-chosen label to something displayable.
func clampLabel(label string) string {
label = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, strings.TrimSpace(label))
if r := []rune(label); len(r) > maxLabel {
label = string(r[:maxLabel])
}
return label
}