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
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"encoding/json"
"fmt"
"os"
"sync"
)
type credentialStore struct {
mu sync.RWMutex
path string
creds map[string]localCred
}
func newCredentialStore(path string) (*credentialStore, error) {
cs := &credentialStore{
path: path,
creds: make(map[string]localCred),
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cs, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > 0 {
if err := json.Unmarshal(data, &cs.creds); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
}
return cs, nil
}
func (cs *credentialStore) persist() error {
data, err := json.MarshalIndent(cs.creds, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if err := os.WriteFile(cs.path, data, 0600); err != nil {
return fmt.Errorf("write %s: %w", cs.path, err)
}
return nil
}
func (cs *credentialStore) Save(id string, publicKey []byte) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if _, exists := cs.creds[id]; exists {
return fmt.Errorf("credential already exists")
}
cs.creds[id] = localCred{PublicKey: publicKey}
return cs.persist()
}
func (cs *credentialStore) Lookup(id string) (publicKey []byte, signCount int64, err error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
cred, ok := cs.creds[id]
if !ok {
return nil, 0, fmt.Errorf("credential not found")
}
return cred.PublicKey, cred.SignCount, nil
}
func (cs *credentialStore) UpdateSignCount(id string, count int64) error {
cs.mu.Lock()
defer cs.mu.Unlock()
cred, ok := cs.creds[id]
if !ok {
return fmt.Errorf("credential not found")
}
cred.SignCount = count
cs.creds[id] = cred
return cs.persist()
}
+17 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"strings"
"testing"
@@ -214,16 +215,26 @@ func TestEnableTool_NoInProcessAuthGate(t *testing.T) {
// --- webauthn handler wiring (contract level, not crypto) ---
func newTestPasskey() *PasskeyHandle {
return newPasskeyHandle(webauthn.Config{
func newTestPasskey(t *testing.T) *PasskeyHandle {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "passkeys-*.json")
if err != nil {
t.Fatal(err)
}
f.Close()
pk, err := newPasskeyHandle(webauthn.Config{
Origin: "https://maven.example",
RPID: "maven.example",
RPName: "maven",
}, nil) // nil core ⇒ assertFn nil; AssertFinish skips the IPC step-up
}, nil, f.Name())
if err != nil {
t.Fatal(err)
}
return pk
}
func TestWebAuthn_Finish_MethodGuards(t *testing.T) {
pk := newTestPasskey()
pk := newTestPasskey(t)
for _, tc := range []struct {
name string
h http.HandlerFunc
@@ -240,7 +251,7 @@ func TestWebAuthn_Finish_MethodGuards(t *testing.T) {
}
func TestWebAuthn_Finish_MalformedJSON_400(t *testing.T) {
pk := newTestPasskey()
pk := newTestPasskey(t)
for _, tc := range []struct {
name string
h http.HandlerFunc
@@ -258,7 +269,7 @@ func TestWebAuthn_Finish_MalformedJSON_400(t *testing.T) {
}
func TestWebAuthn_Begin_ReturnsChallenge(t *testing.T) {
pk := newTestPasskey()
pk := newTestPasskey(t)
for _, tc := range []struct {
name string
h http.HandlerFunc
+6 -2
View File
@@ -83,6 +83,7 @@ func main() {
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
flag.Parse()
var core ipc.CoreAPI
@@ -133,11 +134,14 @@ func main() {
// AuthStepUp actions (tool enable). Without -webauthn-origin, these
// endpoints return 503 and step-up is unavailable (FloorSession).
if *pkOrigin != "" && *pkRPID != "" && core != nil {
pk := newPasskeyHandle(webauthn.Config{
pk, err := newPasskeyHandle(webauthn.Config{
Origin: *pkOrigin,
RPID: *pkRPID,
RPName: "maven",
}, core)
}, core, *pkFile)
if err != nil {
log.Fatalf("passkey store: %v", err)
}
mux.HandleFunc("/auth/passkey", pk.Page)
mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin)
mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish)
+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)