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
+149
-25
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,9 +18,12 @@ import (
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
subject, email, username string
|
||||
emailVerified bool
|
||||
// tokenAuth records the client credentials the server presented.
|
||||
tokenAuth [2]string
|
||||
authCalls int
|
||||
// challenge is the PKCE challenge of the last authorization request.
|
||||
challenge string
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
@@ -39,6 +43,11 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.authCalls++
|
||||
q := r.URL.Query()
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" {
|
||||
http.Error(w, "PKCE required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
idp.challenge = q.Get("code_challenge")
|
||||
back, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
||||
@@ -58,6 +67,10 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "bad code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if pkceChallenge(r.FormValue("code_verifier")) != idp.challenge {
|
||||
http.Error(w, "PKCE verifier does not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
@@ -67,8 +80,9 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
|
||||
"email_verified": idp.emailVerified,
|
||||
})
|
||||
})
|
||||
return idp
|
||||
@@ -136,54 +150,164 @@ func TestOIDCSignsInToTheWebInterface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
var noncePattern = regexp.MustCompile(`name="nonce" value="([^"]+)"`)
|
||||
|
||||
// startDeviceApproval starts a device login and takes a browser through
|
||||
// single sign-on for it, up to the confirmation page.
|
||||
func startDeviceApproval(t *testing.T, h *harness, b *browser) (start map[string]any, nonce string) {
|
||||
t.Helper()
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "",
|
||||
map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
userCode := start["user_code"].(string)
|
||||
|
||||
b := newBrowser(t, h)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(start["user_code"].(string)))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Let a device in?") ||
|
||||
!strings.Contains(page, start["user_code"].(string)) {
|
||||
t.Fatalf("no confirmation page after single sign-on: %d\n%s", status, page)
|
||||
}
|
||||
// A phone is handed back to its app from the provider's redirect too.
|
||||
if !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("no link back to the app after single sign-on:\n%s", page)
|
||||
m := noncePattern.FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no nonce on the confirmation page:\n%s", page)
|
||||
}
|
||||
return start, m[1]
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
poll := func() (int, map[string]any) {
|
||||
return h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
}
|
||||
|
||||
// Signing in at the provider is not yet an approval: one click on a
|
||||
// link must never let somebody else's device in.
|
||||
if status, _ := poll(); status != http.StatusAccepted {
|
||||
t.Fatalf("the device was approved before the person said yes: %d", status)
|
||||
}
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "Device approved") || !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("approval page:\n%s", page)
|
||||
}
|
||||
// A device approval must not also hand the browser a session cookie.
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("approving a device set a web session cookie")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
status, body := poll()
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after single sign-on: %d %v", status, body)
|
||||
t.Fatalf("poll after approval: %d %v", status, body)
|
||||
}
|
||||
if u := body["user"].(map[string]any); u["username"] != "devuser" {
|
||||
t.Fatalf("the session belongs to %v", u["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
|
||||
func TestOIDCDeviceApprovalCanBeRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-6", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"deny"}})
|
||||
if !strings.Contains(page, "not signed in") {
|
||||
t.Fatalf("refusal page:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusGone {
|
||||
t.Fatalf("a refused device can still poll: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack: someone starts a device login and gets the victim's browser to
|
||||
// the provider with that device's code. The victim's browser ends on the
|
||||
// confirmation page; the approval itself cannot be posted from anywhere
|
||||
// else, because it needs this browser's cookie.
|
||||
func TestOIDCApprovalCannotBePostedFromAnotherBrowser(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-7", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
victim := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, victim)
|
||||
|
||||
attacker := newBrowser(t, h)
|
||||
_, page := attacker.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("an approval was accepted from another browser:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusAccepted {
|
||||
t.Fatalf("the device got in: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCNeverLinksByUsername(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-3", "", "admin")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
b.get("/oidc/login")
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "" {
|
||||
t.Fatal("an identity calling itself admin claimed the admin account")
|
||||
}
|
||||
if u := st.UserByOIDC(idp.URL, "subject-3"); u == nil || u.Username == "admin" || u.ServerAdmin {
|
||||
t.Fatalf("the identity got %+v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCLinksAVerifiedEmailOnlyWhenAllowed(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
allow, verified bool
|
||||
wantLinkedToBob bool
|
||||
}{
|
||||
{"off", false, true, false},
|
||||
{"unverified", true, false, false},
|
||||
{"verified", true, true, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-8", "bob@example.com", "someone-else")
|
||||
idp.emailVerified = c.verified
|
||||
h := newHarnessWith(t, func(cfg *Config) {
|
||||
idp.withOIDC(false, "")(cfg)
|
||||
cfg.OIDC.LinkVerifiedEmail = c.allow
|
||||
})
|
||||
_ = h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["bob"] = &store.User{ID: "bob", Username: "bob", Email: "bob@example.com", PassHash: "x"}
|
||||
return nil
|
||||
})
|
||||
newBrowser(t, h).get("/oidc/login")
|
||||
var linked bool
|
||||
h.srv.Store().Read(func(st *store.State) { linked = st.Users["bob"].OIDCSubject == "subject-8" })
|
||||
if linked != c.wantLinkedToBob {
|
||||
t.Fatalf("linked = %v, want %v", linked, c.wantLinkedToBob)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCIsLinkedFromTheAccountPage(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-9", "", "anything")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("an existing account was not matched:\n%s", page)
|
||||
// Without the session's CSRF token, a link elsewhere cannot do it.
|
||||
b.get("/oidc/login?link=1")
|
||||
_, page := b.get("/web/account")
|
||||
if !strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("the account page does not offer linking:\n%s", page)
|
||||
}
|
||||
var u *store.User
|
||||
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
|
||||
if u.OIDCSubject != "subject-3" {
|
||||
t.Fatalf("the existing account was not linked: %+v", u)
|
||||
_, page = b.get("/oidc/login?link=1&csrf=" + url.QueryEscape(csrf))
|
||||
if !strings.Contains(page, "single sign-on") || strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("not linked:\n%s", page)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "subject-9" {
|
||||
t.Fatalf("admin = %+v", st.Users["admin"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
|
||||
@@ -206,7 +330,7 @@ func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
|
||||
if !strings.Contains(page, "expired") {
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("a forged callback was accepted:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
|
||||
Reference in New Issue
Block a user