Files
metacrypt/internal/engine/user/user_test.go
Kyle Isom be3b9d7fe0 Add user-to-user encryption engine with ECDH key exchange and AES-256-GCM
Implements the complete user engine for multi-recipient envelope encryption:
- ECDH key agreement (X25519, P-256, P-384) with HKDF-derived wrapping keys
- Per-message random DEK wrapped individually for each recipient
- 9 operations: register, provision, get-public-key, list-users, encrypt,
  decrypt, re-encrypt, rotate-key, delete-user
- Auto-provisioning of sender and recipients on encrypt
- Role-based authorization (admin-only provision/delete, user-only decrypt)
- gRPC UserService with proto definitions and REST API routes
- 16 comprehensive tests covering lifecycle, crypto roundtrips, multi-recipient,
  key rotation, auth enforcement, and algorithm variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:44:11 -07:00

740 lines
18 KiB
Go

package user
import (
"context"
"encoding/base64"
"strings"
"sync"
"testing"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
// memBarrier is an in-memory barrier for testing.
type memBarrier struct {
data map[string][]byte
mu sync.RWMutex
}
func newMemBarrier() *memBarrier {
return &memBarrier{data: make(map[string][]byte)}
}
func (m *memBarrier) Unseal(_ []byte) error { return nil }
func (m *memBarrier) Seal() error { return nil }
func (m *memBarrier) IsSealed() bool { return false }
func (m *memBarrier) Get(_ context.Context, path string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[path]
if !ok {
return nil, barrier.ErrNotFound
}
cp := make([]byte, len(v))
copy(cp, v)
return cp, nil
}
func (m *memBarrier) Put(_ context.Context, path string, value []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]byte, len(value))
copy(cp, value)
m.data[path] = cp
return nil
}
func (m *memBarrier) Delete(_ context.Context, path string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, path)
return nil
}
func (m *memBarrier) List(_ context.Context, prefix string) ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var paths []string
for k := range m.data {
if strings.HasPrefix(k, prefix) {
paths = append(paths, strings.TrimPrefix(k, prefix))
}
}
return paths, nil
}
func adminCaller() *engine.CallerInfo {
return &engine.CallerInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}
}
func userCaller(name string) *engine.CallerInfo {
return &engine.CallerInfo{Username: name, Roles: []string{"user"}, IsAdmin: false}
}
func guestCaller() *engine.CallerInfo {
return &engine.CallerInfo{Username: "guest", Roles: []string{"guest"}, IsAdmin: false}
}
func setupEngine(t *testing.T) (*UserEngine, *memBarrier) {
t.Helper()
b := newMemBarrier()
eng := NewUserEngine().(*UserEngine) //nolint:errcheck
ctx := context.Background()
config := map[string]interface{}{
"key_algorithm": "x25519",
"sym_algorithm": "aes256-gcm",
}
if err := eng.Initialize(ctx, b, "engine/user/test/", config); err != nil {
t.Fatalf("Initialize: %v", err)
}
return eng, b
}
func TestInitializeAndUnseal(t *testing.T) {
eng, b := setupEngine(t)
ctx := context.Background()
// Register a user.
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("register: %v", err)
}
if resp.Data["username"] != "alice" {
t.Fatalf("expected username alice, got %v", resp.Data["username"])
}
// Seal and unseal.
if err := eng.Seal(); err != nil {
t.Fatalf("seal: %v", err)
}
eng2 := NewUserEngine().(*UserEngine) //nolint:errcheck
if err := eng2.Unseal(ctx, b, "engine/user/test/"); err != nil {
t.Fatalf("unseal: %v", err)
}
// Verify alice's key is loaded.
resp, err = eng2.HandleRequest(ctx, &engine.Request{
Operation: "get-public-key",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"username": "alice"},
})
if err != nil {
t.Fatalf("get-public-key after unseal: %v", err)
}
if resp.Data["username"] != "alice" {
t.Fatalf("expected alice, got %v", resp.Data["username"])
}
}
func TestRegisterCreatesKeypair(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("register: %v", err)
}
pubKey, ok := resp.Data["public_key"].(string)
if !ok || pubKey == "" {
t.Fatal("expected non-empty public key")
}
// Decode to verify it's valid base64.
raw, err := base64.StdEncoding.DecodeString(pubKey)
if err != nil {
t.Fatalf("decode public key: %v", err)
}
if len(raw) != 32 { // X25519 public key is 32 bytes
t.Fatalf("expected 32-byte public key, got %d", len(raw))
}
}
func TestRegisterIdempotent(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
resp1, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("register 1: %v", err)
}
resp2, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("register 2: %v", err)
}
if resp1.Data["public_key"] != resp2.Data["public_key"] {
t.Fatal("register should be idempotent")
}
}
func TestEncryptDecryptSingleRecipient(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Register alice and bob.
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// Alice encrypts to bob.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "hello bob",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
envelope, ok := encResp.Data["envelope"].(string)
if !ok || envelope == "" {
t.Fatal("expected non-empty envelope")
}
// Bob decrypts.
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": envelope},
})
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if decResp.Data["plaintext"] != "hello bob" {
t.Fatalf("expected 'hello bob', got %v", decResp.Data["plaintext"])
}
if decResp.Data["sender"] != "alice" {
t.Fatalf("expected sender alice, got %v", decResp.Data["sender"])
}
}
func TestEncryptDecryptWithMetadata(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "secret message",
"metadata": "important context",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if decResp.Data["plaintext"] != "secret message" {
t.Fatalf("plaintext mismatch: %v", decResp.Data["plaintext"])
}
if decResp.Data["metadata"] != "important context" {
t.Fatalf("metadata mismatch: %v", decResp.Data["metadata"])
}
}
func TestMultiRecipientEncryptDecrypt(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
users := []string{"alice", "bob", "charlie"}
for _, name := range users {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// Alice encrypts to bob and charlie.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "hello everyone",
"recipients": []interface{}{"bob", "charlie"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
envelope := encResp.Data["envelope"].(string)
// Both bob and charlie can decrypt.
for _, name := range []string{"bob", "charlie"} {
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller(name),
Data: map[string]interface{}{"envelope": envelope},
})
if err != nil {
t.Fatalf("decrypt by %s: %v", name, err)
}
if decResp.Data["plaintext"] != "hello everyone" {
t.Fatalf("%s: expected 'hello everyone', got %v", name, decResp.Data["plaintext"])
}
}
// Alice (not a recipient) cannot decrypt.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{"envelope": envelope},
})
if err == nil {
t.Fatal("expected error when non-recipient decrypts")
}
}
func TestReEncrypt(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// Alice encrypts to bob.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "secret",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Bob re-encrypts.
reEncResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "re-encrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err != nil {
t.Fatalf("re-encrypt: %v", err)
}
// Bob can decrypt re-encrypted envelope.
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": reEncResp.Data["envelope"]},
})
if err != nil {
t.Fatalf("decrypt re-encrypted: %v", err)
}
if decResp.Data["plaintext"] != "secret" {
t.Fatalf("expected 'secret', got %v", decResp.Data["plaintext"])
}
if decResp.Data["sender"] != "bob" {
t.Fatalf("expected sender bob after re-encrypt, got %v", decResp.Data["sender"])
}
}
func TestRotateKey(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// Alice encrypts to bob.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "before rotation",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Bob rotates key.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "rotate-key",
CallerInfo: userCaller("bob"),
})
if err != nil {
t.Fatalf("rotate-key: %v", err)
}
// Old envelope should fail to decrypt (sender's pubkey is used to unwrap,
// but the DEK was wrapped with old recipient key).
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err == nil {
t.Fatal("expected decrypt to fail after key rotation")
}
// New encrypt/decrypt should work.
encResp2, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "after rotation",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt after rotation: %v", err)
}
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp2.Data["envelope"]},
})
if err != nil {
t.Fatalf("decrypt after rotation: %v", err)
}
if decResp.Data["plaintext"] != "after rotation" {
t.Fatalf("expected 'after rotation', got %v", decResp.Data["plaintext"])
}
}
func TestAutoProvisionOnEncrypt(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Encrypt without pre-registering anyone.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "auto-provision test",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Both alice and bob should be auto-provisioned. bob can decrypt.
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if decResp.Data["plaintext"] != "auto-provision test" {
t.Fatalf("expected 'auto-provision test', got %v", decResp.Data["plaintext"])
}
}
func TestProvisionAdminOnly(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Non-admin cannot provision.
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "provision",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{"username": "bob"},
})
if err == nil {
t.Fatal("expected error for non-admin provision")
}
// Admin can provision.
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "provision",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"username": "bob"},
})
if err != nil {
t.Fatalf("admin provision: %v", err)
}
if resp.Data["username"] != "bob" {
t.Fatalf("expected bob, got %v", resp.Data["username"])
}
}
func TestDecryptSelfOnly(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
for _, name := range []string{"alice", "bob", "charlie"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// Alice encrypts to bob only.
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "for bob only",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Charlie cannot decrypt.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("charlie"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err == nil {
t.Fatal("expected error when charlie tries to decrypt bob's message")
}
}
func TestGuestRejected(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "list-users",
CallerInfo: guestCaller(),
})
if err == nil {
t.Fatal("expected guest to be rejected from list-users")
}
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "get-public-key",
CallerInfo: guestCaller(),
Data: map[string]interface{}{"username": "alice"},
})
if err == nil {
t.Fatal("expected guest to be rejected from get-public-key")
}
}
func TestDeleteUser(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Register bob.
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("bob"),
})
if err != nil {
t.Fatalf("register: %v", err)
}
// Non-admin cannot delete.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "delete-user",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"username": "bob"},
})
if err == nil {
t.Fatal("expected error for non-admin delete")
}
// Admin can delete.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "delete-user",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"username": "bob"},
})
if err != nil {
t.Fatalf("admin delete: %v", err)
}
// bob should no longer exist.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "get-public-key",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{"username": "bob"},
})
if err == nil {
t.Fatal("expected user not found after delete")
}
}
func TestMaxRecipientsLimit(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("register: %v", err)
}
// Build 101 recipients.
recipients := make([]interface{}, 101)
for i := range recipients {
recipients[i] = "user" + strings.Repeat("x", 5) + string(rune('a'+i%26)) + string(rune('0'+i/26))
}
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "test",
"recipients": recipients,
},
})
if err == nil {
t.Fatal("expected error for too many recipients")
}
if !strings.Contains(err.Error(), "too many recipients") {
t.Fatalf("expected 'too many recipients' error, got: %v", err)
}
}
func TestListUsers(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "list-users",
CallerInfo: userCaller("alice"),
})
if err != nil {
t.Fatalf("list-users: %v", err)
}
users, ok := resp.Data["users"].([]interface{})
if !ok {
t.Fatal("expected users list")
}
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
}
func TestP256Algorithm(t *testing.T) {
b := newMemBarrier()
eng := NewUserEngine().(*UserEngine) //nolint:errcheck
ctx := context.Background()
config := map[string]interface{}{
"key_algorithm": "ecdh-p256",
}
if err := eng.Initialize(ctx, b, "engine/user/p256/", config); err != nil {
t.Fatalf("Initialize: %v", err)
}
for _, name := range []string{"alice", "bob"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "register",
CallerInfo: userCaller(name),
})
if err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
encResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "encrypt",
CallerInfo: userCaller("alice"),
Data: map[string]interface{}{
"plaintext": "p256 test",
"recipients": []interface{}{"bob"},
},
})
if err != nil {
t.Fatalf("encrypt: %v", err)
}
decResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "decrypt",
CallerInfo: userCaller("bob"),
Data: map[string]interface{}{"envelope": encResp.Data["envelope"]},
})
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if decResp.Data["plaintext"] != "p256 test" {
t.Fatalf("expected 'p256 test', got %v", decResp.Data["plaintext"])
}
}