package main import ( "context" "encoding/json" "fmt" "log" "net/http" "time" "github.com/kami/maven/internal/auth" "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 } // keyIPC — satisfies the key-wrap and unlock methods. The only implementation // is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil, // StoreEncryptionKey and Unlock are silently skipped. type keyIPC interface { StoreEncryptionKey(ctx context.Context, publicKey []byte) error Unlock(ctx context.Context, publicKey []byte) error } // PasskeyHandle holds the WebAuthn relying party, a local in-memory credential // store, and the IPC client used to assert step-up and to wrap/unwrap the // daemon's encryption key. 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 encryptFn keyIPC // *ipc.Client when connected; nil ⇒ key wrap/unlock disabled store *credentialStore session *webauthn.PasskeySession } type localCred struct { PublicKey []byte SignCount int64 } func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, session *webauthn.PasskeySession) (*PasskeyHandle, error) { var af assertIPC if c, ok := core.(assertIPC); ok { af = c } var ek keyIPC if c, ok := core.(keyIPC); ok { ek = c } store, err := newCredentialStore(storePath) if err != nil { return nil, fmt.Errorf("credential store: %w", err) } return &PasskeyHandle{ rp: webauthn.NewRP(cfg), assertFn: af, encryptFn: ek, store: store, session: session, }, 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") passkeyTmpl.Execute(w, nil) } // passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom. const passkeyPageHTML = `{{template "shellTop" "passkey"}}

Passkey

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

{{template "shellBottom"}} ` 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 } var enrolledPublicKey []byte save := func(id string, publicKey []byte, _ []byte, _ string) error { enrolledPublicKey = publicKey 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) // If mavend is reachable and supports key wrapping, store the encryption // key wrapped with this credential's public key — enables cold-start unlock. if h.encryptFn != nil && enrolledPublicKey != nil { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil { log.Printf("webauthn: store encryption key: %v", err) // Non-fatal: enrollment still succeeded, the wrapped key can be // created later via the same endpoint. } else { log.Printf("webauthn: encryption key wrapped with 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 } } // If the daemon is locked (cold-start), send the credential's public key // over IPC so mavend can unwrap its encryption key and open the store. // The public key comes from the local credential store (it was stored // during enrollment). Non-fatal: if IPC doesn't support Unlock or the // daemon is already unlocked, the call is a no-op on the server side. if h.encryptFn != nil { publicKey, _, err := h.store.Lookup(credID) if err == nil && publicKey != nil { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := h.encryptFn.Unlock(ctx, publicKey); err != nil { log.Printf("webauthn: unlock via credential %s: %v", credID, err) // Non-fatal: assertion succeeded; if the daemon stays locked // the user will see errors on subsequent pages, but the // assertion itself is valid. } else { log.Printf("webauthn: daemon unlocked via credential %s", credID) } } else if err != nil { log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err) } } // Assert the in-process session so the POST /tools handler sees step-up. if h.session != nil { h.session.Assert(r.Context(), auth.Scope{}) } log.Printf("webauthn: asserted credential %s", credID) json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) }