diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 162cc9b..6b56c51 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -1172,3 +1172,82 @@ func TestHandleTools_GET_MCPUnavailable(t *testing.T) { t.Error("expected the empty-state copy") } } + +// --- voice-path step-up gate (Vikunja #317) --- +// +// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs +// the same router, LLM and act path as POST /api/chat. They used to be +// ungated on the grounds that the voice port is only reachable inside the +// deploy, but mavweb is the thing proxying into it from outside. Speaking +// "выключи свет" is not a smaller act than typing it. + +// unreachableVoice is a closed port: a request that clears the gate fails at +// the dial with 503, which is how these tests tell "passed" from "denied". +const unreachableVoice = "127.0.0.1:1" + +func pttReq() *http.Request { + return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes")) +} + +func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePTT_UnassertedSession_Denied(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true) + if rr.Code == http.StatusForbidden { + t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) + } + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) + } +} + +// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk +// keeps working, resting on the transport-level auth in front of mavweb. +func TestHandlePTT_FailOpenByDefault(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, nil, false) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandleWS_UnassertedSession_Denied(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +// Past the gate the handshake itself fails (httptest's recorder cannot be +// hijacked), which is not a 403. That is all this asserts: the gate let it by. +func TestHandleWS_AssertedSession_PassesGate(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true) + if rr.Code == http.StatusForbidden { + t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 17e49af..5023008 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -351,7 +351,7 @@ func main() { coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") - requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") + requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") @@ -387,12 +387,9 @@ func main() { handleVoice(w, r) })) - mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - handleWS(w, r, *voiceAddr) - }) - mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { - handlePTT(w, r, *voiceAddr) - }) + // /ws and /api/ptt are registered further down, next to /api/chat: they + // carry the same step-up gate and so need stepUpSession, which is only + // built once the passkey endpoints are wired. mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) }) @@ -475,10 +472,29 @@ func main() { mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) } if stepUpSession == nil { + // One surface per line: these are read in a terminal at the moment + // someone is deciding whether the box is safe to expose. + surfaces := []string{ + "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", + "POST /routines accepting schedules recurring firing", + "POST /models chooses the resident model that routes and words every turn", + "POST /api/revert voids the latest fact for a key", + "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", + "POST /api/ptt the same, from audio", + "GET /ws the same, streamed", + } if *requireStepUp { - log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") + log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") } else { - log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") + log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") + } + for _, s := range surfaces { + log.Printf("SECURITY: %s", s) + } + if *requireStepUp { + log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") + } else { + log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") } } @@ -508,13 +524,26 @@ func main() { // POST /routines step-up — accepting schedules recurring firing // POST /api/revert step-up — voids the latest fact for a key // POST /api/chat step-up — reaches the router, LLM and the act path + // POST /api/ptt step-up — audio into runTurn, so the same router, + // LLM and act path as /api/chat + // GET /ws step-up — same, streamed // POST /api/signal none — appends a presence fact, no argv, no act - // POST /api/ptt, /ws none — proxy audio to mavend's voice port, which - // is itself only reachable inside the deploy + // POST /api/ambient shared secret — notification relay, constant-time + // token compare, poster is a phone service + // and not a browser, so step-up cannot apply // // "step-up" means stepUpOK: asserted passkey when WebAuthn is configured, // otherwise fail-open unless -require-stepup, which denies. // + // /api/ptt and /ws used to be ungated, justified by mavend's voice port + // being reachable only inside the deploy. That argument does not hold: + // mavweb is the thing proxying into it from outside. Speaking "выключи + // свет" is not a smaller act than typing it (Vikunja #317). + // + // The gate here is per-request, which costs the hands-free case a passkey + // assertion per turn whenever WebAuthn is configured. A session-scoped + // assertion covering a run of turns is the right shape and is its own task. + // // GET /chat only renders the page and echoes back the q/r query params the // POST redirect set — nothing to gate. mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { @@ -526,6 +555,12 @@ func main() { mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { handleRevert(w, r, core, stepUpSession, *requireStepUp) }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp) + }) + mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { + handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp) + }) srv := &http.Server{Addr: *addr, Handler: mux} @@ -543,7 +578,11 @@ func main() { } } -func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) { +func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, }) @@ -1449,11 +1488,15 @@ func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil } -func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) { +func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { http.Error(w, "POST only", 405) return } + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), 400) diff --git a/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file