From 6967e467b0caba068f74c4a133230db4ab8733f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 01:10:04 +0000 Subject: [PATCH] fix(crypto): use Uint8Array for WebCrypto BufferSource The previous code cast Uint8Array.buffer to ArrayBuffer when calling crypto.subtle.encrypt/decrypt/importKey/deriveKey. Under stricter WebCrypto realms (e.g. jsdom in vitest), the cross-realm ArrayBuffer identity check rejects this as 'not an instance of ArrayBuffer'. Pass Uint8Array directly (BufferSource accepts ArrayBufferView) and constrain helpers to Uint8Array to satisfy lib.dom's BufferSource overload (TypeScript 5.7+ Uint8Array is generic over ArrayBufferLike, which permits SharedArrayBuffer). Fixes the 3 failing tests in src/__tests__/lib/crypto.test.ts: - encrypt and decrypt roundtrip - different users produce different ciphertexts - wrong key fails to decrypt Public API of deriveKey/encrypt/decrypt is unchanged. --- src/lib/crypto.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 764fff3..770c316 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,28 +1,32 @@ const APP_SALT = "tryskills.sh-v1"; const ITERATIONS = 100_000; -function textToBuffer(text: string): ArrayBuffer { - return new TextEncoder().encode(text).buffer as ArrayBuffer; +// Helpers return Uint8Array (not SharedArrayBuffer-backed) so they +// satisfy WebCrypto BufferSource in both Node and jsdom realms. + +function textToBytes(text: string): Uint8Array { + return new Uint8Array(new TextEncoder().encode(text)); } -function bufferToHex(buffer: ArrayBuffer): string { - return Array.from(new Uint8Array(buffer)) +function bytesToHex(bytes: Uint8Array | ArrayBuffer): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + return Array.from(view) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } -function hexToBuffer(hex: string): ArrayBuffer { +function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); } - return bytes.buffer as ArrayBuffer; + return bytes; } export async function deriveKey(userId: string): Promise { const keyMaterial = await crypto.subtle.importKey( "raw", - textToBuffer(userId), + textToBytes(userId), "PBKDF2", false, ["deriveKey"], @@ -31,7 +35,7 @@ export async function deriveKey(userId: string): Promise { return crypto.subtle.deriveKey( { name: "PBKDF2", - salt: textToBuffer(APP_SALT), + salt: textToBytes(APP_SALT), iterations: ITERATIONS, hash: "SHA-256", }, @@ -46,9 +50,8 @@ export async function encrypt( plaintext: string, key: CryptoKey, ): Promise<{ ciphertext: string; iv: string }> { - const ivArray = crypto.getRandomValues(new Uint8Array(12)); - const iv = ivArray.buffer as ArrayBuffer; - const encoded = textToBuffer(plaintext); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = textToBytes(plaintext); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, @@ -57,8 +60,8 @@ export async function encrypt( ); return { - ciphertext: bufferToHex(encrypted), - iv: bufferToHex(iv), + ciphertext: bytesToHex(encrypted), + iv: bytesToHex(iv), }; } @@ -68,9 +71,9 @@ export async function decrypt( key: CryptoKey, ): Promise { const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: hexToBuffer(iv) }, + { name: "AES-GCM", iv: hexToBytes(iv) }, key, - hexToBuffer(ciphertext), + hexToBytes(ciphertext), ); return new TextDecoder().decode(decrypted);