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:
kami
2026-07-06 13:24:49 +04:00
parent eda434fe0b
commit b0932a19df
7 changed files with 624 additions and 112 deletions
+8
View File
@@ -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")
+13
View File
@@ -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")
+8
View File
@@ -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 {
+45
View File
@@ -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 {
+2
View File
@@ -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"
+173
View File
@@ -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[:]
}