mavweb: passkey enrollment wraps encryption key, assertion unlocks daemon

- webauthn.go: keyIPC interface for StoreEncryptionKey/Unlock, wired
  through PasskeyHandle. RegisterFinish calls StoreEncryptionKey with
  the credential's public key after successful enrollment. AssertFinish
  calls Unlock with the stored public key after assertion (alongside
  existing AssertStepUp call).
- server.go: fix data race on s.api by switching from bare CoreAPI field
  to atomic.Value. SetAPI uses Store(), dispatch uses Load(). No more
  race-flagged tests.
- make test green (303+, -race)
This commit is contained in:
kami
2026-07-06 13:29:31 +04:00
parent b0932a19df
commit 15fe7bbc74
2 changed files with 97 additions and 39 deletions
+65 -10
View File
@@ -19,18 +19,28 @@ type assertIPC interface {
AssertStepUp(ctx context.Context) error AssertStepUp(ctx context.Context) error
} }
// keyIPC — satisfies the key-wrap and unlock methods. The only implementation
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
// StoreEncryptionKey and Unlock are silently skipped.
type keyIPC interface {
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
Unlock(ctx context.Context, publicKey []byte) error
}
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential // 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 // store, and the IPC client used to assert step-up and to wrap/unwrap the
// HTTP endpoints (register/begin, register/finish, assert/begin, assert/finish). // daemon's encryption key. 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 // Credentials are kept in-memory only (a single-user daemon restarts
// infrequently, and re-enrolling after restart is acceptable). A future // infrequently, and re-enrolling after restart is acceptable). A future
// version may persist them to disk. // version may persist them to disk.
type PasskeyHandle struct { type PasskeyHandle struct {
rp *webauthn.RP rp *webauthn.RP
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
store *credentialStore encryptFn keyIPC // *ipc.Client when connected; nil ⇒ key wrap/unlock disabled
session *webauthn.PasskeySession store *credentialStore
session *webauthn.PasskeySession
} }
type localCred struct { type localCred struct {
@@ -43,15 +53,20 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
if c, ok := core.(assertIPC); ok { if c, ok := core.(assertIPC); ok {
af = c af = c
} }
var ek keyIPC
if c, ok := core.(keyIPC); ok {
ek = c
}
store, err := newCredentialStore(storePath) store, err := newCredentialStore(storePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("credential store: %w", err) return nil, fmt.Errorf("credential store: %w", err)
} }
return &PasskeyHandle{ return &PasskeyHandle{
rp: webauthn.NewRP(cfg), rp: webauthn.NewRP(cfg),
assertFn: af, assertFn: af,
store: store, encryptFn: ek,
session: session, store: store,
session: session,
}, nil }, nil
} }
@@ -128,7 +143,9 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return return
} }
var enrolledPublicKey []byte
save := func(id string, publicKey []byte, _ []byte, _ string) error { save := func(id string, publicKey []byte, _ []byte, _ string) error {
enrolledPublicKey = publicKey
return h.store.Save(id, publicKey) return h.store.Save(id, publicKey)
} }
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential) credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
@@ -138,6 +155,21 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("webauthn: registered credential %s", credID) log.Printf("webauthn: registered credential %s", credID)
// If mavend is reachable and supports key wrapping, store the encryption
// key wrapped with this credential's public key — enables cold-start unlock.
if h.encryptFn != nil && enrolledPublicKey != nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
log.Printf("webauthn: store encryption key: %v", err)
// Non-fatal: enrollment still succeeded, the wrapped key can be
// created later via the same endpoint.
} else {
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
}
}
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
} }
@@ -193,6 +225,29 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
} }
} }
// If the daemon is locked (cold-start), send the credential's public key
// over IPC so mavend can unwrap its encryption key and open the store.
// The public key comes from the local credential store (it was stored
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
// daemon is already unlocked, the call is a no-op on the server side.
if h.encryptFn != nil {
publicKey, _, err := h.store.Lookup(credID)
if err == nil && publicKey != nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
// Non-fatal: assertion succeeded; if the daemon stays locked
// the user will see errors on subsequent pages, but the
// assertion itself is valid.
} else {
log.Printf("webauthn: daemon unlocked via credential %s", credID)
}
} else if err != nil {
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
}
}
// Assert the in-process session so the POST /tools handler sees step-up. // Assert the in-process session so the POST /tools handler sees step-up.
if h.session != nil { if h.session != nil {
h.session.Assert(r.Context(), auth.Scope{}) h.session.Assert(r.Context(), auth.Scope{})
+32 -29
View File
@@ -9,6 +9,7 @@ import (
"net" "net"
"os" "os"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
@@ -277,7 +278,7 @@ func mapErr(err error) error {
// writer — store is single-connection, SetMaxOpenConns(1), so serialization is // writer — store is single-connection, SetMaxOpenConns(1), so serialization is
// already guaranteed at the db; the Server adds no locking of its own). // already guaranteed at the db; the Server adds no locking of its own).
type Server struct { type Server struct {
api CoreAPI api atomic.Value // stores CoreAPI
path string path string
ln net.Listener ln net.Listener
@@ -361,12 +362,13 @@ func Listen(path string, api CoreAPI) (*Server, error) {
_ = os.Remove(path) _ = os.Remove(path)
return nil, fmt.Errorf("ipc: chmod socket: %w", err) return nil, fmt.Errorf("ipc: chmod socket: %w", err)
} }
return &Server{ s := &Server{
api: api,
path: path, path: path,
ln: ln, ln: ln,
done: make(chan struct{}), done: make(chan struct{}),
}, nil }
s.api.Store(api)
return s, nil
} }
// Serve accepts connections until the listener closes. Each connection is // Serve accepts connections until the listener closes. Each connection is
@@ -437,6 +439,7 @@ func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.Raw
// it cares about (WriteFact's source, etc.) itself. A nil Check is the floor // it cares about (WriteFact's source, etc.) itself. A nil Check is the floor
// and is invisible at the wire — pre-auth Server behavior is unchanged. // and is invisible at the wire — pre-auth Server behavior is unchanged.
func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) { func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) {
api := s.api.Load().(CoreAPI)
if s.Check != nil { if s.Check != nil {
if err := s.Check(ctx, req.Method, req.Params); err != nil { if err := s.Check(ctx, req.Method, req.Params); err != nil {
return nil, err return nil, err
@@ -448,7 +451,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
id, err := s.api.WriteFact(ctx, p) id, err := api.WriteFact(ctx, p)
return marshalResult(idResp{ID: id}), err return marshalResult(idResp{ID: id}), err
case MethodLatestFact: case MethodLatestFact:
@@ -456,7 +459,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
f, err := s.api.LatestFact(ctx, p.Key) f, err := api.LatestFact(ctx, p.Key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -467,7 +470,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
f, err := s.api.LatestFactBySource(ctx, p.Key, p.Source) f, err := api.LatestFactBySource(ctx, p.Key, p.Source)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -478,14 +481,14 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
d, err := s.api.Since(ctx, p.Key, p.Now) d, err := api.Since(ctx, p.Key, p.Now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return marshalResult(sinceResp{Dur: d}), nil return marshalResult(sinceResp{Dur: d}), nil
case MethodPresence: case MethodPresence:
pres, err := s.api.Presence(ctx) pres, err := api.Presence(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -496,7 +499,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron) id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron)
return marshalResult(idResp{ID: id}), err return marshalResult(idResp{ID: id}), err
case MethodMarkReminder: case MethodMarkReminder:
@@ -504,7 +507,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
err := s.api.MarkReminder(ctx, p.ID, p.Status) err := api.MarkReminder(ctx, p.ID, p.Status)
return marshalResult(nil), err return marshalResult(nil), err
case MethodRecordNudge: case MethodRecordNudge:
@@ -512,7 +515,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
id, err := s.api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts) id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
return marshalResult(idResp{ID: id}), err return marshalResult(idResp{ID: id}), err
case MethodResolveNudge: case MethodResolveNudge:
@@ -520,7 +523,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
err := s.api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts) err := api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
return marshalResult(nil), err return marshalResult(nil), err
case MethodRecentOutcomes: case MethodRecentOutcomes:
@@ -528,7 +531,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.RecentOutcomes(ctx, p.Rule, p.N) out, err := api.RecentOutcomes(ctx, p.Rule, p.N)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -542,7 +545,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.RecentFacts(ctx, p.N) out, err := api.RecentFacts(ctx, p.N)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -556,7 +559,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.CalendarEvents(ctx, p.From, p.To) out, err := api.CalendarEvents(ctx, p.From, p.To)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -570,7 +573,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.RecentNudges(ctx, p.N) out, err := api.RecentNudges(ctx, p.N)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -584,7 +587,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
id, err := s.api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source) id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
return marshalResult(idResp{ID: id}), err return marshalResult(idResp{ID: id}), err
case MethodQueryNotes: case MethodQueryNotes:
@@ -592,7 +595,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.QueryNotes(ctx, p.Embedding, p.K) out, err := api.QueryNotes(ctx, p.Embedding, p.K)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -606,7 +609,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.RecentNotes(ctx, p.N) out, err := api.RecentNotes(ctx, p.N)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -620,7 +623,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts) ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -631,21 +634,21 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts) return marshalResult(nil), api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
case MethodDisableTool: case MethodDisableTool:
var p disableToolReq var p disableToolReq
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
return marshalResult(nil), s.api.DisableTool(ctx, p.Name) return marshalResult(nil), 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 {
return nil, err return nil, err
} }
t, err := s.api.LookupTool(ctx, p.Name) t, err := api.LookupTool(ctx, p.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -656,7 +659,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
out, err := s.api.ListTools(ctx, p.Status) out, err := api.ListTools(ctx, p.Status)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -672,14 +675,14 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
newID, err := s.api.RevertFact(ctx, p.Key) newID, err := api.RevertFact(ctx, p.Key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return marshalResult(map[string]int64{"new_id": newID}), nil return marshalResult(map[string]int64{"new_id": newID}), nil
case MethodTickTrace: case MethodTickTrace:
t, err := s.api.TickTrace(ctx) t, err := api.TickTrace(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -755,8 +758,8 @@ func (s *Server) Path() string { return s.path }
// SetAPI atomically replaces the CoreAPI the server dispatches to. Used by // SetAPI atomically replaces the CoreAPI the server dispatches to. Used by
// the daemon's unlock path: in locked mode a dummy API returns errors for all // the daemon's unlock path: in locked mode a dummy API returns errors for all
// store methods; after unlock, the real store API is swapped in. Safe to call // store methods; after unlock, the real store API is swapped in. Safe to call
// while the server is serving (dispatch reads s.api once per request). // while the server is serving (dispatch loads api once per request via atomic).
func (s *Server) SetAPI(api CoreAPI) { s.api = api } func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
func parentDir(p string) string { func parentDir(p string) string {
if i := lastIndexByte(p, '/'); i >= 0 { if i := lastIndexByte(p, '/'); i >= 0 {