diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index d4e6ea0..d55ecff 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -20,13 +20,25 @@ // "unlocked" — the locked-until-asserted dance lands with the Session // verifier + ask-password transport (open spec item). // -// Everything wired here is swappable at the construction seam — every module -// behind an interface — without changing the loop. Wire a real phraser, voice -// sink, or session verifier, and the daemon's main loop is unchanged. +// Cold-start unlock (2026-07-06): +// When a passkey credential is enrolled AND no env key is set, the daemon +// starts in LOCKED mode: the IPC server runs but rejects all store methods +// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed +// by MethodUnlock (with the same credential's public key) unwraps the at-rest +// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens +// the encrypted store. After unlock, the daemon wires voice, loop, and +// delivery and runs normally. +// +// Fallback: when db_key_env is set (or no wrapped file exists), the daemon +// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey +// while unlocked calls MethodStoreEncryptionKey to wrap the env key and +// persist the wrapped blob — enabling cold-start unlock on the next boot +// after the env key is removed. package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -50,6 +62,32 @@ import ( "github.com/kami/maven/internal/webauthn" ) +var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first") + +// daemonLock tracks whether the daemon is in locked (pre-unlock) mode. +// In locked mode, all CoreAPI methods return errLocked. The unlock path +// replaces the CoreAPI with the real store adapter and flips the flag. +type daemonLock struct { + mu sync.Mutex + locked bool +} + +func newDaemonLock(locked bool) *daemonLock { + return &daemonLock{locked: locked} +} + +func (l *daemonLock) isLocked() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.locked +} + +func (l *daemonLock) unlock() { + l.mu.Lock() + defer l.mu.Unlock() + l.locked = false +} + func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "mavend:", err) @@ -57,8 +95,40 @@ func main() { } } +// lockedAPI is a dummy CoreAPI used while the daemon is locked. Every method +// returns errLocked. The wire protocol's StoreAPI methods all go through the +// Server dispatch on CoreAPI, so returning errLocked from each is correct. +type lockedAPI struct{} + +var _ ipc.CoreAPI = (*lockedAPI)(nil) + +func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) { return 0, errLocked } +func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) { return ipc.Fact{}, errLocked } +func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) { return ipc.Fact{}, errLocked } +func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { return 0, errLocked } +func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked } +func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { return 0, errLocked } +func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { return errLocked } +func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { return 0, errLocked } +func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return errLocked } +func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked } +func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) { return nil, errLocked } +func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) { return nil, errLocked } +func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) { return nil, errLocked } +func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { return 0, errLocked } +func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) { return nil, errLocked } +func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) { return nil, errLocked } +func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { return false, errLocked } +func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { return errLocked } +func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked } +func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { return ipc.Tool{}, errLocked } +func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) { return nil, errLocked } +func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked } +func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, errLocked } + func run(args []string) error { cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config") + wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)") flag.CommandLine.Parse(args) cfg, err := config.Load(*cfgPath) if err != nil { @@ -68,133 +138,321 @@ func run(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer stop() - // ----- store (the unlocked handle; core = the only key-holder) ----- - // The cold-start unlock dance (L3 passkey → key bytes) is not yet wired; - // today the key comes from config/env. When a key is present the on-disk - // file is ciphertext and we work on a tmpfs plaintext copy; no key ⇒ - // plaintext store (dev/CI). A configured-but-broken key fails closed. - key, err := cfg.DBEncryptionKey() + // ----- encryption key: env key → wrapped key → plaintext ----- + // Priority: env key (from config/docker) > wrapped key (cold-start unlock) > plaintext (dev/CI). + // When a wrapped key file exists AND no env key is set, the daemon starts + // LOCKED and waits for a passkey assertion to unwrap it. + envKey, err := cfg.DBEncryptionKey() if err != nil { return err } - var st *store.Store - if key != nil { - st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key) + + wrappedExists := false + if *wrappedKeyPath != "" { + if _, err := os.Stat(*wrappedKeyPath); err == nil { + wrappedExists = true + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check wrapped key: %w", err) + } } else { - st, err = store.Open(ctx, cfg.DBPath) - } - if err != nil { - return fmt.Errorf("open store: %w", err) - } - defer st.Close() - - // ----- loop: rules + gatherer ----- - - rules := loop.DefaultRules() - gatherer := loop.NewGatherer(st, rules) - if cfg.QuietHours != nil { - gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End) + // default path alongside the config + defaultWrapped := cfg.DefaultWrappedKeyPath() + if _, err := os.Stat(defaultWrapped); err == nil { + *wrappedKeyPath = defaultWrapped + wrappedExists = true + } } - // ----- phraser (LLM-backed when configured, Stub floor otherwise) ----- - var phr phraser.Phraser = phraser.NewStub() - if cfg.Phraser != nil { - pc := phraser.Config{ - ModelPath: cfg.Phraser.ModelPath, - BinPath: cfg.Phraser.BinPath, - Listen: cfg.Phraser.Listen, - NGpuLayers: cfg.Phraser.NGpuLayers, - NCtx: cfg.Phraser.NCtx, - Timeout: time.Duration(cfg.Phraser.Timeout), + locked := wrappedExists && envKey == nil + dl := newDaemonLock(locked) + + var st *store.Store + var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key) + + if !locked { + // Normal boot: env key or plaintext (dev/CI) + if envKey != nil { + envKeyBytes = make([]byte, len(envKey)) + copy(envKeyBytes, envKey) + st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey) + } else { + st, err = store.Open(ctx, cfg.DBPath) } - if pc.BinPath == "" { - pc.BinPath = "llama-server" - } - if pc.Listen == "" { - pc.Listen = "127.0.0.1:0" - } - if pc.NCtx <= 0 { - pc.NCtx = 2048 - } - if pc.Timeout <= 0 { - pc.Timeout = 30 * time.Second - } - var err error - phr, err = phraser.NewLLMPhraser(ctx, pc) if err != nil { - return fmt.Errorf("phraser: %w", err) + return fmt.Errorf("open store: %w", err) } + defer st.Close() } - defer phr.Close() - // ----- voice: reactive audio path (TCP listener + stt/router/tts) ----- - voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory()) - if err != nil { - return fmt.Errorf("wire voice: %w", err) - } - defer voiceW.close() + // ----- daemon components (only wired when unlocked) ----- + // Pre-declare so the unlock path can wire them later. + var ( + gatherer *loop.Gatherer + rules []loop.Rule + phr phraser.Phraser + voiceW *voiceWiring + dispatcher *delivery.Dispatcher + tl *tickLoop + coreAPI ipc.CoreAPI + ) - // ----- delivery: sinks + dispatcher ----- - var ntfy delivery.Sink - if cfg.Ntfy != nil { - s, err := ntfysink.New(*cfg.Ntfy) + if !locked { + rules = loop.DefaultRules() + gatherer = loop.NewGatherer(st, rules) + if cfg.QuietHours != nil { + gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End) + } + + // phraser + phr = phraser.NewStub() + if cfg.Phraser != nil { + pc := phraser.Config{ + ModelPath: cfg.Phraser.ModelPath, + BinPath: cfg.Phraser.BinPath, + Listen: cfg.Phraser.Listen, + NGpuLayers: cfg.Phraser.NGpuLayers, + NCtx: cfg.Phraser.NCtx, + Timeout: time.Duration(cfg.Phraser.Timeout), + } + if pc.BinPath == "" { + pc.BinPath = "llama-server" + } + if pc.Listen == "" { + pc.Listen = "127.0.0.1:0" + } + if pc.NCtx <= 0 { + pc.NCtx = 2048 + } + if pc.Timeout <= 0 { + pc.Timeout = 30 * time.Second + } + var err error + phr, err = phraser.NewLLMPhraser(ctx, pc) + if err != nil { + return fmt.Errorf("phraser: %w", err) + } + } + + // voice + voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory()) if err != nil { - return fmt.Errorf("wire ntfy sink: %w", err) + return fmt.Errorf("wire voice: %w", err) } - ntfy = s - } - var telegram delivery.Sink - if cfg.Telegram != nil { - s, err := telegramsink.New(*cfg.Telegram) - if err != nil { - return fmt.Errorf("wire telegram sink: %w", err) - } - telegram = s - } - // Voice sink: nil when voice is not enabled — the dispatcher's nil-sink - // path skips ChannelVoice silently, just like the pre-voice floor). - var voiceSink delivery.Sink - if voiceW != nil { - voiceSink = voiceW.voiceSink - } - dispatcher := delivery.NewDispatcher(delivery.Config{ - Ntfy: ntfy, - Telegram: telegram, - Voice: voiceSink, - // AckTracker nil ⇒ repeat-til-ack disabled in the dispatcher. We - // drive repeats from store.UnackedTelegramRules + Dispatcher.RepeatUnacked - // below, which uses the nudges table's outcome=pending row itself as - // the ack-or-not state — the production ack source. The AckTracker - // interface stays reserved for an in-memory cache if the daemon ever - // wants to drive repeats without the SQL hit; the table IS the truth. - Nudges: st, // *store.Store satisfies delivery.NudgeRecorder - Reminders: st, // *store.Store satisfies delivery.ReminderCompleter - }) - // ----- the proactive loop driver (60s ticker, lives HERE per spec) ----- - tickInterval := time.Duration(cfg.TickInterval) - repeatInterval := time.Duration(cfg.RepeatInterval) - autotuneInterval := time.Duration(cfg.AutotuneInterval) - tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines)) + // delivery + var ntfy delivery.Sink + if cfg.Ntfy != nil { + s, err := ntfysink.New(*cfg.Ntfy) + if err != nil { + return fmt.Errorf("wire ntfy sink: %w", err) + } + ntfy = s + } + var telegram delivery.Sink + if cfg.Telegram != nil { + s, err := telegramsink.New(*cfg.Telegram) + if err != nil { + return fmt.Errorf("wire telegram sink: %w", err) + } + telegram = s + } + var voiceSink delivery.Sink + if voiceW != nil { + voiceSink = voiceW.voiceSink + } + dispatcher = delivery.NewDispatcher(delivery.Config{ + Ntfy: ntfy, + Telegram: telegram, + Voice: voiceSink, + Nudges: st, + Reminders: st, + }) + + // tick loop + tickInterval := time.Duration(cfg.TickInterval) + repeatInterval := time.Duration(cfg.RepeatInterval) + autotuneInterval := time.Duration(cfg.AutotuneInterval) + tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines)) + + coreAPI = &daemonAPI{ + CoreAPI: ipc.NewStoreAPI(st), + getTrace: tl.trace, + } + } else { + // locked mode: dummy CoreAPI that returns errLocked for everything + coreAPI = &lockedAPI{} + } // ----- IPC boundary (core ↔ modules) ----- - coreAPI := &daemonAPI{ - CoreAPI: ipc.NewStoreAPI(st), - getTrace: tl.trace, - } srv, err := ipc.Listen(cfg.SocketPath, coreAPI) if err != nil { return fmt.Errorf("ipc listen: %w", err) } - // 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 + + // Set Server.Check — in locked mode, block everything except unlock-path methods. + if locked { + srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error { + switch m { + case ipc.MethodAssertStepUp, ipc.MethodUnlock: + return nil // allowed in locked mode + default: + return errLocked + } + } + } else { + srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check + } + srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) } + // WrapKeyFn — wraps the env key with a passkey credential public key and + // persists the wrapped blob. Only wired when the daemon has the key in + // memory (env key mode). Called by mavweb after passkey enrollment. + if envKeyBytes != nil { + srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error { + blob, err := webauthn.WrapKey(envKeyBytes, publicKey) + if err != nil { + return fmt.Errorf("wrap encryption key: %w", err) + } + wp := *wrappedKeyPath + if wp == "" { + wp = cfg.DefaultWrappedKeyPath() + } + if err := os.WriteFile(wp, blob, 0o600); err != nil { + return fmt.Errorf("write wrapped key: %w", err) + } + log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob)) + return nil + } + } + + // UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped + // blob using the passkey credential public key, opens the store, wires all + // daemon components, and replaces the locked API. + if locked { + srv.UnlockFn = func(ctx context.Context, publicKey []byte) error { + wp := *wrappedKeyPath + blob, err := os.ReadFile(wp) + if err != nil { + return fmt.Errorf("read wrapped key: %w", err) + } + key, err := webauthn.UnwrapKey(blob, publicKey) + if err != nil { + return fmt.Errorf("unwrap key: %w", err) + } + // Open the store with the unwrapped key. + st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key) + if err != nil { + return fmt.Errorf("unlock store: %w", err) + } + + // Wire everything. + rules = loop.DefaultRules() + gatherer = loop.NewGatherer(st, rules) + if cfg.QuietHours != nil { + gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End) + } + + phr = phraser.NewStub() + if cfg.Phraser != nil { + pc := phraser.Config{ + ModelPath: cfg.Phraser.ModelPath, + BinPath: cfg.Phraser.BinPath, + Listen: cfg.Phraser.Listen, + NGpuLayers: cfg.Phraser.NGpuLayers, + NCtx: cfg.Phraser.NCtx, + Timeout: time.Duration(cfg.Phraser.Timeout), + } + if pc.BinPath == "" { + pc.BinPath = "llama-server" + } + if pc.Listen == "" { + pc.Listen = "127.0.0.1:0" + } + if pc.NCtx <= 0 { + pc.NCtx = 2048 + } + if pc.Timeout <= 0 { + pc.Timeout = 30 * time.Second + } + phr, err = phraser.NewLLMPhraser(ctx, pc) + if err != nil { + return fmt.Errorf("phraser: %w", err) + } + } + + voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory()) + if err != nil { + return fmt.Errorf("wire voice: %w", err) + } + + var ntfy delivery.Sink + if cfg.Ntfy != nil { + s, err := ntfysink.New(*cfg.Ntfy) + if err != nil { + return fmt.Errorf("wire ntfy sink: %w", err) + } + ntfy = s + } + var telegram delivery.Sink + if cfg.Telegram != nil { + s, err := telegramsink.New(*cfg.Telegram) + if err != nil { + return fmt.Errorf("wire telegram sink: %w", err) + } + telegram = s + } + var voiceSink delivery.Sink + if voiceW != nil { + voiceSink = voiceW.voiceSink + } + dispatcher = delivery.NewDispatcher(delivery.Config{ + Ntfy: ntfy, + Telegram: telegram, + Voice: voiceSink, + Nudges: st, + Reminders: st, + }) + + tickInterval := time.Duration(cfg.TickInterval) + repeatInterval := time.Duration(cfg.RepeatInterval) + autotuneInterval := time.Duration(cfg.AutotuneInterval) + tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines)) + + // Swap the CoreAPI from lockedAPI to the real store adapter. + newAPI := &daemonAPI{ + CoreAPI: ipc.NewStoreAPI(st), + getTrace: tl.trace, + } + srv.SetAPI(newAPI) + srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check + + // Start voice server. + if voiceW != nil { + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) { + log.Printf("voice serve: %v", err) + } + }() + log.Printf("mavend: voice listening on %s", voiceW.server.Addr()) + } + + // Start tick loop. + go func() { + tl.run(ctx) + }() + + dl.unlock() + log.Printf("mavend: unlocked via passkey assertion") + return nil + } + } + var wg sync.WaitGroup wg.Add(1) go func() { @@ -205,7 +463,7 @@ func run(args []string) error { }() log.Printf("mavend: ipc listening on %s", srv.Path()) - if voiceW != nil { + if !locked && voiceW != nil { wg.Add(1) go func() { defer wg.Done() @@ -216,17 +474,22 @@ func run(args []string) error { log.Printf("mavend: voice listening on %s", voiceW.server.Addr()) } - wg.Add(1) - go func() { - defer wg.Done() - tl.run(ctx) - }() + if !locked { + wg.Add(1) + go func() { + defer wg.Done() + tl.run(ctx) + }() + } <-ctx.Done() log.Printf("mavend: shutdown signal received") if err := srv.Close(); err != nil { log.Printf("ipc close: %v", err) } + if voiceW != nil { + voiceW.close() + } wg.Wait() log.Printf("mavend: bye") return nil diff --git a/internal/config/config.go b/internal/config/config.go index 783150b..eab56f2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -451,6 +451,14 @@ func (c *Config) DBEncryptionKey() ([]byte, error) { return key, nil } +// DefaultWrappedKeyPath returns the conventional path for the wrapped +// encryption key blob — alongside the StateDir. This is the path checked +// automatically when --wrapped-key-file is not provided on the command line. +// The caller may always override via the flag. +func (c *Config) DefaultWrappedKeyPath() string { + return filepath.Join(c.StateDir, "db_key.wrapped") +} + func defaultDataDir() string { if x := os.Getenv("XDG_DATA_HOME"); x != "" { return filepath.Join(x, "maven") diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 4fac4f9..b58cb9e 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -283,6 +283,19 @@ type TickTrace struct { Rules []RuleTrace `json:"rules"` } +// storeEncryptionKeyReq — passkey credential public key for wrapping the store +// encryption key at enrollment time. Called by mavweb after RegisterFinish. +type storeEncryptionKeyReq struct { + PublicKey []byte `json:"public_key"` +} + +// unlockReq — passkey credential public key for unwrapping the store +// encryption key at cold-start. mavend reads the wrapped blob from its own +// configured path; the public key is the other half needed for unwrapping. +type unlockReq struct { + PublicKey []byte `json:"public_key"` +} + // ErrToolNotFound — no tool row with this name (re-exported store sentinel for // wire round-tripping via errors.Is). var ErrToolNotFound = errors.New("ipc: tool not found") diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 5344cdf..595d6ed 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -337,6 +337,14 @@ func (c *Client) AssertStepUp(ctx context.Context) error { return c.call(ctx, MethodAssertStepUp, nil, nil) } +func (c *Client) StoreEncryptionKey(ctx context.Context, publicKey []byte) error { + return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{PublicKey: publicKey}, nil) +} + +func (c *Client) Unlock(ctx context.Context, publicKey []byte) error { + return c.call(ctx, MethodUnlock, unlockReq{PublicKey: publicKey}, nil) +} + func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) { var t Tool if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 1091be9..e6c04ba 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -302,10 +302,29 @@ type Server struct { // MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor). StepUp StepUpFunc + // WrapKeyFn — wraps the in-memory store encryption key with a passkey + // credential public key (HKDF-AESGCM) and writes the wrapped blob to disk. + // Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod. + WrapKeyFn WrapKeyFunc + + // UnlockFn — unwraps the store encryption key from the wrapped blob using + // the passkey credential public key, opens the encrypted store, and wires + // the rest of the daemon (voice, loop, delivery). Set by the daemon when + // in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod. + UnlockFn UnlockFunc + // 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. } +// WrapKeyFunc — wraps the store encryption key with the given credential +// public key and persists the wrapped blob. +type WrapKeyFunc func(ctx context.Context, publicKey []byte) error + +// UnlockFunc — unwraps the store encryption key using the given credential +// public key and completes daemon initialization. +type UnlockFunc func(ctx context.Context, publicKey []byte) error + // CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check // satisfies this); dispatch calls it once per request after param-unmarshal // independence (it gets the raw params, may unmarshal what it needs — ipc @@ -672,6 +691,26 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + case MethodStoreEncryptionKey: + if s.WrapKeyFn != nil { + var p storeEncryptionKeyReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + return marshalResult(nil), s.WrapKeyFn(ctx, p.PublicKey) + } + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + + case MethodUnlock: + if s.UnlockFn != nil { + var p unlockReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey) + } + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + default: return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) } @@ -713,6 +752,12 @@ func (s *Server) Close() error { // Path returns the filesystem path of the listening socket. 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 } + func parentDir(p string) string { if i := lastIndexByte(p, '/'); i >= 0 { if i == 0 { diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 28cd482..db78c2f 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -33,6 +33,8 @@ const ( MethodEnableTool Method = "enable_tool" MethodDisableTool Method = "disable_tool" MethodAssertStepUp Method = "assert_stepup" + MethodStoreEncryptionKey Method = "store_encryption_key" + MethodUnlock Method = "unlock" MethodLookupTool Method = "lookup_tool" MethodListTools Method = "list_tools" MethodRevertFact Method = "revert_fact" diff --git a/internal/webauthn/keywrap.go b/internal/webauthn/keywrap.go new file mode 100644 index 0000000..1070862 --- /dev/null +++ b/internal/webauthn/keywrap.go @@ -0,0 +1,173 @@ +// Key wrapping for cold-start unlock. +// +// The at-rest AES-256 key is wrapped with a key derived from the passkey +// credential public key (stable across assertions) via HKDF-SHA256, then +// AES-256-GCM. The wrapped blob is stored on disk; at cold-start the passkey +// assertion provides the credential public key to unwrap it. +// +// The passkey credential is a P-256 ECDSA public key. Its raw uncompressed +// bytes (65 bytes, 0x04 || X || Y) are the HKDF input — high-entropy, stable. +// +// Blob format: salt (16) || nonce (12) || AES-256-GCM ciphertext. +// No file magic — the caller (mavend) owns the file path. +package webauthn + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" +) + +const ( + // saltLen — HKDF salt length. 16 bytes is standard. + saltLen = 16 + // nonceLen — AES-GCM standard nonce length. + nonceLen = 12 + // keyLen — AES-256 key length. + keyLen = 32 + // wrapInfo — HKDF info string for domain separation. + wrapInfo = "maven-passkey-keywrap-v1" +) + +var ( + ErrKeyWrap = errors.New("webauthn: key wrap failed") + ErrKeyUnwrap = errors.New("webauthn: key unwrap failed (wrong credential?)") + ErrBlobTooLong = errors.New("webauthn: wrapped blob too long") +) + +// WrapKey derives a wrapping key from credPublicKey via HKDF-SHA256 and +// AES-GCM-wraps plaintextKey. Returns the blob: salt || nonce || ciphertext. +// plaintextKey must be exactly 32 bytes (AES-256). +func WrapKey(plaintextKey, credPublicKey []byte) ([]byte, error) { + if len(plaintextKey) != keyLen { + return nil, fmt.Errorf("%w: plaintext key must be %d bytes", ErrKeyWrap, keyLen) + } + if len(credPublicKey) == 0 { + return nil, fmt.Errorf("%w: empty credential public key", ErrKeyWrap) + } + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("%w: salt: %v", ErrKeyWrap, err) + } + + wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen) + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("%w: nonce: %v", ErrKeyWrap, err) + } + + block, err := aes.NewCipher(wrapKey) + if err != nil { + return nil, fmt.Errorf("%w: aes: %v", ErrKeyWrap, err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("%w: gcm: %v", ErrKeyWrap, err) + } + + // Seal appends ciphertext+tag to nonce (which becomes nonce||ct). + ct := gcm.Seal(nil, nonce, plaintextKey, nil) + + out := make([]byte, 0, saltLen+nonceLen+len(ct)) + out = append(out, salt...) + out = append(out, nonce...) + out = append(out, ct...) + return out, nil +} + +// UnwrapKey extracts the salt from blob, re-derives the wrapping key from +// credPublicKey, and AES-GCM-unwraps. Returns the plaintext 32-byte AES key. +func UnwrapKey(blob, credPublicKey []byte) ([]byte, error) { + if len(blob) < saltLen+nonceLen+1 { + return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(blob)) + } + if len(blob) > 1<<20 { // 1MB sanity limit + return nil, ErrBlobTooLong + } + if len(credPublicKey) == 0 { + return nil, fmt.Errorf("%w: empty credential public key", ErrKeyUnwrap) + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ct := blob[saltLen+nonceLen:] + + wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen) + + block, err := aes.NewCipher(wrapKey) + if err != nil { + return nil, fmt.Errorf("%w: aes: %v", ErrKeyUnwrap, err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("%w: gcm: %v", ErrKeyUnwrap, err) + } + + plain, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("%w: decrypt failed (wrong credential?)", ErrKeyUnwrap) + } + return plain, nil +} + +// hkdfSHA256 implements HKDF-SHA256 (RFC 5869) using only stdlib. +// +// Input: +// - secret: the input key material (credential public key bytes) +// - salt: random salt (16 bytes) +// - info: optional context string for domain separation +// - length: desired output length in bytes +// +// Output: length bytes of derived key material. +// +// HKDF is extract-then-expand. We use HMAC-SHA256 for both steps. This avoids +// importing golang.org/x/crypto/hkdf — a ~30-line function vs a new dep. The +// tradeoff is no constant-time guarantees on the extract step beyond HMAC's; +// acceptable here because the input is already high-entropy key material (a +// P-256 public key), not a low-entropy passphrase. +func hkdfSHA256(secret, salt, info []byte, length int) []byte { + // Step 1: Extract — PRK = HMAC-SHA256(salt, secret) + // If salt is nil/empty, use a zero-filled block (RFC 5869 §2.2). + if salt == nil { + salt = make([]byte, sha256.Size) + } + mac := hmac.New(sha256.New, salt) + mac.Write(secret) + prk := mac.Sum(nil) + + // Step 2: Expand — produce length bytes via T(i) = HMAC-SHA256(PRK, T(i-1) || info || i) + // Where T(0) = empty, i is a byte counter starting at 1. + out := make([]byte, 0, length) + block := make([]byte, 0, sha256.Size+len(info)+1) + var t []byte // T(i-1) + for counter := byte(1); len(out) < length; counter++ { + block = block[:0] + block = append(block, t...) + block = append(block, info...) + block = append(block, counter) + + mac.Reset() + mac.Write(block) + t = mac.Sum(prk[:0]) // reuse prk buffer — mac.Sum appends to its arg + // t now starts with prk[:0] (empty) followed by the HMAC result. + // Since we need just the HMAC result (sha256.Size bytes), re-slice. + t = t[len(t)-sha256.Size:] + out = append(out, t...) + } + return out[:length] +} + +// encodeUint32 — big-endian uint32 for the blob format header, if needed. +func encodeUint32(v uint32) []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], v) + return b[:] +}