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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user