Skip to content

Commit 16a31c8

Browse files
authored
Fix Vault crypto implementation for edge runtime compatibility (#1269)
1 parent 583f0df commit 16a31c8

12 files changed

+435
-105
lines changed

src/common/crypto/crypto-provider.ts

+47
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,51 @@ export abstract class CryptoProvider {
3535
* Cryptographically determine whether two signatures are equal
3636
*/
3737
abstract secureCompare(stringA: string, stringB: string): Promise<boolean>;
38+
39+
/**
40+
* Encrypts data using AES-256-GCM algorithm.
41+
*
42+
* @param plaintext The data to encrypt
43+
* @param key The encryption key (should be 32 bytes for AES-256)
44+
* @param iv Optional initialization vector (if not provided, a random one will be generated)
45+
* @param aad Optional additional authenticated data
46+
* @returns Object containing the encrypted ciphertext, the IV used, and the authentication tag
47+
*/
48+
abstract encrypt(
49+
plaintext: Uint8Array,
50+
key: Uint8Array,
51+
iv?: Uint8Array,
52+
aad?: Uint8Array,
53+
): Promise<{
54+
ciphertext: Uint8Array;
55+
iv: Uint8Array;
56+
tag: Uint8Array;
57+
}>;
58+
59+
/**
60+
* Decrypts data that was encrypted using AES-256-GCM algorithm.
61+
*
62+
* @param ciphertext The encrypted data
63+
* @param key The decryption key (must be the same key used for encryption)
64+
* @param iv The initialization vector used during encryption
65+
* @param tag The authentication tag produced during encryption
66+
* @param aad Optional additional authenticated data (must match what was used during encryption)
67+
* @returns The decrypted data
68+
* @throws Will throw an error if authentication fails or the data has been tampered with
69+
*/
70+
abstract decrypt(
71+
ciphertext: Uint8Array,
72+
key: Uint8Array,
73+
iv: Uint8Array,
74+
tag: Uint8Array,
75+
aad?: Uint8Array,
76+
): Promise<Uint8Array>;
77+
78+
/**
79+
* Generates cryptographically secure random bytes.
80+
*
81+
* @param length The number of random bytes to generate
82+
* @returns A Uint8Array containing the random bytes
83+
*/
84+
abstract randomBytes(length: number): Uint8Array;
3885
}

src/common/crypto/node-crypto-provider.ts

+59-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as crypto from 'node:crypto';
1+
import * as crypto from 'crypto';
22
import { CryptoProvider } from './crypto-provider';
33

44
/**
@@ -18,7 +18,7 @@ export class NodeCryptoProvider extends CryptoProvider {
1818
payload: string,
1919
secret: string,
2020
): Promise<string> {
21-
const signature = await this.computeHMACSignature(payload, secret);
21+
const signature = this.computeHMACSignature(payload, secret);
2222
return signature;
2323
}
2424

@@ -39,4 +39,61 @@ export class NodeCryptoProvider extends CryptoProvider {
3939
// Perform a constant time comparison
4040
return crypto.timingSafeEqual(hmacA, hmacB);
4141
}
42+
43+
async encrypt(
44+
plaintext: Uint8Array,
45+
key: Uint8Array,
46+
iv?: Uint8Array,
47+
aad?: Uint8Array,
48+
): Promise<{
49+
ciphertext: Uint8Array;
50+
iv: Uint8Array;
51+
tag: Uint8Array;
52+
}> {
53+
const actualIv = iv || crypto.randomBytes(32);
54+
const cipher = crypto.createCipheriv('aes-256-gcm', key, actualIv);
55+
56+
if (aad) {
57+
cipher.setAAD(Buffer.from(aad));
58+
}
59+
60+
const ciphertext = Buffer.concat([
61+
cipher.update(Buffer.from(plaintext)),
62+
cipher.final(),
63+
]);
64+
65+
const tag = cipher.getAuthTag();
66+
67+
return {
68+
ciphertext: new Uint8Array(ciphertext),
69+
iv: new Uint8Array(actualIv),
70+
tag: new Uint8Array(tag),
71+
};
72+
}
73+
74+
async decrypt(
75+
ciphertext: Uint8Array,
76+
key: Uint8Array,
77+
iv: Uint8Array,
78+
tag: Uint8Array,
79+
aad?: Uint8Array,
80+
): Promise<Uint8Array> {
81+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
82+
decipher.setAuthTag(Buffer.from(tag));
83+
84+
if (aad) {
85+
decipher.setAAD(Buffer.from(aad));
86+
}
87+
88+
const decrypted = Buffer.concat([
89+
decipher.update(Buffer.from(ciphertext)),
90+
decipher.final(),
91+
]);
92+
93+
return new Uint8Array(decrypted);
94+
}
95+
96+
randomBytes(length: number): Uint8Array {
97+
return new Uint8Array(crypto.randomBytes(length));
98+
}
4299
}

src/common/crypto/subtle-crypto-provider.ts

+94
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,100 @@ export class SubtleCryptoProvider extends CryptoProvider {
7979

8080
return equal;
8181
}
82+
83+
async encrypt(
84+
plaintext: Uint8Array,
85+
key: Uint8Array,
86+
iv?: Uint8Array,
87+
aad?: Uint8Array,
88+
): Promise<{
89+
ciphertext: Uint8Array;
90+
iv: Uint8Array;
91+
tag: Uint8Array;
92+
}> {
93+
const actualIv = iv || crypto.getRandomValues(new Uint8Array(32));
94+
95+
const cryptoKey = await this.subtleCrypto.importKey(
96+
'raw',
97+
key,
98+
{ name: 'AES-GCM' },
99+
false,
100+
['encrypt'],
101+
);
102+
103+
const encryptParams: AesGcmParams = {
104+
name: 'AES-GCM',
105+
iv: actualIv,
106+
};
107+
108+
if (aad) {
109+
encryptParams.additionalData = aad;
110+
}
111+
112+
const encryptedData = await this.subtleCrypto.encrypt(
113+
encryptParams,
114+
cryptoKey,
115+
plaintext,
116+
);
117+
118+
const encryptedBytes = new Uint8Array(encryptedData);
119+
120+
// Extract tag (last 16 bytes)
121+
const tagSize = 16;
122+
const tagStart = encryptedBytes.length - tagSize;
123+
const tag = encryptedBytes.slice(tagStart);
124+
const ciphertext = encryptedBytes.slice(0, tagStart);
125+
126+
return {
127+
ciphertext,
128+
iv: actualIv,
129+
tag,
130+
};
131+
}
132+
133+
async decrypt(
134+
ciphertext: Uint8Array,
135+
key: Uint8Array,
136+
iv: Uint8Array,
137+
tag: Uint8Array,
138+
aad?: Uint8Array,
139+
): Promise<Uint8Array> {
140+
// SubtleCrypto expects tag to be appended to ciphertext for AES-GCM
141+
const combinedData = new Uint8Array(ciphertext.length + tag.length);
142+
combinedData.set(ciphertext, 0);
143+
combinedData.set(tag, ciphertext.length);
144+
145+
const cryptoKey = await this.subtleCrypto.importKey(
146+
'raw',
147+
key,
148+
{ name: 'AES-GCM' },
149+
false,
150+
['decrypt'],
151+
);
152+
153+
const decryptParams: AesGcmParams = {
154+
name: 'AES-GCM',
155+
iv,
156+
};
157+
158+
if (aad) {
159+
decryptParams.additionalData = aad;
160+
}
161+
162+
const decryptedData = await this.subtleCrypto.decrypt(
163+
decryptParams,
164+
cryptoKey,
165+
combinedData,
166+
);
167+
168+
return new Uint8Array(decryptedData);
169+
}
170+
171+
randomBytes(length: number): Uint8Array {
172+
const bytes = new Uint8Array(length);
173+
crypto.getRandomValues(bytes);
174+
return bytes;
175+
}
82176
}
83177

84178
// Cached mapping of byte to hex representation. We do this once to avoid re-

src/common/utils/base64.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Cross-runtime compatible base64 encoding/decoding utilities
3+
* that work in both Node.js and browser environments
4+
*/
5+
6+
/**
7+
* Converts a base64 string to a Uint8Array
8+
*/
9+
export function base64ToUint8Array(base64: string): Uint8Array {
10+
// In browsers and modern Node.js
11+
if (typeof atob === 'function') {
12+
const binary = atob(base64);
13+
const bytes = new Uint8Array(binary.length);
14+
for (let i = 0; i < binary.length; i++) {
15+
bytes[i] = binary.charCodeAt(i);
16+
}
17+
return bytes;
18+
}
19+
// Node.js fallback using Buffer
20+
else if (typeof Buffer !== 'undefined') {
21+
return new Uint8Array(Buffer.from(base64, 'base64'));
22+
}
23+
// Fallback implementation if neither is available
24+
else {
25+
throw new Error('No base64 decoding implementation available');
26+
}
27+
}
28+
29+
/**
30+
* Converts a Uint8Array to a base64 string
31+
*/
32+
export function uint8ArrayToBase64(bytes: Uint8Array): string {
33+
// In browsers and modern Node.js
34+
if (typeof btoa === 'function') {
35+
let binary = '';
36+
for (let i = 0; i < bytes.byteLength; i++) {
37+
binary += String.fromCharCode(bytes[i]);
38+
}
39+
return btoa(binary);
40+
}
41+
// Node.js fallback using Buffer
42+
else if (typeof Buffer !== 'undefined') {
43+
return Buffer.from(bytes).toString('base64');
44+
}
45+
// Fallback implementation if neither is available
46+
else {
47+
throw new Error('No base64 encoding implementation available');
48+
}
49+
}

src/index.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ class WorkOSNode extends WorkOS {
5454

5555
/** @override */
5656
createWebhookClient(): Webhooks {
57+
return new Webhooks(this.getCryptoProvider());
58+
}
59+
60+
override getCryptoProvider(): CryptoProvider {
5761
let cryptoProvider: CryptoProvider;
5862

5963
if (typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined') {
@@ -62,20 +66,12 @@ class WorkOSNode extends WorkOS {
6266
cryptoProvider = new NodeCryptoProvider();
6367
}
6468

65-
return new Webhooks(cryptoProvider);
69+
return cryptoProvider;
6670
}
6771

6872
/** @override */
6973
createActionsClient(): Actions {
70-
let cryptoProvider: CryptoProvider;
71-
72-
if (typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined') {
73-
cryptoProvider = new SubtleCryptoProvider();
74-
} else {
75-
cryptoProvider = new NodeCryptoProvider();
76-
}
77-
78-
return new Actions(cryptoProvider);
74+
return new Actions(this.getCryptoProvider());
7975
}
8076

8177
/** @override */

src/index.worker.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Actions } from './actions/actions';
2+
import { CryptoProvider } from './common/crypto/crypto-provider';
23
import { SubtleCryptoProvider } from './common/crypto/subtle-crypto-provider';
34
import { EdgeIronSessionProvider } from './common/iron-session/edge-iron-session-provider';
45
import { IronSessionProvider } from './common/iron-session/iron-session-provider';
@@ -45,6 +46,10 @@ class WorkOSWorker extends WorkOS {
4546
return new Webhooks(cryptoProvider);
4647
}
4748

49+
override getCryptoProvider(): CryptoProvider {
50+
return new SubtleCryptoProvider();
51+
}
52+
4853
/** @override */
4954
createActionsClient(): Actions {
5055
const cryptoProvider = new SubtleCryptoProvider();

src/vault/cryptography/decrypt.ts

-46
This file was deleted.

0 commit comments

Comments
 (0)