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:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
+213
-48
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user