12c2ae1d17
CLAUDE.md says every page is its own embedded .html file next to main.go, and that no page markup lives in Go. Two pages were still Go string constants: passkeyPageHTML in webauthn.go and modelsHTML in models.go. They are now passkey.html and models.html, embedded. The identifiers keep their names, so passkey_prf_test.go still reads passkeyPageHTML and still asserts on the same bytes. Both templates now build through parsePage, and Page and handleModels render through renderPage. handleEcosystem did too and now does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
11 KiB
Go
290 lines
11 KiB
Go
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)
|
|
}
|
|
}
|