From 8d04b0fde96562a0806299dbfbf6cf7b5f3dbc26 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 21 Jun 2026 23:32:23 +0100 Subject: [PATCH] feat(crypto): AES-256-GCM field encryption keyed from EMCLI_KEY Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/crypto/crypto.go | 65 ++++++++++++++++++++++++++++++++ internal/crypto/crypto_test.go | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/crypto_test.go diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..1d00c10 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,65 @@ +// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" +) + +var ( + ErrNoKey = errors.New("EMCLI_KEY is not set") + ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes") +) + +// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY. +func KeyFromEnv() ([]byte, error) { + raw := os.Getenv("EMCLI_KEY") + if raw == "" { + return nil, ErrNoKey + } + key, err := base64.StdEncoding.DecodeString(raw) + if err != nil || len(key) != 32 { + return nil, ErrBadKey + } + return key, nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// Seal encrypts plaintext, returning nonce||ciphertext. +func Seal(key, plaintext []byte) ([]byte, error) { + gcm, err := newGCM(key) + 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 +} + +// Open reverses Seal. A wrong key or tampered blob returns an error. +func Open(key, blob []byte) ([]byte, error) { + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + ns := gcm.NonceSize() + if len(blob) < ns { + return nil, errors.New("ciphertext too short") + } + nonce, ct := blob[:ns], blob[ns:] + return gcm.Open(nil, nonce, ct, nil) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..62e86f7 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,69 @@ +package crypto + +import ( + "bytes" + "encoding/base64" + "testing" +) + +func testKey() []byte { + k := make([]byte, 32) + for i := range k { + k[i] = byte(i) + } + return k +} + +func TestSealOpenRoundTrip(t *testing.T) { + key := testKey() + msg := []byte("hunter2-the-password") + blob, err := Seal(key, msg) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if bytes.Contains(blob, msg) { + t.Fatal("ciphertext must not contain plaintext") + } + got, err := Open(key, blob) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(got, msg) { + t.Fatalf("round-trip mismatch: %q", got) + } +} + +func TestSealUsesRandomNonce(t *testing.T) { + key := testKey() + a, _ := Seal(key, []byte("x")) + b, _ := Seal(key, []byte("x")) + if bytes.Equal(a, b) { + t.Fatal("two seals of same plaintext must differ (random nonce)") + } +} + +func TestOpenWrongKeyFails(t *testing.T) { + blob, _ := Seal(testKey(), []byte("secret")) + wrong := make([]byte, 32) // all zeros + if _, err := Open(wrong, blob); err == nil { + t.Fatal("Open with wrong key must fail") + } +} + +func TestKeyFromEnv(t *testing.T) { + t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey())) + k, err := KeyFromEnv() + if err != nil || len(k) != 32 { + t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err) + } + + t.Setenv("EMCLI_KEY", "") + if _, err := KeyFromEnv(); err != ErrNoKey { + t.Fatalf("empty key: want ErrNoKey, got %v", err) + } + + t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort"))) + if _, err := KeyFromEnv(); err != ErrBadKey { + t.Fatalf("short key: want ErrBadKey, got %v", err) + } +}