From cb0425f18d3d8a2c7769c5cbe5994a7bb9223e09 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 22:52:21 +0100 Subject: [PATCH] feat(store): envelope DEK with admin/agent wrap slots Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs; Unlock loads it from the role's slot (admin slot has no agent fallback). s.key becomes the DEK, so account/mail crypto is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/store/keys.go | 102 ++++++++++++++++++++++++++++++++ internal/store/keys_test.go | 110 +++++++++++++++++++++++++++++++++++ internal/store/store.go | 6 +- internal/store/store_test.go | 20 +++---- 4 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 internal/store/keys.go create mode 100644 internal/store/keys_test.go diff --git a/internal/store/keys.go b/internal/store/keys.go new file mode 100644 index 0000000..fcebc4d --- /dev/null +++ b/internal/store/keys.go @@ -0,0 +1,102 @@ +package store + +import ( + "database/sql" + "encoding/base64" + "errors" + "fmt" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" +) + +// Role selects which DEK wrap slot a command may unlock. +type Role int + +const ( + RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback) + RoleAdmin // all commands; uses dek_wrap_admin ONLY +) + +const ( + settingDEKWrapAdmin = "dek_wrap_admin" + settingDEKWrapAgent = "dek_wrap_agent" +) + +// ErrLocked means the DB has no DEK wrap slots yet (never initialized). +var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`") + +// dbPath returns the file path SQLite opened (used by tests to re-open). +func (s *Store) dbPath() string { + var p string + _ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p) + return p +} + +// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes +// both wrap slots, and unlocks the store. If the slots already exist it does +// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init). +func (s *Store) InitKeys(adminKey, agentKey []byte) error { + if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil { + return s.Unlock(RoleAdmin, adminKey, nil) + } + dek, err := crypto.NewDEK() + if err != nil { + return err + } + wrapAdmin, err := crypto.Seal(adminKey, dek) + if err != nil { + return err + } + wrapAgent, err := crypto.Seal(agentKey, dek) + if err != nil { + return err + } + if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil { + return err + } + if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil { + return err + } + s.key = dek + return nil +} + +// Unlock loads the DEK into the store by decrypting the wrap slot for role. +// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and +// falls back to the admin slot only when no agent key is supplied. +func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error { + switch role { + case RoleAdmin: + return s.unlockSlot(settingDEKWrapAdmin, adminKey) + case RoleAgent: + if len(agentKey) > 0 { + return s.unlockSlot(settingDEKWrapAgent, agentKey) + } + return s.unlockSlot(settingDEKWrapAdmin, adminKey) + default: + return fmt.Errorf("unknown role %d", role) + } +} + +func (s *Store) unlockSlot(settingKey string, kek []byte) error { + if len(kek) == 0 { + return ErrLocked + } + enc, err := s.GetSetting(settingKey) + if errors.Is(err, sql.ErrNoRows) { + return ErrLocked + } + if err != nil { + return err + } + blob, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err) + } + dek, err := crypto.Open(kek, blob) + if err != nil { + return errors.New("wrong key for this DB") + } + s.key = dek + return nil +} diff --git a/internal/store/keys_test.go b/internal/store/keys_test.go new file mode 100644 index 0000000..72ebfef --- /dev/null +++ b/internal/store/keys_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "path/filepath" + "testing" +) + +func k(b byte) []byte { + key := make([]byte, 32) + for i := range key { + key[i] = b + } + return key +} + +func tempStore(t *testing.T) *Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "emcli.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) { + admin, agent := k(0xAA), k(0xBB) + st := tempStore(t) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatalf("InitKeys: %v", err) + } + // Seal a password under the DEK that InitKeys set. + if _, err := st.AddAccount(Account{ + Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, + IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", + }); err != nil { + t.Fatalf("AddAccount: %v", err) + } + + // Re-open and unlock via the AGENT slot. + path := st.dbPath() + st.Close() + st2, _ := Open(path) + if err := st2.Unlock(RoleAgent, nil, agent); err != nil { + t.Fatalf("Unlock(agent): %v", err) + } + got, err := st2.GetAccount("a") + if err != nil || got.Password != "pw" { + t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err) + } + st2.Close() + + // Unlock via the ADMIN slot recovers the same DEK. + st3, _ := Open(path) + if err := st3.Unlock(RoleAdmin, admin, nil); err != nil { + t.Fatalf("Unlock(admin): %v", err) + } + got3, err := st3.GetAccount("a") + if err != nil || got3.Password != "pw" { + t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err) + } + st3.Close() +} + +func TestUnlockWrongKeyFails(t *testing.T) { + st := tempStore(t) + if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil { + t.Fatal(err) + } + path := st.dbPath() + st.Close() + st2, _ := Open(path) + if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil { + t.Fatal("Unlock with wrong admin key must fail") + } + st2.Close() +} + +func TestAdminSlotNotOpenableByAgentKey(t *testing.T) { + st := tempStore(t) + admin, agent := k(0xAA), k(0xBB) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatal(err) + } + // RoleAdmin must use the admin slot; passing the agent key as the admin + // key must fail — there is no fallback to the agent slot. + if err := st.Unlock(RoleAdmin, agent, agent); err == nil { + t.Fatal("agent key must not unlock the admin slot") + } +} + +func TestInitKeysIdempotentKeepsDEK(t *testing.T) { + st := tempStore(t) + admin, agent := k(0xAA), k(0xBB) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatal(err) + } + st.AddAccount(Account{ + Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, + IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", + }) + // Second InitKeys must NOT regenerate the DEK (would orphan the password). + if err := st.InitKeys(admin, agent); err != nil { + t.Fatalf("re-InitKeys: %v", err) + } + got, err := st.GetAccount("a") + if err != nil || got.Password != "pw" { + t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 2e5092b..5fd37f5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -19,7 +19,9 @@ type Store struct { } // Open opens (creating if needed) the DB at path and applies the schema. -func Open(path string, key []byte) (*Store, error) { +// The store opens LOCKED: call InitKeys (first run) or Unlock before any +// secret read/write. +func Open(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("create db dir: %w", err) } @@ -39,7 +41,7 @@ func Open(path string, key []byte) (*Store, error) { db.Close() return nil, fmt.Errorf("apply schema: %w", err) } - s := &Store{db: db, key: key} + s := &Store{db: db} if _, err := s.GetSetting("schema_version"); err != nil { if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil { db.Close() diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4a4dc45..5479f24 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -5,29 +5,25 @@ import ( "testing" ) -func testKey() []byte { - k := make([]byte, 32) - for i := range k { - k[i] = byte(i) - } - return k -} - -// openTemp opens a fresh store in a temp dir. +// openTemp opens a fresh store in a temp dir and initialises keys so that +// account tests (which do crypto) work without needing their own setup. func openTemp(t *testing.T) *Store { t.Helper() p := filepath.Join(t.TempDir(), "emcli.db") - s, err := Open(p, testKey()) + s, err := Open(p) if err != nil { t.Fatalf("Open: %v", err) } + if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil { + t.Fatalf("InitKeys: %v", err) + } t.Cleanup(func() { s.Close() }) return s } func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) { p := filepath.Join(t.TempDir(), "emcli.db") - s, err := Open(p, testKey()) + s, err := Open(p) if err != nil { t.Fatalf("first Open: %v", err) } @@ -38,7 +34,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) { s.Close() // Re-open: must not error or duplicate. - s2, err := Open(p, testKey()) + s2, err := Open(p) if err != nil { t.Fatalf("second Open: %v", err) }