From 490d055cec7e423dca1a076fd616843d46110c91 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 22:35:19 +0300 Subject: [PATCH] Delete stored security keys --- app.js | 3 +-- config.go | 3 +++ main_test.go | 27 +++++++++++++++++++++++++++ mfa.go | 40 +++++++++++++++++++++------------------- users.go | 2 +- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/app.js b/app.js index b1cc459..0e8a104 100644 --- a/app.js +++ b/app.js @@ -144,7 +144,6 @@ if (!m) return ''; const parts = []; if (m.totp) parts.push('App'); - if (m.keys) parts.push(m.keys === 1 ? '1 key' : m.keys + ' keys'); if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys'); return parts.join(', '); } @@ -907,7 +906,7 @@ } for (const k of s.keys) { rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name), - h('div', { class: 'hint' }, (k.passkey ? 'Passkey' : 'Security key') + ' · added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))), + h('div', { class: 'hint' }, 'Added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))), h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'), h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove'))); } diff --git a/config.go b/config.go index 496a817..6993c8a 100644 --- a/config.go +++ b/config.go @@ -204,6 +204,9 @@ func (c *Config) applyDefaults() { c.Users = []User{u} } c.Admin = nil + for i := range c.Users { + dropSecurityKeys(&c.Users[i]) + } for i := range c.APITokens { if c.APITokens[i].UserID == "" { c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed diff --git a/main_test.go b/main_test.go index 9122208..03afa84 100644 --- a/main_test.go +++ b/main_test.go @@ -1121,3 +1121,30 @@ func TestMFA(t *testing.T) { t.Fatal("reset left methods behind") } } + +// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on +// load, and recovery codes with them when nothing else is left. +func TestDropSecurityKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := `{"users": [ + {"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}}, + {"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}} + ]}` + if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + store, err := openStore(path) + if err != nil { + t.Fatal(err) + } + c := store.Get() + if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 { + t.Fatalf("user a kept %v", a) + } + if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 { + t.Fatalf("user b: %v", b) + } + if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") { + t.Fatal("security key still in config.json") + } +} diff --git a/mfa.go b/mfa.go index 9103baa..1b346fc 100644 --- a/mfa.go +++ b/mfa.go @@ -42,17 +42,29 @@ type UserMFA struct { Handle []byte `json:"handle,omitempty"` // WebAuthn user handle } -// MFAKey is a passkey. Keys added by v0.3.0 as plain security keys have -// Passkey false; they still work as the second step. +// MFAKey is a passkey. type MFAKey struct { ID string `json:"id"` Name string `json:"name"` - Passkey bool `json:"passkey"` // discoverable: signs in without a password + Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted Created time.Time `json:"created"` LastUsed *time.Time `json:"lastUsed,omitempty"` Credential webauthn.Credential `json:"credential"` } +// dropSecurityKeys deletes the security keys v0.3.0 could add; only +// passkeys are supported. A user left without a method loses their +// recovery codes too. +func dropSecurityKeys(u *User) { + if u.MFA == nil { + return + } + u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey }) + if !u.hasMFA() { + u.MFA.RecoveryCodes = nil + } +} + func (u *User) hasMFA() bool { return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0) } @@ -205,7 +217,6 @@ type ticket struct { type ceremony struct { userID string // "" for a passkey sign-in - passkey bool data *webauthn.SessionData expires time.Time } @@ -563,7 +574,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) { u := &cfg.Users[i] if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) { for _, k := range u.MFA.Keys { - if k.Passkey && bytes.Equal(k.Credential.ID, rawID) { + if bytes.Equal(k.Credential.ID, rawID) { found = u return waUser{u}, nil } @@ -589,7 +600,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) { type keyView struct { ID string `json:"id"` Name string `json:"name"` - Passkey bool `json:"passkey"` Created time.Time `json:"created"` LastUsed *time.Time `json:"lastUsed"` } @@ -606,7 +616,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) { if m := u.MFA; m != nil { keys := []keyView{} for _, k := range m.Keys { - keys = append(keys, keyView{k.ID, k.Name, k.Passkey, k.Created, k.LastUsed}) + keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed}) } out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes) } @@ -752,7 +762,7 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) { return } a.auth.mu.Lock() - a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, passkey: true, data: data, expires: time.Now().Add(ticketTTL)} + a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)} a.auth.mu.Unlock() writeJSON(w, http.StatusOK, opts) } @@ -792,7 +802,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) { name = name[:maxKeyName] } var codes []string - key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred} + key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred} if err := a.store.Update(func(c *Config) error { _, u := c.userByID(p.UserID) if u == nil || u.MFA == nil { @@ -911,17 +921,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) { // mfaSummary is what user lists show. func mfaSummary(u *User) map[string]any { - out := map[string]any{"totp": false, "keys": 0, "passkeys": 0} + out := map[string]any{"totp": false, "passkeys": 0} if m := u.MFA; m != nil { - keys, passkeys := 0, 0 - for _, k := range m.Keys { - if k.Passkey { - passkeys++ - } else { - keys++ - } - } - out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys + out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys) } return out } diff --git a/users.go b/users.go index e3fa9a0..302a8d7 100644 --- a/users.go +++ b/users.go @@ -21,7 +21,7 @@ type userView struct { LastLogin *tokenUse `json:"lastLogin"` // since the service started Tokens int `json:"tokens"` You bool `json:"you"` - MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n} + MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n} } func (a *App) userView(c *Config, u *User, me string) userView {