cold-start unlock: key wrap/unwrap, locked-mode daemon, IPC unlock methods
- internal/webauthn/keywrap.go: HKDF-SHA256 + AES-256-GCM WrapKey/UnwrapKey - internal/ipc/: MethodStoreEncryptionKey/MethodUnlock wire, api structs, server dispatch callbacks (WrapKeyFn/UnlockFn), client stubs - internal/config/config.go: DefaultWrappedKeyPath() method - cmd/mavend/main.go: locked-mode boot path - detects wrapped key, starts locked with lockedAPI stub, wires UnlockFn that opens store + replaces CoreAPI on passkey assertion. env-key path stores WrapKeyFn for enrollment. make test green (303+, -race)
This commit is contained in:
+375
-112
@@ -20,13 +20,25 @@
|
|||||||
// "unlocked" — the locked-until-asserted dance lands with the Session
|
// "unlocked" — the locked-until-asserted dance lands with the Session
|
||||||
// verifier + ask-password transport (open spec item).
|
// verifier + ask-password transport (open spec item).
|
||||||
//
|
//
|
||||||
// Everything wired here is swappable at the construction seam — every module
|
// Cold-start unlock (2026-07-06):
|
||||||
// behind an interface — without changing the loop. Wire a real phraser, voice
|
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||||
// sink, or session verifier, and the daemon's main loop is unchanged.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -50,6 +62,32 @@ import (
|
|||||||
"github.com/kami/maven/internal/webauthn"
|
"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() {
|
func main() {
|
||||||
if err := run(os.Args[1:]); err != nil {
|
if err := run(os.Args[1:]); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "mavend:", err)
|
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 {
|
func run(args []string) error {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
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)
|
flag.CommandLine.Parse(args)
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,133 +138,321 @@ func run(args []string) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// ----- store (the unlocked handle; core = the only key-holder) -----
|
// ----- encryption key: env key → wrapped key → plaintext -----
|
||||||
// The cold-start unlock dance (L3 passkey → key bytes) is not yet wired;
|
// Priority: env key (from config/docker) > wrapped key (cold-start unlock) > plaintext (dev/CI).
|
||||||
// today the key comes from config/env. When a key is present the on-disk
|
// When a wrapped key file exists AND no env key is set, the daemon starts
|
||||||
// file is ciphertext and we work on a tmpfs plaintext copy; no key ⇒
|
// LOCKED and waits for a passkey assertion to unwrap it.
|
||||||
// plaintext store (dev/CI). A configured-but-broken key fails closed.
|
envKey, err := cfg.DBEncryptionKey()
|
||||||
key, err := cfg.DBEncryptionKey()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var st *store.Store
|
|
||||||
if key != nil {
|
wrappedExists := false
|
||||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
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 {
|
} else {
|
||||||
st, err = store.Open(ctx, cfg.DBPath)
|
// default path alongside the config
|
||||||
}
|
defaultWrapped := cfg.DefaultWrappedKeyPath()
|
||||||
if err != nil {
|
if _, err := os.Stat(defaultWrapped); err == nil {
|
||||||
return fmt.Errorf("open store: %w", err)
|
*wrappedKeyPath = defaultWrapped
|
||||||
}
|
wrappedExists = true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- phraser (LLM-backed when configured, Stub floor otherwise) -----
|
locked := wrappedExists && envKey == nil
|
||||||
var phr phraser.Phraser = phraser.NewStub()
|
dl := newDaemonLock(locked)
|
||||||
if cfg.Phraser != nil {
|
|
||||||
pc := phraser.Config{
|
var st *store.Store
|
||||||
ModelPath: cfg.Phraser.ModelPath,
|
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
||||||
BinPath: cfg.Phraser.BinPath,
|
|
||||||
Listen: cfg.Phraser.Listen,
|
if !locked {
|
||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
// Normal boot: env key or plaintext (dev/CI)
|
||||||
NCtx: cfg.Phraser.NCtx,
|
if envKey != nil {
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
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 {
|
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) -----
|
// ----- daemon components (only wired when unlocked) -----
|
||||||
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory())
|
// Pre-declare so the unlock path can wire them later.
|
||||||
if err != nil {
|
var (
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
gatherer *loop.Gatherer
|
||||||
}
|
rules []loop.Rule
|
||||||
defer voiceW.close()
|
phr phraser.Phraser
|
||||||
|
voiceW *voiceWiring
|
||||||
|
dispatcher *delivery.Dispatcher
|
||||||
|
tl *tickLoop
|
||||||
|
coreAPI ipc.CoreAPI
|
||||||
|
)
|
||||||
|
|
||||||
// ----- delivery: sinks + dispatcher -----
|
if !locked {
|
||||||
var ntfy delivery.Sink
|
rules = loop.DefaultRules()
|
||||||
if cfg.Ntfy != nil {
|
gatherer = loop.NewGatherer(st, rules)
|
||||||
s, err := ntfysink.New(*cfg.Ntfy)
|
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 {
|
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) -----
|
// delivery
|
||||||
tickInterval := time.Duration(cfg.TickInterval)
|
var ntfy delivery.Sink
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
if cfg.Ntfy != nil {
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
s, err := ntfysink.New(*cfg.Ntfy)
|
||||||
tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines))
|
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) -----
|
// ----- IPC boundary (core ↔ modules) -----
|
||||||
coreAPI := &daemonAPI{
|
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
|
||||||
getTrace: tl.trace,
|
|
||||||
}
|
|
||||||
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
|
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ipc listen: %w", err)
|
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)
|
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{}) }
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -205,7 +463,7 @@ func run(args []string) error {
|
|||||||
}()
|
}()
|
||||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||||
|
|
||||||
if voiceW != nil {
|
if !locked && voiceW != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -216,17 +474,22 @@ func run(args []string) error {
|
|||||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(1)
|
if !locked {
|
||||||
go func() {
|
wg.Add(1)
|
||||||
defer wg.Done()
|
go func() {
|
||||||
tl.run(ctx)
|
defer wg.Done()
|
||||||
}()
|
tl.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
log.Printf("mavend: shutdown signal received")
|
log.Printf("mavend: shutdown signal received")
|
||||||
if err := srv.Close(); err != nil {
|
if err := srv.Close(); err != nil {
|
||||||
log.Printf("ipc close: %v", err)
|
log.Printf("ipc close: %v", err)
|
||||||
}
|
}
|
||||||
|
if voiceW != nil {
|
||||||
|
voiceW.close()
|
||||||
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Printf("mavend: bye")
|
log.Printf("mavend: bye")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -451,6 +451,14 @@ func (c *Config) DBEncryptionKey() ([]byte, error) {
|
|||||||
return key, nil
|
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 {
|
func defaultDataDir() string {
|
||||||
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
|
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
|
||||||
return filepath.Join(x, "maven")
|
return filepath.Join(x, "maven")
|
||||||
|
|||||||
@@ -283,6 +283,19 @@ type TickTrace struct {
|
|||||||
Rules []RuleTrace `json:"rules"`
|
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
|
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
||||||
// wire round-tripping via errors.Is).
|
// wire round-tripping via errors.Is).
|
||||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||||
|
|||||||
@@ -337,6 +337,14 @@ func (c *Client) AssertStepUp(ctx context.Context) error {
|
|||||||
return c.call(ctx, MethodAssertStepUp, nil, nil)
|
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) {
|
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
var t Tool
|
var t Tool
|
||||||
if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
|
if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
|
||||||
|
|||||||
@@ -302,10 +302,29 @@ type Server struct {
|
|||||||
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
||||||
StepUp StepUpFunc
|
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
|
// 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.
|
// 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
|
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
// 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)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
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.
|
// Path returns the filesystem path of the listening socket.
|
||||||
func (s *Server) Path() string { return s.path }
|
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 {
|
func parentDir(p string) string {
|
||||||
if i := lastIndexByte(p, '/'); i >= 0 {
|
if i := lastIndexByte(p, '/'); i >= 0 {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const (
|
|||||||
MethodEnableTool Method = "enable_tool"
|
MethodEnableTool Method = "enable_tool"
|
||||||
MethodDisableTool Method = "disable_tool"
|
MethodDisableTool Method = "disable_tool"
|
||||||
MethodAssertStepUp Method = "assert_stepup"
|
MethodAssertStepUp Method = "assert_stepup"
|
||||||
|
MethodStoreEncryptionKey Method = "store_encryption_key"
|
||||||
|
MethodUnlock Method = "unlock"
|
||||||
MethodLookupTool Method = "lookup_tool"
|
MethodLookupTool Method = "lookup_tool"
|
||||||
MethodListTools Method = "list_tools"
|
MethodListTools Method = "list_tools"
|
||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
|
|||||||
@@ -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[:]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user