package main import ( "context" _ "embed" "encoding/json" "errors" "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, explicit bool) 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) { renderPage(w, passkeyTmpl, nil) } // passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl // with shellTop/shellBottom. It was a Go string constant, which is the one // place page markup still lived in Go. // //go:embed passkey.html var passkeyPageHTML string var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil) 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. // // Known property, accepted deliberately: this value is supplied by // the client and is NOT covered by the assertion signature. WebAuthn // client extension outputs never are, and binding one would need a // per-assertion salt, which would make the wrapped blob unopenable on // the next boot. Nothing here can tell a real PRF output from 32 // bytes a compromised page chose. What limits the damage is that the // daemon refuses to rewrite an existing blob unless the operator // asked for it — see Explicit below and cmd/mavend/keyfile.go. PRF string `json:"prf"` // Explicit marks the "rewrite cold-start key" button rather than a // plain step-up. Only then may the daemon replace a blob that is // already on disk. Explicit bool `json:"explicit"` } 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 this // assertion just produced. The secret is used here and dropped; it is // never stored on this side, and it must never be logged — unlike a // signature it does not expire, so one copy in a proxy log or a HAR file // is permanent access to the wrapped blob. // // Order matters: unlock first (if the daemon is locked there is nothing to // wrap yet), then wrap. Both are best-effort, because the assertion itself // is valid either way. if h.encryptFn != nil { if secret, err := webauthn.DecodePRFResult(body.PRF); err != nil { log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err) } else { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() h.coldStart(ctx, credID, secret, body.Explicit) } } // 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}) } // coldStart unlocks a locked daemon with this assertion's PRF output and then // asks it to wrap the at-rest key. Never fatal: a locked or unreachable daemon // does not invalidate the step-up. // // # The legacy retry // // A box enrolled before Vikunja #14 has a v1 blob, wrapped under the // credential PUBLIC key. The PRF secret cannot open it, and this handler is // the only caller of Unlock, so without a second attempt that box could never // cold-start again: it would sit locked while a perfectly good passkey was // asserted, and the only way back in would be putting MAVEN_DB_KEY into the // environment — the exact thing cold-start unlock exists to avoid. // // So a failed PRF unlock is retried with the public key from the credential // store. That is not a weaker fallback being offered to new deployments: // nothing writes v1 any more, and a v2 blob does not open under a public key // either. It is a one-way door out of the old format, and the operator is told // to walk through it. func (h *PasskeyHandle) coldStart(ctx context.Context, credID string, secret []byte, explicit bool) { legacy := false err := h.encryptFn.Unlock(ctx, secret) if err != nil && !errors.Is(err, ipc.ErrUnknownMethod) { if pub, _, lerr := h.store.Lookup(credID); lerr == nil && len(pub) > 0 { if err2 := h.encryptFn.Unlock(ctx, pub); err2 == nil { err, legacy = nil, true } } } switch { case errors.Is(err, ipc.ErrUnknownMethod): // Env-key mode: the daemon was never locked and has no UnlockFn. Not // a failure, and the old code logged it as one on every assertion. case err != nil: log.Printf("webauthn: unlock via credential %s failed: %v", credID, err) case legacy: log.Printf("SECURITY: webauthn: daemon unlocked from a LEGACY v1 wrapped key using credential %s. That blob is derived from the credential public key, which sits in passkeys.json beside it, so it protects nothing. Press \"rewrite cold-start key\" on this page to replace it with a v2 blob.", credID) default: log.Printf("webauthn: daemon reports unlocked, credential %s", credID) } // explicit=false means "write the blob only if there is none". The daemon // enforces that; sending the flag is the whole of this side's part in it. switch err := h.encryptFn.StoreEncryptionKey(ctx, secret, explicit); { case err == nil && explicit: log.Printf("webauthn: cold-start key rewritten under credential %s", credID) case err == nil: case errors.Is(err, ipc.ErrUnknownMethod): // No key to wrap: a plaintext dev store, or a daemon still locked. default: log.Printf("webauthn: wrap encryption key: %v", err) } }