35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
294 lines
12 KiB
Go
294 lines
12 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 {
|
|
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
|
"passkey registration could not start", fmt.Errorf("webauthn register begin: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
|
"POST only", nil)
|
|
return
|
|
}
|
|
var body struct {
|
|
Challenge string `json:"challenge"`
|
|
Credential map[string]any `json:"credential"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"invalid registration request", fmt.Errorf("decode webauthn registration: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
|
"passkey registration failed", fmt.Errorf("webauthn register finish: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
|
"passkey assertion could not start", fmt.Errorf("webauthn assert begin: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
|
"POST only", nil)
|
|
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 {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"invalid assertion request", fmt.Errorf("decode webauthn assertion: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
|
"passkey assertion failed", fmt.Errorf("webauthn assert finish: %w", err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusBadGateway, problemWebAuthnStepUp,
|
|
"step-up assertion failed", fmt.Errorf("assert step-up in core: %w", err))
|
|
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)
|
|
}
|
|
}
|