chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
function encryptWithLegacyDynamicSalt(secret: string, plaintext: string): string {
|
||||
const key = scryptSync(secret, createHash("sha256").update(secret).digest().slice(0, 16), 32);
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
const authTag = cipher.getAuthTag().toString("hex");
|
||||
return `enc:v1:${iv.toString("hex")}:${encrypted}:${authTag}`;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (ORIGINAL_STORAGE_KEY === undefined) {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("encryption stays in passthrough mode when no storage key is configured", async () => {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
assert.equal(encryption.isEncryptionEnabled(), false);
|
||||
assert.equal(encryption.encrypt("plain-text"), "plain-text");
|
||||
assert.equal(encryption.decrypt("plain-text"), "plain-text");
|
||||
assert.equal(encryption.encrypt(""), "");
|
||||
assert.equal(encryption.decrypt(null), null);
|
||||
assert.equal(encryption.decrypt(undefined), undefined);
|
||||
assert.equal("validateEncryptionConfig" in encryption, false);
|
||||
});
|
||||
|
||||
test("encrypt/decrypt round-trip uses the expected serialized format", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-a";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const encrypted = encryption.encrypt("hello world");
|
||||
const decrypted = encryption.decrypt(encrypted);
|
||||
|
||||
assert.equal(encryption.isEncryptionEnabled(), true);
|
||||
assert.match(encrypted, /^enc:v1:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
assert.equal(decrypted, "hello world");
|
||||
assert.equal(encryption.encrypt(encrypted), encrypted);
|
||||
});
|
||||
|
||||
test("decrypt rejects a truncated GCM authentication tag (authTagLength pinned to 16 bytes)", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-authtag";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const encrypted = encryption.encrypt("forge-me");
|
||||
assert.equal(encryption.decrypt(encrypted), "forge-me");
|
||||
|
||||
// Truncate the trailing auth tag to 1 byte (2 hex chars). With authTagLength
|
||||
// pinned to 16 bytes on createDecipheriv, Node rejects the short tag instead
|
||||
// of verifying a weakened tag, so decrypt must fail closed (null).
|
||||
const [prefix, version, ivHex, encryptedHex] = encrypted.split(":");
|
||||
const truncated = `${prefix}:${version}:${ivHex}:${encryptedHex}:ab`;
|
||||
assert.equal(encryption.decrypt(truncated), null);
|
||||
});
|
||||
|
||||
test("connection field helpers encrypt and decrypt all supported credential fields", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-b";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const connection = {
|
||||
apiKey: "sk-123",
|
||||
accessToken: "access-123",
|
||||
refreshToken: "refresh-123",
|
||||
idToken: "id-123",
|
||||
untouched: "keep-me",
|
||||
};
|
||||
|
||||
const encrypted = encryption.encryptConnectionFields({ ...connection });
|
||||
const decrypted = encryption.decryptConnectionFields(encrypted);
|
||||
|
||||
assert.notEqual(encrypted.apiKey, connection.apiKey);
|
||||
assert.match(encrypted.apiKey, /^enc:v1:/);
|
||||
assert.match(encrypted.accessToken, /^enc:v1:/);
|
||||
assert.match(encrypted.refreshToken, /^enc:v1:/);
|
||||
assert.match(encrypted.idToken, /^enc:v1:/);
|
||||
assert.deepEqual(decrypted, connection);
|
||||
});
|
||||
|
||||
test("decrypt returns null when the value is malformed or the key is wrong", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-c";
|
||||
const firstModule = await importFresh("src/lib/db/encryption.ts");
|
||||
const encrypted = firstModule.encrypt("top-secret");
|
||||
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-d";
|
||||
const secondModule = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
// When decryption fails with wrong key, return null (not encrypted ciphertext)
|
||||
// This prevents sending encrypted tokens to APIs
|
||||
assert.equal(secondModule.decrypt(encrypted), null);
|
||||
assert.equal(secondModule.decrypt("enc:v1:not-valid"), null);
|
||||
});
|
||||
|
||||
test("legacy encryption migration parses ciphertext in canonical payload order", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-legacy-secret";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
const legacyCiphertext = encryptWithLegacyDynamicSalt(
|
||||
process.env.STORAGE_ENCRYPTION_KEY,
|
||||
"legacy-provider-token"
|
||||
);
|
||||
|
||||
assert.equal(encryption.decrypt(legacyCiphertext), null);
|
||||
|
||||
const migrated = encryption.migrateLegacyEncryptedString(legacyCiphertext);
|
||||
|
||||
assert.equal(migrated.updated, true);
|
||||
assert.match(migrated.value, /^enc:v1:/);
|
||||
assert.equal(encryption.decrypt(migrated.value), "legacy-provider-token");
|
||||
});
|
||||
Reference in New Issue
Block a user