Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions alchemy/src/encrypt.ts
Original file line number Diff line number Diff line change
@@ -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<Encrypted> {
return scryptEncrypt(value, key);
}

export function decryptWithKey(
value: string | Encrypted,
key: string,
): Promise<string> {
if (typeof value === "string") {
return libsodiumDecrypt(value, key);
}
return scryptDecrypt(value, key);
}

export async function scryptEncrypt(
value: string,
passphrase: string,
): Promise<Encrypted> {
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<string> {
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<Buffer> {
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<string> {
export async function libsodiumEncrypt(
value: string,
key: string,
): Promise<string> {
const sodium = (await import("libsodium-wrappers")).default;
// Initialize libsodium
await sodium.ready;
Expand Down Expand Up @@ -40,8 +143,9 @@ export async function encrypt(value: string, key: string): Promise<string> {
* @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<string> {
Expand Down
2 changes: 1 addition & 1 deletion alchemy/src/serde.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if data is not present? Do we just skip it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick answer: no, we don't skip it. The data property indicates an encrypted object; otherwise it's an encrypted string. This is the same as before.

-      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),
          ),
        );
      }
      return new Secret(await decryptWithKey(value["@secret"], scope.password));

Why the change? The encrypted value is now an object with properties, so typeof value["@secret"] === "object" is always true. Checking for the data property is how we differentiate the two cases.

The serialization logic is completely unchanged.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was looking for the code that is handling backwards compatibility (decrypting old secrets). Can you point that out to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return new Secret(
JSON.parse(
await decryptWithKey(value["@secret"].data, scope.password),
Expand Down
52 changes: 52 additions & 0 deletions alchemy/test/encrypt.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
68 changes: 64 additions & 4 deletions alchemy/test/serde.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down
Loading