diff --git a/cmd/mavweb/credentials.go b/cmd/mavweb/credentials.go new file mode 100644 index 0000000..2324c82 --- /dev/null +++ b/cmd/mavweb/credentials.go @@ -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() +} diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 1f66bd6..05075af 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -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 diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a079fa5..a9cd01e 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -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) diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index c04ddae..f2da023 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -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)