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
This commit is contained in:
kami
2026-07-05 11:55:44 +04:00
parent c225ba37b2
commit 5afff001c3
5 changed files with 79 additions and 31 deletions
+3 -3
View File
@@ -60,11 +60,11 @@ Notes:
| # | Task | Commit | Status | | # | 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 | | 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 | | 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 | | 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: Notes:
- Recurring reminders: needs schema migration (#1). Add `cron TEXT` and `next_fire_ts INTEGER` to reminders table. - 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 | | 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 | | 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 | | 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: Notes:
- Current `/tools` has no in-process auth — `handlers_test.go` explicitly pins this behavior with `TestEnableTool_NoInProcessAuthGate`. - Current `/tools` has no in-process auth — `handlers_test.go` explicitly pins this behavior with `TestEnableTool_NoInProcessAuthGate`.
+48 -24
View File
@@ -11,6 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn" "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}}, enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
} }
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) 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) { func TestHandleTools_NilCore_503(t *testing.T) {
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rr.Code) t.Fatalf("status = %d, want 503", rr.Code)
} }
@@ -173,6 +174,14 @@ func postForm(action string, vals url.Values) *http.Request {
return r 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) { func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -180,7 +189,7 @@ func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
"name": {"svc"}, "name": {"svc"},
"cmd": {"systemctl restart nginx"}, "cmd": {"systemctl restart nginx"},
"destructive": {"on"}, "destructive": {"on"},
}), core) }), core, stepUpSession())
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) 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) { func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) 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) { func TestHandleTools_POST_Enable_MissingCmd_400(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) t.Fatalf("status = %d, want 400", rr.Code)
} }
@@ -219,7 +228,7 @@ func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{ handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"}, "name": {"svc"}, "cmd": {"systemctl restart"},
}), core) }), core, stepUpSession())
if rr.Code != http.StatusBadGateway { if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code) 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) { func TestHandleTools_POST_UnknownAction_400(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) 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) { func TestHandleTools_POST_Disable_HappyPath(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) 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) { func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handleTools(rr, postForm("disable", url.Values{}), core) handleTools(rr, postForm("disable", url.Values{}), core, stepUpSession())
if rr.Code != http.StatusBadRequest { if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) 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) { func TestHandleTools_POST_Disable_CoreError_502(t *testing.T) {
core := &fakeCore{disableErr: ipc.ErrToolNotFound} core := &fakeCore{disableErr: ipc.ErrToolNotFound}
rr := httptest.NewRecorder() 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 { if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code) t.Fatalf("status = %d, want 502", rr.Code)
} }
} }
// TestEnableTool_NoInProcessAuthGate documents CURRENT behavior: handleTools // TestEnableTool_NoInProcessAuthGate verifies that a POST /tools without a
// performs the boundary-moving EnableTool with no in-process authentication — // prior WebAuthn assertion is rejected with 403 Forbidden — the in-process
// there is no passkey/session check in the Go handler. A POST enable with no // auth gate requires step-up before any mutation (enable/disable).
// 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.
func TestEnableTool_NoInProcessAuthGate(t *testing.T) { func TestEnableTool_NoInProcessAuthGate(t *testing.T) {
core := &fakeCore{} core := &fakeCore{}
rr := httptest.NewRecorder() 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{ handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"}, "name": {"svc"}, "cmd": {"systemctl restart"},
}), core) }), core, nil)
if rr.Code != http.StatusOK || core.gotEnableName != "svc" { if rr.Code != http.StatusForbidden {
t.Fatalf("expected unauthenticated enable to reach core (status=%d, name=%q); "+ t.Fatalf("status = %d, want %d (expected auth gate to reject)", rr.Code, http.StatusForbidden)
"if this now fails, an in-process auth gate was added", rr.Code, core.gotEnableName) }
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", Origin: "https://maven.example",
RPID: "maven.example", RPID: "maven.example",
RPName: "maven", RPName: "maven",
}, nil, f.Name()) }, nil, f.Name(), nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -638,7 +662,7 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{ handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"}, "name": {"svc"}, "cmd": {"systemctl restart"},
}), core) }), core, stepUpSession())
if rr.Code != http.StatusBadGateway { if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
} }
+9 -3
View File
@@ -142,12 +142,14 @@ func main() {
// Passkey registration + assertion are the step-up mechanism for // Passkey registration + assertion are the step-up mechanism for
// AuthStepUp actions (tool enable). Without -webauthn-origin, these // AuthStepUp actions (tool enable). Without -webauthn-origin, these
// endpoints return 503 and step-up is unavailable (FloorSession). // endpoints return 503 and step-up is unavailable (FloorSession).
stepUpSession := webauthn.NewPasskeySession(5 * time.Minute)
if *pkOrigin != "" && *pkRPID != "" && core != nil { if *pkOrigin != "" && *pkRPID != "" && core != nil {
pk, err := newPasskeyHandle(webauthn.Config{ pk, err := newPasskeyHandle(webauthn.Config{
Origin: *pkOrigin, Origin: *pkOrigin,
RPID: *pkRPID, RPID: *pkRPID,
RPName: "maven", RPName: "maven",
}, core, *pkFile) }, core, *pkFile, stepUpSession)
if err != nil { if err != nil {
log.Fatalf("passkey store: %v", err) 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, // Enabling is the boundary-moving act (maven.md), so it lives ONLY here,
// behind wg+nginx+auth — never the voice/chat path. // behind wg+nginx+auth — never the voice/chat path.
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) { 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} 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 // whitespace-split — argv with embedded spaces isn't supported (ponytail: no
// shell-word parsing; the box owner controls this input, quote a wrapper script // shell-word parsing; the box owner controls this input, quote a wrapper script
// if an arg needs spaces). // 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 { if core == nil {
http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable) http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable)
return return
@@ -433,6 +435,10 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
ctx := r.Context() ctx := r.Context()
var msg string var msg string
if r.Method == http.MethodPost { 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") action := r.FormValue("action")
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
switch action { switch action {
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn" "github.com/kami/maven/internal/webauthn"
) )
@@ -29,6 +30,7 @@ type PasskeyHandle struct {
rp *webauthn.RP rp *webauthn.RP
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
store *credentialStore store *credentialStore
session *webauthn.PasskeySession
} }
type localCred struct { type localCred struct {
@@ -36,7 +38,7 @@ type localCred struct {
SignCount int64 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 var af assertIPC
if c, ok := core.(assertIPC); ok { if c, ok := core.(assertIPC); ok {
af = c af = c
@@ -49,6 +51,7 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) (
rp: webauthn.NewRP(cfg), rp: webauthn.NewRP(cfg),
assertFn: af, assertFn: af,
store: store, store: store,
session: session,
}, nil }, 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) log.Printf("webauthn: asserted credential %s", credID)
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
} }
+10
View File
@@ -47,6 +47,16 @@ func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Laye
return auth.Layer2 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 // Assert records a successful step-up gesture. The session bumps to L3 for
// the assertion TTL. A nil receiver returns ErrStepUpUnsupported. // the assertion TTL. A nil receiver returns ErrStepUpUnsupported.
func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error { func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {