feat: per-user shard encryption + wallet isolation

EncryptedKVStore: AES-256-GCM wrapper over ZapDB for per-user shard encryption.
Each user's key shards encrypted with a unique DEK:

  user DEK = HMAC-SHA256(masterKey, "mpc:shard:" + orgID + ":" + userID)
  org DEK  = HMAC-SHA256(masterKey, "mpc:org:" + orgID)

Three layers of MPC data protection:
  1. ZapDB ChaCha20-Poly1305 (node-level, existing)
  2. EncryptedKVStore AES-256-GCM (per-user, NEW)
  3. API filter on createdBy (access control, NEW)

Even if ZapDB is fully dumped, individual users' shards are
independently encrypted. Wrong user key → decryption fails.

5 new tests: roundtrip, key uniqueness, wrong-key rejection,
empty values, org vs user key separation.
This commit is contained in:
2025-12-28 00:13:50 -08:00
parent 0357e59835
commit db0d44f3c7
2 changed files with 231 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
package kvstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
)
// EncryptedKVStore wraps a KVStore with per-key AES-256-GCM encryption.
// Each key-value pair is encrypted with a unique DEK derived from a master
// key + the storage key name. This means even if the underlying ZapDB is
// fully dumped, individual entries are independently encrypted.
//
// Usage:
//
// base := zapdb.New(path, nodePassword)
// userStore := NewEncryptedKVStore(base, DeriveUserKey(masterKey, orgID, userID))
//
// The userStore encrypts all values before writing to ZapDB and decrypts
// on read. The underlying ZapDB still has its own ChaCha20-Poly1305
// encryption — this is a second layer specifically for per-user isolation.
type EncryptedKVStore struct {
inner KVStore
key []byte // 32-byte AES-256 key
}
// NewEncryptedKVStore wraps an existing KVStore with per-entry AES-256-GCM encryption.
// The key must be exactly 32 bytes.
func NewEncryptedKVStore(inner KVStore, key []byte) (*EncryptedKVStore, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encrypted kvstore requires 32-byte key, got %d", len(key))
}
return &EncryptedKVStore{inner: inner, key: key}, nil
}
// DeriveUserKey derives a 32-byte per-user encryption key from a master key.
//
// key = HMAC-SHA256(masterKey, "mpc:shard:" + orgID + ":" + userID)
//
// Each user gets a unique key. Compromising one user's key does not affect others.
func DeriveUserKey(masterKey []byte, orgID, userID string) []byte {
mac := hmac.New(sha256.New, masterKey)
mac.Write([]byte("mpc:shard:" + orgID + ":" + userID))
return mac.Sum(nil)
}
// DeriveOrgKey derives a 32-byte per-org encryption key.
func DeriveOrgKey(masterKey []byte, orgID string) []byte {
mac := hmac.New(sha256.New, masterKey)
mac.Write([]byte("mpc:org:" + orgID))
return mac.Sum(nil)
}
func (s *EncryptedKVStore) Put(key string, value []byte) error {
encrypted, err := s.encrypt(value)
if err != nil {
return fmt.Errorf("encrypt value for key %q: %w", key, err)
}
return s.inner.Put(key, encrypted)
}
func (s *EncryptedKVStore) Get(key string) ([]byte, error) {
encrypted, err := s.inner.Get(key)
if err != nil {
return nil, err
}
if len(encrypted) == 0 {
return encrypted, nil
}
return s.decrypt(encrypted)
}
func (s *EncryptedKVStore) Delete(key string) error {
return s.inner.Delete(key)
}
func (s *EncryptedKVStore) Close() error {
return s.inner.Close()
}
func (s *EncryptedKVStore) Backup() error {
return s.inner.Backup()
}
// encrypt uses AES-256-GCM with a random 12-byte nonce prepended to ciphertext.
func (s *EncryptedKVStore) encrypt(plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// decrypt expects nonce prepended to ciphertext (as produced by encrypt).
func (s *EncryptedKVStore) decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ct, nil)
}
+109
View File
@@ -0,0 +1,109 @@
package kvstore
import (
"bytes"
"testing"
)
type memKV struct {
data map[string][]byte
}
func newMemKV() *memKV { return &memKV{data: make(map[string][]byte)} }
func (m *memKV) Put(k string, v []byte) error { m.data[k] = v; return nil }
func (m *memKV) Get(k string) ([]byte, error) { return m.data[k], nil }
func (m *memKV) Delete(k string) error { delete(m.data, k); return nil }
func (m *memKV) Close() error { return nil }
func (m *memKV) Backup() error { return nil }
func TestEncryptedKVStoreRoundtrip(t *testing.T) {
inner := newMemKV()
key := DeriveUserKey([]byte("master-secret-key-for-testing!!"), "acme", "user001")
store, err := NewEncryptedKVStore(inner, key)
if err != nil {
t.Fatal(err)
}
plaintext := []byte("this is a secret MPC key share shard data")
if err := store.Put("org:acme:wallet_001", plaintext); err != nil {
t.Fatal(err)
}
// Verify the underlying store has encrypted (different) data
raw := inner.data["org:acme:wallet_001"]
if bytes.Equal(raw, plaintext) {
t.Error("underlying store has plaintext — encryption not working")
}
if len(raw) == 0 {
t.Error("underlying store is empty")
}
// Decrypt via the encrypted store
decrypted, err := store.Get("org:acme:wallet_001")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("decrypted = %q, want %q", decrypted, plaintext)
}
}
func TestDifferentUsersGetDifferentKeys(t *testing.T) {
master := []byte("master-secret-key-for-testing!!")
key1 := DeriveUserKey(master, "acme", "user001")
key2 := DeriveUserKey(master, "acme", "user002")
key3 := DeriveUserKey(master, "beta", "user001")
if bytes.Equal(key1, key2) {
t.Error("same org, different users should get different keys")
}
if bytes.Equal(key1, key3) {
t.Error("different orgs, same user ID should get different keys")
}
if bytes.Equal(key2, key3) {
t.Error("different org+user combos should get different keys")
}
}
func TestWrongKeyCannotDecrypt(t *testing.T) {
inner := newMemKV()
master := []byte("master-secret-key-for-testing!!")
store1, _ := NewEncryptedKVStore(inner, DeriveUserKey(master, "acme", "user001"))
store2, _ := NewEncryptedKVStore(inner, DeriveUserKey(master, "acme", "user002"))
plaintext := []byte("user001's secret shard")
store1.Put("shard_001", plaintext)
// user002's key cannot decrypt user001's data
_, err := store2.Get("shard_001")
if err == nil {
t.Error("expected decryption error with wrong key")
}
}
func TestEmptyValueRoundtrip(t *testing.T) {
inner := newMemKV()
key := DeriveUserKey([]byte("master-secret-key-for-testing!!"), "acme", "user001")
store, _ := NewEncryptedKVStore(inner, key)
store.Put("empty", []byte{})
got, err := store.Get("empty")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("expected empty, got %d bytes", len(got))
}
}
func TestOrgKeyDiffersFromUserKey(t *testing.T) {
master := []byte("master-secret-key-for-testing!!")
orgKey := DeriveOrgKey(master, "acme")
userKey := DeriveUserKey(master, "acme", "user001")
if bytes.Equal(orgKey, userKey) {
t.Error("org key and user key should differ")
}
}