Add Vercel encryption implementation (AES-256-GCM with HKDF)#956
Add Vercel encryption implementation (AES-256-GCM with HKDF)#956TooTallNate wants to merge 1 commit intonate/async-serializationfrom
Conversation
|
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (157 failed)mongodb (39 failed):
redis (39 failed):
starter (40 failed):
turso (39 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
There was a problem hiding this comment.
Pull request overview
This PR adds AES-256-GCM encryption with HKDF-SHA256 key derivation to the @workflow/world-vercel package. The implementation provides per-run encryption isolation by deriving unique keys from a deployment key, project ID, and run ID. It integrates seamlessly with the async serialization infrastructure added in PR #955 and uses the client-generated run IDs from PR #954.
Changes:
- Implements
createEncryptor()andcreateEncryptorFromEnv()functions with full Encryptor interface support - Adds AES-256-GCM encryption with random nonces and 128-bit authentication tags
- Uses HKDF-SHA256 for per-run key derivation with projectId and runId as context
- Wires encryption into
createVercelWorld()via environment variables (VERCEL_DEPLOYMENT_KEY, VERCEL_PROJECT_ID) - Includes 18 comprehensive tests covering round-trip, format validation, isolation, and tamper detection
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
| packages/world-vercel/src/encryption.ts | Core encryption implementation with key derivation, encrypt/decrypt, and key material access |
| packages/world-vercel/src/encryption.test.ts | Comprehensive test suite with 18 tests covering functionality and security properties |
| packages/world-vercel/src/index.ts | Integration into World creation and public API exports |
Comments suppressed due to low confidence (1)
packages/world-vercel/src/index.ts:12
- The VercelEncryptionConfig interface is exported from encryption.ts but not re-exported from index.ts. Users who want to use createEncryptor() directly would need to import from the internal encryption module, which is not a typical pattern.
Consider adding to index.ts:
export type { VercelEncryptionConfig } from './encryption.js';
This follows the pattern already established with APIConfig and makes the public API more discoverable.
export { createEncryptor, createEncryptorFromEnv } from './encryption.js';
export { createQueue } from './queue.js';
export { createStorage } from './storage.js';
export { createStreamer } from './streamer.js';
export type { APIConfig } from './utils.js';
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Create an Encryptor from environment variables. | ||
| * | ||
| * Requires: | ||
| * - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (32+ bytes recommended) |
There was a problem hiding this comment.
The documentation comment says "32+ bytes recommended" but the actual implementation requires exactly 32 bytes. This discrepancy could confuse users who provide keys longer than 32 bytes, which would fail at runtime.
The documentation should be updated to state "exactly 32 bytes" to match the validation in createEncryptor(), or the implementation should be changed to accept keys >= 32 bytes and truncate/hash them to 32 bytes.
| * - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (32+ bytes recommended) | |
| * - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (exactly 32 bytes / 256 bits) |
| describe('createEncryptor', () => { | ||
| const testProjectId = 'prj_test123'; | ||
| const testRunId = 'wrun_abc123'; | ||
| // 32 bytes for AES-256 | ||
| const testDeploymentKey = new Uint8Array([ | ||
| 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, | ||
| 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, | ||
| 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, | ||
| ]); | ||
|
|
||
| const encryptor = createEncryptor({ | ||
| deploymentKey: testDeploymentKey, | ||
| projectId: testProjectId, | ||
| }); | ||
|
|
||
| describe('encrypt/decrypt round-trip', () => { | ||
| it('should encrypt and decrypt data correctly', async () => { | ||
| const plaintext = new TextEncoder().encode('Hello, World!'); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
| const decrypted = await encryptor.decrypt(encrypted, context); | ||
|
|
||
| expect(decrypted).toEqual(plaintext); | ||
| expect(new TextDecoder().decode(decrypted)).toBe('Hello, World!'); | ||
| }); | ||
|
|
||
| it('should encrypt and decrypt empty data', async () => { | ||
| const plaintext = new Uint8Array(0); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
| const decrypted = await encryptor.decrypt(encrypted, context); | ||
|
|
||
| expect(decrypted).toEqual(plaintext); | ||
| }); | ||
|
|
||
| it('should encrypt and decrypt large data', async () => { | ||
| // 64KB of random data (max for getRandomValues) | ||
| const plaintext = new Uint8Array(65536); | ||
| crypto.getRandomValues(plaintext); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
| const decrypted = await encryptor.decrypt(encrypted, context); | ||
|
|
||
| expect(decrypted).toEqual(plaintext); | ||
| }); | ||
|
|
||
| it('should encrypt and decrypt binary data with all byte values', async () => { | ||
| // All possible byte values 0-255 | ||
| const plaintext = new Uint8Array(256); | ||
| for (let i = 0; i < 256; i++) { | ||
| plaintext[i] = i; | ||
| } | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
| const decrypted = await encryptor.decrypt(encrypted, context); | ||
|
|
||
| expect(decrypted).toEqual(plaintext); | ||
| }); | ||
| }); | ||
|
|
||
| describe('encrypted data format', () => { | ||
| it('should produce encrypted data with "encr" prefix', async () => { | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
|
|
||
| // Check prefix is "encr" | ||
| const prefix = new TextDecoder().decode(encrypted.subarray(0, 4)); | ||
| expect(prefix).toBe('encr'); | ||
| }); | ||
|
|
||
| it('should produce encrypted data with correct structure', async () => { | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted = await encryptor.encrypt(plaintext, context); | ||
|
|
||
| // Format: [encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag] | ||
| // Minimum size: 4 + 12 + 16 (auth tag) = 32 bytes | ||
| expect(encrypted.length).toBeGreaterThanOrEqual(32); | ||
|
|
||
| // Ciphertext should be at least as long as plaintext + auth tag (16 bytes) | ||
| const ciphertextLength = encrypted.length - 4 - 12; | ||
| expect(ciphertextLength).toBeGreaterThanOrEqual(plaintext.length + 16); | ||
| }); | ||
|
|
||
| it('should produce different ciphertext for same data (random nonce)', async () => { | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const context = { runId: testRunId }; | ||
|
|
||
| const encrypted1 = await encryptor.encrypt(plaintext, context); | ||
| const encrypted2 = await encryptor.encrypt(plaintext, context); | ||
|
|
||
| // Encrypted data should be different due to random nonce | ||
| expect(encrypted1).not.toEqual(encrypted2); | ||
|
|
||
| // But both should decrypt to the same plaintext | ||
| const decrypted1 = await encryptor.decrypt(encrypted1, context); | ||
| const decrypted2 = await encryptor.decrypt(encrypted2, context); | ||
| expect(decrypted1).toEqual(plaintext); | ||
| expect(decrypted2).toEqual(plaintext); | ||
| }); | ||
| }); | ||
|
|
||
| describe('per-run key isolation', () => { | ||
| it('should produce different ciphertext for different runIds', async () => { | ||
| const plaintext = new TextEncoder().encode('sensitive data'); | ||
|
|
||
| const encrypted1 = await encryptor.encrypt(plaintext, { | ||
| runId: 'wrun_run1', | ||
| }); | ||
| const encrypted2 = await encryptor.encrypt(plaintext, { | ||
| runId: 'wrun_run2', | ||
| }); | ||
|
|
||
| // Even ignoring the random nonce, the key derivation differs | ||
| // So the auth tags will be different | ||
| expect(encrypted1).not.toEqual(encrypted2); | ||
| }); | ||
|
|
||
| it('should fail to decrypt with wrong runId', async () => { | ||
| const plaintext = new TextEncoder().encode('sensitive data'); | ||
| const encrypted = await encryptor.encrypt(plaintext, { | ||
| runId: 'wrun_run1', | ||
| }); | ||
|
|
||
| // Try to decrypt with a different runId - should fail auth check | ||
| await expect( | ||
| encryptor.decrypt(encrypted, { runId: 'wrun_run2' }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('decrypt validation', () => { | ||
| it('should throw on invalid prefix', async () => { | ||
| const invalidData = new TextEncoder().encode('invalidprefix'); | ||
|
|
||
| await expect( | ||
| encryptor.decrypt(invalidData, { runId: testRunId }) | ||
| ).rejects.toThrow("Expected 'encr' prefix"); | ||
| }); | ||
|
|
||
| it('should throw on tampered ciphertext', async () => { | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const encrypted = await encryptor.encrypt(plaintext, { | ||
| runId: testRunId, | ||
| }); | ||
|
|
||
| // Tamper with the ciphertext (flip a bit in the middle) | ||
| const tampered = new Uint8Array(encrypted); | ||
| tampered[20] ^= 0xff; | ||
|
|
||
| // AES-GCM should detect tampering via auth tag | ||
| await expect( | ||
| encryptor.decrypt(tampered, { runId: testRunId }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it('should throw on truncated data', async () => { | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const encrypted = await encryptor.encrypt(plaintext, { | ||
| runId: testRunId, | ||
| }); | ||
|
|
||
| // Truncate the data | ||
| const truncated = encrypted.subarray(0, 20); | ||
|
|
||
| await expect( | ||
| encryptor.decrypt(truncated, { runId: testRunId }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getKeyMaterial', () => { | ||
| it('should return key material with correct structure', async () => { | ||
| const keyMaterial = await encryptor.getKeyMaterial({}); | ||
|
|
||
| expect(keyMaterial).not.toBeNull(); | ||
| expect(keyMaterial!.key).toEqual(testDeploymentKey); | ||
| expect(keyMaterial!.derivationContext).toEqual({ | ||
| projectId: testProjectId, | ||
| }); | ||
| expect(keyMaterial!.algorithm).toBe('AES-256-GCM'); | ||
| expect(keyMaterial!.kdf).toBe('HKDF-SHA256'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('different project isolation', () => { | ||
| it('should fail to decrypt with different projectId', async () => { | ||
| const encryptor2 = createEncryptor({ | ||
| deploymentKey: testDeploymentKey, | ||
| projectId: 'prj_different', | ||
| }); | ||
|
|
||
| const plaintext = new TextEncoder().encode('test'); | ||
| const encrypted = await encryptor.encrypt(plaintext, { | ||
| runId: testRunId, | ||
| }); | ||
|
|
||
| // Different projectId means different derived key | ||
| await expect( | ||
| encryptor2.decrypt(encrypted, { runId: testRunId }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it('should fail to decrypt with different deploymentKey', async () => { | ||
| const differentKey = new Uint8Array(32); | ||
| crypto.getRandomValues(differentKey); | ||
|
|
||
| const encryptor2 = createEncryptor({ | ||
| deploymentKey: differentKey, | ||
| projectId: testProjectId, | ||
| }); | ||
|
|
||
| const plaintext = new TextEncoder().encode('test'); | ||
| const encrypted = await encryptor.encrypt(plaintext, { | ||
| runId: testRunId, | ||
| }); | ||
|
|
||
| // Different key means decryption fails | ||
| await expect( | ||
| encryptor2.decrypt(encrypted, { runId: testRunId }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
There are no tests validating that createEncryptor() throws an error for invalid key lengths (e.g., 16 bytes, 24 bytes, 33 bytes, or 0 bytes). This is a critical validation path that should be tested to ensure the key length enforcement works as expected.
Consider adding test cases like:
- Too short key (e.g., 16 bytes)
- Too long key (e.g., 33 bytes)
- Empty key (0 bytes)
| { | ||
| name: 'HKDF', | ||
| hash: 'SHA-256', | ||
| salt: new Uint8Array(32), // Zero salt - key material is already random |
There was a problem hiding this comment.
The HKDF implementation uses a zero salt (32 bytes of zeros). While this is acceptable when the input key material has sufficient entropy (which should be the case for a 32-byte deployment key), best practice would be to use a random or constant non-zero salt.
The comment "Zero salt - key material is already random" is correct from a security perspective, but consider:
- Using a constant non-zero salt (e.g., "vercel-workflow-v1") for better defense-in-depth
- Documenting why zero salt is acceptable in this specific case
- Following HKDF RFC 5869 recommendations more closely
This is marked as nit because the current implementation is cryptographically sound given the high-entropy IKM and unique info parameter.
| ); | ||
| if (prefix !== FORMAT_PREFIX) { | ||
| throw new Error( | ||
| `Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'` |
There was a problem hiding this comment.
The error message reveals the exact prefix value from the encrypted data, which could potentially leak information about the data format or partial content. While "encr" itself is not sensitive, revealing the actual bytes received could help an attacker understand the data structure.
Consider changing the error message to not include the actual prefix value:
`Invalid encrypted data format: expected '${FORMAT_PREFIX}' prefix`
This follows the principle of minimal information disclosure in error messages.
| `Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'` | |
| `Invalid encrypted data format: expected '${FORMAT_PREFIX}' prefix` |
| const deploymentKey = Uint8Array.from( | ||
| Buffer.from(deploymentKeyBase64, 'base64') |
There was a problem hiding this comment.
If VERCEL_DEPLOYMENT_KEY contains invalid base64, Buffer.from() will silently decode what it can, potentially producing a key of incorrect length. This could lead to a confusing error message from createEncryptor() about key length rather than the root cause being invalid base64.
Consider wrapping the base64 decoding in a try-catch to provide a more helpful error message:
try {
const deploymentKey = Uint8Array.from(Buffer.from(deploymentKeyBase64, 'base64'));
} catch (e) {
throw new Error('VERCEL_DEPLOYMENT_KEY contains invalid base64 encoding');
}
Alternatively, validate that the base64 string only contains valid base64 characters before decoding.
| const deploymentKey = Uint8Array.from( | |
| Buffer.from(deploymentKeyBase64, 'base64') | |
| const trimmedDeploymentKeyBase64 = deploymentKeyBase64.trim(); | |
| // Basic base64 validation to provide a clear error on misconfiguration | |
| const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/; | |
| const isLengthValid = trimmedDeploymentKeyBase64.length % 4 === 0; | |
| if (!isLengthValid || !base64Regex.test(trimmedDeploymentKeyBase64)) { | |
| throw new Error( | |
| 'VERCEL_DEPLOYMENT_KEY contains invalid base64 encoding' | |
| ); | |
| } | |
| const deploymentKey = Uint8Array.from( | |
| Buffer.from(trimmedDeploymentKeyBase64, 'base64') |
| describe('createEncryptorFromEnv', () => { | ||
| const originalEnv = process.env; | ||
|
|
||
| beforeEach(() => { | ||
| vi.resetModules(); | ||
| process.env = { ...originalEnv }; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = originalEnv; | ||
| }); | ||
|
|
||
| it('should return empty object when VERCEL_DEPLOYMENT_KEY is not set', () => { | ||
| delete process.env.VERCEL_DEPLOYMENT_KEY; | ||
| delete process.env.VERCEL_PROJECT_ID; | ||
|
|
||
| const encryptor = createEncryptorFromEnv(); | ||
|
|
||
| expect(encryptor.encrypt).toBeUndefined(); | ||
| expect(encryptor.decrypt).toBeUndefined(); | ||
| expect(encryptor.getKeyMaterial).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('should return empty object when VERCEL_PROJECT_ID is not set', () => { | ||
| // 32 bytes base64 encoded | ||
| process.env.VERCEL_DEPLOYMENT_KEY = | ||
| 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='; | ||
| delete process.env.VERCEL_PROJECT_ID; | ||
|
|
||
| const encryptor = createEncryptorFromEnv(); | ||
|
|
||
| expect(encryptor.encrypt).toBeUndefined(); | ||
| expect(encryptor.decrypt).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('should return working encryptor when both env vars are set', async () => { | ||
| // 32 bytes base64 encoded | ||
| process.env.VERCEL_DEPLOYMENT_KEY = | ||
| 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='; | ||
| process.env.VERCEL_PROJECT_ID = 'prj_test'; | ||
|
|
||
| const encryptor = createEncryptorFromEnv(); | ||
|
|
||
| expect(encryptor.encrypt).toBeDefined(); | ||
| expect(encryptor.decrypt).toBeDefined(); | ||
| expect(encryptor.getKeyMaterial).toBeDefined(); | ||
|
|
||
| // Test round-trip | ||
| const plaintext = new TextEncoder().encode('test'); | ||
| const encrypted = await encryptor.encrypt!(plaintext, { | ||
| runId: 'wrun_123', | ||
| }); | ||
| const decrypted = await encryptor.decrypt!(encrypted, { | ||
| runId: 'wrun_123', | ||
| }); | ||
| expect(decrypted).toEqual(plaintext); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
There are no tests validating that createEncryptorFromEnv() throws an error when VERCEL_DEPLOYMENT_KEY contains a base64-encoded key that decodes to the wrong length (e.g., not 32 bytes). Users could provide an incorrectly sized key, and the error would only surface when createEncryptor() is called.
Consider adding test cases that verify:
- Invalid key length from environment variable (e.g., base64 that decodes to 16 or 64 bytes)
- Invalid base64 encoding in VERCEL_DEPLOYMENT_KEY
| async function deriveKey( | ||
| keyMaterial: Uint8Array, | ||
| projectId: string, | ||
| runId: string | ||
| ): Promise<CryptoKey> { | ||
| const baseKey = await webcrypto.subtle.importKey( | ||
| 'raw', | ||
| keyMaterial, | ||
| 'HKDF', | ||
| false, | ||
| ['deriveKey'] | ||
| ); | ||
|
|
||
| const info = new TextEncoder().encode(`${projectId}|${runId}`); |
There was a problem hiding this comment.
The deriveKey function doesn't validate that projectId and runId are non-empty strings. If either is empty, the HKDF info parameter would be just a pipe character ("|") or similar, which could lead to unexpected key derivation behavior. While the Encryptor interface may guarantee these are valid, defensive validation would make the code more robust.
Consider adding validation:
if (!projectId || !runId) {
throw new Error('projectId and runId must be non-empty strings');
}
This is especially important since these values come from external input (environment variables and runtime context).
| if (prefix !== FORMAT_PREFIX) { | ||
| throw new Error( | ||
| `Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'` | ||
| ); | ||
| } |
There was a problem hiding this comment.
The prefix comparison using string equality could potentially be vulnerable to timing attacks, though this is a minor concern since the prefix is not secret. However, for defense-in-depth, consider using a constant-time comparison or simply accepting that the prefix check is not security-critical (since the real authentication happens via the GCM tag).
This is marked as a nit because:
- The prefix "encr" is public information
- AES-GCM's authentication tag provides cryptographic integrity
- Timing attacks on the prefix are impractical
However, it's worth documenting that the prefix check is for format validation only, not security.
| async getKeyMaterial( | ||
| _options: Record<string, unknown> | ||
| ): Promise<KeyMaterial | null> { | ||
| // Return the key material for external decryption (e.g., o11y tooling) | ||
| // In production, this might fetch from a secure endpoint based on deploymentId | ||
| return { | ||
| key: deploymentKey, | ||
| derivationContext: { projectId }, | ||
| algorithm: 'AES-256-GCM', | ||
| kdf: 'HKDF-SHA256', | ||
| }; | ||
| }, |
There was a problem hiding this comment.
The getKeyMaterial() function returns the raw deployment key unconditionally. While this is intended for observability tooling as noted in the comment, exposing the key material without any access control could be a security risk if this function is ever called through an API or exposed to untrusted code.
The comment mentions "In production, this might fetch from a secure endpoint based on deploymentId" which suggests this is a known limitation. However, consider:
- Adding a TODO comment to implement proper access control
- Documenting in the function's JSDoc that this method returns sensitive key material
- Considering whether to return null by default unless explicitly configured to allow key material access
This is particularly important since getKeyMaterial is part of the public Encryptor interface and could be called by any code with access to the World instance.
| // Verify format prefix | ||
| const prefix = new TextDecoder().decode( | ||
| data.subarray(0, FORMAT_PREFIX_LENGTH) | ||
| ); | ||
| if (prefix !== FORMAT_PREFIX) { | ||
| throw new Error( | ||
| `Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'` | ||
| ); | ||
| } |
There was a problem hiding this comment.
The decrypt() function doesn't validate the minimum length of encrypted data before attempting to extract components. While the WebCrypto API will eventually throw an error for invalid data, checking the length upfront would provide clearer error messages and prevent potential edge cases.
Consider adding explicit length validation before the prefix check:
const minLength = FORMAT_PREFIX_LENGTH + NONCE_LENGTH + 16; // 16 bytes for auth tag
if (data.length < minLength) {
throw new Error(`Encrypted data is too short: expected at least ${minLength} bytes, got ${data.length} bytes`);
}
This would make debugging easier for users and follows defensive programming practices.
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
d880bc8 to
162847a
Compare
efd031c to
c1795c7
Compare
162847a to
ede75a0
Compare
c1795c7 to
60fac27
Compare
ede75a0 to
2118740
Compare
60fac27 to
0d60577
Compare
2118740 to
8a2fcdc
Compare
0d60577 to
2fcf24e
Compare

Summary
createEncryptor()andcreateEncryptorFromEnv()to@workflow/world-vercel[encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag]HKDF(key=deploymentKey, salt=zeros, info="${projectId}|${runId}")createEncryptorFromEnv()intocreateVercelWorld()(readsVERCEL_DEPLOYMENT_KEYandVERCEL_PROJECT_IDenv vars)This PR adds the encryption module but does not wire it into the serialization layer yet — that happens in the next PR.
Stack
Test plan
All 42 world-vercel tests pass (18 new encryption tests + 24 existing).