Implement Beat 2: Age Encryption Envelope

This commit completes Beat 2 of the SequentialThinkingForCHORUS implementation,
adding end-to-end age encryption for all MCP communications.

## Deliverables

### 1. Age Encryption/Decryption Package (pkg/seqthink/ageio/)
- `crypto.go`: Core encryption/decryption with age
- `testkeys.go`: Test key generation and convenience functions
- `crypto_test.go`: Comprehensive unit tests (11 tests, all passing)
- `golden_test.go`: Golden tests with real MCP payloads (12 tests, all passing)

**Features:**
- File-based identity and recipient key loading
- Streaming encryption/decryption support
- Proper error handling for all failure modes
- Performance benchmarks showing 400+ MB/s throughput

**Test Coverage:**
- Round-trip encryption/decryption for various payload sizes
- Unicode and emoji support
- Large payload handling (100KB+)
- Invalid ciphertext rejection
- Wrong key detection
- Truncated/modified ciphertext detection

### 2. Encrypted Proxy Handlers (pkg/seqthink/proxy/)
- `server_encrypted.go`: Encrypted tool call handler
- Updated `server.go`: Automatic routing based on encryption config
- Content-Type enforcement: `application/age` required when encryption enabled
- Metrics tracking for encryption/decryption failures

**Flow:**
1. Client sends encrypted request with `Content-Type: application/age`
2. Wrapper decrypts using age identity
3. Wrapper calls MCP server (plaintext on loopback)
4. Wrapper encrypts response
5. Client receives encrypted response with `Content-Type: application/age`

### 3. SSE Streaming with Encryption (pkg/seqthink/proxy/sse.go)
- `handleSSEEncrypted()`: Encrypted Server-Sent Events streaming
- `handleSSEPlaintext()`: Plaintext SSE for testing
- Base64-encoded encrypted frames for SSE transport
- `DecryptSSEFrame()`: Client-side frame decryption helper
- `ReadSSEStream()`: SSE stream parsing utility

**SSE Frame Format (Encrypted):**
```
event: thought
data: <base64-encoded age-encrypted JSON>
id: 1
```

### 4. Configuration-Based Mode Switching
The wrapper now operates in two modes based on environment variables:

**Encrypted Mode** (AGE_IDENT_PATH and AGE_RECIPS_PATH set):
- All requests/responses encrypted with age
- Content-Type: application/age enforced
- SSE frames base64-encoded and encrypted

**Plaintext Mode** (no encryption paths set):
- Direct plaintext proxying for development/testing
- Standard JSON Content-Type
- Plaintext SSE frames

## Testing Results

### Unit Tests
```
PASS: TestEncryptDecryptRoundTrip (all variants)
PASS: TestEncryptEmptyData
PASS: TestDecryptEmptyData
PASS: TestDecryptInvalidCiphertext
PASS: TestDecryptWrongKey
PASS: TestStreamingEncryptDecrypt
PASS: TestConvenienceFunctions
```

### Golden Tests
```
PASS: TestGoldenEncryptionRoundTrip (7 scenarios)
  - sequential_thinking_request (283→483 bytes, 70.7% overhead)
  - sequential_thinking_revision (303→503 bytes, 66.0% overhead)
  - sequential_thinking_branching (315→515 bytes, 63.5% overhead)
  - sequential_thinking_final (320→520 bytes, 62.5% overhead)
  - large_context_payload (3800→4000 bytes, 5.3% overhead)
  - unicode_payload (264→464 bytes, 75.8% overhead)
  - special_characters (140→340 bytes, 142.9% overhead)

PASS: TestGoldenDecryptionFailures (5 scenarios)
```

### Performance Benchmarks
```
Encryption:
  - 1KB:   5.44 MB/s
  - 10KB:  52.57 MB/s
  - 100KB: 398.66 MB/s

Decryption:
  - 1KB:   9.22 MB/s
  - 10KB:  85.41 MB/s
  - 100KB: 504.46 MB/s
```

## Security Properties

 **Confidentiality**: All payloads encrypted with age (X25519+ChaCha20-Poly1305)
 **Authenticity**: age provides AEAD with Poly1305 MAC
 **Forward Secrecy**: Each encryption uses fresh ephemeral keys
 **Key Management**: File-based identity/recipient keys
 **Tampering Detection**: Modified ciphertext rejected
 **No Plaintext Leakage**: MCP server only on 127.0.0.1 loopback

## Next Steps (Beat 3)

Beat 3 will add KACHING JWT policy enforcement:
- JWT token validation (`pkg/seqthink/policy/`)
- Scope checking for `sequentialthinking.run`
- JWKS fetching and caching
- Policy denial metrics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
anthonyrawlins
2025-10-13 08:42:28 +11:00
parent 3ce9811826
commit a658a7364d
9 changed files with 1271 additions and 10 deletions

View File

@@ -0,0 +1,126 @@
package ageio
import (
"bytes"
"fmt"
"io"
"os"
"filippo.io/age"
)
// Encryptor handles age encryption operations
type Encryptor struct {
recipients []age.Recipient
}
// Decryptor handles age decryption operations
type Decryptor struct {
identities []age.Identity
}
// NewEncryptor creates an encryptor from a recipients file
func NewEncryptor(recipientsPath string) (*Encryptor, error) {
if recipientsPath == "" {
return nil, fmt.Errorf("recipients path is empty")
}
data, err := os.ReadFile(recipientsPath)
if err != nil {
return nil, fmt.Errorf("read recipients file: %w", err)
}
recipients, err := age.ParseRecipients(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("parse recipients: %w", err)
}
if len(recipients) == 0 {
return nil, fmt.Errorf("no recipients found in file")
}
return &Encryptor{recipients: recipients}, nil
}
// NewDecryptor creates a decryptor from an identity file
func NewDecryptor(identityPath string) (*Decryptor, error) {
if identityPath == "" {
return nil, fmt.Errorf("identity path is empty")
}
data, err := os.ReadFile(identityPath)
if err != nil {
return nil, fmt.Errorf("read identity file: %w", err)
}
identities, err := age.ParseIdentities(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("parse identities: %w", err)
}
if len(identities) == 0 {
return nil, fmt.Errorf("no identities found in file")
}
return &Decryptor{identities: identities}, nil
}
// Encrypt encrypts plaintext data with age
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
if len(plaintext) == 0 {
return nil, fmt.Errorf("plaintext is empty")
}
var buf bytes.Buffer
w, err := age.Encrypt(&buf, e.recipients...)
if err != nil {
return nil, fmt.Errorf("create encryptor: %w", err)
}
if _, err := w.Write(plaintext); err != nil {
return nil, fmt.Errorf("write plaintext: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close encryptor: %w", err)
}
return buf.Bytes(), nil
}
// Decrypt decrypts age-encrypted data
func (d *Decryptor) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, fmt.Errorf("ciphertext is empty")
}
r, err := age.Decrypt(bytes.NewReader(ciphertext), d.identities...)
if err != nil {
return nil, fmt.Errorf("create decryptor: %w", err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read plaintext: %w", err)
}
return plaintext, nil
}
// EncryptStream creates an encrypted writer for streaming
func (e *Encryptor) EncryptStream(w io.Writer) (io.WriteCloser, error) {
ew, err := age.Encrypt(w, e.recipients...)
if err != nil {
return nil, fmt.Errorf("create stream encryptor: %w", err)
}
return ew, nil
}
// DecryptStream creates a decrypted reader for streaming
func (d *Decryptor) DecryptStream(r io.Reader) (io.Reader, error) {
dr, err := age.Decrypt(r, d.identities...)
if err != nil {
return nil, fmt.Errorf("create stream decryptor: %w", err)
}
return dr, nil
}

View File

@@ -0,0 +1,291 @@
package ageio
import (
"bytes"
"os"
"path/filepath"
"testing"
"filippo.io/age"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
// Generate test key pair
tmpDir := t.TempDir()
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
// Create encryptor and decryptor
enc, err := NewEncryptor(recipientPath)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
// Test data
testCases := []struct {
name string
plaintext []byte
}{
{
name: "simple text",
plaintext: []byte("hello world"),
},
{
name: "json data",
plaintext: []byte(`{"tool":"sequentialthinking","payload":{"thought":"test"}}`),
},
{
name: "large data",
plaintext: bytes.Repeat([]byte("ABCDEFGHIJ"), 1000), // 10KB
},
{
name: "unicode",
plaintext: []byte("Hello 世界 🌍"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Encrypt
ciphertext, err := enc.Encrypt(tc.plaintext)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Verify ciphertext is not empty and different from plaintext
if len(ciphertext) == 0 {
t.Fatal("ciphertext is empty")
}
if bytes.Equal(ciphertext, tc.plaintext) {
t.Fatal("ciphertext equals plaintext (not encrypted)")
}
// Decrypt
decrypted, err := dec.Decrypt(ciphertext)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
// Verify decrypted matches original
if !bytes.Equal(decrypted, tc.plaintext) {
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted, tc.plaintext)
}
})
}
}
func TestEncryptEmptyData(t *testing.T) {
tmpDir := t.TempDir()
_, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
enc, err := NewEncryptor(recipientPath)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
_, err = enc.Encrypt([]byte{})
if err == nil {
t.Fatal("expected error encrypting empty data")
}
}
func TestDecryptEmptyData(t *testing.T) {
tmpDir := t.TempDir()
identityPath, _, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
_, err = dec.Decrypt([]byte{})
if err == nil {
t.Fatal("expected error decrypting empty data")
}
}
func TestDecryptInvalidCiphertext(t *testing.T) {
tmpDir := t.TempDir()
identityPath, _, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
// Try to decrypt garbage data
_, err = dec.Decrypt([]byte("not a valid age ciphertext"))
if err == nil {
t.Fatal("expected error decrypting invalid ciphertext")
}
}
func TestDecryptWrongKey(t *testing.T) {
tmpDir := t.TempDir()
// Generate two separate key pairs
identity1Path := filepath.Join(tmpDir, "key1.age")
recipient1Path := filepath.Join(tmpDir, "key1.pub")
identity2Path := filepath.Join(tmpDir, "key2.age")
// Create first key pair
id1, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("generate key 1: %v", err)
}
os.WriteFile(identity1Path, []byte(id1.String()+"\n"), 0600)
os.WriteFile(recipient1Path, []byte(id1.Recipient().String()+"\n"), 0644)
// Create second key pair
id2, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("generate key 2: %v", err)
}
os.WriteFile(identity2Path, []byte(id2.String()+"\n"), 0600)
// Encrypt with key 1
enc, err := NewEncryptor(recipient1Path)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
ciphertext, err := enc.Encrypt([]byte("secret message"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Try to decrypt with key 2 (should fail)
dec, err := NewDecryptor(identity2Path)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
_, err = dec.Decrypt(ciphertext)
if err == nil {
t.Fatal("expected error decrypting with wrong key")
}
}
func TestNewEncryptorInvalidPath(t *testing.T) {
_, err := NewEncryptor("/nonexistent/path/to/recipients")
if err == nil {
t.Fatal("expected error with nonexistent recipients file")
}
}
func TestNewDecryptorInvalidPath(t *testing.T) {
_, err := NewDecryptor("/nonexistent/path/to/identity")
if err == nil {
t.Fatal("expected error with nonexistent identity file")
}
}
func TestNewEncryptorEmptyPath(t *testing.T) {
_, err := NewEncryptor("")
if err == nil {
t.Fatal("expected error with empty recipients path")
}
}
func TestNewDecryptorEmptyPath(t *testing.T) {
_, err := NewDecryptor("")
if err == nil {
t.Fatal("expected error with empty identity path")
}
}
func TestStreamingEncryptDecrypt(t *testing.T) {
// Generate test key pair
tmpDir := t.TempDir()
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
// Create encryptor and decryptor
enc, err := NewEncryptor(recipientPath)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
// Test streaming encryption
plaintext := []byte("streaming test data")
var ciphertextBuf bytes.Buffer
encWriter, err := enc.EncryptStream(&ciphertextBuf)
if err != nil {
t.Fatalf("create encrypt stream: %v", err)
}
if _, err := encWriter.Write(plaintext); err != nil {
t.Fatalf("write to encrypt stream: %v", err)
}
if err := encWriter.Close(); err != nil {
t.Fatalf("close encrypt stream: %v", err)
}
// Test streaming decryption
decReader, err := dec.DecryptStream(&ciphertextBuf)
if err != nil {
t.Fatalf("create decrypt stream: %v", err)
}
decrypted := make([]byte, len(plaintext))
n, err := decReader.Read(decrypted)
if err != nil {
t.Fatalf("read from decrypt stream: %v", err)
}
if !bytes.Equal(decrypted[:n], plaintext) {
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted[:n], plaintext)
}
}
func TestConvenienceFunctions(t *testing.T) {
// Generate test keys in memory
identity, recipient, err := GenerateTestKeys()
if err != nil {
t.Fatalf("generate test keys: %v", err)
}
plaintext := []byte("test message")
// Encrypt with convenience function
ciphertext, err := EncryptBytes(plaintext, recipient)
if err != nil {
t.Fatalf("encrypt bytes: %v", err)
}
// Decrypt with convenience function
decrypted, err := DecryptBytes(ciphertext, identity)
if err != nil {
t.Fatalf("decrypt bytes: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted, plaintext)
}
}

View File

@@ -0,0 +1,354 @@
package ageio
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// TestGoldenEncryptionRoundTrip validates encryption/decryption with golden test data
func TestGoldenEncryptionRoundTrip(t *testing.T) {
// Generate test key pair once
tmpDir := t.TempDir()
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
// Create encryptor and decryptor
enc, err := NewEncryptor(recipientPath)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
// Golden test cases representing real MCP payloads
goldenTests := []struct {
name string
payload []byte
description string
}{
{
name: "sequential_thinking_request",
payload: []byte(`{
"tool": "mcp__sequential-thinking__sequentialthinking",
"payload": {
"thought": "First, I need to analyze the problem by breaking it down into smaller components.",
"thoughtNumber": 1,
"totalThoughts": 5,
"nextThoughtNeeded": true,
"isRevision": false
}
}`),
description: "Initial sequential thinking request",
},
{
name: "sequential_thinking_revision",
payload: []byte(`{
"tool": "mcp__sequential-thinking__sequentialthinking",
"payload": {
"thought": "Wait, I need to revise my previous thought - I missed considering edge cases.",
"thoughtNumber": 3,
"totalThoughts": 6,
"nextThoughtNeeded": true,
"isRevision": true,
"revisesThought": 2
}
}`),
description: "Revision of previous thought",
},
{
name: "sequential_thinking_branching",
payload: []byte(`{
"tool": "mcp__sequential-thinking__sequentialthinking",
"payload": {
"thought": "Let me explore an alternative approach using event sourcing instead.",
"thoughtNumber": 4,
"totalThoughts": 8,
"nextThoughtNeeded": true,
"branchFromThought": 2,
"branchId": "alternative-approach-1"
}
}`),
description: "Branching to explore alternative",
},
{
name: "sequential_thinking_final",
payload: []byte(`{
"tool": "mcp__sequential-thinking__sequentialthinking",
"payload": {
"thought": "Based on all previous analysis, I recommend implementing the event sourcing pattern with CQRS for optimal scalability.",
"thoughtNumber": 8,
"totalThoughts": 8,
"nextThoughtNeeded": false,
"confidence": 0.85
}
}`),
description: "Final thought with conclusion",
},
{
name: "large_context_payload",
payload: bytes.Repeat([]byte(`{"key": "value", "data": "ABCDEFGHIJ"}`), 100),
description: "Large payload testing encryption of substantial data",
},
{
name: "unicode_payload",
payload: []byte(`{
"tool": "mcp__sequential-thinking__sequentialthinking",
"payload": {
"thought": "分析日本語でのデータ処理 🌸🎌 and mixed language content: 你好世界",
"thoughtNumber": 1,
"totalThoughts": 1,
"nextThoughtNeeded": false
}
}`),
description: "Unicode and emoji content",
},
{
name: "special_characters",
payload: []byte(`{
"tool": "test",
"payload": {
"special": "Testing: \n\t\r\b\"'\\\/\u0000\u001f",
"symbols": "!@#$%^&*()_+-=[]{}|;:,.<>?~"
}
}`),
description: "Special characters and escape sequences",
},
}
for _, gt := range goldenTests {
t.Run(gt.name, func(t *testing.T) {
t.Logf("Testing: %s", gt.description)
t.Logf("Original size: %d bytes", len(gt.payload))
// Encrypt
ciphertext, err := enc.Encrypt(gt.payload)
if err != nil {
t.Fatalf("encrypt failed: %v", err)
}
t.Logf("Encrypted size: %d bytes (%.1f%% overhead)",
len(ciphertext),
float64(len(ciphertext)-len(gt.payload))/float64(len(gt.payload))*100)
// Verify ciphertext is different from plaintext
if bytes.Equal(ciphertext, gt.payload) {
t.Fatal("ciphertext equals plaintext - encryption failed")
}
// Verify ciphertext doesn't contain plaintext patterns
// (basic sanity check - not cryptographically rigorous)
if bytes.Contains(ciphertext, []byte("mcp__sequential-thinking")) {
t.Error("ciphertext contains plaintext patterns - weak encryption")
}
// Decrypt
decrypted, err := dec.Decrypt(ciphertext)
if err != nil {
t.Fatalf("decrypt failed: %v", err)
}
// Verify perfect round-trip
if !bytes.Equal(decrypted, gt.payload) {
t.Errorf("decrypted data doesn't match original\nOriginal: %s\nDecrypted: %s",
string(gt.payload), string(decrypted))
}
// Optional: Save golden files for inspection
if os.Getenv("SAVE_GOLDEN") == "1" {
goldenDir := filepath.Join(tmpDir, "golden")
os.MkdirAll(goldenDir, 0755)
plainPath := filepath.Join(goldenDir, gt.name+".plain.json")
encPath := filepath.Join(goldenDir, gt.name+".encrypted.age")
os.WriteFile(plainPath, gt.payload, 0644)
os.WriteFile(encPath, ciphertext, 0644)
t.Logf("Golden files saved to: %s", goldenDir)
}
})
}
}
// TestGoldenDecryptionFailures validates proper error handling
func TestGoldenDecryptionFailures(t *testing.T) {
tmpDir := t.TempDir()
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
t.Fatalf("generate test key pair: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
t.Fatalf("create decryptor: %v", err)
}
enc, err := NewEncryptor(recipientPath)
if err != nil {
t.Fatalf("create encryptor: %v", err)
}
failureTests := []struct {
name string
ciphertext []byte
expectError string
}{
{
name: "empty_ciphertext",
ciphertext: []byte{},
expectError: "ciphertext is empty",
},
{
name: "invalid_age_format",
ciphertext: []byte("not a valid age ciphertext"),
expectError: "create decryptor",
},
{
name: "corrupted_header",
ciphertext: []byte("-----BEGIN AGE ENCRYPTED FILE-----\ngarbage\n-----END AGE ENCRYPTED FILE-----"),
expectError: "create decryptor",
},
}
for _, ft := range failureTests {
t.Run(ft.name, func(t *testing.T) {
_, err := dec.Decrypt(ft.ciphertext)
if err == nil {
t.Fatal("expected error but got none")
}
// Just verify we got an error - specific error messages may vary
t.Logf("Got expected error: %v", err)
})
}
// Test truncated ciphertext
t.Run("truncated_ciphertext", func(t *testing.T) {
// Create valid ciphertext
validPlaintext := []byte("test message")
validCiphertext, err := enc.Encrypt(validPlaintext)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Truncate it
truncated := validCiphertext[:len(validCiphertext)/2]
// Try to decrypt
_, err = dec.Decrypt(truncated)
if err == nil {
t.Fatal("expected error decrypting truncated ciphertext")
}
t.Logf("Got expected error for truncated ciphertext: %v", err)
})
// Test modified ciphertext
t.Run("modified_ciphertext", func(t *testing.T) {
// Create valid ciphertext
validPlaintext := []byte("test message")
validCiphertext, err := enc.Encrypt(validPlaintext)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
// Flip a bit in the middle
modified := make([]byte, len(validCiphertext))
copy(modified, validCiphertext)
modified[len(modified)/2] ^= 0x01
// Try to decrypt
_, err = dec.Decrypt(modified)
if err == nil {
t.Fatal("expected error decrypting modified ciphertext")
}
t.Logf("Got expected error for modified ciphertext: %v", err)
})
}
// BenchmarkEncryption benchmarks encryption performance
func BenchmarkEncryption(b *testing.B) {
tmpDir := b.TempDir()
_, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
b.Fatalf("generate test key pair: %v", err)
}
enc, err := NewEncryptor(recipientPath)
if err != nil {
b.Fatalf("create encryptor: %v", err)
}
payloads := map[string][]byte{
"small_1KB": bytes.Repeat([]byte("A"), 1024),
"medium_10KB": bytes.Repeat([]byte("A"), 10*1024),
"large_100KB": bytes.Repeat([]byte("A"), 100*1024),
}
for name, payload := range payloads {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := enc.Encrypt(payload)
if err != nil {
b.Fatalf("encrypt: %v", err)
}
}
})
}
}
// BenchmarkDecryption benchmarks decryption performance
func BenchmarkDecryption(b *testing.B) {
tmpDir := b.TempDir()
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
if err != nil {
b.Fatalf("generate test key pair: %v", err)
}
enc, err := NewEncryptor(recipientPath)
if err != nil {
b.Fatalf("create encryptor: %v", err)
}
dec, err := NewDecryptor(identityPath)
if err != nil {
b.Fatalf("create decryptor: %v", err)
}
payloads := map[string][]byte{
"small_1KB": bytes.Repeat([]byte("A"), 1024),
"medium_10KB": bytes.Repeat([]byte("A"), 10*1024),
"large_100KB": bytes.Repeat([]byte("A"), 100*1024),
}
for name, payload := range payloads {
// Pre-encrypt
ciphertext, err := enc.Encrypt(payload)
if err != nil {
b.Fatalf("encrypt: %v", err)
}
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := dec.Decrypt(ciphertext)
if err != nil {
b.Fatalf("decrypt: %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,88 @@
package ageio
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"filippo.io/age"
)
// GenerateTestKeyPair generates a test age key pair and returns paths
func GenerateTestKeyPair(dir string) (identityPath, recipientPath string, err error) {
// Generate identity
identity, err := age.GenerateX25519Identity()
if err != nil {
return "", "", fmt.Errorf("generate identity: %w", err)
}
// Create identity file
identityPath = filepath.Join(dir, "age.key")
if err := os.WriteFile(identityPath, []byte(identity.String()+"\n"), 0600); err != nil {
return "", "", fmt.Errorf("write identity file: %w", err)
}
// Create recipient file
recipientPath = filepath.Join(dir, "age.pub")
recipient := identity.Recipient().String()
if err := os.WriteFile(recipientPath, []byte(recipient+"\n"), 0644); err != nil {
return "", "", fmt.Errorf("write recipient file: %w", err)
}
return identityPath, recipientPath, nil
}
// GenerateTestKeys generates test keys in memory
func GenerateTestKeys() (identity age.Identity, recipient age.Recipient, err error) {
id, err := age.GenerateX25519Identity()
if err != nil {
return nil, nil, fmt.Errorf("generate identity: %w", err)
}
return id, id.Recipient(), nil
}
// MustGenerateTestKeyPair generates a test key pair or panics
func MustGenerateTestKeyPair(dir string) (identityPath, recipientPath string) {
identityPath, recipientPath, err := GenerateTestKeyPair(dir)
if err != nil {
panic(fmt.Sprintf("failed to generate test key pair: %v", err))
}
return identityPath, recipientPath
}
// EncryptBytes is a convenience function for one-shot encryption
func EncryptBytes(plaintext []byte, recipients ...age.Recipient) ([]byte, error) {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipients...)
if err != nil {
return nil, fmt.Errorf("create encryptor: %w", err)
}
if _, err := w.Write(plaintext); err != nil {
return nil, fmt.Errorf("write plaintext: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close encryptor: %w", err)
}
return buf.Bytes(), nil
}
// DecryptBytes is a convenience function for one-shot decryption
func DecryptBytes(ciphertext []byte, identities ...age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(ciphertext), identities...)
if err != nil {
return nil, fmt.Errorf("create decryptor: %w", err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read plaintext: %w", err)
}
return plaintext, nil
}