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:
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user