diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index 3aeed9f..8054c55 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -19,18 +19,28 @@ type assertIPC interface { 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 -// 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). +// store, and the IPC client used to assert step-up and to wrap/unwrap the +// 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 // 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 - store *credentialStore - session *webauthn.PasskeySession + rp *webauthn.RP + assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC + encryptFn keyIPC // *ipc.Client when connected; nil ⇒ key wrap/unlock disabled + store *credentialStore + session *webauthn.PasskeySession } type localCred struct { @@ -43,15 +53,20 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s if c, ok := core.(assertIPC); ok { af = c } + var ek keyIPC + if c, ok := core.(keyIPC); ok { + ek = c + } store, err := newCredentialStore(storePath) if err != nil { return nil, fmt.Errorf("credential store: %w", err) } return &PasskeyHandle{ - rp: webauthn.NewRP(cfg), - assertFn: af, - store: store, - session: session, + rp: webauthn.NewRP(cfg), + assertFn: af, + encryptFn: ek, + store: store, + session: session, }, nil } @@ -128,7 +143,9 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } + var enrolledPublicKey []byte save := func(id string, publicKey []byte, _ []byte, _ string) error { + enrolledPublicKey = publicKey return h.store.Save(id, publicKey) } 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 } 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}) } @@ -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. if h.session != nil { h.session.Assert(r.Context(), auth.Scope{}) diff --git a/internal/ipc/server.go b/internal/ipc/server.go index e6c04ba..f3d55cc 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -9,6 +9,7 @@ import ( "net" "os" "sync" + "sync/atomic" "time" "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 // already guaranteed at the db; the Server adds no locking of its own). type Server struct { - api CoreAPI + api atomic.Value // stores CoreAPI path string ln net.Listener @@ -361,12 +362,13 @@ func Listen(path string, api CoreAPI) (*Server, error) { _ = os.Remove(path) return nil, fmt.Errorf("ipc: chmod socket: %w", err) } - return &Server{ - api: api, + s := &Server{ path: path, ln: ln, done: make(chan struct{}), - }, nil + } + s.api.Store(api) + return s, nil } // 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 // and is invisible at the wire — pre-auth Server behavior is unchanged. func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) { + api := s.api.Load().(CoreAPI) if s.Check != nil { if err := s.Check(ctx, req.Method, req.Params); err != nil { 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 { return nil, err } - id, err := s.api.WriteFact(ctx, p) + id, err := api.WriteFact(ctx, p) return marshalResult(idResp{ID: id}), err 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 { return nil, err } - f, err := s.api.LatestFact(ctx, p.Key) + f, err := api.LatestFact(ctx, p.Key) if err != nil { 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 { 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 { 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 { 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 { return nil, err } return marshalResult(sinceResp{Dur: d}), nil case MethodPresence: - pres, err := s.api.Presence(ctx) + pres, err := api.Presence(ctx) if err != nil { 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 { 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 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 { return nil, err } - err := s.api.MarkReminder(ctx, p.ID, p.Status) + err := api.MarkReminder(ctx, p.ID, p.Status) return marshalResult(nil), err 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 { 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 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 { 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 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 { 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 { 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 { return nil, err } - out, err := s.api.RecentFacts(ctx, p.N) + out, err := api.RecentFacts(ctx, p.N) if err != nil { 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 { 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 { 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 { return nil, err } - out, err := s.api.RecentNudges(ctx, p.N) + out, err := api.RecentNudges(ctx, p.N) if err != nil { 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 { 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 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 { 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 { 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 { return nil, err } - out, err := s.api.RecentNotes(ctx, p.N) + out, err := api.RecentNotes(ctx, p.N) if err != nil { 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 { 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 { 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 { 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: var p disableToolReq if err := unmarshalParams(req.Params, &p); err != nil { return nil, err } - return marshalResult(nil), s.api.DisableTool(ctx, p.Name) + return marshalResult(nil), api.DisableTool(ctx, p.Name) case MethodLookupTool: var p lookupToolReq if err := unmarshalParams(req.Params, &p); err != nil { return nil, err } - t, err := s.api.LookupTool(ctx, p.Name) + t, err := api.LookupTool(ctx, p.Name) if err != nil { 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 { return nil, err } - out, err := s.api.ListTools(ctx, p.Status) + out, err := api.ListTools(ctx, p.Status) if err != nil { 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 { return nil, err } - newID, err := s.api.RevertFact(ctx, p.Key) + newID, err := api.RevertFact(ctx, p.Key) if err != nil { return nil, err } return marshalResult(map[string]int64{"new_id": newID}), nil case MethodTickTrace: - t, err := s.api.TickTrace(ctx) + t, err := api.TickTrace(ctx) if err != nil { 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 // 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 -// while the server is serving (dispatch reads s.api once per request). -func (s *Server) SetAPI(api CoreAPI) { s.api = api } +// while the server is serving (dispatch loads api once per request via atomic). +func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) } func parentDir(p string) string { if i := lastIndexByte(p, '/'); i >= 0 {