6239eca243
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>
214 lines
7.8 KiB
Go
214 lines
7.8 KiB
Go
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})
|
|
}
|