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>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:38:32 +01:00
co-authored by Claude Opus 5.5
parent f8f634fbf7
commit 4ffc434b1b
77 changed files with 4192 additions and 776 deletions
+213 -48
View File
@@ -1,6 +1,8 @@
package server
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -28,6 +30,12 @@ type OIDCConfig struct {
AutoCreate bool `json:"auto_create"`
// DefaultGroupID is the group auto-created users join.
DefaultGroupID string `json:"default_group_id"`
// LinkVerifiedEmail lets an identity claim an existing, unlinked local
// account whose email it shares, when the provider says the email is
// verified. Off by default: whoever controls an identity provider's
// email field then controls those accounts. Accounts can always be
// linked explicitly from their Account page.
LinkVerifiedEmail bool `json:"link_verified_email"`
}
func (c OIDCConfig) Enabled() bool {
@@ -85,14 +93,24 @@ func (p *oidcProvider) metadata() (*oidcMetadata, error) {
}
type oidcIdentity struct {
Subject string `json:"sub"`
Email string `json:"email"`
Username string `json:"preferred_username"`
Name string `json:"name"`
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified flexBool `json:"email_verified"`
Username string `json:"preferred_username"`
Name string `json:"name"`
}
// flexBool reads a JSON boolean that some providers send as a string.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
v := strings.Trim(string(data), `"`)
*b = flexBool(v == "true")
return nil
}
// exchange turns an authorization code into the caller's identity.
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
func (p *oidcProvider) exchange(code, verifier string) (*oidcIdentity, error) {
meta, err := p.metadata()
if err != nil {
return nil, err
@@ -101,6 +119,8 @@ func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {p.redirectURI},
// PKCE: only the party that started the flow can spend the code.
"code_verifier": {verifier},
}
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -153,59 +173,114 @@ func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, er
return &id, nil
}
// handleOIDCLogin starts the authorization code flow for a pending device.
// oidcCookie binds a single sign-on round trip to the browser that started
// it. The state in the URL alone is not enough: a callback link forwarded to
// someone else, or a flow started by an attacker's page in a victim's
// browser, would otherwise finish in the wrong hands. Lax, because the
// provider's redirect back is a cross-site navigation.
const oidcCookie = "keeporg_oidc"
func (s *Server) setOIDCCookie(w http.ResponseWriter, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: oidcCookie, Value: value, Path: "/oidc", HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()),
})
}
func oidcCookieMatches(r *http.Request, want string) bool {
c, err := r.Cookie(oidcCookie)
return err == nil && want != "" && secret.Equal(c.Value, want)
}
// pkceChallenge is the S256 challenge for a verifier.
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// handleOIDCLogin starts the authorization code flow. It has three uses:
// with ?code= it identifies the person approving a waiting client; with
// ?link=1 it links an identity to the account signed in here; without
// either it signs a browser in to the web interface.
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
s.renderDone(w, "Too many sign-in attempts", (&errThrottled{RetryAfter: wait}).Error())
return
}
meta, err := s.oidc.metadata()
if err != nil {
s.log.Printf("oidc discovery: %v", err)
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return
}
// With a user code this approves a waiting client; without one it signs
// a browser in to the web interface.
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
state := secret.Token()
q := r.URL.Query()
userCode := strings.ToUpper(strings.TrimSpace(q.Get("code")))
var linkUser string
if q.Get("link") == "1" {
// Linking changes how an account signs in, so it needs that
// account's session and its CSRF token: a page elsewhere must not
// be able to link the attacker's identity to the victim's account.
sess := s.webUser(r)
if sess == nil || !secret.Equal(sess.csrf, q.Get("csrf")) {
s.renderDone(w, "Single sign-on not linked", "Start again from your Account page.")
return
}
linkUser = sess.user.ID
}
state, verifier := secret.Token(), secret.Token()
if err := s.st.Update(func(st *store.State) error {
if userCode == "" {
if !pruneDevices(st) {
return errBusy
}
st.Devices[state] = &store.DeviceAuth{
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
DeviceCode: state, Web: linkUser == "", LinkUserID: linkUser, Label: "web",
OIDCState: state, PKCEVerifier: verifier,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
}
return nil
}
d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) {
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
return errors.New("unknown or expired device code")
}
d.OIDCState = state
d.OIDCState, d.PKCEVerifier = state, verifier
return nil
}); err != nil {
s.renderDevice(w, userCode, err.Error())
return
}
q := url.Values{
"response_type": {"code"},
"client_id": {s.oidc.cfg.ClientID},
"redirect_uri": {s.oidc.redirectURI},
"scope": {s.oidc.cfg.Scopes},
"state": {state},
s.setOIDCCookie(w, state, deviceTTL)
v := url.Values{
"response_type": {"code"},
"client_id": {s.oidc.cfg.ClientID},
"redirect_uri": {s.oidc.redirectURI},
"scope": {s.oidc.cfg.Scopes},
"state": {state},
"code_challenge": {pkceChallenge(verifier)},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+v.Encode(), http.StatusFound)
}
// handleOIDCCallback finishes the flow and approves the waiting device.
// handleOIDCCallback finishes the flow. A browser sign-in ends signed in; a
// link ends linked; a waiting client is not approved here but shown to the
// person for an explicit decision, because arriving at this URL proves only
// that a browser with a provider session was sent here, not that its owner
// meant to let a device in.
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
s.renderDone(w, "Sign-in failed", e)
if q.Get("error") != "" {
s.renderDone(w, "Sign-in failed", "The identity provider refused the sign-in.")
return
}
state, code := q.Get("state"), q.Get("code")
@@ -213,26 +288,34 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
return
}
// Bind the state to a pending device before spending the code.
var found bool
if !oidcCookieMatches(r, state) {
s.renderDone(w, "Sign-in failed",
"This sign-in was not started in this browser. Start again from the same browser.")
return
}
s.setOIDCCookie(w, "", -time.Second)
var verifier string
s.st.Read(func(st *store.State) {
for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
found = true
verifier = d.PKCEVerifier
}
}
})
if !found {
if verifier == "" {
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
return
}
id, err := s.oidc.exchange(code)
id, err := s.oidc.exchange(code, verifier)
if err != nil {
s.log.Printf("oidc exchange: %v", err)
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return
}
var webToken, returnURL string
var (
webToken, linked string
confirm *webDeviceConfirm
)
if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth
for _, d := range st.Devices {
@@ -243,32 +326,110 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if dev == nil || time.Now().After(dev.ExpiresAt) {
return errors.New("this sign-in request has expired")
}
dev.OIDCState, dev.PKCEVerifier = "", ""
if dev.LinkUserID != "" {
delete(st.Devices, dev.DeviceCode)
return s.linkIdentity(st, dev.LinkUserID, id, &linked)
}
u, err := s.linkOIDCUser(st, id)
if err != nil {
return err
}
dev.OIDCState = ""
if dev.Web {
token := s.newBrowserSession(st, u.ID, r).Token
// Nothing is waiting to poll for this one; the browser in front
// of us is the client.
webToken = token
webToken = s.newBrowserSession(st, u.ID, r).Token
delete(st.Devices, dev.DeviceCode)
return nil
}
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
returnURL = dev.ReturnURL
dev.PendingUserID, dev.ApprovalNonce = u.ID, secret.Token()
confirm = &webDeviceConfirm{Nonce: dev.ApprovalNonce, User: u.Username, Label: dev.Label,
Code: dev.UserCode, Started: ago(dev.CreatedAt)}
return nil
}); err != nil {
s.renderDone(w, "Sign-in failed", err.Error())
s.renderDone(w, "Sign-in failed", humanError(err))
return
}
if webToken != "" {
switch {
case webToken != "":
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound)
case linked != "":
http.Redirect(w, r, "/web/account", http.StatusFound)
default:
s.setOIDCCookie(w, confirm.Nonce, deviceTTL)
render(w, deviceConfirmTmpl, map[string]any{"Title": "Approve a device", "D": confirm})
}
}
// webDeviceConfirm is the question put to someone who signed in through
// their provider to approve a device.
type webDeviceConfirm struct {
Nonce, User, Label, Code, Started string
}
// handleOIDCApprove is the person's answer. It needs the nonce from the
// confirmation page and the same nonce in this browser's cookie, so it can
// only come from the page the callback showed in this browser.
func (s *Server) handleOIDCApprove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
s.renderApproved(w, returnURL)
nonce := r.FormValue("nonce")
if !oidcCookieMatches(r, nonce) {
s.renderDone(w, "Not approved", "This approval was not started in this browser.")
return
}
s.setOIDCCookie(w, "", -time.Second)
approve := r.FormValue("decision") == "approve"
var returnURL string
err := s.st.Update(func(st *store.State) error {
for code, d := range st.Devices {
if d.ApprovalNonce == "" || !secret.Equal(d.ApprovalNonce, nonce) {
continue
}
if time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
return errors.New("this sign-in request has expired")
}
if !approve {
delete(st.Devices, code)
return nil
}
u := st.Users[d.PendingUserID]
if u == nil || u.Disabled {
return errors.New("this account cannot sign in")
}
d.ApprovalNonce = ""
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
returnURL = d.ReturnURL
return nil
}
return errors.New("this sign-in request has expired")
})
switch {
case err != nil:
s.renderDone(w, "Not approved", humanError(err))
case !approve:
s.renderDone(w, "Device refused", "The device was not signed in. If you did not start this, someone tried to.")
default:
s.renderApproved(w, returnURL)
}
}
// linkIdentity links an identity to an account its owner is signed in to.
func (s *Server) linkIdentity(st *store.State, userID string, id *oidcIdentity, linked *string) error {
u := st.Users[userID]
if u == nil || u.Disabled {
return errors.New("this account cannot be linked")
}
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
if other := st.UserByOIDC(issuer, id.Subject); other != nil && other.ID != u.ID {
return errors.New("this identity is already linked to another account")
}
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
*linked = u.Username
return nil
}
// linkOIDCUser finds the account behind an identity, provisioning one when
@@ -281,20 +442,24 @@ func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, e
}
return u, nil
}
// A local account may claim the identity by matching username or email,
// which is how an invited user upgrades to single sign-on.
for _, u := range st.Users {
if u.OIDCSubject != "" {
continue
}
if (id.Username != "" && u.Username == id.Username) ||
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
return u, nil
// Never by username: anyone who can choose their preferred_username at
// the provider could otherwise claim "admin". By email only when the
// deployment allows it and the provider vouches for the address, and
// never for a server administrator, whose account is linked from its
// own Account page or not at all.
if s.oidc.cfg.LinkVerifiedEmail && bool(id.EmailVerified) && id.Email != "" {
for _, u := range st.Users {
if u.OIDCSubject != "" || u.ServerAdmin || u.Disabled {
continue
}
if strings.EqualFold(u.Email, id.Email) {
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
return u, nil
}
}
}
if !s.oidc.cfg.AutoCreate {
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
return nil, errors.New("no account is linked to this identity; sign in with your password and link single sign-on from your Account page")
}
name := id.Username
if name == "" {