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:
+10
-8
@@ -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)
|
||||
|
||||
+20
-21
@@ -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 ""
|
||||
}
|
||||
|
||||
+58
-12
@@ -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}</style>
|
||||
<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}}
|
||||
<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>
|
||||
@@ -306,15 +329,20 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
||||
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
|
||||
<td><form method=post action=/tools>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=action value=enable>
|
||||
<input type=text name=cmd placeholder="systemctl restart" required>
|
||||
<label><input type=checkbox name=destructive> destructive</label>
|
||||
<button>enable</button></form></td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p>none pending.</p>{{end}}
|
||||
<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>
|
||||
<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}}
|
||||
`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user