// Package server implements the KeepOrg synchronization server: accounts, // nested groups, per-database grants, and the versioned KDBX sync endpoints. // // The server never opens a KDBX file. It moves opaque bytes and arbitrates // who wrote last, which keeps the master passwords entirely on the clients. package server import ( "encoding/json" "errors" "fmt" "log" "net" "net/http" "path/filepath" "strings" "time" "cc.tiredsysadmin.keeporg/internal/blob" "cc.tiredsysadmin.keeporg/internal/kdbx" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/sqldb" "cc.tiredsysadmin.keeporg/internal/store" ) // Config is the server's whole configuration surface. type Config struct { Addr string `json:"addr"` DataDir string `json:"data_dir"` ExternalURL string `json:"external_url"` SessionTTL time.Duration `json:"session_ttl"` MaxDBSize int64 `json:"max_db_size"` TLSCert string `json:"tls_cert,omitempty"` TLSKey string `json:"tls_key,omitempty"` OIDC OIDCConfig `json:"oidc"` // TrustedProxies are the reverse proxies whose X-Forwarded-For is // believed, as addresses or CIDR ranges. Rate limits count per client // address, and behind a proxy every request otherwise comes from it. TrustedProxies []string `json:"trusted_proxies,omitempty"` // Database is where the server keeps its data: postgres://… for // PostgreSQL, or sqlite:. The default is a SQLite file in DataDir. Database string `json:"database"` // MasterKeyFile holds the key that encrypts everything in the database // and wraps the vault keys the server keeps in order to merge. It belongs // outside DataDir and outside the database's backups: either alone must // not be enough to read anything. MasterKeyFile string `json:"master_key_file"` // VaultKDF is the work factor of vaults this server creates. The server // pays it three times per merge, and so does every client on every open. VaultKDF kdbx.KDFParams `json:"vault_kdf"` } // Defaults fills in every value a deployment may leave unset. func (c *Config) Defaults() { if c.Addr == "" { c.Addr = ":8765" } if c.DataDir == "" { c.DataDir = "/var/lib/keeporg" } if c.ExternalURL == "" { // ":8765" means every interface; "127.0.0.1:8765" names its host. host := c.Addr if strings.HasPrefix(host, ":") { host = "localhost" + host } c.ExternalURL = "http://" + host } c.ExternalURL = strings.TrimRight(c.ExternalURL, "/") if c.SessionTTL == 0 { c.SessionTTL = 90 * 24 * time.Hour } if c.MaxDBSize == 0 { c.MaxDBSize = 32 << 20 } if c.Database == "" { c.Database = "sqlite:" + filepath.Join(c.DataDir, "keeporg.db") } if c.MasterKeyFile == "" { c.MasterKeyFile = "/etc/keeporg/master.key" } if c.VaultKDF.MemoryKiB == 0 { c.VaultKDF = kdbx.DefaultKDF } } type Server struct { cfg Config db *sqldb.DB st *store.Store blobs *blob.Store hub *Hub oidc *oidcProvider keyring *secret.Keyring limits *limiter proxies []*net.IPNet merges chan struct{} // a slot per merge allowed to run at once mux *http.ServeMux log *log.Logger } // New opens the server's database and wires up its routes. func New(cfg Config, lg *log.Logger) (*Server, error) { cfg.Defaults() proxies, err := parseProxies(cfg.TrustedProxies) if err != nil { return nil, err } // Everything in the database is sealed under the master key, so there // is no serving without it. kr, err := secret.LoadKeyring(cfg.MasterKeyFile) if err != nil { return nil, fmt.Errorf("master key: %w", err) } st, db, err := OpenStore(cfg, kr) if err != nil { return nil, err } s := &Server{cfg: cfg, db: db, st: st, blobs: blob.Open(db, kr.Derive("blobs")), keyring: kr, limits: newLimiter(), proxies: proxies, merges: make(chan struct{}, 2), hub: NewHub(), mux: http.NewServeMux(), log: lg} if err := s.importLegacy(); err != nil { s.Close() return nil, fmt.Errorf("import %s: %w", cfg.DataDir, err) } if cfg.OIDC.Enabled() { s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback") } s.checkVaultKeys() s.routes() return s, nil } // checkVaultKeys reports at startup whether the master key still opens the // vault keys in the state document. Getting this wrong is quiet and // expensive: a container started without its master key mounted mints a new // one, and every merge then fails one push at a time. Saying so once, at // startup, is the difference between a puzzle and a fixable mistake. func (s *Server) checkVaultKeys() { var total, broken int s.st.Read(func(st *store.State) { for id, d := range st.Databases { if d.WrappedKey == "" { continue } total++ if s.keyring == nil { broken++ continue } if _, err := s.keyring.Unwrap(id, d.WrappedKey); err != nil { broken++ } } }) switch { case broken == 0 && total > 0: s.log.Printf("%d vault keys available for merging", total) case broken > 0: s.log.Printf("WARNING: %d of %d stored vault keys do not open with this "+ "master key. Those vaults cannot be merged until the original key is "+ "restored ($%s, $%s, or %s). Nothing is lost: clients still open the "+ "vaults with their own passwords.", broken, total, secret.MasterKeyEnv, secret.MasterKeyFileEnv, s.cfg.MasterKeyFile) } } // Store exposes the metadata store for bootstrap commands. func (s *Server) Store() *store.Store { return s.st } // Config returns the effective configuration. func (s *Server) Config() Config { return s.cfg } // Handler is the server's HTTP entry point. // Handler is the server's HTTP surface, with the security headers every // response carries. func (s *Server) Handler() http.Handler { hsts := strings.HasPrefix(s.cfg.ExternalURL, "https://") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") // The pages have no scripts, no images and no frames; their only // style is inline. Nothing may frame them, which is what stops a // sign-out or delete button being clicked through a disguise. h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+ "form-action 'self'; frame-ancestors 'none'; base-uri 'none'") h.Set("X-Frame-Options", "DENY") h.Set("Cross-Origin-Opener-Policy", "same-origin") // Pages carry CSRF tokens, invite links and, once, a TOTP secret; // API answers carry bearer tokens. None of it belongs in a cache. h.Set("Cache-Control", "no-store") if hsts { h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") } s.mux.ServeHTTP(w, r) }) } // StartHousekeeping runs periodic expiry until done is closed. func (s *Server) StartHousekeeping(done <-chan struct{}) { go func() { t := time.NewTicker(time.Hour) defer t.Stop() for { select { case <-done: return case <-t.C: if err := s.st.GC(time.Now()); err != nil { s.log.Printf("housekeeping: %v", err) } } } }() } func (s *Server) routes() { m := s.mux m.HandleFunc("GET /healthz", s.handleHealth) // Authentication. m.HandleFunc("POST /api/v1/auth/login", s.handleLogin) m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout)) m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami)) m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword)) m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll)) m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm)) m.HandleFunc("DELETE /api/v1/auth/totp", s.authed(s.handleTOTPDisable)) m.HandleFunc("GET /api/v1/auth/sessions", s.authed(s.handleListSessions)) m.HandleFunc("DELETE /api/v1/auth/sessions", s.authed(s.handleRevokeOtherSessions)) m.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.authed(s.handleRevokeSession)) m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) // The web interface: sign in, see your vaults, create one, and run the // server. Every page goes through webPage and every form through // webForm, which is where the session and the CSRF token are checked. m.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/web", http.StatusFound) }) m.HandleFunc("GET /web", s.handleWebHome) m.HandleFunc("POST /web/signin", s.handleWebSignIn) m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut)) m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults)) m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault)) m.HandleFunc("GET /web/account", s.webPage(s.handleWebAccount)) m.HandleFunc("POST /web/account", s.webForm(s.handleWebAccountAction)) m.HandleFunc("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage)) m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault)) m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups)) m.HandleFunc("POST /web/groups", s.webForm(s.handleWebCreateGroup)) m.HandleFunc("GET /web/groups/{id}", s.webPage(s.handleWebGroup)) m.HandleFunc("POST /web/groups/{id}", s.webForm(s.handleWebGroupAction)) m.HandleFunc("GET /web/users", s.webPage(s.handleWebUsers)) m.HandleFunc("POST /web/users", s.webForm(s.handleWebUserAction)) m.HandleFunc("GET /web/invites", s.webPage(s.handleWebInvites)) m.HandleFunc("POST /web/invites", s.webForm(s.handleWebInviteAction)) // Browser-facing pages: device approval, invite registration, OIDC. m.HandleFunc("GET /device", s.handleDevicePage) m.HandleFunc("POST /device", s.handleDeviceApprove) m.HandleFunc("GET /invite/{token}", s.handleInvitePage) m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem) m.HandleFunc("GET /oidc/login", s.handleOIDCLogin) m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback) m.HandleFunc("POST /oidc/approve", s.handleOIDCApprove) // Synchronization. m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases)) m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob)) m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob)) m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents)) // Vaults: a database the server created, and can merge. m.HandleFunc("POST /api/v1/vaults", s.authed(s.handleCreateVault)) m.HandleFunc("POST /api/v1/databases/{id}/key", s.authed(s.handleSetVaultKey)) // Administration. m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase)) m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase)) m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase)) m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups)) m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup)) m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup)) m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember)) m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember)) m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant)) m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant)) m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite)) m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites)) m.HandleFunc("DELETE /api/v1/invites/{token}", s.authed(s.handleRevokeInvite)) m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers)) m.HandleFunc("PATCH /api/v1/users/{id}", s.authed(s.handlePatchUser)) } // ---- request plumbing ---- type apiError struct { Code string `json:"code"` Message string `json:"error"` } var errUnauthorized = errors.New("unauthorized") func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { _ = json.NewEncoder(w).Encode(v) } } func fail(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, apiError{Code: code, Message: msg}) } func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { fail(w, http.StatusBadRequest, "bad_request", err.Error()) return false } return true } // handler is an authenticated request handler. type handler func(http.ResponseWriter, *http.Request, *store.User) // authed resolves the bearer token to a live, enabled user before calling fn. // Per-object permission is then checked inside the handler, where the object // being acted on is known. func (s *Server) authed(fn handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, err := s.userFromRequest(r) if err != nil { w.Header().Set("WWW-Authenticate", `Bearer realm="keeporg"`) fail(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") return } fn(w, r, u) } } func (s *Server) userFromRequest(r *http.Request) (*store.User, error) { tok := bearer(r) if tok == "" { return nil, errUnauthorized } var ( out *store.User seen time.Time ) s.st.Read(func(st *store.State) { sess := st.Sessions[tok] if sess == nil || time.Now().After(sess.ExpiresAt) { return } if u := st.Users[sess.UserID]; u != nil && !u.Disabled { cp := *u out, seen = &cp, sess.LastSeenAt } }) if out == nil { return nil, errUnauthorized } s.markSeen(tok, seen) return out, nil } // seenResolution is how stale a session's LastSeenAt may get before a // request refreshes it. Each refresh rewrites the state document, so it is // minutes, not seconds; the devices list only needs to tell today from last // month. const seenResolution = 5 * time.Minute // markSeen records that a session was used, when its record is stale. func (s *Server) markSeen(token string, last time.Time) { if time.Since(last) < seenResolution { return } _ = s.st.Update(func(st *store.State) error { if sess := st.Sessions[token]; sess != nil { sess.LastSeenAt = time.Now() } return nil }) } // bearer extracts a token from the Authorization header. The event stream // alone also takes ?access_token=, which clients before 0.12 sent; a token // in a URL ends up in proxy logs, so nothing else accepts it, and current // clients send the header there too. func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if after, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(after) } if r.URL.Path == "/api/v1/events" { return r.URL.Query().Get("access_token") } return "" } // sessionAlive reports whether a token still belongs to a live session of // an enabled account, for long requests that outlast their authentication. func (s *Server) sessionAlive(token string) bool { alive := false s.st.Read(func(st *store.State) { sess := st.Sessions[token] if sess == nil || time.Now().After(sess.ExpiresAt) { return } u := st.Users[sess.UserID] alive = u != nil && !u.Disabled }) return alive } func (s *Server) newSession(st *store.State, userID, label string) *store.Session { now := time.Now() sess := &store.Session{ Token: secret.Token(), UserID: userID, Label: label, CSRF: secret.Token(), CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionTTL), LastSeenAt: now, } st.Sessions[sess.Token] = sess return sess } // newBrowserSession is a session for a web cookie, named after the browser // so that a list of them can be told apart. func (s *Server) newBrowserSession(st *store.State, userID string, r *http.Request) *store.Session { sess := s.newSession(st, userID, browserLabel(r.UserAgent())) sess.Browser = true return sess } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { var users, dbs int s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) }) writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "users": users, "databases": dbs, "streams": s.hub.Count(), }) }