Merge branch 'fix/g02' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:18:10 +04:00
9 changed files with 385 additions and 59 deletions
+16 -1
View File
@@ -30,6 +30,13 @@ import (
// A notification with no recognisable clock reading stores NOTHING. Maven is
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
// is worse than a gap.
//
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
// meeting is good enough to recite and not good enough to stop a nudge —
// calendar_busy is still written only by the CalDAV poller. That is backwards,
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
// calendar_busy is a level rather than an event, so an ambient writer needs an
// expiry, which is its own task and not a change here.
// ambientMaxBody bounds the request. A notification is two short lines.
const ambientMaxBody = 8 << 10
@@ -120,8 +127,16 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
// header, compared in constant time.
//
// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone
// client sending "bearer <tok>" used to fall through to the X-Maven-Token
// branch and get a silent 401 with nothing to see from the phone's side.
func ambientAuthorized(r *http.Request, token string) bool {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
got := ""
if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") &&
strings.EqualFold(authz[:len("Bearer")], "Bearer") {
got = strings.TrimSpace(authz[len("Bearer"):])
}
if got == "" {
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
}
+21
View File
@@ -166,6 +166,27 @@ func TestHandleAmbientAuth(t *testing.T) {
}
})
// RFC 7235 says the scheme is case-insensitive. A phone sending
// "bearer <tok>" used to fall through to the X-Maven-Token branch and get a
// 401 that looked, from the phone's side, like a wrong token.
t.Run("lowercase bearer scheme accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
}
})
// A bare token with no scheme is not a bearer header. Accepting it made the
// Authorization branch a second, undocumented X-Maven-Token.
t.Run("bare token in Authorization rejected", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
})
t.Run("X-Maven-Token accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
+79
View File
@@ -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())
}
}
+56 -13
View File
@@ -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)