diff --git a/.gitignore b/.gitignore index 37d5c7e..e4c764c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /mavttsd /mavweb /mavpoll +/mavcaldav # Certs (private keys, don't commit) certs/ diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 5923af9..d51e68c 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -47,6 +47,7 @@ import ( "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/webauthn" ) func main() { @@ -114,7 +115,7 @@ func run(args []string) error { defer phr.Close() // ----- voice: reactive audio path (TCP listener + stt/router/tts) ----- - voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st)) + voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr) if err != nil { return fmt.Errorf("wire voice: %w", err) } @@ -169,13 +170,14 @@ func run(args []string) error { if err != nil { return fmt.Errorf("ipc listen: %w", err) } - // auth floor: any same-uid caller is fully trusted (FloorEnrollment + - // FloorSession — L3, step-up satisfied). The cold-start unlock dance and a - // real passkey Session are the open spec items; today the daemon runs - // unlocked — plain sqlite, sqlcipher deferred. FloorSession keeps the floor - // consistent so the authed mavweb /tools page can EnableTool (AuthStepUp) - // against the local socket; the passkey verifier swaps FloorSession later. - srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: auth.FloorSession{}}).Check + // auth: Enrolled callers can assert step-up via MethodAssertStepUp (calls + // Session.Assert). After a successful passkey assertion, the session bumps + // to L3 for assertionTTL, enabling AuthStepUp methods (EnableTool). + // FloorEnrollment still trusts same-uid callers; the passkey verifier + // (WebAuthn) gates the session step-up, not the enrollment. + passkeySess := webauthn.NewPasskeySession(5 * time.Minute) + srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check + srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) } var wg sync.WaitGroup wg.Add(1) diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 5529a79..7792a6f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -59,6 +59,7 @@ import ( "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/stt" "github.com/kami/maven/internal/tool" @@ -106,7 +107,7 @@ func (w *voiceWiring) close() { // // When voice is enabled, MUST wire a voicesink into the dispatcher's Voice // slot using w.sessions (the caller does that — see main.go). -func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI) (*voiceWiring, error) { +func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*voiceWiring, error) { if cfg.Voice == nil || !cfg.Voice.Enabled { return nil, nil } @@ -187,14 +188,15 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI) (*voiceWiring, error) { // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) ----- h := &reactiveHandler{ - stt: transcriber, - tts: synthesizer, - router: rtr, + stt: transcriber, + tts: synthesizer, + router: rtr, embedder: emb, - api: coreAPI, - tools: exec, + api: coreAPI, + tools: exec, + phraser: phr, replier: voice.NewStubReplier(), - now: time.Now, + now: time.Now, } // ----- the server (TCP listener) ----- @@ -219,6 +221,7 @@ type reactiveHandler struct { embedder router.Embedder // reused for note write/query (same model as the classifier) api ipc.CoreAPI tools *tool.Executor + phraser phraser.Phraser replier voice.Replier now func() time.Time @@ -425,22 +428,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) if len(notes) == 0 || notes[0].Score < queryMinScore { return "у меня нет заметок по этому вопросу." } - // Full RAG (phraser-composed) is deferred — this is the browse surface. - // Return a summary of the best match(es) so the user gets context, not just - // one verbatim snippet. The phraser seam in the replier will natural-language - // the results when the LLM-backed Replier swaps in. - if len(notes) == 1 { - return "ты записал: " + notes[0].Text - } - var b strings.Builder - b.WriteString("вот что нашла: ") + texts := make([]string, len(notes)) for i, n := range notes { - if i > 0 { - b.WriteString("; ") - } - b.WriteString(n.Text) + texts[i] = n.Text } - return b.String() + reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, texts) + if err != nil { + log.Printf("voice: phrase query: %v", err) + } + if reply == "" { + reply = "вот что я нашла: " + texts[0] + } + return reply } return "" } diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index bb42d1b..e55f2ea 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -25,6 +25,7 @@ import ( "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/voice" + "github.com/kami/maven/internal/webauthn" ) // presenceSignals — the only fact keys /api/signal may write. mavweb is a @@ -73,6 +74,8 @@ func main() { // facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC // script). Empty ⇒ /api/signal returns 503 and presence stays unfed. 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)") flag.Parse() var core ipc.CoreAPI @@ -116,6 +119,25 @@ func main() { mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) { handleDash(w, r, core) }) + // ----- passkey (WebAuthn) endpoints ----- + // Wired when both -core and a configured origin are present. The origin + // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). + // 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). + if *pkOrigin != "" && *pkRPID != "" && core != nil { + pk := newPasskeyHandle(webauthn.Config{ + Origin: *pkOrigin, + RPID: *pkRPID, + RPName: "maven", + }, core) + mux.HandleFunc("/auth/passkey", pk.Page) + mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin) + mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish) + mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin) + mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) + } + // /tools — the authed enable surface. maven proposes acts she can't run; // this page is where a human reviews and enables them (proposed→enabled). // Enabling is the boundary-moving act (maven.md), so it lives ONLY here, @@ -298,6 +320,7 @@ table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4 input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem} .d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}

maven · tools

+

enabling requires step-up — assert a passkey first.

{{if .Msg}}
{{.Msg}}
{{end}}

proposed ({{len .Proposed}})

{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.

@@ -306,15 +329,20 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem} {{.Name}}{{.Utterance}}
+
{{end}} {{else}}

none pending.

{{end}}

enabled ({{len .Enabled}})

-{{if .Enabled}} +{{if .Enabled}}
namecommand
{{range .Enabled}} -{{end}}
namecommand
{{.Name}}{{join .Cmd " "}}{{if .Destructive}}destructive{{end}}
+{{if .Destructive}}destructive{{end}} +
+ + +
{{end}} {{else}}

none enabled.

{{end}} ` @@ -331,19 +359,37 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { ctx := r.Context() var msg string if r.Method == http.MethodPost { + action := r.FormValue("action") name := strings.TrimSpace(r.FormValue("name")) - cmd := strings.Fields(r.FormValue("cmd")) - destructive := r.FormValue("destructive") != "" - if name == "" || len(cmd) == 0 { - http.Error(w, "name and cmd required", http.StatusBadRequest) + switch action { + case "enable": + cmd := strings.Fields(r.FormValue("cmd")) + destructive := r.FormValue("destructive") != "" + if name == "" || len(cmd) == 0 { + http.Error(w, "name and cmd required", http.StatusBadRequest) + return + } + if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil { + log.Printf("tools: enable %q: %v", name, err) + http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "enabled " + name + case "disable": + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if err := core.DisableTool(ctx, name); err != nil { + log.Printf("tools: disable %q: %v", name, err) + http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "disabled " + name + default: + http.Error(w, "unknown action", http.StatusBadRequest) return } - if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil { - log.Printf("tools: enable %q: %v", name, err) - http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "enabled " + name } proposed, err1 := core.ListTools(ctx, "proposed") enabled, err2 := core.ListTools(ctx, "enabled") diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go new file mode 100644 index 0000000..c04ddae --- /dev/null +++ b/cmd/mavweb/webauthn.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "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 +} + +// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential +// store, and the IPC client used to assert step-up. 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 + mu sync.RWMutex + creds map[string]localCred // credential ID → stored credential +} + +type localCred struct { + PublicKey []byte + SignCount int64 +} + +func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI) *PasskeyHandle { + var af assertIPC + if c, ok := core.(assertIPC); ok { + af = c + } + return &PasskeyHandle{ + rp: webauthn.NewRP(cfg), + assertFn: af, + creds: make(map[string]localCred), + } +} + +// 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) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(passkeyPageHTML)) +} + +const passkeyPageHTML = ` + +maven · passkey + +

maven passkey

+

Enroll a passkey once, then assert it to unlock destructive actions +(tool enable) for a few minutes.

+ + + +
+` + +func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) { + opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user") + if err != nil { + log.Printf("webauthn: register begin: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + 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 { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Challenge string `json:"challenge"` + Credential map[string]any `json:"credential"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + save := func(id string, publicKey []byte, _ []byte, _ string) error { + h.mu.Lock() + defer h.mu.Unlock() + if _, exists := h.creds[id]; exists { + return fmt.Errorf("credential already exists") + } + h.creds[id] = localCred{PublicKey: publicKey} + return nil + } + credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential) + if err != nil { + log.Printf("webauthn: register finish: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("webauthn: registered credential %s", credID) + 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 { + log.Printf("webauthn: assert begin: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + 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 { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Challenge string `json:"challenge"` + Credential map[string]any `json:"credential"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + + lookup := func(id string) ([]byte, int64, error) { + h.mu.RLock() + defer h.mu.RUnlock() + cred, ok := h.creds[id] + if !ok { + return nil, 0, fmt.Errorf("credential not found") + } + return cred.PublicKey, cred.SignCount, nil + } + update := func(id string, count int64) error { + h.mu.Lock() + defer h.mu.Unlock() + cred, ok := h.creds[id] + if !ok { + return fmt.Errorf("credential not found") + } + cred.SignCount = count + h.creds[id] = cred + return nil + } + + credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential) + if err != nil { + log.Printf("webauthn: assert finish: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + 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 { + log.Printf("webauthn: assert step-up: %v", err) + http.Error(w, "step-up assertion failed", http.StatusBadGateway) + return + } + } + + log.Printf("webauthn: asserted credential %s", credID) + json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c6c32bb..6625263 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -369,6 +369,9 @@ func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error { return nil } +func (r *recordingAPI) DisableTool(_ context.Context, _ string) error { + return nil +} func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) { return ipc.Tool{}, ipc.ErrToolNotFound } diff --git a/internal/auth/policy.go b/internal/auth/policy.go index 61d0011..6a48537 100644 --- a/internal/auth/policy.go +++ b/internal/auth/policy.go @@ -52,6 +52,8 @@ func Requirement(m ipc.Method) Authority { return AuthStepUp case ipc.MethodWriteFact: return AuthWrite + case ipc.MethodAssertStepUp: + return AuthRead case ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince, diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index 3bc2f59..db91aa5 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" ) // NudgeRecorder — the seam the store implements. the dispatcher records one @@ -87,7 +88,8 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim c := pn.Candidate channels := ChannelsFor(c.Severity, c.State.Presence) var out []Dispatch - for _, ch := range channels { + for i := 0; i < len(channels); i++ { + ch := channels[i] if ch == ChannelDrop { continue } @@ -107,7 +109,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim } if err := sink.Send(ctx, s); err != nil { if errors.Is(err, ErrVoiceNoSession) { - log.Printf("dispatcher: no live voice session for %s, falling through", c.Rule.Name) + // voice was assumed reachable (presence=present) but no live + // session exists — the presence guess was wrong. reroute through + // the AWAY table per § away-channel fallthrough: sev3→ntfy, + // sev4→telegram-repeat-til-ack, sev≤2→drop. voice is always the + // first present channel, so nothing has been sent yet; replace + // the remaining list wholesale. away channels never include + // voice, so this can't re-trigger. + log.Printf("dispatcher: no live voice session for %s, rerouting to away channels", c.Rule.Name) + channels = ChannelsFor(c.Severity, store.Away) + i = -1 continue } return out, fmt.Errorf("send %s: %w", ch, err) @@ -143,7 +154,8 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n rd := pr.Decision channels := ChannelsForReminder(rd.State.Presence) var out []Dispatch - for _, ch := range channels { + for i := 0; i < len(channels); i++ { + ch := channels[i] s := Sendable{ Channel: ch, Kind: KindReminder, @@ -158,7 +170,12 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n } if err := sink.Send(ctx, s); err != nil { if errors.Is(err, ErrVoiceNoSession) { - log.Printf("dispatcher: no live voice session for reminder %d, falling through", rd.Reminder.ID) + // presence guess was wrong — reroute reminder to the away + // channel (ntfy). voice is the only present channel, so nothing + // has been sent yet. + log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID) + channels = ChannelsForReminder(store.Away) + i = -1 continue } return out, fmt.Errorf("send %s: %w", ch, err) diff --git a/internal/delivery/dispatcher_test.go b/internal/delivery/dispatcher_test.go index 0cc348a..37d5921 100644 --- a/internal/delivery/dispatcher_test.go +++ b/internal/delivery/dispatcher_test.go @@ -391,6 +391,116 @@ func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) { } } +// ------------------ voice no-session → away-channel reroute ----------------- +// +// When the routing table picks voice (presence=present) but no live session +// exists at push time, the presence guess was wrong. The dispatcher must +// reroute through the AWAY table (§ away-channel fallthrough), not silently +// drop or fall to the wrong channel. + +func TestDispatchNudgeVoiceNoSessionSev3RoutesNtfy(t *testing.T) { + // present sev3 → [voice]. voice has no session → away sev3 = ntfy. + voice := &fakeSink{err: ErrVoiceNoSession} + ntfy := &fakeSink{} + telegram := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Present), + Body: "cert expiring", Summary: "cert expiring", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 1 { + t.Fatalf("sev3 voice-no-session: want 1 ntfy send, got %d", len(ntfy.sends)) + } + if len(telegram.sends) != 0 { + t.Fatalf("sev3 must not hit telegram, got %d", len(telegram.sends)) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy { + t.Fatalf("want 1 ntfy dispatch, got %+v", out) + } +} + +func TestDispatchNudgeVoiceNoSessionSev4RoutesTelegramRepeatUntilAck(t *testing.T) { + // present sev4 → [voice, ntfy]. voice has no session → away sev4 = + // telegram-repeat-til-ack (NOT the present-list ntfy remainder). + voice := &fakeSink{err: ErrVoiceNoSession} + ntfy := &fakeSink{} + telegram := &fakeSink{} + ack := newFakeAck() + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Ack: ack, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("service_down", loop.Sev4, store.Present), + Body: "backup down", Summary: "backup down", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(telegram.sends) != 1 { + t.Fatalf("sev4 voice-no-session: want 1 telegram send, got %d", len(telegram.sends)) + } + if len(ntfy.sends) != 0 { + t.Fatalf("sev4 away reroute must not fall to ntfy, got %d", len(ntfy.sends)) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || !out[0].Sendable.RepeatUntilAck { + t.Fatalf("want 1 telegram RepeatUntilAck dispatch, got %+v", out) + } + if _, ok := ack.lastSent["service_down"]; !ok { + t.Fatalf("repeat-til-ack reroute must MarkSent in the ack tracker") + } +} + +func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) { + // present sev2 → [voice]. voice has no session → away sev2 = drop. + voice := &fakeSink{err: ErrVoiceNoSession} + ntfy := &fakeSink{} + telegram := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev2, store.Present), + Body: "drink water", Summary: "drink water", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 0 || len(telegram.sends) != 0 || len(out) != 0 { + t.Fatalf("sev2 voice-no-session must drop silently, got ntfy=%d telegram=%d out=%d", + len(ntfy.sends), len(telegram.sends), len(out)) + } +} + +func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) { + // present reminder → [voice]. voice has no session → away = ntfy. + voice := &fakeSink{err: ErrVoiceNoSession} + ntfy := &fakeSink{} + rc := &fakeReminderCompleter{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc}) + + rd := loop.ReminderDecision{ + Reminder: store.Reminder{ID: 99, Status: "pending"}, + State: loop.State{Now: refNow(), Presence: store.Present}, + } + out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: rd, Body: "wake up", Summary: "wake up", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 1 || len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy { + t.Fatalf("reminder voice-no-session: want 1 ntfy, got ntfy=%d out=%+v", len(ntfy.sends), out) + } + if len(rc.marked) != 1 || rc.marked[0].status != "fired" { + t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked) + } +} + // ----------------------------- repeat-til-ack ------------------------------- func TestShouldRepeat(t *testing.T) { diff --git a/internal/ipc/api.go b/internal/ipc/api.go index a45ed0f..d986de0 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -176,6 +176,10 @@ type enableToolReq struct { Destructive bool `json:"destructive"` Ts time.Time `json:"ts"` } +type disableToolReq struct { + Name string `json:"name"` +} + type lookupToolReq struct { Name string `json:"name"` } @@ -215,10 +219,12 @@ type CoreAPI interface { // ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable); // returns whether a new proposal was written. EnableTool fills cmd + - // destructive and flips to 'enabled' — the human-only "enable" act, gated - // at AuthStepUp (see auth/policy.go). LookupTool/ListTools read them. + // destructive and flips status to 'enabled'. DisableTool reverts an + // enabled tool back to proposed (it stays in the store, won't run). + // All three gate at AuthStepUp. LookupTool/ListTools read them. ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error + DisableTool(ctx context.Context, name string) error LookupTool(ctx context.Context, name string) (Tool, error) ListTools(ctx context.Context, status string) ([]Tool, error) } diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 1a54191..bed6d78 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -242,6 +242,14 @@ func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, dest return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Cmd: cmd, Destructive: destructive, Ts: ts}, nil) } +func (c *Client) DisableTool(ctx context.Context, name string) error { + return c.call(ctx, MethodDisableTool, disableToolReq{Name: name}, nil) +} + +func (c *Client) AssertStepUp(ctx context.Context) error { + return c.call(ctx, MethodAssertStepUp, nil, nil) +} + func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) { var t Tool if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index cbab303..7ad83ba 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -153,6 +153,10 @@ func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, de return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, ts)) } +func (a *storeAPI) DisableTool(ctx context.Context, name string) error { + return mapErr(a.s.DisableTool(ctx, name)) +} + func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) { t, err := a.s.LookupTool(ctx, name) if err != nil { @@ -267,6 +271,12 @@ type Server struct { // change to gain or lose the seam. Check CheckFunc + // StepUp — optional handler for MethodAssertStepUp. When a real Session + // (PasskeySession) is wired, the daemon sets this to session.Assert so a + // module (mavweb) can assert a user-verification gesture over IPC. Nil ⇒ + // MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor). + StepUp StepUpFunc + // now is injected so tests can drive time; the loop already works in // absolute ts supplied by callers, so this isn't load-bearing for live ops. } @@ -279,6 +289,11 @@ type Server struct { // auth doesn't need to leak implementation into ipc. type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error +// StepUpFunc — records a user-verification gesture. Set by the daemon when +// a real Session is wired (PasskeySession); nil means not available. +// MethodAssertStepUp dispatch calls this instead of going through CoreAPI. +type StepUpFunc func(ctx context.Context) error + // Listen creates a Server bound to path. path's parent dir must exist and be // 0700 (we chmod it if we own it); the socket file itself is created 0600 so // only the same unix user can connect — the current "auth floor", same radius @@ -560,6 +575,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Ts) + case MethodDisableTool: + var p disableToolReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + return marshalResult(nil), s.api.DisableTool(ctx, p.Name) + case MethodLookupTool: var p lookupToolReq if err := unmarshalParams(req.Params, &p); err != nil { @@ -585,6 +607,12 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(listToolsResp{Tools: out}), nil + case MethodAssertStepUp: + if s.StepUp != nil { + return marshalResult(nil), s.StepUp(ctx) + } + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + default: return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index ed3cfe4..1cc24c6 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -30,6 +30,8 @@ const ( MethodRecentNotes Method = "recent_notes" MethodProposeTool Method = "propose_tool" MethodEnableTool Method = "enable_tool" + MethodDisableTool Method = "disable_tool" + MethodAssertStepUp Method = "assert_stepup" MethodLookupTool Method = "lookup_tool" MethodListTools Method = "list_tools" ) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 40b00be..05d6ad1 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -166,6 +166,31 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil } +// PhraseQuery prompts the LLM with the user's utterance and matching notes to +// compose a natural answer. Falls back to "вот что я нашла: " on any +// LLM error — better to give the raw data than silence. +func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) { + if len(notes) == 0 { + return "у меня нет заметок по этому вопросу.", nil + } + if len(notes) == 1 { + notes[0] = strings.TrimSpace(notes[0]) + } + sys := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond with just the answer text, no JSON wrapper." + prompt := fmt.Sprintf( + `The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`, + utterance, strings.Join(notes, `"; "`), + ) + resp, err := p.chatWithSystem(ctx, sys, prompt, 256) + if err != nil { + if len(notes) == 1 { + return "вот что я нашла: " + notes[0], nil + } + return "вот что я нашла: " + strings.Join(notes, "; "), nil + } + return resp, nil +} + func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) { text := extractReminderText(d.Reminder.Payload) if text == "" { @@ -214,13 +239,17 @@ type chatResp struct { } func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) { + return p.chatWithSystem(ctx, systemPrompt(), userPrompt, 256) +} + +func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) { req := chatReq{ Messages: []chatMsg{ - {Role: "system", Content: systemPrompt()}, - {Role: "user", Content: userPrompt}, + {Role: "system", Content: system}, + {Role: "user", Content: user}, }, Temperature: 0.7, - MaxTokens: 256, + MaxTokens: maxTokens, } body, err := json.Marshal(req) if err != nil { diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index 9edbe66..b818e50 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -43,6 +43,7 @@ import ( type Phraser interface { PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) + PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) Close() error } @@ -59,6 +60,17 @@ type Stub struct{} // NewStub builds the floor phraser. no config — the Stub is stateless. func NewStub() *Stub { return &Stub{} } +// PhraseQuery returns a deterministic summary of the best matching notes. +func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) { + if len(notes) == 0 { + return "у меня нет заметок по этому вопросу.", nil + } + if len(notes) == 1 { + return "вот что я нашла: " + notes[0], nil + } + return "вот что я нашла: " + strings.Join(notes, "; "), nil +} + // Close implements Phraser.Close (no-op for the stub). func (s *Stub) Close() error { return nil } diff --git a/internal/store/tools.go b/internal/store/tools.go index 3cc47da..8bb02b9 100644 --- a/internal/store/tools.go +++ b/internal/store/tools.go @@ -78,6 +78,20 @@ func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destr return nil } +// DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is +// the "disable" act on the authed surface — the tool stays in the store (its +// provenance preserved) but won't run until re-enabled. Idempotent: disabling +// a tool that is already proposed or doesn't exist is a no-op. +func (s *Store) DisableTool(ctx context.Context, name string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`, + time.Now().UnixMilli(), name) + if err != nil { + return fmt.Errorf("disable tool: %w", err) + } + return nil +} + // LookupTool returns the tool by name. ErrToolNotFound when absent. func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) { row := s.db.QueryRowContext(ctx, ` diff --git a/internal/store/tools_test.go b/internal/store/tools_test.go new file mode 100644 index 0000000..27cc8b6 --- /dev/null +++ b/internal/store/tools_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// TestToolLifecycle covers propose → enable → disable, the states the authed +// /tools page drives. Disable must revert an enabled tool to 'proposed' (kept +// in the store, won't run) and be idempotent. +func TestToolLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now() + + if _, err := s.ProposeTool(ctx, "restart_svc", "restart the service", now); err != nil { + t.Fatalf("propose: %v", err) + } + if err := s.EnableTool(ctx, "restart_svc", []string{"systemctl", "restart", "x"}, true, now); err != nil { + t.Fatalf("enable: %v", err) + } + if tl, _ := s.LookupTool(ctx, "restart_svc"); tl.Status != "enabled" { + t.Fatalf("after enable: status=%q want enabled", tl.Status) + } + + if err := s.DisableTool(ctx, "restart_svc"); err != nil { + t.Fatalf("disable: %v", err) + } + tl, err := s.LookupTool(ctx, "restart_svc") + if err != nil { + t.Fatalf("lookup after disable: %v", err) + } + if tl.Status != "proposed" { + t.Fatalf("after disable: status=%q want proposed", tl.Status) + } + + // idempotent: disabling an already-proposed (or absent) tool is a no-op. + if err := s.DisableTool(ctx, "restart_svc"); err != nil { + t.Fatalf("disable idempotent: %v", err) + } + if err := s.DisableTool(ctx, "does_not_exist"); err != nil { + t.Fatalf("disable absent must be no-op: %v", err) + } +} diff --git a/internal/webauthn/cbor.go b/internal/webauthn/cbor.go new file mode 100644 index 0000000..4aa07ae --- /dev/null +++ b/internal/webauthn/cbor.go @@ -0,0 +1,244 @@ +package webauthn + +import ( + "fmt" + "math" +) + +// cborValue is one decoded CBOR item. Only the subset needed for WebAuthn +// COSE key + attestation object parsing is handled: integers, byte strings, +// text strings, arrays, maps. +type cborValue struct { + typ cborType + u uint64 // unsigned integer value + n int64 // negative integer value (-1 - u) + b []byte // byte string + t string // text string + items []cborValue // array items or map key-value pairs (flattened) +} + +type cborType int + +const ( + cborUint cborType = 0 + cborNegInt cborType = 1 + cborBytes cborType = 2 + cborText cborType = 3 + cborArray cborType = 4 + cborMap cborType = 5 + cborSimple cborType = 7 +) + +func (v cborValue) Int() (int, error) { + switch v.typ { + case cborUint: + return int(v.u), nil + case cborNegInt: + return int(v.n), nil + default: + return 0, fmt.Errorf("cbor: expected int, got type %d", v.typ) + } +} + +func (v cborValue) Int64() (int64, error) { + switch v.typ { + case cborUint: + return int64(v.u), nil + case cborNegInt: + return v.n, nil + default: + return 0, fmt.Errorf("cbor: expected int, got type %d", v.typ) + } +} + +func (v cborValue) Bytes() ([]byte, error) { + if v.typ != cborBytes { + return nil, fmt.Errorf("cbor: expected bytes, got type %d", v.typ) + } + return v.b, nil +} + +func (v cborValue) Text() (string, error) { + if v.typ != cborText { + return "", fmt.Errorf("cbor: expected text, got type %d", v.typ) + } + return v.t, nil +} + +func (v cborValue) Map() (map[int64]cborValue, error) { + if v.typ != cborMap { + return nil, fmt.Errorf("cbor: expected map, got type %d", v.typ) + } + m := make(map[int64]cborValue, len(v.items)/2) + for i := 0; i+1 < len(v.items); i += 2 { + k, err := v.items[i].Int64() + if err != nil { + return nil, fmt.Errorf("cbor: map key: %w", err) + } + m[k] = v.items[i+1] + } + return m, nil +} + +func (v cborValue) MapText() (map[string]cborValue, error) { + if v.typ != cborMap { + return nil, fmt.Errorf("cbor: expected map, got type %d", v.typ) + } + m := make(map[string]cborValue, len(v.items)/2) + for i := 0; i+1 < len(v.items); i += 2 { + k, err := v.items[i].Text() + if err != nil { + return nil, fmt.Errorf("cbor: map text key: %w", err) + } + m[k] = v.items[i+1] + } + return m, nil +} + +func (v cborValue) At(i int) (cborValue, error) { + if v.typ != cborArray { + return cborValue{}, fmt.Errorf("cbor: expected array, got type %d", v.typ) + } + if i < 0 || i >= len(v.items) { + return cborValue{}, fmt.Errorf("cbor: index %d out of range (len %d)", i, len(v.items)) + } + return v.items[i], nil +} + +// decodeCBOR decodes a single CBOR item from data. It handles only the subset +// needed for WebAuthn COSE key + attestation parsing. +func decodeCBOR(data []byte) (cborValue, error) { + v, _, err := decodeItem(data) + return v, err +} + +func decodeItem(data []byte) (cborValue, int, error) { + if len(data) == 0 { + return cborValue{}, 0, fmt.Errorf("cbor: empty data") + } + ib := data[0] + mt := ib >> 5 + ai := ib & 0x1f + off := 1 + + arg, n, err := decodeArg(data, off, ai) + if err != nil { + return cborValue{}, 0, err + } + off = n + + switch mt { + case 0: // unsigned integer + return cborValue{typ: cborUint, u: arg}, off, nil + + case 1: // negative integer + return cborValue{typ: cborNegInt, n: -1 - int64(arg)}, off, nil + + case 2: // byte string + if off+int(arg) > len(data) { + return cborValue{}, 0, fmt.Errorf("cbor: byte string length %d exceeds data", arg) + } + b := make([]byte, arg) + copy(b, data[off:off+int(arg)]) + return cborValue{typ: cborBytes, b: b}, off + int(arg), nil + + case 3: // text string + if off+int(arg) > len(data) { + return cborValue{}, 0, fmt.Errorf("cbor: text string length %d exceeds data", arg) + } + return cborValue{typ: cborText, t: string(data[off : off+int(arg)])}, off + int(arg), nil + + case 4: // array + items := make([]cborValue, 0, arg) + pos := off + for i := uint64(0); i < arg; i++ { + item, n, err := decodeItem(data[pos:]) + if err != nil { + return cborValue{}, 0, fmt.Errorf("cbor: array item %d: %w", i, err) + } + items = append(items, item) + pos += n + } + return cborValue{typ: cborArray, items: items}, pos, nil + + case 5: // map + items := make([]cborValue, 0, 2*arg) + pos := off + for i := uint64(0); i < arg; i++ { + k, n, err := decodeItem(data[pos:]) + if err != nil { + return cborValue{}, 0, fmt.Errorf("cbor: map key %d: %w", i, err) + } + pos += n + v, n, err := decodeItem(data[pos:]) + if err != nil { + return cborValue{}, 0, fmt.Errorf("cbor: map value %d: %w", i, err) + } + pos += n + items = append(items, k, v) + } + return cborValue{typ: cborMap, items: items}, pos, nil + + case 7: // simple / float + switch ai { + case 20: // false + return cborValue{typ: cborSimple, u: 20}, off, nil + case 21: // true + return cborValue{typ: cborSimple, u: 21}, off, nil + case 22: // null + return cborValue{typ: cborSimple, u: 22}, off, nil + case 25: // half-precision float (not needed but avoid panic) + return cborValue{typ: cborSimple, u: 25}, off + 2, nil + case 26: // single-precision float + if off+4 > len(data) { + return cborValue{}, 0, fmt.Errorf("cbor: truncated float32") + } + _ = math.Float32frombits(readBE32(data[off:])) + return cborValue{typ: cborSimple, u: 26}, off + 4, nil + case 27: // double-precision float + if off+8 > len(data) { + return cborValue{}, 0, fmt.Errorf("cbor: truncated float64") + } + _ = math.Float64frombits(readBE64(data[off:])) + return cborValue{typ: cborSimple, u: 27}, off + 8, nil + default: + return cborValue{typ: cborSimple, u: arg}, off, nil + } + + default: + return cborValue{}, 0, fmt.Errorf("cbor: unsupported major type %d", mt) + } +} + +func decodeArg(data []byte, off int, ai byte) (uint64, int, error) { + switch { + case ai <= 23: + return uint64(ai), off, nil + case ai == 24: + if off >= len(data) { + return 0, 0, fmt.Errorf("cbor: truncated additional info") + } + return uint64(data[off]), off + 1, nil + case ai == 25: + if off+2 > len(data) { + return 0, 0, fmt.Errorf("cbor: truncated uint16") + } + return uint64(readBE16(data[off:])), off + 2, nil + case ai == 26: + if off+4 > len(data) { + return 0, 0, fmt.Errorf("cbor: truncated uint32") + } + return uint64(readBE32(data[off:])), off + 4, nil + case ai == 27: + if off+8 > len(data) { + return 0, 0, fmt.Errorf("cbor: truncated uint64") + } + return readBE64(data[off:]), off + 8, nil + default: + return 0, 0, fmt.Errorf("cbor: reserved additional info %d", ai) + } +} + +func readBE16(b []byte) uint16 { return uint16(b[0])<<8 | uint16(b[1]) } +func readBE32(b []byte) uint32 { return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) } +func readBE64(b []byte) uint64 { return uint64(readBE32(b))<<32 | uint64(readBE32(b[4:])) } diff --git a/internal/webauthn/session.go b/internal/webauthn/session.go new file mode 100644 index 0000000..397a776 --- /dev/null +++ b/internal/webauthn/session.go @@ -0,0 +1,60 @@ +package webauthn + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/kami/maven/internal/auth" +) + +// PasskeySession implements auth.Session backed by WebAuthn passkey assertion. +// The session starts at Layer2 (passkey is enrolled, this session exists) and +// bumps to Layer3 on successful Assert(), which lasts for assertionTTL before +// decaying back to Layer2. +// +// A nil *PasskeySession is a valid zero: it acts like a session with no +// credentials enrolled (always L2, Assert returns ErrStepUpUnsupported). +// This mirrors the FloorSession behavior when passkey is not configured. +type PasskeySession struct { + mu sync.Mutex + assertedAt time.Time // zero = not asserted this session + assertionTTL time.Duration +} + +// NewPasskeySession creates a session. The caller chooses the assertion TTL +// (how long a step-up gesture remains valid). 5 minutes is a sensible default. +func NewPasskeySession(assertionTTL time.Duration) *PasskeySession { + if assertionTTL <= 0 { + assertionTTL = 5 * time.Minute + } + return &PasskeySession{assertionTTL: assertionTTL} +} + +// CurrentLayer returns L3 if step-up has been asserted within the TTL, +// otherwise L2 (passkey enrolled, this session proven). A nil receiver +// returns L2 (no way to reach L3 without a session). +func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Layer { + if s == nil { + return auth.Layer2 + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL { + return auth.Layer3 + } + return auth.Layer2 +} + +// 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 { + if s == nil { + return fmt.Errorf("%w: passkey session not configured", auth.ErrStepUpUnsupported) + } + s.mu.Lock() + defer s.mu.Unlock() + s.assertedAt = time.Now() + return nil +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go new file mode 100644 index 0000000..4522be5 --- /dev/null +++ b/internal/webauthn/webauthn.go @@ -0,0 +1,413 @@ +// Package webauthn implements the server side of the WebAuthn (FIDO2) protocol +// for passkey-based user verification. It handles both registration (creating +// a new credential) and assertion (verifying the user), using standard library +// crypto and a minimal CBOR decoder. +// +// Only ECDSA P-256 (ES256, COSE algorithm -7) credentials are supported. +// Attestation is read but not verified — we trust the authenticator attestation +// is honest for this deployment (single-user, self-hosted). +package webauthn + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "time" +) + +// Config — the WebAuthn Relying Party parameters. Must match what the browser +// sees (origin = the page's origin, rpID = the effective domain). +type Config struct { + Origin string // e.g. "https://maven.kvmx.ru" + RPID string // e.g. "maven.kvmx.ru" + RPName string // e.g. "maven" +} + +// CredentialLookup is the function signature the RP needs to load a stored +// credential for assertion verification. Returns the COSE public key bytes +// and the current sign count. +type CredentialLookup func(id string) (publicKey []byte, signCount int64, err error) + +// CredentialSaver stores a newly registered credential. +type CredentialSaver func(id string, publicKey []byte, userID []byte, userDisplayName string) error + +// SignCountUpdater persists an updated sign counter after a successful assertion. +type SignCountUpdater func(id string, count int64) error + +// credentialRegistration is the in-memory state for an in-flight registration. +type credentialRegistration struct { + Challenge string + UserID []byte + CreatedAt time.Time +} + +// credentialAssertion is the in-memory state for an in-flight assertion. +type credentialAssertion struct { + Challenge string + CreatedAt time.Time +} + +// RP — the relying party instance. Holds config and transient challenge state. +// A single-user daemon has one RP. +type RP struct { + cfg Config + regs map[string]*credentialRegistration + asserts map[string]*credentialAssertion + challengeTTL time.Duration +} + +// NewRP creates a relying party with the given WebAuthn configuration. +func NewRP(cfg Config) *RP { + return &RP{ + cfg: cfg, + regs: make(map[string]*credentialRegistration), + asserts: make(map[string]*credentialAssertion), + challengeTTL: 5 * time.Minute, + } +} + +// CleanExpired removes challenges older than the TTL. +func (rp *RP) CleanExpired() { + now := time.Now() + for k, r := range rp.regs { + if now.Sub(r.CreatedAt) > rp.challengeTTL { + delete(rp.regs, k) + } + } + for k, a := range rp.asserts { + if now.Sub(a.CreatedAt) > rp.challengeTTL { + delete(rp.asserts, k) + } + } +} + +// CreationOptions returns the PublicKeyCredentialCreationOptions as a +// JSON-serializable map for the browser to create a credential. +func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, string, error) { + challenge := make([]byte, 32) + if _, err := rand.Read(challenge); err != nil { + return nil, "", fmt.Errorf("webauthn: challenge: %w", err) + } + challengeB64 := base64.RawURLEncoding.EncodeToString(challenge) + + rp.CleanExpired() + rp.regs[challengeB64] = &credentialRegistration{ + Challenge: challengeB64, + UserID: userID, + CreatedAt: time.Now(), + } + + return map[string]any{ + "rp": map[string]string{ + "name": rp.cfg.RPName, + "id": rp.cfg.RPID, + }, + "user": map[string]any{ + "id": base64.RawURLEncoding.EncodeToString(userID), + "name": userName, + "displayName": userName, + }, + "challenge": challengeB64, + // ES256 only — parseCOSEKey verifies P-256/ES256 exclusively. Offering + // RS256 here would let an authenticator register a key we can never + // verify at assertion time (register-ok, assert-fail forever). + "pubKeyCredParams": []map[string]any{ + {"type": "public-key", "alg": -7}, // ES256 + }, + "timeout": 60000, + "attestation": "none", + "excludeCredentials": []any{}, + }, challengeB64, nil +} + +// FinishRegistration parses the browser's response and stores the credential. +func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) { + rp.CleanExpired() + reg, ok := rp.regs[challengeB64] + if !ok { + return "", fmt.Errorf("webauthn: unknown or expired challenge") + } + delete(rp.regs, challengeB64) + + credID := rawString(resp, "id") + if credID == "" { + return "", fmt.Errorf("webauthn: missing credential id") + } + + rawResponse, ok := resp["response"].(map[string]any) + if !ok { + return "", fmt.Errorf("webauthn: missing response") + } + + cdjB64 := rawString(rawResponse, "clientDataJSON") + cdjRaw, err := base64.RawURLEncoding.DecodeString(cdjB64) + if err != nil { + return "", fmt.Errorf("webauthn: clientDataJSON: %w", err) + } + if err := verifyClientDataBytes(cdjRaw, "webauthn.create", challengeB64, rp.cfg.Origin); err != nil { + return "", err + } + + attObjB64 := rawString(rawResponse, "attestationObject") + attObj, err := base64.RawURLEncoding.DecodeString(attObjB64) + if err != nil { + return "", fmt.Errorf("webauthn: attestationObject: %w", err) + } + + publicKey, err := extractPublicKey(attObj) + if err != nil { + return "", fmt.Errorf("webauthn: extract key: %w", err) + } + + if err := save(credID, publicKey, reg.UserID, "maven user"); err != nil { + return "", fmt.Errorf("webauthn: store credential: %w", err) + } + + return credID, nil +} + +// AssertionOptions returns a JSON-serializable map for browser authentication. +func (rp *RP) AssertionOptions() (map[string]any, string, error) { + challenge := make([]byte, 32) + if _, err := rand.Read(challenge); err != nil { + return nil, "", fmt.Errorf("webauthn: challenge: %w", err) + } + challengeB64 := base64.RawURLEncoding.EncodeToString(challenge) + + rp.CleanExpired() + rp.asserts[challengeB64] = &credentialAssertion{ + Challenge: challengeB64, + CreatedAt: time.Now(), + } + + return map[string]any{ + "challenge": challengeB64, + "timeout": 60000, + "rpId": rp.cfg.RPID, + "allowCredentials": []any{}, + "userVerification": "required", + }, challengeB64, nil +} + +// FinishAssertion verifies the browser's assertion response and returns the +// verified credential ID. +func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) { + rp.CleanExpired() + if _, ok := rp.asserts[challengeB64]; !ok { + return "", fmt.Errorf("webauthn: unknown or expired challenge") + } + delete(rp.asserts, challengeB64) + + credID := rawString(resp, "id") + if credID == "" { + return "", fmt.Errorf("webauthn: missing credential id") + } + + rawResponse, ok := resp["response"].(map[string]any) + if !ok { + return "", fmt.Errorf("webauthn: missing response") + } + + cdjRaw, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "clientDataJSON")) + if err != nil { + return "", fmt.Errorf("webauthn: clientDataJSON: %w", err) + } + if err := verifyClientDataBytes(cdjRaw, "webauthn.get", challengeB64, rp.cfg.Origin); err != nil { + return "", err + } + + authenticatorData, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "authenticatorData")) + if err != nil { + return "", fmt.Errorf("webauthn: authenticatorData: %w", err) + } + // Bind the assertion to this RP and require a verified user gesture. Origin + // is checked via clientDataJSON above; rpIdHash + flags bind the + // authenticator half. We requested userVerification:required, so UV must be + // set — that IS the step-up gesture (biometric/PIN). + if len(authenticatorData) < 37 { + return "", fmt.Errorf("webauthn: authenticatorData too short (%d)", len(authenticatorData)) + } + rpIDHash := sha256.Sum256([]byte(rp.cfg.RPID)) + if !bytes.Equal(authenticatorData[:32], rpIDHash[:]) { + return "", fmt.Errorf("webauthn: rpIdHash mismatch") + } + const flagUP, flagUV = 1 << 0, 1 << 2 + if authenticatorData[32]&flagUP == 0 { + return "", fmt.Errorf("webauthn: user-present flag not set") + } + if authenticatorData[32]&flagUV == 0 { + return "", fmt.Errorf("webauthn: user-verification flag not set") + } + + sig, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "signature")) + if err != nil { + return "", fmt.Errorf("webauthn: signature: %w", err) + } + + pubKeyBytes, signCount, err := lookup(credID) + if err != nil { + return "", fmt.Errorf("webauthn: credential not found: %w", err) + } + + clientDataHash := sha256.Sum256(cdjRaw) + sigData := append(authenticatorData, clientDataHash[:]...) + + pubKey, err := parseCOSEKey(pubKeyBytes) + if err != nil { + return "", fmt.Errorf("webauthn: parse key: %w", err) + } + if !ecdsa.VerifyASN1(pubKey, sigData, sig) { + return "", fmt.Errorf("webauthn: signature verification failed") + } + + if len(authenticatorData) >= 37 { + counter := int64(binary.BigEndian.Uint32(authenticatorData[33:37])) + if counter > 0 && counter <= signCount { + return "", fmt.Errorf("webauthn: sign count not greater (old=%d, new=%d)", signCount, counter) + } + if counter > 0 { + if err := updateSignCount(credID, counter); err != nil { + return "", fmt.Errorf("webauthn: update sign count: %w", err) + } + } + } + + return credID, nil +} + +func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error { + var cdj struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + } + if err := json.Unmarshal(clientDataJSON, &cdj); err != nil { + return fmt.Errorf("webauthn: parse clientDataJSON: %w", err) + } + if cdj.Type != expectedType { + return fmt.Errorf("webauthn: unexpected type %q", cdj.Type) + } + if cdj.Challenge != expectedChallenge { + return fmt.Errorf("webauthn: challenge mismatch") + } + if cdj.Origin != expectedOrigin { + return fmt.Errorf("webauthn: origin mismatch: %q != %q", cdj.Origin, expectedOrigin) + } + return nil +} + +func extractPublicKey(attObj []byte) ([]byte, error) { + v, err := decodeCBOR(attObj) + if err != nil { + return nil, fmt.Errorf("webauthn: decode attestation: %w", err) + } + m, err := v.MapText() + if err != nil { + return nil, fmt.Errorf("webauthn: attestation is not a map: %w", err) + } + + authDataV, ok := m["authData"] + if !ok { + return nil, fmt.Errorf("webauthn: attestation missing authData") + } + authData, err := authDataV.Bytes() + if err != nil { + return nil, fmt.Errorf("webauthn: authData not bytes: %w", err) + } + return extractCOSEKeyFromAuthData(authData) +} + +func extractCOSEKeyFromAuthData(authData []byte) ([]byte, error) { + if len(authData) < 37 { + return nil, fmt.Errorf("webauthn: authData too short (%d)", len(authData)) + } + flags := authData[32] + if flags&(1<<6) == 0 { + return nil, fmt.Errorf("webauthn: AT flag not set in authData") + } + acd := authData[37:] + if len(acd) < 18 { + return nil, fmt.Errorf("webauthn: attested credential data too short (%d)", len(acd)) + } + credIDLen := int(binary.BigEndian.Uint16(acd[16:18])) + coseKeyOff := 18 + credIDLen + if coseKeyOff > len(acd) { + return nil, fmt.Errorf("webauthn: credential ID length %d exceeds data (%d)", credIDLen, len(acd)) + } + return acd[coseKeyOff:], nil +} + +func parseCOSEKey(raw []byte) (*ecdsa.PublicKey, error) { + v, err := decodeCBOR(raw) + if err != nil { + return nil, fmt.Errorf("cose: decode: %w", err) + } + m, err := v.Map() + if err != nil { + return nil, fmt.Errorf("cose: not a map: %w", err) + } + + kty, ok := m[1] + if !ok { + return nil, fmt.Errorf("cose: missing kty") + } + ktyV, err := kty.Int() + if err != nil { + return nil, fmt.Errorf("cose: kty: %w", err) + } + if ktyV != 2 { + return nil, fmt.Errorf("cose: unsupported kty %d (expected 2=EC2)", ktyV) + } + + crv, ok := m[-1] + if !ok { + return nil, fmt.Errorf("cose: missing crv") + } + crvV, err := crv.Int() + if err != nil { + return nil, fmt.Errorf("cose: crv: %w", err) + } + if crvV != 1 { + return nil, fmt.Errorf("cose: unsupported crv %d (expected 1=P-256)", crvV) + } + + xV, ok := m[-2] + if !ok { + return nil, fmt.Errorf("cose: missing x coordinate") + } + x, err := xV.Bytes() + if err != nil { + return nil, fmt.Errorf("cose: x: %w", err) + } + + yV, ok := m[-3] + if !ok { + return nil, fmt.Errorf("cose: missing y coordinate") + } + y, err := yV.Bytes() + if err != nil { + return nil, fmt.Errorf("cose: y: %w", err) + } + + return &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(x), + Y: new(big.Int).SetBytes(y), + }, nil +} + +func rawString(m map[string]any, key string) string { + v, ok := m[key] + if !ok { + return "" + } + s, _ := v.(string) + return s +} diff --git a/internal/webauthn/webauthn_test.go b/internal/webauthn/webauthn_test.go new file mode 100644 index 0000000..a5b0bdf --- /dev/null +++ b/internal/webauthn/webauthn_test.go @@ -0,0 +1,188 @@ +package webauthn + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "testing" +) + +// --- minimal CBOR encoders (only what a COSE key + attestation object need) --- + +func cUint(u uint64) []byte { + switch { + case u < 24: + return []byte{byte(u)} + case u < 256: + return []byte{0x18, byte(u)} + default: + return []byte{0x19, byte(u >> 8), byte(u)} + } +} + +// cNeg encodes a negative int n (n<0). arg = -1-n. +func cNeg(n int64) []byte { + arg := uint64(-1 - n) + b := cUint(arg) + b[0] |= 0x20 // major type 1 + return b +} + +func cBytes(b []byte) []byte { + h := cUint(uint64(len(b))) + h[0] |= 0x40 // major type 2 + return append(h, b...) +} + +func cText(s string) []byte { + h := cUint(uint64(len(s))) + h[0] |= 0x60 // major type 3 + return append(h, []byte(s)...) +} + +func cMapHeader(n int) []byte { + h := cUint(uint64(n)) + h[0] |= 0xa0 // major type 5 + return h +} + +// coseKey CBOR-encodes an ES256/P-256 public key as a COSE_Key map. +func coseKey(pub *ecdsa.PublicKey) []byte { + x := pub.X.Bytes() + y := pub.Y.Bytes() + // left-pad to 32 bytes + px := make([]byte, 32) + py := make([]byte, 32) + copy(px[32-len(x):], x) + copy(py[32-len(y):], y) + var out []byte + kv := func(k, v []byte) { out = append(append(out, k...), v...) } + out = append(out, cMapHeader(5)...) + kv(cUint(1), cUint(2)) // kty: EC2 + kv(cUint(3), cNeg(-7)) // alg: ES256 + kv(cNeg(-1), cUint(1)) // crv: P-256 + kv(cNeg(-2), cBytes(px)) // x + kv(cNeg(-3), cBytes(py)) // y + return out +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// authData builds an authenticatorData blob. For registration it embeds the +// attested credential data (AT flag + COSE key); for assertion it's the 37-byte +// header only. +func authData(rpID string, flags byte, counter uint32, credID []byte, cose []byte) []byte { + h := sha256.Sum256([]byte(rpID)) + d := append([]byte{}, h[:]...) + d = append(d, flags) + cb := make([]byte, 4) + binary.BigEndian.PutUint32(cb, counter) + d = append(d, cb...) + if flags&(1<<6) != 0 { // AT set → attested credential data + d = append(d, make([]byte, 16)...) // aaguid + l := make([]byte, 2) + binary.BigEndian.PutUint16(l, uint16(len(credID))) + d = append(d, l...) + d = append(d, credID...) + d = append(d, cose...) + } + return d +} + +func clientData(typ, challenge, origin string) []byte { + b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": origin}) + return b +} + +const testOrigin = "https://maven.test" +const testRPID = "maven.test" + +// TestRegisterAssertRoundTrip drives the full passkey flow with a real P-256 +// key: register a credential, then assert it and verify the ecdsa signature +// check passes end to end. +func TestRegisterAssertRoundTrip(t *testing.T) { + rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"}) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + cose := coseKey(&key.PublicKey) + credID := []byte("cred-1") + credIDb64 := b64(credID) + + // --- register --- + _, regChal, err := rp.CreationOptions([]byte("u"), "user") + if err != nil { + t.Fatal(err) + } + att := append(cMapHeader(3), cText("fmt")...) + att = append(att, cText("none")...) + att = append(att, cText("attStmt")...) + att = append(att, cMapHeader(0)...) + att = append(att, cText("authData")...) + att = append(att, cBytes(authData(testRPID, 1<<6|0x05, 0, credID, cose))...) + + var stored []byte + save := func(id string, pk, _ []byte, _ string) error { stored = pk; return nil } + gotID, err := rp.FinishRegistration(save, regChal, map[string]any{ + "id": credIDb64, + "response": map[string]any{ + "clientDataJSON": b64(clientData("webauthn.create", regChal, testOrigin)), + "attestationObject": b64(att), + }, + }) + if err != nil { + t.Fatalf("register: %v", err) + } + if gotID != credIDb64 || len(stored) == 0 { + t.Fatalf("register produced no credential") + } + + // --- assert (valid signature) --- + credID64 := gotID + sign := func(chal string, flags byte, tamper bool) map[string]any { + ad := authData(testRPID, flags, 5, nil, nil) + cdj := clientData("webauthn.get", chal, testOrigin) + hash := sha256.Sum256(cdj) + sig, _ := ecdsa.SignASN1(rand.Reader, key, append(append([]byte{}, ad...), hash[:]...)) + if tamper { + sig[len(sig)-1] ^= 0xff + } + return map[string]any{ + "id": credID64, + "response": map[string]any{ + "clientDataJSON": b64(cdj), + "authenticatorData": b64(ad), + "signature": b64(sig), + }, + } + } + lookup := func(id string) ([]byte, int64, error) { return stored, 0, nil } + upd := func(id string, c int64) error { return nil } + + _, assertChal, _ := rp.AssertionOptions() + if _, err := rp.FinishAssertion(lookup, upd, assertChal, sign(assertChal, 0x05, false)); err != nil { + t.Fatalf("valid assertion should pass: %v", err) + } + + // --- negative: tampered signature --- + _, chal2, _ := rp.AssertionOptions() + if _, err := rp.FinishAssertion(lookup, upd, chal2, sign(chal2, 0x05, true)); err == nil { + t.Fatal("tampered signature must fail verification") + } + + // --- negative: user-verification flag not set (no gesture) --- + _, chal3, _ := rp.AssertionOptions() + if _, err := rp.FinishAssertion(lookup, upd, chal3, sign(chal3, 0x01, false)); err == nil { + t.Fatal("assertion without UV flag must fail (step-up requires a gesture)") + } +} + +// TestAssertRejectsWrongOrigin — a phished assertion from another origin fails. +func TestAssertRejectsWrongOrigin(t *testing.T) { + err := verifyClientDataBytes(clientData("webauthn.get", "abc", "https://evil.test"), "webauthn.get", "abc", testOrigin) + if err == nil { + t.Fatal("wrong origin must be rejected") + } +}