package main import ( "context" "encoding/json" "fmt" "log" "net/http" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) // assertIPC — satisfies the AssertStepUp caller shape. The only implementation // is *ipc.Client; in-process CoreAPI adapters return ErrUnknownMethod. type assertIPC interface { AssertStepUp(ctx context.Context) error } // PasskeyHandle holds the WebAuthn relying party, a local in-memory credential // store, and the IPC client used to assert step-up. It serves the four WebAuthn // HTTP endpoints (register/begin, register/finish, assert/begin, assert/finish). // // Credentials are kept in-memory only (a single-user daemon restarts // infrequently, and re-enrolling after restart is acceptable). A future // version may persist them to disk. type PasskeyHandle struct { rp *webauthn.RP assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC store *credentialStore } type localCred struct { PublicKey []byte SignCount int64 } 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, store: store, }, nil } // Page serves the passkey enrollment + step-up UI. It's the only surface that // can perform a WebAuthn gesture, so it's the gate the /tools enable depends // on: assert here (bumps the daemon session to L3 for the assertion TTL), then // enable a tool on /tools within that window. func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(passkeyPageHTML)) } const passkeyPageHTML = ` maven · passkey

maven passkey

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

` func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) { opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user") if err != nil { log.Printf("webauthn: register begin: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"challenge": challenge, "options": opts}) } func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body struct { Challenge string `json:"challenge"` Credential map[string]any `json:"credential"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } save := func(id string, publicKey []byte, _ []byte, _ string) error { return h.store.Save(id, publicKey) } credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential) if err != nil { log.Printf("webauthn: register finish: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } log.Printf("webauthn: registered credential %s", credID) json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) } func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) { opts, challenge, err := h.rp.AssertionOptions() if err != nil { log.Printf("webauthn: assert begin: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"challenge": challenge, "options": opts}) } func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body struct { Challenge string `json:"challenge"` Credential map[string]any `json:"credential"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } lookup := func(id string) ([]byte, int64, error) { return h.store.Lookup(id) } update := func(id string, count int64) error { return h.store.UpdateSignCount(id, count) } credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential) if err != nil { log.Printf("webauthn: assert finish: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } // Assert step-up on the IPC (mavend) side so subsequent EnableTool calls // see L3. Best-effort: if IPC fails (no -core or mavend unreachable), the // user still sees success but the enable will fail with AuthStepUp. if h.assertFn != nil { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if err := h.assertFn.AssertStepUp(ctx); err != nil { log.Printf("webauthn: assert step-up: %v", err) http.Error(w, "step-up assertion failed", http.StatusBadGateway) return } } log.Printf("webauthn: asserted credential %s", credID) json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) }