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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user