Delete stored security keys

This commit is contained in:
Daniel Redetzke
2026-10-04 22:35:19 +03:00
parent 56978b28e9
commit 490d055cec
5 changed files with 53 additions and 22 deletions
+1 -2
View File
@@ -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')));
}
+3
View File
@@ -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
+27
View File
@@ -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")
}
}
+21 -19
View File
@@ -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
}
+1 -1
View File
@@ -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 {