items 5-7: passkey step-up, tools enable/disable, note RAG — end to end
Completes the three in-flight open items and fixes the away-fallthrough bug. Item 7 — passkey step-up (WebAuthn): - internal/webauthn: ES256/P-256 register + assert with real ecdsa signature verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert, decays after TTL). Drop the RS256 offer we can't verify (register-ok/ assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is the step-up gesture. Round-trip test with negative cases (tampered sig, missing UV, wrong origin). - cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do a WebAuthn gesture) + the four begin/finish endpoints. Without this the daemon's PasskeySession swap leaves /tools enable permanently blocked. - daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates MethodAssertStepUp at AuthRead. Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a disable action and a link to the passkey page. Lifecycle test. Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k notes, raw-notes fallback); IntentQuery routes through it. Stub returns a deterministic summary. Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop) instead of silently dropping / mis-routing to the present-list remainder. Covers DispatchNudge + DispatchReminder. 4 tests. Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc, gitignore /mavcaldav. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
/mavttsd
|
/mavttsd
|
||||||
/mavweb
|
/mavweb
|
||||||
/mavpoll
|
/mavpoll
|
||||||
|
/mavcaldav
|
||||||
|
|
||||||
# Certs (private keys, don't commit)
|
# Certs (private keys, don't commit)
|
||||||
certs/
|
certs/
|
||||||
|
|||||||
+10
-8
@@ -47,6 +47,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -114,7 +115,7 @@ func run(args []string) error {
|
|||||||
defer phr.Close()
|
defer phr.Close()
|
||||||
|
|
||||||
// ----- voice: reactive audio path (TCP listener + stt/router/tts) -----
|
// ----- 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,13 +170,14 @@ func run(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ipc listen: %w", err)
|
return fmt.Errorf("ipc listen: %w", err)
|
||||||
}
|
}
|
||||||
// auth floor: any same-uid caller is fully trusted (FloorEnrollment +
|
// auth: Enrolled callers can assert step-up via MethodAssertStepUp (calls
|
||||||
// FloorSession — L3, step-up satisfied). The cold-start unlock dance and a
|
// Session.Assert). After a successful passkey assertion, the session bumps
|
||||||
// real passkey Session are the open spec items; today the daemon runs
|
// to L3 for assertionTTL, enabling AuthStepUp methods (EnableTool).
|
||||||
// unlocked — plain sqlite, sqlcipher deferred. FloorSession keeps the floor
|
// FloorEnrollment still trusts same-uid callers; the passkey verifier
|
||||||
// consistent so the authed mavweb /tools page can EnableTool (AuthStepUp)
|
// (WebAuthn) gates the session step-up, not the enrollment.
|
||||||
// against the local socket; the passkey verifier swaps FloorSession later.
|
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: auth.FloorSession{}}).Check
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|||||||
+20
-21
@@ -59,6 +59,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
"github.com/kami/maven/internal/delivery/voicesink"
|
"github.com/kami/maven/internal/delivery/voicesink"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/stt"
|
"github.com/kami/maven/internal/stt"
|
||||||
"github.com/kami/maven/internal/tool"
|
"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
|
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
|
||||||
// slot using w.sessions (the caller does that — see main.go).
|
// 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 {
|
if cfg.Voice == nil || !cfg.Voice.Enabled {
|
||||||
return nil, nil
|
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) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
embedder: emb,
|
||||||
api: coreAPI,
|
api: coreAPI,
|
||||||
tools: exec,
|
tools: exec,
|
||||||
|
phraser: phr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the server (TCP listener) -----
|
// ----- the server (TCP listener) -----
|
||||||
@@ -219,6 +221,7 @@ type reactiveHandler struct {
|
|||||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||||
api ipc.CoreAPI
|
api ipc.CoreAPI
|
||||||
tools *tool.Executor
|
tools *tool.Executor
|
||||||
|
phraser phraser.Phraser
|
||||||
replier voice.Replier
|
replier voice.Replier
|
||||||
now func() time.Time
|
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 {
|
if len(notes) == 0 || notes[0].Score < queryMinScore {
|
||||||
return "у меня нет заметок по этому вопросу."
|
return "у меня нет заметок по этому вопросу."
|
||||||
}
|
}
|
||||||
// Full RAG (phraser-composed) is deferred — this is the browse surface.
|
texts := make([]string, len(notes))
|
||||||
// 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("вот что нашла: ")
|
|
||||||
for i, n := range notes {
|
for i, n := range notes {
|
||||||
if i > 0 {
|
texts[i] = n.Text
|
||||||
b.WriteString("; ")
|
|
||||||
}
|
|
||||||
b.WriteString(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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-12
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
|
// 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
|
// facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC
|
||||||
// script). Empty ⇒ /api/signal returns 503 and presence stays unfed.
|
// script). Empty ⇒ /api/signal returns 503 and presence stays unfed.
|
||||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
var core ipc.CoreAPI
|
var core ipc.CoreAPI
|
||||||
@@ -116,6 +119,25 @@ func main() {
|
|||||||
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleDash(w, r, core)
|
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;
|
// /tools — the authed enable surface. maven proposes acts she can't run;
|
||||||
// this page is where a human reviews and enables them (proposed→enabled).
|
// 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,
|
// 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}
|
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}</style>
|
.d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}</style>
|
||||||
<h1>maven · tools</h1>
|
<h1>maven · tools</h1>
|
||||||
|
<p><small>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</small></p>
|
||||||
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
||||||
<h2>proposed <small>({{len .Proposed}})</small></h2>
|
<h2>proposed <small>({{len .Proposed}})</small></h2>
|
||||||
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
||||||
@@ -306,15 +329,20 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
|||||||
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
|
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
|
||||||
<td><form method=post action=/tools>
|
<td><form method=post action=/tools>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=action value=enable>
|
||||||
<input type=text name=cmd placeholder="systemctl restart" required>
|
<input type=text name=cmd placeholder="systemctl restart" required>
|
||||||
<label><input type=checkbox name=destructive> destructive</label>
|
<label><input type=checkbox name=destructive> destructive</label>
|
||||||
<button>enable</button></form></td>
|
<button>enable</button></form></td>
|
||||||
</tr>{{end}}</table>
|
</tr>{{end}}</table>
|
||||||
{{else}}<p>none pending.</p>{{end}}
|
{{else}}<p>none pending.</p>{{end}}
|
||||||
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
||||||
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th></tr>
|
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th><th></th></tr>
|
||||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
||||||
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td></tr>{{end}}</table>
|
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
|
||||||
|
<td><form method=post action=/tools style=display:inline>
|
||||||
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=action value=disable>
|
||||||
|
<button>disable</button></form></td></tr>{{end}}</table>
|
||||||
{{else}}<p>none enabled.</p>{{end}}
|
{{else}}<p>none enabled.</p>{{end}}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -331,19 +359,37 @@ 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 {
|
||||||
|
action := r.FormValue("action")
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
cmd := strings.Fields(r.FormValue("cmd"))
|
switch action {
|
||||||
destructive := r.FormValue("destructive") != ""
|
case "enable":
|
||||||
if name == "" || len(cmd) == 0 {
|
cmd := strings.Fields(r.FormValue("cmd"))
|
||||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
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
|
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")
|
proposed, err1 := core.ListTools(ctx, "proposed")
|
||||||
enabled, err2 := core.ListTools(ctx, "enabled")
|
enabled, err2 := core.ListTools(ctx, "enabled")
|
||||||
|
|||||||
@@ -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 = `<!doctype html><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
|
<title>maven · passkey</title>
|
||||||
|
<style>body{font:16px system-ui;max-width:34rem;margin:3rem auto;padding:0 1rem}
|
||||||
|
button{font:inherit;padding:.5rem 1rem;margin:.3rem .3rem 0 0;cursor:pointer}
|
||||||
|
#msg{margin-top:1rem;padding:.6rem;border-radius:.3rem;white-space:pre-wrap}
|
||||||
|
.ok{background:#e6ffed}.err{background:#ffe6e6}</style>
|
||||||
|
<h1>maven passkey</h1>
|
||||||
|
<p>Enroll a passkey once, then assert it to unlock destructive actions
|
||||||
|
(tool enable) for a few minutes.</p>
|
||||||
|
<button onclick=enroll()>enroll passkey</button>
|
||||||
|
<button onclick=assert()>assert (step-up)</button>
|
||||||
|
<a href=/tools><button>→ tools</button></a>
|
||||||
|
<div id=msg></div>
|
||||||
|
<script>
|
||||||
|
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||||
|
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
|
||||||
|
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'ok':'err';};
|
||||||
|
async function enroll(){try{
|
||||||
|
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||||
|
options.challenge=ub64(options.challenge);
|
||||||
|
options.user.id=ub64(options.user.id);
|
||||||
|
const c=await navigator.credentials.create({publicKey:options});
|
||||||
|
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
|
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||||
|
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||||
|
say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok);
|
||||||
|
}catch(e){say('enroll error: '+e,false);}}
|
||||||
|
async function assert(){try{
|
||||||
|
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||||
|
options.challenge=ub64(options.challenge);
|
||||||
|
const c=await navigator.credentials.get({publicKey:options});
|
||||||
|
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
|
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||||
|
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||||
|
signature:b64u(c.response.signature)}}})});
|
||||||
|
say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok);
|
||||||
|
}catch(e){say('assert error: '+e,false);}}
|
||||||
|
</script>`
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -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 {
|
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
|
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
|
||||||
return ipc.Tool{}, ipc.ErrToolNotFound
|
return ipc.Tool{}, ipc.ErrToolNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
return AuthStepUp
|
return AuthStepUp
|
||||||
case ipc.MethodWriteFact:
|
case ipc.MethodWriteFact:
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
|
case ipc.MethodAssertStepUp:
|
||||||
|
return AuthRead
|
||||||
case ipc.MethodLatestFact,
|
case ipc.MethodLatestFact,
|
||||||
ipc.MethodLatestFactBySource,
|
ipc.MethodLatestFactBySource,
|
||||||
ipc.MethodSince,
|
ipc.MethodSince,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NudgeRecorder — the seam the store implements. the dispatcher records one
|
// 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
|
c := pn.Candidate
|
||||||
channels := ChannelsFor(c.Severity, c.State.Presence)
|
channels := ChannelsFor(c.Severity, c.State.Presence)
|
||||||
var out []Dispatch
|
var out []Dispatch
|
||||||
for _, ch := range channels {
|
for i := 0; i < len(channels); i++ {
|
||||||
|
ch := channels[i]
|
||||||
if ch == ChannelDrop {
|
if ch == ChannelDrop {
|
||||||
continue
|
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 err := sink.Send(ctx, s); err != nil {
|
||||||
if errors.Is(err, ErrVoiceNoSession) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
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
|
rd := pr.Decision
|
||||||
channels := ChannelsForReminder(rd.State.Presence)
|
channels := ChannelsForReminder(rd.State.Presence)
|
||||||
var out []Dispatch
|
var out []Dispatch
|
||||||
for _, ch := range channels {
|
for i := 0; i < len(channels); i++ {
|
||||||
|
ch := channels[i]
|
||||||
s := Sendable{
|
s := Sendable{
|
||||||
Channel: ch,
|
Channel: ch,
|
||||||
Kind: KindReminder,
|
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 err := sink.Send(ctx, s); err != nil {
|
||||||
if errors.Is(err, ErrVoiceNoSession) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
return out, fmt.Errorf("send %s: %w", ch, err)
|
||||||
|
|||||||
@@ -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 -------------------------------
|
// ----------------------------- repeat-til-ack -------------------------------
|
||||||
|
|
||||||
func TestShouldRepeat(t *testing.T) {
|
func TestShouldRepeat(t *testing.T) {
|
||||||
|
|||||||
+8
-2
@@ -176,6 +176,10 @@ type enableToolReq struct {
|
|||||||
Destructive bool `json:"destructive"`
|
Destructive bool `json:"destructive"`
|
||||||
Ts time.Time `json:"ts"`
|
Ts time.Time `json:"ts"`
|
||||||
}
|
}
|
||||||
|
type disableToolReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type lookupToolReq struct {
|
type lookupToolReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
@@ -215,10 +219,12 @@ type CoreAPI interface {
|
|||||||
|
|
||||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||||
// destructive and flips to 'enabled' — the human-only "enable" act, gated
|
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||||
// at AuthStepUp (see auth/policy.go). LookupTool/ListTools read them.
|
// 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)
|
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
|
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)
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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) {
|
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
var t Tool
|
var t Tool
|
||||||
if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
|
if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
|
||||||
|
|||||||
@@ -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))
|
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) {
|
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
t, err := a.s.LookupTool(ctx, name)
|
t, err := a.s.LookupTool(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,6 +271,12 @@ type Server struct {
|
|||||||
// change to gain or lose the seam.
|
// change to gain or lose the seam.
|
||||||
Check CheckFunc
|
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
|
// 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.
|
// 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.
|
// auth doesn't need to leak implementation into ipc.
|
||||||
type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
|
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
|
// 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
|
// 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
|
// 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)
|
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:
|
case MethodLookupTool:
|
||||||
var p lookupToolReq
|
var p lookupToolReq
|
||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
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
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const (
|
|||||||
MethodRecentNotes Method = "recent_notes"
|
MethodRecentNotes Method = "recent_notes"
|
||||||
MethodProposeTool Method = "propose_tool"
|
MethodProposeTool Method = "propose_tool"
|
||||||
MethodEnableTool Method = "enable_tool"
|
MethodEnableTool Method = "enable_tool"
|
||||||
|
MethodDisableTool Method = "disable_tool"
|
||||||
|
MethodAssertStepUp Method = "assert_stepup"
|
||||||
MethodLookupTool Method = "lookup_tool"
|
MethodLookupTool Method = "lookup_tool"
|
||||||
MethodListTools Method = "list_tools"
|
MethodListTools Method = "list_tools"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
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 "вот что я нашла: <notes>" 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) {
|
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||||
text := extractReminderText(d.Reminder.Payload)
|
text := extractReminderText(d.Reminder.Payload)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@@ -214,13 +239,17 @@ type chatResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
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{
|
req := chatReq{
|
||||||
Messages: []chatMsg{
|
Messages: []chatMsg{
|
||||||
{Role: "system", Content: systemPrompt()},
|
{Role: "system", Content: system},
|
||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: user},
|
||||||
},
|
},
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxTokens: 256,
|
MaxTokens: maxTokens,
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import (
|
|||||||
type Phraser interface {
|
type Phraser interface {
|
||||||
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
|
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
|
||||||
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
|
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
|
||||||
|
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,17 @@ type Stub struct{}
|
|||||||
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
||||||
func NewStub() *Stub { return &Stub{} }
|
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).
|
// Close implements Phraser.Close (no-op for the stub).
|
||||||
func (s *Stub) Close() error { return nil }
|
func (s *Stub) Close() error { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destr
|
|||||||
return nil
|
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.
|
// LookupTool returns the tool by name. ErrToolNotFound when absent.
|
||||||
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:])) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user