webauthn: persist credentials to JSON file instead of in-memory map

- New credentialStore type in credentials.go loads/saves
  map[id]localCred to a JSON file. Thread-safe with sync.RWMutex,
  writes to disk on every mutation.
- PasskeyHandle replaces sync.RWMutex+map with *credentialStore.
  Inline save/lookip/update closures delegate to store methods.
- newPasskeyHandle now takes a storePath parameter and returns an
  error; callers updated.
- New -passkey-file flag (default ./passkeys.json) configures the
  credential store path in main.go.
- Tests use os.CreateTemp in t.TempDir() so each test gets an
  isolated, auto-cleaned store file.
This commit is contained in:
kami
2026-07-05 02:09:56 +04:00
parent b9248ef2e6
commit 44807b612c
4 changed files with 111 additions and 37 deletions
+11 -29
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/kami/maven/internal/ipc"
@@ -29,8 +28,7 @@ type assertIPC interface {
type PasskeyHandle struct {
rp *webauthn.RP
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
mu sync.RWMutex
creds map[string]localCred // credential ID → stored credential
store *credentialStore
}
type localCred struct {
@@ -38,16 +36,20 @@ type localCred struct {
SignCount int64
}
func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI) *PasskeyHandle {
func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) (*PasskeyHandle, error) {
var af assertIPC
if c, ok := core.(assertIPC); ok {
af = c
}
store, err := newCredentialStore(storePath)
if err != nil {
return nil, fmt.Errorf("credential store: %w", err)
}
return &PasskeyHandle{
rp: webauthn.NewRP(cfg),
assertFn: af,
creds: make(map[string]localCred),
}
store: store,
}, nil
}
// Page serves the passkey enrollment + step-up UI. It's the only surface that
@@ -124,13 +126,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
return
}
save := func(id string, publicKey []byte, _ []byte, _ string) error {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.creds[id]; exists {
return fmt.Errorf("credential already exists")
}
h.creds[id] = localCred{PublicKey: publicKey}
return nil
return h.store.Save(id, publicKey)
}
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
if err != nil {
@@ -168,24 +164,10 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
}
lookup := func(id string) ([]byte, int64, error) {
h.mu.RLock()
defer h.mu.RUnlock()
cred, ok := h.creds[id]
if !ok {
return nil, 0, fmt.Errorf("credential not found")
}
return cred.PublicKey, cred.SignCount, nil
return h.store.Lookup(id)
}
update := func(id string, count int64) error {
h.mu.Lock()
defer h.mu.Unlock()
cred, ok := h.creds[id]
if !ok {
return fmt.Errorf("credential not found")
}
cred.SignCount = count
h.creds[id] = cred
return nil
return h.store.UpdateSignCount(id, count)
}
credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential)