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:
Jon 2026-05-19 10:35:49 +01:00 committed by GitHub
parent 60b5aa643d
commit a5f90bf564
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 672 additions and 4 deletions

View File

@ -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: '',
},
];
}

View File

@ -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

View File

@ -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,
);
});
});
});
});