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 prepends navHTML.
const passkeyPageHTML = `
maven · passkey
{{template "nav" "passkey"}}
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
}
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})
}