Skip to content

Commit 60fac27

Browse files
committed
Add Vercel encryption implementation (AES-256-GCM with HKDF)
1 parent ede75a0 commit 60fac27

File tree

3 files changed

+472
-0
lines changed

3 files changed

+472
-0
lines changed
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { createEncryptor, createEncryptorFromEnv } from './encryption.js';
3+
4+
describe('createEncryptor', () => {
5+
const testProjectId = 'prj_test123';
6+
const testRunId = 'wrun_abc123';
7+
// 32 bytes for AES-256
8+
const testDeploymentKey = new Uint8Array([
9+
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
10+
0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
11+
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
12+
]);
13+
14+
const encryptor = createEncryptor({
15+
deploymentKey: testDeploymentKey,
16+
projectId: testProjectId,
17+
});
18+
19+
describe('encrypt/decrypt round-trip', () => {
20+
it('should encrypt and decrypt data correctly', async () => {
21+
const plaintext = new TextEncoder().encode('Hello, World!');
22+
const context = { runId: testRunId };
23+
24+
const encrypted = await encryptor.encrypt(plaintext, context);
25+
const decrypted = await encryptor.decrypt(encrypted, context);
26+
27+
expect(decrypted).toEqual(plaintext);
28+
expect(new TextDecoder().decode(decrypted)).toBe('Hello, World!');
29+
});
30+
31+
it('should encrypt and decrypt empty data', async () => {
32+
const plaintext = new Uint8Array(0);
33+
const context = { runId: testRunId };
34+
35+
const encrypted = await encryptor.encrypt(plaintext, context);
36+
const decrypted = await encryptor.decrypt(encrypted, context);
37+
38+
expect(decrypted).toEqual(plaintext);
39+
});
40+
41+
it('should encrypt and decrypt large data', async () => {
42+
// 64KB of random data (max for getRandomValues)
43+
const plaintext = new Uint8Array(65536);
44+
crypto.getRandomValues(plaintext);
45+
const context = { runId: testRunId };
46+
47+
const encrypted = await encryptor.encrypt(plaintext, context);
48+
const decrypted = await encryptor.decrypt(encrypted, context);
49+
50+
expect(decrypted).toEqual(plaintext);
51+
});
52+
53+
it('should encrypt and decrypt binary data with all byte values', async () => {
54+
// All possible byte values 0-255
55+
const plaintext = new Uint8Array(256);
56+
for (let i = 0; i < 256; i++) {
57+
plaintext[i] = i;
58+
}
59+
const context = { runId: testRunId };
60+
61+
const encrypted = await encryptor.encrypt(plaintext, context);
62+
const decrypted = await encryptor.decrypt(encrypted, context);
63+
64+
expect(decrypted).toEqual(plaintext);
65+
});
66+
});
67+
68+
describe('encrypted data format', () => {
69+
it('should produce encrypted data with "encr" prefix', async () => {
70+
const plaintext = new TextEncoder().encode('test');
71+
const context = { runId: testRunId };
72+
73+
const encrypted = await encryptor.encrypt(plaintext, context);
74+
75+
// Check prefix is "encr"
76+
const prefix = new TextDecoder().decode(encrypted.subarray(0, 4));
77+
expect(prefix).toBe('encr');
78+
});
79+
80+
it('should produce encrypted data with correct structure', async () => {
81+
const plaintext = new TextEncoder().encode('test');
82+
const context = { runId: testRunId };
83+
84+
const encrypted = await encryptor.encrypt(plaintext, context);
85+
86+
// Format: [encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag]
87+
// Minimum size: 4 + 12 + 16 (auth tag) = 32 bytes
88+
expect(encrypted.length).toBeGreaterThanOrEqual(32);
89+
90+
// Ciphertext should be at least as long as plaintext + auth tag (16 bytes)
91+
const ciphertextLength = encrypted.length - 4 - 12;
92+
expect(ciphertextLength).toBeGreaterThanOrEqual(plaintext.length + 16);
93+
});
94+
95+
it('should produce different ciphertext for same data (random nonce)', async () => {
96+
const plaintext = new TextEncoder().encode('test');
97+
const context = { runId: testRunId };
98+
99+
const encrypted1 = await encryptor.encrypt(plaintext, context);
100+
const encrypted2 = await encryptor.encrypt(plaintext, context);
101+
102+
// Encrypted data should be different due to random nonce
103+
expect(encrypted1).not.toEqual(encrypted2);
104+
105+
// But both should decrypt to the same plaintext
106+
const decrypted1 = await encryptor.decrypt(encrypted1, context);
107+
const decrypted2 = await encryptor.decrypt(encrypted2, context);
108+
expect(decrypted1).toEqual(plaintext);
109+
expect(decrypted2).toEqual(plaintext);
110+
});
111+
});
112+
113+
describe('per-run key isolation', () => {
114+
it('should produce different ciphertext for different runIds', async () => {
115+
const plaintext = new TextEncoder().encode('sensitive data');
116+
117+
const encrypted1 = await encryptor.encrypt(plaintext, {
118+
runId: 'wrun_run1',
119+
});
120+
const encrypted2 = await encryptor.encrypt(plaintext, {
121+
runId: 'wrun_run2',
122+
});
123+
124+
// Even ignoring the random nonce, the key derivation differs
125+
// So the auth tags will be different
126+
expect(encrypted1).not.toEqual(encrypted2);
127+
});
128+
129+
it('should fail to decrypt with wrong runId', async () => {
130+
const plaintext = new TextEncoder().encode('sensitive data');
131+
const encrypted = await encryptor.encrypt(plaintext, {
132+
runId: 'wrun_run1',
133+
});
134+
135+
// Try to decrypt with a different runId - should fail auth check
136+
await expect(
137+
encryptor.decrypt(encrypted, { runId: 'wrun_run2' })
138+
).rejects.toThrow();
139+
});
140+
});
141+
142+
describe('decrypt validation', () => {
143+
it('should throw on invalid prefix', async () => {
144+
const invalidData = new TextEncoder().encode('invalidprefix');
145+
146+
await expect(
147+
encryptor.decrypt(invalidData, { runId: testRunId })
148+
).rejects.toThrow("Expected 'encr' prefix");
149+
});
150+
151+
it('should throw on tampered ciphertext', async () => {
152+
const plaintext = new TextEncoder().encode('test');
153+
const encrypted = await encryptor.encrypt(plaintext, {
154+
runId: testRunId,
155+
});
156+
157+
// Tamper with the ciphertext (flip a bit in the middle)
158+
const tampered = new Uint8Array(encrypted);
159+
tampered[20] ^= 0xff;
160+
161+
// AES-GCM should detect tampering via auth tag
162+
await expect(
163+
encryptor.decrypt(tampered, { runId: testRunId })
164+
).rejects.toThrow();
165+
});
166+
167+
it('should throw on truncated data', async () => {
168+
const plaintext = new TextEncoder().encode('test');
169+
const encrypted = await encryptor.encrypt(plaintext, {
170+
runId: testRunId,
171+
});
172+
173+
// Truncate the data
174+
const truncated = encrypted.subarray(0, 20);
175+
176+
await expect(
177+
encryptor.decrypt(truncated, { runId: testRunId })
178+
).rejects.toThrow();
179+
});
180+
});
181+
182+
describe('getKeyMaterial', () => {
183+
it('should return key material with correct structure', async () => {
184+
const keyMaterial = await encryptor.getKeyMaterial({});
185+
186+
expect(keyMaterial).not.toBeNull();
187+
expect(keyMaterial!.key).toEqual(testDeploymentKey);
188+
expect(keyMaterial!.derivationContext).toEqual({
189+
projectId: testProjectId,
190+
});
191+
expect(keyMaterial!.algorithm).toBe('AES-256-GCM');
192+
expect(keyMaterial!.kdf).toBe('HKDF-SHA256');
193+
});
194+
});
195+
196+
describe('different project isolation', () => {
197+
it('should fail to decrypt with different projectId', async () => {
198+
const encryptor2 = createEncryptor({
199+
deploymentKey: testDeploymentKey,
200+
projectId: 'prj_different',
201+
});
202+
203+
const plaintext = new TextEncoder().encode('test');
204+
const encrypted = await encryptor.encrypt(plaintext, {
205+
runId: testRunId,
206+
});
207+
208+
// Different projectId means different derived key
209+
await expect(
210+
encryptor2.decrypt(encrypted, { runId: testRunId })
211+
).rejects.toThrow();
212+
});
213+
214+
it('should fail to decrypt with different deploymentKey', async () => {
215+
const differentKey = new Uint8Array(32);
216+
crypto.getRandomValues(differentKey);
217+
218+
const encryptor2 = createEncryptor({
219+
deploymentKey: differentKey,
220+
projectId: testProjectId,
221+
});
222+
223+
const plaintext = new TextEncoder().encode('test');
224+
const encrypted = await encryptor.encrypt(plaintext, {
225+
runId: testRunId,
226+
});
227+
228+
// Different key means decryption fails
229+
await expect(
230+
encryptor2.decrypt(encrypted, { runId: testRunId })
231+
).rejects.toThrow();
232+
});
233+
});
234+
});
235+
236+
describe('createEncryptorFromEnv', () => {
237+
const originalEnv = process.env;
238+
239+
beforeEach(() => {
240+
vi.resetModules();
241+
process.env = { ...originalEnv };
242+
});
243+
244+
afterEach(() => {
245+
process.env = originalEnv;
246+
});
247+
248+
it('should return empty object when VERCEL_DEPLOYMENT_KEY is not set', () => {
249+
delete process.env.VERCEL_DEPLOYMENT_KEY;
250+
delete process.env.VERCEL_PROJECT_ID;
251+
252+
const encryptor = createEncryptorFromEnv();
253+
254+
expect(encryptor.encrypt).toBeUndefined();
255+
expect(encryptor.decrypt).toBeUndefined();
256+
expect(encryptor.getKeyMaterial).toBeUndefined();
257+
});
258+
259+
it('should return empty object when VERCEL_PROJECT_ID is not set', () => {
260+
// 32 bytes base64 encoded
261+
process.env.VERCEL_DEPLOYMENT_KEY =
262+
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=';
263+
delete process.env.VERCEL_PROJECT_ID;
264+
265+
const encryptor = createEncryptorFromEnv();
266+
267+
expect(encryptor.encrypt).toBeUndefined();
268+
expect(encryptor.decrypt).toBeUndefined();
269+
});
270+
271+
it('should return working encryptor when both env vars are set', async () => {
272+
// 32 bytes base64 encoded
273+
process.env.VERCEL_DEPLOYMENT_KEY =
274+
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=';
275+
process.env.VERCEL_PROJECT_ID = 'prj_test';
276+
277+
const encryptor = createEncryptorFromEnv();
278+
279+
expect(encryptor.encrypt).toBeDefined();
280+
expect(encryptor.decrypt).toBeDefined();
281+
expect(encryptor.getKeyMaterial).toBeDefined();
282+
283+
// Test round-trip
284+
const plaintext = new TextEncoder().encode('test');
285+
const encrypted = await encryptor.encrypt!(plaintext, {
286+
runId: 'wrun_123',
287+
});
288+
const decrypted = await encryptor.decrypt!(encrypted, {
289+
runId: 'wrun_123',
290+
});
291+
expect(decrypted).toEqual(plaintext);
292+
});
293+
});

0 commit comments

Comments
 (0)