From 5afff001c368669407152fc57db1fdc38ed06545 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 5 Jul 2026 11:55:44 +0400 Subject: [PATCH] mavweb: add in-process auth gate for POST /tools Add a local PasskeySession that handleTools checks before processing any POST action (enable/disable). If the session hasn't been asserted within the 5-minute TTL, return 403 Forbidden. Changes: - webauthn/session.go: add IsStepUp() convenience method (nil-safe) - webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish calls session.Assert() after IPC step-up - main.go: create stepUpSession, pass to handleTools and newPasskeyHandle; handleTools returns 403 if !session.IsStepUp() - handlers_test.go: update TestEnableTool_NoInProcessAuthGate to expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for the happy path with asserted session; update all 10 call sites --- SESSION-05-07-2026.md | 6 +-- cmd/mavweb/handlers_test.go | 72 ++++++++++++++++++++++++------------ cmd/mavweb/main.go | 12 ++++-- cmd/mavweb/webauthn.go | 10 ++++- internal/webauthn/session.go | 10 +++++ 5 files changed, 79 insertions(+), 31 deletions(-) diff --git a/SESSION-05-07-2026.md b/SESSION-05-07-2026.md index 1959db4..70b7786 100644 --- a/SESSION-05-07-2026.md +++ b/SESSION-05-07-2026.md @@ -60,11 +60,11 @@ Notes: | # | Task | Commit | Status | |---|------|--------|--------| -| 12 | **Recurring reminders** — cron expression in reminder table. New `cron` column, `next_fire_ts` computed from cron. "trash every tuesday" | — | pending | +| 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done | | 13 | **Capability model** — `scope` column on tools table, migration, UI, tests | 6b80fd0 | done | | 14 | **Notification batching / digest mode** — morning/evening rollup instead of per-event nudges. Configurable window, accumulated messages in one delivery | — | pending | | 15 | **Rule trace / explanation engine** — `why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending | -| 16 | **Backup/restore automation** — script: `cp` the encrypted DB + re-encrypt-verify + restore flow | — | pending | +| 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done | Notes: - Recurring reminders: needs schema migration (#1). Add `cron TEXT` and `next_fire_ts INTEGER` to reminders table. @@ -82,7 +82,7 @@ Notes: | 19 | **Rule trace page** — new `/trace` route showing predicate eval results per rule per tick | — | pending | | 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done | | 21 | **PWA icons** — add proper icon array to `manifest.json` (generate or inline SVG) | — | pending | -| 22 | **Language unification** — pick Russian or add `lang` URL param toggle | — | pending | +| 22 | **Language unification** — bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done | Notes: - Current `/tools` has no in-process auth — `handlers_test.go` explicitly pins this behavior with `TestEnableTool_NoInProcessAuthGate`. diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index a6eb167..557cab1 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/kami/maven/internal/auth" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) @@ -138,7 +139,7 @@ func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}}, } rr := httptest.NewRecorder() - handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core) + handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) @@ -158,7 +159,7 @@ func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { func TestHandleTools_NilCore_503(t *testing.T) { rr := httptest.NewRecorder() - handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil) + handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil, nil) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rr.Code) } @@ -173,6 +174,14 @@ func postForm(action string, vals url.Values) *http.Request { return r } +// stepUpSession returns a PasskeySession that has already been asserted, +// so POST /tools calls can pass the in-process auth gate. +func stepUpSession() *webauthn.PasskeySession { + s := webauthn.NewPasskeySession(5 * time.Minute) + s.Assert(context.Background(), auth.Scope{}) + return s +} + func TestHandleTools_POST_Enable_HappyPath(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() @@ -180,7 +189,7 @@ func TestHandleTools_POST_Enable_HappyPath(t *testing.T) { "name": {"svc"}, "cmd": {"systemctl restart nginx"}, "destructive": {"on"}, - }), core) + }), core, stepUpSession()) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) @@ -199,7 +208,7 @@ func TestHandleTools_POST_Enable_HappyPath(t *testing.T) { func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core) + handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core, stepUpSession()) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } @@ -208,7 +217,7 @@ func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) { func TestHandleTools_POST_Enable_MissingCmd_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core) + handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core, stepUpSession()) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } @@ -219,7 +228,7 @@ func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) { rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, - }), core) + }), core, stepUpSession()) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502", rr.Code) } @@ -228,7 +237,7 @@ func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) { func TestHandleTools_POST_UnknownAction_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core) + handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core, stepUpSession()) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } @@ -239,7 +248,7 @@ func TestHandleTools_POST_UnknownAction_400(t *testing.T) { func TestHandleTools_POST_Disable_HappyPath(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core) + handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession()) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } @@ -251,7 +260,7 @@ func TestHandleTools_POST_Disable_HappyPath(t *testing.T) { func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - handleTools(rr, postForm("disable", url.Values{}), core) + handleTools(rr, postForm("disable", url.Values{}), core, stepUpSession()) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } @@ -260,29 +269,44 @@ func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) { func TestHandleTools_POST_Disable_CoreError_502(t *testing.T) { core := &fakeCore{disableErr: ipc.ErrToolNotFound} rr := httptest.NewRecorder() - handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core) + handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession()) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502", rr.Code) } } -// TestEnableTool_NoInProcessAuthGate documents CURRENT behavior: handleTools -// performs the boundary-moving EnableTool with no in-process authentication — -// there is no passkey/session check in the Go handler. A POST enable with no -// prior WebAuthn AssertFinish succeeds (reaches core.EnableTool). Authorization -// is delegated entirely to the nginx+wg layer in front of mavweb. Whether that -// is the intended sole gate is a maintainer decision; this test only pins the -// observed behavior so a future auth gate change is a deliberate, visible edit. +// TestEnableTool_NoInProcessAuthGate verifies that a POST /tools without a +// prior WebAuthn assertion is rejected with 403 Forbidden — the in-process +// auth gate requires step-up before any mutation (enable/disable). func TestEnableTool_NoInProcessAuthGate(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() - // No passkey session, no cookie, no header — just the raw POST. + // No passkey session (nil) — no step-up, gate rejects. handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, - }), core) - if rr.Code != http.StatusOK || core.gotEnableName != "svc" { - t.Fatalf("expected unauthenticated enable to reach core (status=%d, name=%q); "+ - "if this now fails, an in-process auth gate was added", rr.Code, core.gotEnableName) + }), core, nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d (expected auth gate to reject)", rr.Code, http.StatusForbidden) + } + if core.gotEnableName != "" { + t.Errorf("core.EnableTool was called with name=%q, but auth gate should have blocked it", core.gotEnableName) + } +} + +// TestEnableTool_WithAuthGate_RequiresStepUp verifies that a POST /tools with +// an asserted passkey session proceeds past the auth gate to core.EnableTool. +func TestEnableTool_WithAuthGate_RequiresStepUp(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + sess := stepUpSession() + handleTools(rr, postForm("enable", url.Values{ + "name": {"svc"}, "cmd": {"systemctl restart"}, + }), core, sess) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if core.gotEnableName != "svc" { + t.Errorf("name = %q, want svc", core.gotEnableName) } } @@ -299,7 +323,7 @@ func newTestPasskey(t *testing.T) *PasskeyHandle { Origin: "https://maven.example", RPID: "maven.example", RPName: "maven", - }, nil, f.Name()) + }, nil, f.Name(), nil) if err != nil { t.Fatal(err) } @@ -638,7 +662,7 @@ func TestHandleTools_ListToolsError_502(t *testing.T) { rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, - }), core) + }), core, stepUpSession()) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) } diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index efd0f1c..7f28b67 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -142,12 +142,14 @@ func main() { // Passkey registration + assertion are the step-up mechanism for // AuthStepUp actions (tool enable). Without -webauthn-origin, these // endpoints return 503 and step-up is unavailable (FloorSession). + stepUpSession := webauthn.NewPasskeySession(5 * time.Minute) + if *pkOrigin != "" && *pkRPID != "" && core != nil { pk, err := newPasskeyHandle(webauthn.Config{ Origin: *pkOrigin, RPID: *pkRPID, RPName: "maven", - }, core, *pkFile) + }, core, *pkFile, stepUpSession) if err != nil { log.Fatalf("passkey store: %v", err) } @@ -163,7 +165,7 @@ func main() { // Enabling is the boundary-moving act (maven.md), so it lives ONLY here, // behind wg+nginx+auth — never the voice/chat path. mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) { - handleTools(w, r, core) + handleTools(w, r, core, stepUpSession) }) srv := &http.Server{Addr: *addr, Handler: mux} @@ -425,7 +427,7 @@ func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { // whitespace-split — argv with embedded spaces isn't supported (ponytail: no // shell-word parsing; the box owner controls this input, quote a wrapper script // if an arg needs spaces). -func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { +func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession) { if core == nil { http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable) return @@ -433,6 +435,10 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { ctx := r.Context() var msg string if r.Method == http.MethodPost { + if !session.IsStepUp() { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } action := r.FormValue("action") name := strings.TrimSpace(r.FormValue("name")) switch action { diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index f2da023..c7acee3 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/kami/maven/internal/auth" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) @@ -29,6 +30,7 @@ type PasskeyHandle struct { rp *webauthn.RP assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC store *credentialStore + session *webauthn.PasskeySession } type localCred struct { @@ -36,7 +38,7 @@ type localCred struct { SignCount int64 } -func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) (*PasskeyHandle, error) { +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 @@ -49,6 +51,7 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) ( rp: webauthn.NewRP(cfg), assertFn: af, store: store, + session: session, }, nil } @@ -190,6 +193,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) { } } + // 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}) } diff --git a/internal/webauthn/session.go b/internal/webauthn/session.go index 397a776..a637794 100644 --- a/internal/webauthn/session.go +++ b/internal/webauthn/session.go @@ -47,6 +47,16 @@ func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Laye return auth.Layer2 } +// IsStepUp returns true if the session was recently asserted (within TTL). +func (s *PasskeySession) IsStepUp() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL +} + // Assert records a successful step-up gesture. The session bumps to L3 for // the assertion TTL. A nil receiver returns ErrStepUpUnsupported. func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {