diff --git a/alchemy/src/encrypt.ts b/alchemy/src/encrypt.ts index 497eac572..c3663a8fa 100644 --- a/alchemy/src/encrypt.ts +++ b/alchemy/src/encrypt.ts @@ -1,11 +1,114 @@ +import crypto from "node:crypto"; + +const KEY_LEN = 32; +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const SALT_LEN = 16; +const IV_LEN = 12; +const ALGO = "aes-256-gcm"; + +interface Encrypted { + version: "v1"; + ciphertext: string; // base64 + iv: string; // base64 + salt: string; // base64 + tag: string; // base64 +} + +export function encrypt(value: string, key: string): Promise { + return scryptEncrypt(value, key); +} + +export function decryptWithKey( + value: string | Encrypted, + key: string, +): Promise { + if (typeof value === "string") { + return libsodiumDecrypt(value, key); + } + return scryptDecrypt(value, key); +} + +export async function scryptEncrypt( + value: string, + passphrase: string, +): Promise { + const salt = crypto.randomBytes(SALT_LEN); + const key = await deriveScryptKey(passphrase, salt); + const iv = crypto.randomBytes(IV_LEN); + + const cipher = crypto.createCipheriv(ALGO, key, iv); + const ciphertext = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return { + version: "v1", + ciphertext: ciphertext.toString("base64"), + iv: iv.toString("base64"), + salt: salt.toString("base64"), + tag: tag.toString("base64"), + }; +} + +export async function scryptDecrypt( + parts: Encrypted, + passphrase: string, +): Promise { + const salt = Buffer.from(parts.salt, "base64"); + const iv = Buffer.from(parts.iv, "base64"); + const ciphertext = Buffer.from(parts.ciphertext, "base64"); + const tag = Buffer.from(parts.tag, "base64"); + + const key = await deriveScryptKey(passphrase, salt); + + const decipher = crypto.createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return plaintext.toString("utf8"); +} + +async function deriveScryptKey( + passphrase: string, + salt: Buffer, +): Promise { + return new Promise((resolve, reject) => { + crypto.scrypt( + passphrase, + salt, + KEY_LEN, + { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + }, + (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }, + ); + }); +} + /** * Encrypt a value with a symmetric key using libsodium * * @param value - The value to encrypt * @param key - The encryption key * @returns The base64-encoded encrypted value with nonce + * @internal - Exposed for testing */ -export async function encrypt(value: string, key: string): Promise { +export async function libsodiumEncrypt( + value: string, + key: string, +): Promise { const sodium = (await import("libsodium-wrappers")).default; // Initialize libsodium await sodium.ready; @@ -40,8 +143,9 @@ export async function encrypt(value: string, key: string): Promise { * @param encryptedValue - The base64-encoded encrypted value with nonce * @param key - The decryption key * @returns The decrypted string + * @internal - Exposed for testing */ -export async function decryptWithKey( +export async function libsodiumDecrypt( encryptedValue: string, key: string, ): Promise { diff --git a/alchemy/src/serde.ts b/alchemy/src/serde.ts index 67743b98d..110ea4193 100644 --- a/alchemy/src/serde.ts +++ b/alchemy/src/serde.ts @@ -183,7 +183,7 @@ export async function deserialize( "See: https://alchemy.run/concepts/secret/#encryption-password", ); } - if (typeof value["@secret"] === "object") { + if (typeof value["@secret"] === "object" && "data" in value["@secret"]) { return new Secret( JSON.parse( await decryptWithKey(value["@secret"].data, scope.password), diff --git a/alchemy/test/encrypt.test.ts b/alchemy/test/encrypt.test.ts new file mode 100644 index 000000000..69d06b2bb --- /dev/null +++ b/alchemy/test/encrypt.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { decryptWithKey, encrypt, libsodiumEncrypt } from "../src/encrypt.ts"; + +describe("encrypt", () => { + it("encrypts and decrypts a string", async () => { + const passphrase = crypto.randomUUID(); + const value = "test-value"; + const encrypted = await encrypt(value, passphrase); + expect(encrypted).toMatchObject({ + version: "v1", + ciphertext: expect.any(String), + iv: expect.any(String), + salt: expect.any(String), + tag: expect.any(String), + }); + const decrypted = await decryptWithKey(encrypted, passphrase); + expect(decrypted).toBe(value); + }); + + it("decrypts a string encrypted with libsodium", async () => { + const passphrase = crypto.randomUUID(); + const value = "test-value"; + const encrypted = await libsodiumEncrypt(value, passphrase); + const decrypted = await decryptWithKey(encrypted, passphrase); + expect(decrypted).toBe(value); + }); + + it("fails to decrypt from libsodium with incorrect passphrase", async () => { + const passphrase = crypto.randomUUID(); + const value = "test-value"; + const encrypted = await libsodiumEncrypt(value, passphrase); + await expect( + decryptWithKey(encrypted, crypto.randomUUID()), + ).rejects.toThrow(); + }); + + it("fails to decrypt from scrypt with incorrect passphrase", async () => { + const passphrase = crypto.randomUUID(); + const value = "test-value"; + const encrypted = await encrypt(value, passphrase); + expect(encrypted).toMatchObject({ + version: "v1", + ciphertext: expect.any(String), + iv: expect.any(String), + salt: expect.any(String), + tag: expect.any(String), + }); + await expect( + decryptWithKey(encrypted, crypto.randomUUID()), + ).rejects.toThrow(); + }); +}); diff --git a/alchemy/test/serde.test.ts b/alchemy/test/serde.test.ts index 28b374138..f7e8ec4be 100644 --- a/alchemy/test/serde.test.ts +++ b/alchemy/test/serde.test.ts @@ -66,7 +66,13 @@ describe("serde", async () => { const serialized = await serialize(scope, secret); expect(serialized).toHaveProperty("@secret"); - expect(typeof serialized["@secret"]).toBe("string"); + expect(serialized["@secret"]).toMatchObject({ + version: "v1", + ciphertext: expect.any(String), + iv: expect.any(String), + salt: expect.any(String), + tag: expect.any(String), + }); expect(serialized["@secret"]).not.toContain("sensitive-data"); const deserialized = await deserialize(scope, serialized); @@ -91,9 +97,15 @@ describe("serde", async () => { const serialized = await serialize(scope, secret); expect(serialized).toHaveProperty("@secret"); - expect(typeof serialized["@secret"]).toBe("object"); - expect(serialized["@secret"]).toHaveProperty("data"); - expect(typeof serialized["@secret"].data).toBe("string"); + expect(serialized["@secret"]).toMatchObject({ + data: { + version: "v1", + ciphertext: expect.any(String), + iv: expect.any(String), + salt: expect.any(String), + tag: expect.any(String), + }, + }); expect(serialized["@secret"].data).not.toContain("sk-1234567890abcdef"); const deserialized = await deserialize(scope, serialized); @@ -162,6 +174,54 @@ describe("serde", async () => { } }); + test("decrypts complex objects with secrets encoded by libsodium", async (scope) => { + try { + const encrypted = { + name: "test", + credentials: { + username: "user", + password: { + "@secret": + "yGFwMrw2A2ZOuDNgg3S/aDJTYeDSO3KRxC/QrICkznZZsVKAib+VlLmwv6NbLOpFOIbXoA==", + }, + apiKey: { + "@secret": + "VHD8Lt+5vTse5Qh5U5cgzFuAd31zLmWkbPlPTn6lrn15ux4KOQAjoi+ml/4CNHknw81H", + }, + }, + settings: { + enabled: true, + tokens: [ + { + "@secret": + "d5RfcYaucutM6Vy2sixa3MihNmu76ordWlPz+koWj9wQmSqZYOXdPSVZv97Ogw==", + }, + { + "@secret": + "gC9xHgqvEg3Bt30X3DUPD1Kcqa91Xe99/tIrmqu1HjfHcdO+6qsas9GuxuvtWQ==", + }, + ], + }, + }; + const deserialized = await deserialize(scope, encrypted); + + // Verify structure + expect(deserialized).toHaveProperty("name", "test"); + expect(deserialized.credentials.username).toBe("user"); + expect(deserialized.credentials.password).toBeInstanceOf(Secret); + expect(deserialized.credentials.password.unencrypted).toBe( + "super-secret", + ); + expect(deserialized.credentials.apiKey.unencrypted).toBe("api-key-123"); + expect(deserialized.settings.enabled).toBe(true); + + expect(deserialized.settings.tokens[0].unencrypted).toBe("token1"); + expect(deserialized.settings.tokens[1].unencrypted).toBe("token2"); + } finally { + await destroy(scope); + } + }); + test("props", async (scope) => { try { const props = {