mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
feat(Crypto Node): Add encryption and decryption actions (#30540)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
parent
60b5aa643d
commit
a5f90bf564
|
|
@ -33,5 +33,39 @@ export class Crypto implements ICredentialType {
|
|||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Encryption Passphrase',
|
||||
name: 'encryptionPassphrase',
|
||||
type: 'string',
|
||||
description:
|
||||
'Passphrase for symmetric Encrypt/Decrypt. Use 16+ random characters or a strong passphrase generated by a password manager.',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Encryption Public Key',
|
||||
name: 'encryptionPublicKey',
|
||||
type: 'string',
|
||||
description:
|
||||
'RSA public key (PEM, SPKI format) used by Encrypt in asymmetric mode. RSA-OAEP-SHA256 can only encrypt small payloads (~190 bytes with a 2048-bit key); use symmetric mode for larger data.',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Encryption Private Key',
|
||||
name: 'encryptionPrivateKey',
|
||||
type: 'string',
|
||||
description: 'RSA private key (PEM, PKCS#8 format) used by Decrypt in asymmetric mode',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import type { BinaryToTextEncoding } from 'crypto';
|
||||
import { createHash, createHmac, createSign, getHashes, randomBytes } from 'crypto';
|
||||
import type { BinaryToTextEncoding, CipherGCMTypes } from 'crypto';
|
||||
import {
|
||||
constants,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
createSign,
|
||||
getHashes,
|
||||
privateDecrypt,
|
||||
publicEncrypt,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
} from 'crypto';
|
||||
import set from 'lodash/set';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
|
|
@ -28,6 +40,34 @@ const supportedAlgorithms = getHashes()
|
|||
.filter((algorithm) => !unsupportedAlgorithms.includes(algorithm))
|
||||
.map((algorithm) => ({ name: algorithm, value: algorithm }));
|
||||
|
||||
type SymmetricCipher = 'aes-128-gcm' | 'aes-192-gcm' | 'aes-256-gcm' | 'chacha20-poly1305';
|
||||
|
||||
const CIPHER_KEY_LENGTHS: Record<SymmetricCipher, number> = {
|
||||
'aes-128-gcm': 16,
|
||||
'aes-192-gcm': 24,
|
||||
'aes-256-gcm': 32,
|
||||
'chacha20-poly1305': 32,
|
||||
};
|
||||
|
||||
const SYMMETRIC_SALT_LENGTH = 16;
|
||||
const SYMMETRIC_IV_LENGTH = 12;
|
||||
const SYMMETRIC_AUTH_TAG_LENGTH = 16;
|
||||
// Payload layout v1: [version=0x01][salt:16][iv:12][tag:16][ciphertext]
|
||||
// Bump the version if the layout, KDF, or cipher framing ever changes.
|
||||
const SYMMETRIC_FORMAT_VERSION = 0x01;
|
||||
// N=65536 balances brute-force resistance against per-call memory pressure on small deployments.
|
||||
// maxmem must be raised explicitly because 128*N*r (~64MB) exceeds Node's default 32MB cap.
|
||||
const SYMMETRIC_SCRYPT_OPTIONS = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 } as const;
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Buffer, keylen: number): Promise<Buffer> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
scrypt(passphrase, salt, keylen, SYMMETRIC_SCRYPT_OPTIONS, (error, key) => {
|
||||
if (error) reject(error);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Crypto',
|
||||
name: 'crypto',
|
||||
|
|
@ -51,7 +91,7 @@ const versionDescription: INodeTypeDescription = {
|
|||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
action: ['hmac', 'sign'],
|
||||
action: ['hmac', 'sign', 'encrypt', 'decrypt'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -62,6 +102,18 @@ const versionDescription: INodeTypeDescription = {
|
|||
name: 'action',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Decrypt',
|
||||
description: 'Decrypt a string with a passphrase or private key',
|
||||
value: 'decrypt',
|
||||
action: 'Decrypt a string',
|
||||
},
|
||||
{
|
||||
name: 'Encrypt',
|
||||
description: 'Encrypt a string with a passphrase or public key',
|
||||
value: 'encrypt',
|
||||
action: 'Encrypt a string',
|
||||
},
|
||||
{
|
||||
name: 'Generate',
|
||||
description: 'Generate random string',
|
||||
|
|
@ -420,6 +472,78 @@ const versionDescription: INodeTypeDescription = {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Symmetric (Passphrase)',
|
||||
value: 'symmetric',
|
||||
description: 'Encrypt or decrypt with a passphrase using an authenticated cipher',
|
||||
},
|
||||
{
|
||||
name: 'Asymmetric (RSA)',
|
||||
value: 'asymmetric',
|
||||
description: 'Encrypt with an RSA public key, decrypt with an RSA private key',
|
||||
},
|
||||
],
|
||||
default: 'symmetric',
|
||||
displayOptions: {
|
||||
show: {
|
||||
action: ['encrypt', 'decrypt'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Cipher',
|
||||
name: 'cipher',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'AES-256-GCM', value: 'aes-256-gcm' },
|
||||
{ name: 'AES-192-GCM', value: 'aes-192-gcm' },
|
||||
{ name: 'AES-128-GCM', value: 'aes-128-gcm' },
|
||||
{ name: 'ChaCha20-Poly1305', value: 'chacha20-poly1305' },
|
||||
],
|
||||
default: 'aes-256-gcm',
|
||||
description:
|
||||
'Authenticated cipher to use. The same value must be selected on encrypt and decrypt.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
action: ['encrypt', 'decrypt'],
|
||||
mode: ['symmetric'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'For Encrypt: the plaintext to encrypt. For Decrypt: the base64 ciphertext produced by this node.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
action: ['encrypt', 'decrypt'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Property Name',
|
||||
name: 'dataPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
description: 'Name of the property to which to write the result',
|
||||
displayOptions: {
|
||||
show: {
|
||||
action: ['encrypt', 'decrypt'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -442,11 +566,17 @@ export class CryptoV2 implements INodeType {
|
|||
|
||||
let hmacSecret = '';
|
||||
let signPrivateKey = '';
|
||||
let encryptionPassphrase = '';
|
||||
let encryptionPublicKey = '';
|
||||
let encryptionPrivateKey = '';
|
||||
|
||||
if (action === 'hmac' || action === 'sign') {
|
||||
if (action === 'hmac' || action === 'sign' || action === 'encrypt' || action === 'decrypt') {
|
||||
const credentials = await this.getCredentials<{
|
||||
hmacSecret?: string;
|
||||
signPrivateKey?: string;
|
||||
encryptionPassphrase?: string;
|
||||
encryptionPublicKey?: string;
|
||||
encryptionPrivateKey?: string;
|
||||
}>('crypto');
|
||||
|
||||
if (action === 'hmac') {
|
||||
|
|
@ -468,6 +598,40 @@ export class CryptoV2 implements INodeType {
|
|||
}
|
||||
signPrivateKey = formatPrivateKey(credentials.signPrivateKey);
|
||||
}
|
||||
|
||||
if (action === 'encrypt' || action === 'decrypt') {
|
||||
const mode = this.getNodeParameter('mode', 0) as string;
|
||||
|
||||
if (mode === 'symmetric') {
|
||||
if (!credentials.encryptionPassphrase) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No encryption passphrase set in credentials. Please add an Encryption Passphrase to your Crypto credentials.',
|
||||
);
|
||||
}
|
||||
encryptionPassphrase = credentials.encryptionPassphrase;
|
||||
}
|
||||
|
||||
if (mode === 'asymmetric' && action === 'encrypt') {
|
||||
if (!credentials.encryptionPublicKey) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No encryption public key set in credentials. Please add an Encryption Public Key to your Crypto credentials.',
|
||||
);
|
||||
}
|
||||
encryptionPublicKey = formatPrivateKey(credentials.encryptionPublicKey, true);
|
||||
}
|
||||
|
||||
if (mode === 'asymmetric' && action === 'decrypt') {
|
||||
if (!credentials.encryptionPrivateKey) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No encryption private key set in credentials. Please add an Encryption Private Key to your Crypto credentials.',
|
||||
);
|
||||
}
|
||||
encryptionPrivateKey = formatPrivateKey(credentials.encryptionPrivateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let item: INodeExecutionData;
|
||||
|
|
@ -530,6 +694,116 @@ export class CryptoV2 implements INodeType {
|
|||
newValue = sign.sign(signPrivateKey, encoding);
|
||||
}
|
||||
|
||||
if (action === 'encrypt') {
|
||||
const mode = this.getNodeParameter('mode', i) as string;
|
||||
|
||||
if (mode === 'symmetric') {
|
||||
const cipher = this.getNodeParameter('cipher', i) as SymmetricCipher;
|
||||
const keyLength = CIPHER_KEY_LENGTHS[cipher];
|
||||
const salt = randomBytes(SYMMETRIC_SALT_LENGTH);
|
||||
const iv = randomBytes(SYMMETRIC_IV_LENGTH);
|
||||
const key = await deriveKey(encryptionPassphrase, salt, keyLength);
|
||||
// Cast: Node's typings only model AEAD methods for CipherGCMTypes,
|
||||
// but chacha20-poly1305 has the same getAuthTag/setAuthTag runtime API.
|
||||
const cipherInstance = createCipheriv(cipher as CipherGCMTypes, key, iv);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipherInstance.update(value, 'utf8'),
|
||||
cipherInstance.final(),
|
||||
]);
|
||||
const authTag = cipherInstance.getAuthTag();
|
||||
newValue = Buffer.concat([
|
||||
Buffer.from([SYMMETRIC_FORMAT_VERSION]),
|
||||
salt,
|
||||
iv,
|
||||
authTag,
|
||||
ciphertext,
|
||||
]).toString('base64');
|
||||
} else {
|
||||
try {
|
||||
const encrypted = publicEncrypt(
|
||||
{
|
||||
key: encryptionPublicKey,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from(value, 'utf8'),
|
||||
);
|
||||
newValue = encrypted.toString('base64');
|
||||
} catch (error) {
|
||||
const opensslError = error as { code?: string; message?: string };
|
||||
if (
|
||||
opensslError.code === 'ERR_OSSL_RSA_DATA_TOO_LARGE_FOR_KEY_SIZE' ||
|
||||
/data too large/i.test(opensslError.message ?? '')
|
||||
) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Plaintext is too large for the RSA key. Use symmetric mode for larger data.',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'decrypt') {
|
||||
const mode = this.getNodeParameter('mode', i) as string;
|
||||
|
||||
try {
|
||||
if (mode === 'symmetric') {
|
||||
const cipher = this.getNodeParameter('cipher', i) as SymmetricCipher;
|
||||
const keyLength = CIPHER_KEY_LENGTHS[cipher];
|
||||
const payload = Buffer.from(value, 'base64');
|
||||
const headerLength =
|
||||
1 + SYMMETRIC_SALT_LENGTH + SYMMETRIC_IV_LENGTH + SYMMETRIC_AUTH_TAG_LENGTH;
|
||||
if (payload.length < headerLength) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Ciphertext is malformed or truncated',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
const version = payload[0];
|
||||
if (version !== SYMMETRIC_FORMAT_VERSION) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Unsupported ciphertext version 0x${version.toString(16).padStart(2, '0')}`,
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
const ivStart = 1 + SYMMETRIC_SALT_LENGTH;
|
||||
const tagStart = ivStart + SYMMETRIC_IV_LENGTH;
|
||||
const ctStart = tagStart + SYMMETRIC_AUTH_TAG_LENGTH;
|
||||
const salt = payload.subarray(1, ivStart);
|
||||
const iv = payload.subarray(ivStart, tagStart);
|
||||
const authTag = payload.subarray(tagStart, ctStart);
|
||||
const ciphertext = payload.subarray(ctStart);
|
||||
const key = await deriveKey(encryptionPassphrase, salt, keyLength);
|
||||
const decipher = createDecipheriv(cipher as CipherGCMTypes, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
newValue = plaintext.toString('utf8');
|
||||
} else {
|
||||
const decrypted = privateDecrypt(
|
||||
{
|
||||
key: encryptionPrivateKey,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from(value, 'base64'),
|
||||
);
|
||||
newValue = decrypted.toString('utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof NodeOperationError) throw error;
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Decryption failed: wrong passphrase, key, cipher, or corrupted payload',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let newItem: INodeExecutionData;
|
||||
if (dataPropertyName.includes('.')) {
|
||||
// Uses dot notation so copy all data
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { generateKeyPairSync } from 'crypto';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INodeTypeBaseDescription } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
|
@ -236,4 +237,363 @@ describe('CryptoV2 Node', () => {
|
|||
expect(mockExecuteFunctions.getCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Encrypt / Decrypt actions', () => {
|
||||
const passphrase = 'correct horse battery staple';
|
||||
const plaintext = 'The quick brown fox jumps over the lazy dog';
|
||||
const symmetricCiphers = [
|
||||
'aes-128-gcm',
|
||||
'aes-192-gcm',
|
||||
'aes-256-gcm',
|
||||
'chacha20-poly1305',
|
||||
] as const;
|
||||
|
||||
let rsaPublicKey: string;
|
||||
let rsaPrivateKey: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const keys = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
rsaPublicKey = keys.publicKey;
|
||||
rsaPrivateKey = keys.privateKey;
|
||||
});
|
||||
|
||||
const mockEncryptParams = (
|
||||
overrides: Partial<Record<string, string>> = {},
|
||||
): Record<string, string> => ({
|
||||
action: 'encrypt',
|
||||
mode: 'symmetric',
|
||||
cipher: 'aes-256-gcm',
|
||||
value: plaintext,
|
||||
dataPropertyName: 'data',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockDecryptParams = (
|
||||
value: string,
|
||||
overrides: Partial<Record<string, string>> = {},
|
||||
): Record<string, string> => ({
|
||||
action: 'decrypt',
|
||||
mode: 'symmetric',
|
||||
cipher: 'aes-256-gcm',
|
||||
value,
|
||||
dataPropertyName: 'data',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Credential validation', () => {
|
||||
it('throws when symmetric encrypt has no passphrase', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
const params = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((name: string) => params[name]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({ encryptionPassphrase: '' });
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'No encryption passphrase set',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when asymmetric encrypt has no public key', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
const params = mockEncryptParams({ mode: 'asymmetric' });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((name: string) => params[name]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({ encryptionPublicKey: '' });
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'No encryption public key set',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when symmetric decrypt has no passphrase', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
const params = mockDecryptParams('AAAA');
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((name: string) => params[name]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({ encryptionPassphrase: '' });
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'No encryption passphrase set',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when asymmetric decrypt has no private key', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
const params = mockDecryptParams('AAAA', { mode: 'asymmetric' });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((name: string) => params[name]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({ encryptionPrivateKey: '' });
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'No encryption private key set',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symmetric round-trip', () => {
|
||||
it.each(symmetricCiphers)('encrypts and decrypts with %s', async (cipher) => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
|
||||
const encryptParams = mockEncryptParams({ cipher });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
expect(typeof ciphertext).toBe('string');
|
||||
expect(ciphertext.length).toBeGreaterThan(0);
|
||||
expect(ciphertext).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
|
||||
const decryptParams = mockDecryptParams(ciphertext, { cipher });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
const decryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(decryptResult[0][0].json.data).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces different ciphertext each run (random salt/iv)', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const params = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((name: string) => params[name]);
|
||||
|
||||
const first = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const second = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(first[0][0].json.data).not.toBe(second[0][0].json.data);
|
||||
});
|
||||
|
||||
it('throws when decrypting a tampered payload', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const encryptParams = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
const tampered = Buffer.from(ciphertext, 'base64');
|
||||
tampered[tampered.length - 1] ^= 0xff;
|
||||
const tamperedB64 = tampered.toString('base64');
|
||||
|
||||
const decryptParams = mockDecryptParams(tamperedB64);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when decrypting with the wrong passphrase', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const encryptParams = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPassphrase: 'wrong passphrase',
|
||||
});
|
||||
const decryptParams = mockDecryptParams(ciphertext);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when decrypting with the wrong cipher selection', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
|
||||
const encryptParams = mockEncryptParams({ cipher: 'aes-256-gcm' });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
const decryptParams = mockDecryptParams(ciphertext, { cipher: 'chacha20-poly1305' });
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Asymmetric round-trip', () => {
|
||||
it('encrypts with public key and decrypts with private key', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPublicKey: rsaPublicKey,
|
||||
});
|
||||
const encryptParams = mockEncryptParams({ mode: 'asymmetric' });
|
||||
delete encryptParams.cipher;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
expect(typeof ciphertext).toBe('string');
|
||||
expect(ciphertext).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPrivateKey: rsaPrivateKey,
|
||||
});
|
||||
const decryptParams = mockDecryptParams(ciphertext, { mode: 'asymmetric' });
|
||||
delete decryptParams.cipher;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
const decryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(decryptResult[0][0].json.data).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('throws a clear error when RSA plaintext exceeds the key size limit', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPublicKey: rsaPublicKey,
|
||||
});
|
||||
|
||||
// 2048-bit RSA-OAEP-SHA256 max plaintext is ~190 bytes.
|
||||
const oversized = 'a'.repeat(512);
|
||||
const encryptParams = mockEncryptParams({ mode: 'asymmetric', value: oversized });
|
||||
delete encryptParams.cipher;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'Plaintext is too large',
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps asymmetric decrypt failures in a NodeOperationError', async () => {
|
||||
const otherKeyPair = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPublicKey: rsaPublicKey,
|
||||
});
|
||||
const encryptParams = mockEncryptParams({ mode: 'asymmetric' });
|
||||
delete encryptParams.cipher;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPrivateKey: otherKeyPair.privateKey,
|
||||
});
|
||||
const decryptParams = mockDecryptParams(ciphertext, { mode: 'asymmetric' });
|
||||
delete decryptParams.cipher;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payload format validation', () => {
|
||||
it('throws a clear error when symmetric ciphertext is truncated', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const decryptParams = mockDecryptParams(Buffer.from([0x01, 0x02, 0x03]).toString('base64'));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'Ciphertext is malformed or truncated',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error on unsupported ciphertext version', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const encryptParams = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
const buf = Buffer.from(ciphertext, 'base64');
|
||||
buf[0] = 0xff;
|
||||
const decryptParams = mockDecryptParams(buf.toString('base64'));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
'Unsupported ciphertext version 0xff',
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps symmetric decrypt failures in a NodeOperationError', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPassphrase: passphrase,
|
||||
});
|
||||
const encryptParams = mockEncryptParams();
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => encryptParams[name],
|
||||
);
|
||||
const encryptResult = await cryptoNode.execute.call(mockExecuteFunctions);
|
||||
const ciphertext = encryptResult[0][0].json.data as string;
|
||||
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValueOnce({
|
||||
encryptionPassphrase: 'wrong passphrase',
|
||||
});
|
||||
const decryptParams = mockDecryptParams(ciphertext);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(name: string) => decryptParams[name],
|
||||
);
|
||||
|
||||
await expect(cryptoNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user