// 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" "log" "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/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"` // MasterKeyFile holds the key that wraps the vault keys the server keeps // in order to merge. It belongs outside DataDir: a copy of the data // directory alone must not be enough to open a vault. 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 == "" { c.ExternalURL = "http://localhost" + c.Addr } 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.MasterKeyFile == "" { c.MasterKeyFile = "/etc/keeporg/master.key" } if c.VaultKDF.MemoryKiB == 0 { c.VaultKDF = kdbx.DefaultKDF } } type Server struct { cfg Config st *store.Store blobs *blob.Store hub *Hub oidc *oidcProvider keyring *secret.Keyring mux *http.ServeMux log *log.Logger } // New opens the server's data directory and wires up its routes. func New(cfg Config, lg *log.Logger) (*Server, error) { cfg.Defaults() st, err := store.Open(filepath.Join(cfg.DataDir, "state.json")) if err != nil { return nil, err } blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs")) if err != nil { return nil, err } s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg} // Without a master key the server still serves every strategy that does // not need to read a vault; only merging and vault creation are refused. if kr, kerr := secret.LoadKeyring(cfg.MasterKeyFile); kerr != nil { lg.Printf("no master key, so this server cannot create or merge vaults: %v", kerr) } else { s.keyring = kr } 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. func (s *Server) Handler() http.Handler { return s.mux } // 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("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("POST /web/password", s.webForm(s.handleWebChangePassword)) 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) // 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 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 = &cp } }) if out == nil { return nil, errUnauthorized } return out, nil } // bearer extracts a token from the Authorization header, or from the // access_token query parameter, which EventSource clients need because they // cannot set headers. func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if after, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(after) } return r.URL.Query().Get("access_token") } func (s *Server) newSession(st *store.State, userID, label string) *store.Session { sess := &store.Session{ Token: secret.Token(), UserID: userID, Label: label, CSRF: secret.Token(), CreatedAt: time.Now(), ExpiresAt: time.Now().Add(s.cfg.SessionTTL), } st.Sessions[sess.Token] = sess 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(), }) }