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, secret []byte) error Unlock(ctx context.Context, secret []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"}}
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 } 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) // Note what does NOT happen here: the encryption key is not wrapped at // enrolment. Wrapping needs the authenticator's PRF output, and create() // does not produce one on most authenticators — it only reports whether // the extension is supported. The wrapped key is written on the first // assertion instead (see AssertFinish). // // This used to wrap the key under the credential *public* key, which is // written to passkeys.json next to the wrapped blob. See the header of // internal/webauthn/keywrap.go. 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"` // PRF is the base64url WebAuthn PRF output the browser read out of // getClientExtensionResults(). Empty when the authenticator has no // PRF extension: cold-start unlock is then unavailable and we say so // rather than falling back to something weaker. PRF string `json:"prf"` } 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 } } // Cold-start unlock and key wrapping, both keyed on the PRF secret that // this assertion just produced. The secret is used here and dropped; it is // never stored on this side. // // Order matters: unlock first (if the daemon is locked there is nothing to // wrap yet), then re-wrap, which writes the blob on the first assertion // after enrolment and is a harmless rewrite afterwards. Both are // best-effort — the assertion itself is valid either way. if h.encryptFn != nil { secret, err := webauthn.DecodePRFResult(body.PRF) switch { case err != nil: log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err) default: ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := h.encryptFn.Unlock(ctx, secret); err != nil { log.Printf("webauthn: unlock via credential %s: %v", credID, err) } else { log.Printf("webauthn: daemon unlocked via credential %s", credID) } if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil { log.Printf("webauthn: wrap encryption key: %v", err) } else { log.Printf("webauthn: encryption key wrapped for credential %s", credID) } } } // 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}) }