refactor: Extract formatPemBlock into @n8n/utils (#32998)

This commit is contained in:
Ilfat Mindubaev
2026-06-29 11:19:29 +03:00
committed by GitHub
parent 88544528d2
commit 6c85fdcd81
35 changed files with 202 additions and 357 deletions
+1
View File
@@ -23,6 +23,7 @@
"dist/**/*"
],
"dependencies": {
"@n8n/utils": "workspace:*",
"axios": "catalog:"
},
"devDependencies": {
@@ -1,7 +1,6 @@
import { formatPemBlock } from '@n8n/utils';
import { createPrivateKey, createSign, randomUUID, X509Certificate } from 'node:crypto';
import { formatPrivateKey } from './format-private-key';
// private_key_jwt (RFC 7521/7523): the client proves its identity with a JWT
// signed by its private key instead of a shared secret. The `x5t` header (SHA-1
// thumbprint of the certificate) tells the server which public key verifies it.
@@ -14,9 +13,7 @@ function base64url(input: Buffer | string): string {
}
function certificateThumbprint(certificate: string): string {
// `formatPrivateKey` also normalizes CERTIFICATE PEMs; the name-vs-usage
// mismatch is resolved by the shared-helper rename tracked in ENT-114.
const fingerprint = new X509Certificate(formatPrivateKey(certificate)).fingerprint;
const fingerprint = new X509Certificate(formatPemBlock(certificate)).fingerprint;
return Buffer.from(fingerprint.replace(/:/g, ''), 'hex').toString('base64url');
}
@@ -44,7 +41,7 @@ export function buildClientAssertion(options: BuildClientAssertionOptions): stri
// `createSign('RSA-SHA256')` also signs EC/Ed25519 keys, producing a signature
// that contradicts the pinned `alg: RS256` header. Reject non-RSA keys up front.
const privateKey = createPrivateKey(formatPrivateKey(options.privateKey));
const privateKey = createPrivateKey(formatPemBlock(options.privateKey));
if (privateKey.asymmetricKeyType !== 'rsa') {
throw new Error('Certificate authentication requires an RSA private key');
}
@@ -1,62 +0,0 @@
// NOTE: Copied verbatim from `n8n-nodes-base` (utils/utilities.ts). It can't be
// imported from there because nodes-base already depends on this package, so a
// reverse dependency would be a cycle. De-duplicating both copies into a shared
// low-level package is tracked in ENT-114.
const PEM_BODY_LINE_LENGTH = 64;
function formatCompactPem(privateKey: string, keyIsPublic: boolean): string | undefined {
const trimmed = privateKey.trim();
if ((trimmed.match(/-----BEGIN /g) ?? []).length !== 1) return undefined;
const labelPattern = keyIsPublic ? '[A-Z0-9 ]*PUBLIC KEY' : '[A-Z0-9 ]*PRIVATE KEY|CERTIFICATE';
const pemMatch = trimmed.match(
new RegExp(`^-----BEGIN (${labelPattern})-----([\\s\\S]*?)-----END \\1-----$`),
);
if (!pemMatch) return undefined;
const [, label, body] = pemMatch;
const normalizedBody = body.replace(/\\n/g, '\n').trim();
const formattedBody = /\s/.test(normalizedBody)
? normalizedBody.replace(/:\s+/g, ':').replace(/\s+/g, '\n')
: (normalizedBody.match(new RegExp(`.{1,${PEM_BODY_LINE_LENGTH}}`, 'g')) ?? []).join('\n');
return `-----BEGIN ${label}-----\n${formattedBody}\n-----END ${label}-----`;
}
/**
* Formats a private key by removing unnecessary whitespace and adding line breaks.
* @param privateKey - The private key to format.
* @returns The formatted private key.
*/
export function formatPrivateKey(privateKey: string, keyIsPublic = false): string {
let regex = /(PRIVATE KEY|CERTIFICATE)/;
if (keyIsPublic) {
regex = /(PUBLIC KEY)/;
}
if (!privateKey || /\n/.test(privateKey)) {
return privateKey;
}
const compactPem = formatCompactPem(privateKey, keyIsPublic);
if (compactPem !== undefined) {
return compactPem;
}
let formattedPrivateKey = '';
const parts = privateKey.split('-----').filter((item) => item !== '');
parts.forEach((part) => {
if (regex.test(part)) {
formattedPrivateKey += `-----${part}-----`;
} else {
const passRegex = /Proc-Type|DEK-Info/;
if (passRegex.test(part)) {
part = part.replace(/:\s+/g, ':');
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
} else {
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
}
}
});
return formattedPrivateKey;
}
@@ -1,7 +1,7 @@
import { formatPemBlock } from '@n8n/utils';
import { createVerify, generateKeyPairSync, X509Certificate } from 'node:crypto';
import { buildClientAssertion } from '@/client-assertion';
import { formatPrivateKey } from '@/format-private-key';
import * as config from './config';
@@ -37,7 +37,7 @@ describe('buildClientAssertion', () => {
const verified = createVerify('RSA-SHA256')
.update(`${headerSeg}.${payloadSeg}`)
.verify(
new X509Certificate(formatPrivateKey(config.certificate)).publicKey,
new X509Certificate(formatPemBlock(config.certificate)).publicKey,
Buffer.from(signatureSeg, 'base64url'),
);
expect(verified).toBe(true);
@@ -1,6 +1,7 @@
import { ProjectsClient } from '@google-cloud/resource-manager';
import { VertexAIEmbeddings } from '@langchain/google-vertexai';
import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import { formatPemBlock } from '@n8n/utils';
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
ILoadOptionsFunctions,
@@ -10,8 +11,6 @@ import type {
SupplyData,
} from 'n8n-workflow';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
export class EmbeddingsGoogleVertex implements INodeType {
methods = {
listSearch: {
@@ -19,7 +18,7 @@ export class EmbeddingsGoogleVertex implements INodeType {
const results: Array<{ name: string; value: string }> = [];
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const client = new ProjectsClient({
@@ -129,7 +128,7 @@ export class EmbeddingsGoogleVertex implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const region = credentials.region as string;
@@ -1,7 +1,12 @@
import { ProjectsClient } from '@google-cloud/resource-manager';
import type { GoogleAISafetySetting } from '@langchain/google-common';
import { ChatVertexAI, type ChatVertexAIInput } from '@langchain/google-vertexai';
import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities';
import {
makeN8nLlmFailedAttemptHandler,
N8nLlmTracing,
getConnectionHintNoticeField,
} from '@n8n/ai-utilities';
import { formatPemBlock } from '@n8n/utils';
import {
NodeConnectionTypes,
type INodeType,
@@ -16,11 +21,6 @@ import {
import { makeErrorFromStatus } from './error-handling';
import { getAdditionalOptions } from '../gemini-common/additional-options';
import {
makeN8nLlmFailedAttemptHandler,
N8nLlmTracing,
getConnectionHintNoticeField,
} from '@n8n/ai-utilities';
export class LmChatGoogleVertex implements INodeType {
description: INodeTypeDescription = {
@@ -106,7 +106,7 @@ export class LmChatGoogleVertex implements INodeType {
const results: Array<{ name: string; value: string }> = [];
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const client = new ProjectsClient({
@@ -134,7 +134,7 @@ export class LmChatGoogleVertex implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const region = credentials.region as string;
@@ -8,8 +8,8 @@ import { LmChatGoogleVertex } from '../LmChatGoogleVertex.node';
vi.mock('@langchain/google-vertexai');
vi.mock('@n8n/ai-utilities');
vi.mock('n8n-nodes-base/dist/utils/utilities', () => ({
formatPrivateKey: vi.fn().mockImplementation((key: string) => key),
vi.mock('@n8n/utils', () => ({
formatPemBlock: vi.fn().mockImplementation((key: string) => key),
}));
const MockedChatVertexAI = vi.mocked(ChatVertexAI);
@@ -280,6 +280,7 @@
"@n8n/json-schema-to-zod": "workspace:*",
"@n8n/typeorm": "0.3.20-16",
"@n8n/typescript-config": "workspace:*",
"@n8n/utils": "workspace:*",
"@oracle/langchain-oracledb": "0.2.0",
"@pinecone-database/pinecone": "^5.0.2",
"@qdrant/js-client-rest": "^1.16.2",
@@ -1,10 +1,12 @@
import { formatPrivateKey } from '@/format-private-key';
import { describe, expect, it } from 'vitest';
describe('formatPrivateKey', () => {
import { formatPemBlock } from './format-pem-block';
describe('formatPemBlock', () => {
it('should format compact private PEM blocks with wrapped body lines', () => {
const compactKey = `-----BEGIN OPENSSH PRIVATE KEY-----${'A'.repeat(130)}-----END OPENSSH PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
expect(formatPemBlock(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
${'A'.repeat(64)}
${'A'.repeat(64)}
${'A'.repeat(2)}
@@ -14,7 +16,7 @@ ${'A'.repeat(2)}
it('should format compact public PEM blocks with wrapped body lines', () => {
const compactKey = `-----BEGIN PUBLIC KEY-----${'B'.repeat(66)}-----END PUBLIC KEY-----`;
expect(formatPrivateKey(compactKey, true)).toBe(`-----BEGIN PUBLIC KEY-----
expect(formatPemBlock(compactKey, true)).toBe(`-----BEGIN PUBLIC KEY-----
${'B'.repeat(64)}
${'B'.repeat(2)}
-----END PUBLIC KEY-----`);
@@ -25,25 +27,34 @@ ${'B'.repeat(2)}
ABC
-----END OPENSSH PRIVATE KEY-----`;
expect(formatPrivateKey(multilineKey)).toBe(multilineKey);
expect(formatPemBlock(multilineKey)).toBe(multilineKey);
});
it('should return empty string for empty input', () => {
expect(formatPrivateKey('')).toBe('');
expect(formatPemBlock('')).toBe('');
});
it('should format compact RSA PRIVATE KEY block', () => {
const compactKey = `-----BEGIN RSA PRIVATE KEY-----${'C'.repeat(64)}-----END RSA PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN RSA PRIVATE KEY-----
expect(formatPemBlock(compactKey)).toBe(`-----BEGIN RSA PRIVATE KEY-----
${'C'.repeat(64)}
-----END RSA PRIVATE KEY-----`);
});
it('should format compact EC PRIVATE KEY block', () => {
const compactKey = `-----BEGIN EC PRIVATE KEY-----${'D'.repeat(70)}-----END EC PRIVATE KEY-----`;
expect(formatPemBlock(compactKey)).toBe(`-----BEGIN EC PRIVATE KEY-----
${'D'.repeat(64)}
${'D'.repeat(6)}
-----END EC PRIVATE KEY-----`);
});
it('should format compact CERTIFICATE block (not just private keys)', () => {
const compactCert = `-----BEGIN CERTIFICATE-----${'E'.repeat(128)}-----END CERTIFICATE-----`;
expect(formatPrivateKey(compactCert)).toBe(`-----BEGIN CERTIFICATE-----
expect(formatPemBlock(compactCert)).toBe(`-----BEGIN CERTIFICATE-----
${'E'.repeat(64)}
${'E'.repeat(64)}
-----END CERTIFICATE-----`);
@@ -52,7 +63,7 @@ ${'E'.repeat(64)}
it('should strip surrounding whitespace before formatting compact PEM', () => {
const compactKey = ` -----BEGIN OPENSSH PRIVATE KEY-----${'A'.repeat(64)}-----END OPENSSH PRIVATE KEY----- `;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
expect(formatPemBlock(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
${'A'.repeat(64)}
-----END OPENSSH PRIVATE KEY-----`);
});
@@ -60,7 +71,7 @@ ${'A'.repeat(64)}
it('should convert escaped \\n sequences in compact body to newlines', () => {
const compactKey = `-----BEGIN PRIVATE KEY-----\\n${'F'.repeat(64)}\\n${'F'.repeat(32)}\\n-----END PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN PRIVATE KEY-----
expect(formatPemBlock(compactKey)).toBe(`-----BEGIN PRIVATE KEY-----
${'F'.repeat(64)}
${'F'.repeat(32)}
-----END PRIVATE KEY-----`);
@@ -69,13 +80,35 @@ ${'F'.repeat(32)}
it('should preserve a compact certificate chain unchanged (chain guard)', () => {
const chain = `-----BEGIN CERTIFICATE-----${'A'.repeat(10)}-----END CERTIFICATE----------BEGIN CERTIFICATE-----${'B'.repeat(10)}-----END CERTIFICATE-----`;
expect(formatPrivateKey(chain)).toBe(chain);
expect(formatPemBlock(chain)).toBe(chain);
});
it('should preserve multi-line certificate chain unchanged', () => {
const chain = `-----BEGIN CERTIFICATE-----
AAA
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
BBB
-----END CERTIFICATE-----`;
expect(formatPemBlock(chain)).toBe(chain);
});
it('should not match when BEGIN/END labels differ', () => {
const mismatched = `-----BEGIN RSA PRIVATE KEY-----${'A'.repeat(64)}-----END EC PRIVATE KEY-----`;
expect(formatPrivateKey(mismatched)).toBe(mismatched);
expect(formatPemBlock(mismatched)).toBe(mismatched);
});
it('should keep a multiline encrypted PEM with Proc-Type/DEK-Info unchanged', () => {
const encrypted = `-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,1234567890ABCDEF
${'X'.repeat(64)}
-----END RSA PRIVATE KEY-----`;
expect(formatPemBlock(encrypted)).toBe(encrypted);
});
it('should collapse Proc-Type/DEK-Info headers on the fallback path', () => {
@@ -83,6 +116,6 @@ ${'F'.repeat(32)}
// the encrypted-key headers exercises the Proc-Type/DEK-Info branch.
const encrypted = `-----BEGIN RSA PRIVATE KEY-----Proc-Type: 4,ENCRYPTED ${'A'.repeat(20)}-----END EC PRIVATE KEY-----`;
expect(formatPrivateKey(encrypted)).toContain('Proc-Type:4,ENCRYPTED');
expect(formatPemBlock(encrypted)).toContain('Proc-Type:4,ENCRYPTED');
});
});
@@ -0,0 +1,61 @@
const PEM_BODY_LINE_LENGTH = 64;
function formatCompactPem(pem: string, isPublic: boolean): string | undefined {
const trimmed = pem.trim();
if ((trimmed.match(/-----BEGIN /g) ?? []).length !== 1) return undefined;
const labelPattern = isPublic ? '[A-Z0-9 ]*PUBLIC KEY' : '[A-Z0-9 ]*PRIVATE KEY|CERTIFICATE';
const pemMatch = trimmed.match(
new RegExp(`^-----BEGIN (${labelPattern})-----([\\s\\S]*?)-----END \\1-----$`),
);
if (!pemMatch) return undefined;
const [, label, body] = pemMatch;
const normalizedBody = body.replace(/\\n/g, '\n').trim();
const formattedBody = /\s/.test(normalizedBody)
? normalizedBody.replace(/:\s+/g, ':').replace(/\s+/g, '\n')
: (normalizedBody.match(new RegExp(`.{1,${PEM_BODY_LINE_LENGTH}}`, 'g')) ?? []).join('\n');
return `-----BEGIN ${label}-----\n${formattedBody}\n-----END ${label}-----`;
}
/**
* Normalize a single PEM-encoded block (private key, public key, or certificate)
* by collapsing whitespace and wrapping the body at 64 chars. Multi-block PEM
* chains are returned unchanged.
*
* @param pem - The PEM-encoded block to format.
* @param isPublic - When true, match `PUBLIC KEY` labels instead of the default `PRIVATE KEY` / `CERTIFICATE`.
* @returns The formatted PEM block.
*/
export function formatPemBlock(pem: string, isPublic = false): string {
let regex = /(PRIVATE KEY|CERTIFICATE)/;
if (isPublic) {
regex = /(PUBLIC KEY)/;
}
if (!pem || /\n/.test(pem)) {
return pem;
}
const compactPem = formatCompactPem(pem, isPublic);
if (compactPem !== undefined) {
return compactPem;
}
let formattedPem = '';
const parts = pem.split('-----').filter((item) => item !== '');
parts.forEach((part) => {
if (regex.test(part)) {
formattedPem += `-----${part}-----`;
} else {
const passRegex = /Proc-Type|DEK-Info/;
if (passRegex.test(part)) {
part = part.replace(/:\s+/g, ':');
formattedPem += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
} else {
formattedPem += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
}
}
});
return formattedPem;
}
+1
View File
@@ -12,6 +12,7 @@ export * from './files/sanitize-filename';
export * from './files/is-windows-file-path';
export * from './placeholder';
export * from './get-jwt-expiry';
export * from './format-pem-block';
export * from './scrub-secrets';
export type * from './types';
export * from './is-record';
@@ -30,11 +30,7 @@ import { deepCopy, Workflow } from 'n8n-workflow';
import { generateKeyPairSync } from 'node:crypto';
import { SalesforceJwtApi } from 'n8n-nodes-base/credentials/SalesforceJwtApi.credentials';
// The credential module resolves to nodes-base source, which uses a package-internal
// path alias not mapped by cli's jest config.
jest.mock('@utils/utilities', () => ({ formatPrivateKey: (key: string) => key }), {
virtual: true,
});
jest.mock('@n8n/utils', () => ({ formatPemBlock: (key: string) => key }));
// SalesforceJwtApi.preAuthentication exchanges its signed JWT for a token through the
// shared outbound HTTP client (`getTokenRequestClient`), not `this.helpers.httpRequest`.
@@ -1,3 +1,5 @@
import { formatPemBlock } from '@n8n/utils';
import jwt from 'jsonwebtoken';
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
@@ -7,10 +9,6 @@ import type {
INodeProperties,
} from 'n8n-workflow';
import jwt from 'jsonwebtoken';
import { formatPrivateKey } from '@utils/utilities';
export class GithubAppApi implements ICredentialType {
name = 'githubAppApi';
@@ -66,7 +64,7 @@ export class GithubAppApi implements ICredentialType {
): Promise<ICredentialDataDecryptedObject> {
const now = Math.floor(Date.now() / 1000);
const baseUrl = String(credentials.server ?? 'https://api.github.com').replace(/\/$/, '');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
let appJwt: string;
try {
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import jwt from 'jsonwebtoken';
import moment from 'moment-timezone';
import type {
@@ -10,8 +11,6 @@ import type {
} from 'n8n-workflow';
import { OperationalError } from 'n8n-workflow';
import { formatPrivateKey } from '@utils/utilities';
import { getTokenRequestClient, TOKEN_REQUEST_TIMEOUT } from './common/token-request';
export class SalesforceJwtApi implements ICredentialType {
@@ -99,7 +98,7 @@ export class SalesforceJwtApi implements ICredentialType {
async preAuthentication(this: IHttpRequestHelper, credentials: ICredentialDataDecryptedObject) {
const now = moment().unix();
const authUrl = resolveAuthUrl(credentials);
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
const signature = jwt.sign(
{
iss: credentials.clientId as string,
@@ -8,8 +8,8 @@ vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn() },
}));
vi.mock('@utils/utilities', () => ({
formatPrivateKey: (key: string) => key,
vi.mock('@n8n/utils', () => ({
formatPemBlock: (key: string) => key,
}));
describe('GithubAppApi Credential', () => {
@@ -9,8 +9,8 @@ import { SalesforceJwtApi } from '../SalesforceJwtApi.credentials';
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn(() => 'signed-jwt') },
}));
vi.mock('@utils/utilities', () => ({
formatPrivateKey: (key: string) => key,
vi.mock('@n8n/utils', () => ({
formatPemBlock: (key: string) => key,
}));
interface CapturedRequest {
@@ -14,8 +14,8 @@ import { SalesforceJwtApi, resolveAuthUrl } from '../SalesforceJwtApi.credential
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn() },
}));
vi.mock('@utils/utilities', () => ({
formatPrivateKey: (key: string) => key,
vi.mock('@n8n/utils', () => ({
formatPemBlock: (key: string) => key,
}));
describe('SalesforceJwtApi Credential', () => {
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import type { BinaryToTextEncoding, CipherGCMTypes } from 'crypto';
import {
constants,
@@ -25,8 +26,6 @@ import { deepCopy, BINARY_ENCODING, NodeConnectionTypes, NodeOperationError } fr
import { pipeline } from 'stream/promises';
import { v4 as uuid } from 'uuid';
import { formatPrivateKey } from '../../../utils/utilities';
const unsupportedAlgorithms = [
'RSA-MD4',
'RSA-MDC2',
@@ -604,7 +603,7 @@ export class CryptoV2 implements INodeType {
'No private key set in credentials. Please add a private key to your Crypto credentials.',
);
}
signPrivateKey = formatPrivateKey(credentials.signPrivateKey);
signPrivateKey = formatPemBlock(credentials.signPrivateKey);
}
if (action === 'encrypt' || action === 'decrypt') {
@@ -627,7 +626,7 @@ export class CryptoV2 implements INodeType {
'No encryption public key set in credentials. Please add an Encryption Public Key to your Crypto credentials.',
);
}
encryptionPublicKey = formatPrivateKey(credentials.encryptionPublicKey, true);
encryptionPublicKey = formatPemBlock(credentials.encryptionPublicKey, true);
}
if (mode === 'asymmetric' && action === 'decrypt') {
@@ -637,7 +636,7 @@ export class CryptoV2 implements INodeType {
'No encryption private key set in credentials. Please add an Encryption Private Key to your Crypto credentials.',
);
}
encryptionPrivateKey = formatPrivateKey(credentials.encryptionPrivateKey);
encryptionPrivateKey = formatPemBlock(credentials.encryptionPrivateKey);
}
}
}
+4 -4
View File
@@ -1,3 +1,5 @@
import { formatPemBlock } from '@n8n/utils';
import { generatePairedItemData } from '@utils/utilities';
import { createWriteStream } from 'fs';
import { BINARY_ENCODING, NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
import type {
@@ -20,8 +22,6 @@ import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { file as tmpFile } from 'tmp-promise';
import { formatPrivateKey, generatePairedItemData } from '@utils/utilities';
interface ReturnFtpItem {
type: string;
name: string;
@@ -558,7 +558,7 @@ export class Ftp implements INodeType {
port: credentials.port as number,
username: credentials.username as string,
password: (credentials.password as string) || undefined,
privateKey: formatPrivateKey(credentials.privateKey as string),
privateKey: formatPemBlock(credentials.privateKey as string),
passphrase: credentials.passphrase as string | undefined,
});
} else {
@@ -613,7 +613,7 @@ export class Ftp implements INodeType {
port: credentials.port as number,
username: credentials.username as string,
password: (credentials.password as string) || undefined,
privateKey: formatPrivateKey(credentials.privateKey as string),
privateKey: formatPemBlock(credentials.privateKey as string),
passphrase: credentials.passphrase as string | undefined,
readyTimeout: connectionTimeout,
algorithms: {
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import * as jwt from 'jsonwebtoken';
import { DateTime } from 'luxon';
import moment from 'moment-timezone';
@@ -12,8 +13,6 @@ import {
NodeOperationError,
} from 'n8n-workflow';
import { formatPrivateKey } from '@utils/utilities';
export const googleServiceAccountScopes = {
bigquery: ['https://www.googleapis.com/auth/bigquery'],
books: ['https://www.googleapis.com/auth/books'],
@@ -87,7 +86,7 @@ export async function getGoogleAccessToken(
const scopes = googleServiceAccountScopes[service];
const privateKey = formatPrivateKey(credentials.privateKey as string);
const privateKey = formatPemBlock(credentials.privateKey as string);
credentials.email = ((credentials.email as string) || '').trim();
const now = moment().unix();
@@ -1,6 +1,6 @@
import { formatPemBlock } from '@n8n/utils';
import FormData from 'form-data';
import get from 'lodash/get';
import type { Readable } from 'stream';
import isPlainObject from 'lodash/isPlainObject';
import set from 'lodash/set';
import {
@@ -13,10 +13,10 @@ import {
type IOAuth2Options,
type IRequestOptions,
} from 'n8n-workflow';
import type { Readable } from 'stream';
import type { SecureContextOptions } from 'tls';
import type { HttpSslAuthCredentials } from './interfaces';
import { formatPrivateKey } from '../../utils/utilities';
export type BodyParameter = {
name: string;
@@ -284,11 +284,11 @@ export const setAgentOptions = (
) => {
if (sslCertificates) {
const agentOptions: SecureContextOptions = {};
if (sslCertificates.ca) agentOptions.ca = formatPrivateKey(sslCertificates.ca);
if (sslCertificates.cert) agentOptions.cert = formatPrivateKey(sslCertificates.cert);
if (sslCertificates.key) agentOptions.key = formatPrivateKey(sslCertificates.key);
if (sslCertificates.ca) agentOptions.ca = formatPemBlock(sslCertificates.ca);
if (sslCertificates.cert) agentOptions.cert = formatPemBlock(sslCertificates.cert);
if (sslCertificates.key) agentOptions.key = formatPemBlock(sslCertificates.key);
if (sslCertificates.passphrase)
agentOptions.passphrase = formatPrivateKey(sslCertificates.passphrase);
agentOptions.passphrase = formatPemBlock(sslCertificates.passphrase);
requestOptions.agentOptions = agentOptions;
}
};
+3 -3
View File
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import jwt from 'jsonwebtoken';
import type {
IDataObject,
@@ -8,7 +9,6 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { formatPrivateKey } from '../../utils/utilities';
import { parseJsonParameter } from '../Set/v2/helpers/utils';
const prettifyOperation = (operation: string) => {
@@ -405,7 +405,7 @@ export class Jwt implements INodeType {
if (credentials.keyType === 'passphrase') {
secretOrPrivateKey = credentials.secret;
} else {
secretOrPrivateKey = formatPrivateKey(credentials.privateKey);
secretOrPrivateKey = formatPemBlock(credentials.privateKey);
}
const algorithm = options.algorithm ?? credentials.algorithm;
@@ -442,7 +442,7 @@ export class Jwt implements INodeType {
if (credentials.keyType === 'passphrase') {
secretOrPublicKey = credentials.secret;
} else {
secretOrPublicKey = formatPrivateKey(credentials.publicKey, true);
secretOrPublicKey = formatPemBlock(credentials.publicKey, true);
}
const { ignoreExpiration, ignoreNotBefore, clockTolerance, complete } = options;
@@ -1,8 +1,7 @@
import { formatPemBlock } from '@n8n/utils';
import { connect, type IClientOptions, type MqttClient } from 'mqtt';
import { OperationalError, randomString } from 'n8n-workflow';
import { formatPrivateKey } from '@utils/utilities';
interface BaseMqttCredential {
protocol: 'mqtt' | 'mqtts' | 'ws';
host: string;
@@ -44,9 +43,9 @@ export const createClient = async (credentials: MqttCredential): Promise<MqttCli
}
if (credentials.ssl) {
clientOptions.ca = formatPrivateKey(credentials.ca);
clientOptions.cert = formatPrivateKey(credentials.cert);
clientOptions.key = formatPrivateKey(credentials.key);
clientOptions.ca = formatPemBlock(credentials.ca);
clientOptions.cert = formatPemBlock(credentials.cert);
clientOptions.key = formatPemBlock(credentials.key);
clientOptions.rejectUnauthorized = credentials.rejectUnauthorized;
}
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import get from 'lodash/get';
import set from 'lodash/set';
import { MongoClient, ObjectId } from 'mongodb';
@@ -15,7 +16,6 @@ import type {
IMongoCredentialsType,
IMongoParametricCredentials,
} from './mongoDb.types';
import { formatPrivateKey } from '../../utils/utilities';
/**
* Standard way of building the MongoDB connection string, unless overridden with a provided string
@@ -196,9 +196,9 @@ export async function connectMongoClient(
};
if (credentials.tls) {
const ca = credentials.ca ? formatPrivateKey(credentials.ca as string) : undefined;
const cert = credentials.cert ? formatPrivateKey(credentials.cert as string) : undefined;
const key = credentials.key ? formatPrivateKey(credentials.key as string) : undefined;
const ca = credentials.ca ? formatPemBlock(credentials.ca as string) : undefined;
const cert = credentials.cert ? formatPemBlock(credentials.cert as string) : undefined;
const key = credentials.key ? formatPemBlock(credentials.key as string) : undefined;
const passphrase = (credentials.passphrase as string) || undefined;
const secureContext = createSecureContext({
@@ -1,3 +1,5 @@
import { formatPemBlock } from '@n8n/utils';
import { LOCALHOST } from '@utils/constants';
import mysql2 from 'mysql2/promise';
import type {
ICredentialTestFunctions,
@@ -7,9 +9,6 @@ import type {
} from 'n8n-workflow';
import { createServer, type AddressInfo } from 'node:net';
import { LOCALHOST } from '@utils/constants';
import { formatPrivateKey } from '@utils/utilities';
import type { Mysql2Pool, MysqlNodeCredentials } from '../helpers/interfaces';
export async function createPool(
@@ -32,12 +31,12 @@ export async function createPool(
connectionOptions.ssl = {};
if (credentials.caCertificate) {
connectionOptions.ssl.ca = formatPrivateKey(credentials.caCertificate);
connectionOptions.ssl.ca = formatPemBlock(credentials.caCertificate);
}
if (credentials.clientCertificate || credentials.clientPrivateKey) {
connectionOptions.ssl.cert = formatPrivateKey(credentials.clientCertificate);
connectionOptions.ssl.key = formatPrivateKey(credentials.clientPrivateKey);
connectionOptions.ssl.cert = formatPemBlock(credentials.clientCertificate);
connectionOptions.ssl.key = formatPemBlock(credentials.clientPrivateKey);
}
}
@@ -65,7 +64,7 @@ export async function createPool(
return mysql2.createPool(connectionOptions);
} else {
if (credentials.sshAuthenticateWith === 'privateKey' && credentials.privateKey) {
credentials.privateKey = formatPrivateKey(credentials.privateKey);
credentials.privateKey = formatPemBlock(credentials.privateKey);
}
const sshClient = await this.helpers.getSSHClient(credentials);
@@ -1,3 +1,6 @@
import { formatPemBlock } from '@n8n/utils';
import { ConnectionPoolManager } from '@utils/connection-pool-manager';
import { LOCALHOST } from '@utils/constants';
import type {
IExecuteFunctions,
ICredentialTestFunctions,
@@ -8,10 +11,6 @@ import type {
import { createServer, type AddressInfo, type Server } from 'node:net';
import pgPromise from 'pg-promise';
import { ConnectionPoolManager } from '@utils/connection-pool-manager';
import { LOCALHOST } from '@utils/constants';
import { formatPrivateKey } from '@utils/utilities';
import type {
ConnectionsData,
PgpConnectionParameters,
@@ -142,7 +141,7 @@ export async function configurePostgres(
return { db, pgp };
} else {
if (credentials.sshAuthenticateWith === 'privateKey' && credentials.privateKey) {
credentials.privateKey = formatPrivateKey(credentials.privateKey);
credentials.privateKey = formatPemBlock(credentials.privateKey);
}
const sshClient = await this.helpers.getSSHClient(credentials, abortController);
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import * as amqplib from 'amqplib';
import type {
IDeferredPromise,
@@ -10,8 +11,6 @@ import type {
} from 'n8n-workflow';
import { jsonParse, sleep } from 'n8n-workflow';
import { formatPrivateKey } from '@utils/utilities';
import type { ExchangeType, Options, RabbitMQCredentials, TriggerOptions } from './types';
const credentialKeys = ['hostname', 'port', 'username', 'password', 'vhost'] as const;
@@ -28,13 +27,12 @@ export async function rabbitmqConnect(
if (credentials.ssl) {
credentialData.protocol = 'amqps';
optsData.ca =
credentials.ca === '' ? undefined : [Buffer.from(formatPrivateKey(credentials.ca))];
optsData.ca = credentials.ca === '' ? undefined : [Buffer.from(formatPemBlock(credentials.ca))];
if (credentials.passwordless) {
optsData.cert =
credentials.cert === '' ? undefined : Buffer.from(formatPrivateKey(credentials.cert));
credentials.cert === '' ? undefined : Buffer.from(formatPemBlock(credentials.cert));
optsData.key =
credentials.key === '' ? undefined : Buffer.from(formatPrivateKey(credentials.key));
credentials.key === '' ? undefined : Buffer.from(formatPemBlock(credentials.key));
optsData.passphrase = credentials.passphrase === '' ? undefined : credentials.passphrase;
optsData.credentials = amqplib.credentials.external();
}
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import jwt from 'jsonwebtoken';
import set from 'lodash/set';
import type {
@@ -24,7 +25,7 @@ import type { Readable } from 'stream';
import { getBinaryResponse } from './utils/binary';
import { configuredOutputs } from './utils/outputs';
import { formatPrivateKey, generatePairedItemData } from '../../utils/utilities';
import { generatePairedItemData } from '../../utils/utilities';
const respondWithProperty: INodeProperties = {
displayName: 'Respond With',
@@ -450,7 +451,7 @@ export class RespondToWebhook implements INodeType {
if (keyType === 'passphrase') {
secretOrPrivateKey = secret;
} else {
secretOrPrivateKey = formatPrivateKey(privateKey);
secretOrPrivateKey = formatPemBlock(privateKey);
}
const payload = this.getNodeParameter('payload', 0, {}) as IDataObject;
const token = jwt.sign(payload, secretOrPrivateKey, { algorithm });
@@ -1,9 +1,8 @@
import { formatPemBlock } from '@n8n/utils';
import { createPrivateKey } from 'crypto';
import pick from 'lodash/pick';
import type snowflake from 'snowflake-sdk';
import { formatPrivateKey } from '@utils/utilities';
const commonConnectionFields = [
'account',
'database',
@@ -36,7 +35,7 @@ export type SnowflakeCredential = Pick<
);
const extractPrivateKey = (credential: { privateKey: string; passphrase?: string }) => {
const key = formatPrivateKey(credential.privateKey);
const key = formatPemBlock(credential.privateKey);
if (!credential.passphrase) return key;
+3 -4
View File
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import { writeFile } from 'fs/promises';
import type {
ICredentialTestFunctions,
@@ -20,8 +21,6 @@ import { NodeSSH } from 'node-ssh';
import type { Readable } from 'stream';
import { file as tmpFile } from 'tmp-promise';
import { formatPrivateKey } from '@utils/utilities';
async function resolveHomeDir(
this: IExecuteFunctions,
path: string,
@@ -301,7 +300,7 @@ export class Ssh implements INodeType {
host: credentials.host as string,
username: credentials.username as string,
port: credentials.port as number,
privateKey: formatPrivateKey(credentials.privateKey as string),
privateKey: formatPemBlock(credentials.privateKey as string),
};
if (credentials.passphrase) {
@@ -354,7 +353,7 @@ export class Ssh implements INodeType {
host: credentials.host as string,
username: credentials.username as string,
port: credentials.port as number,
privateKey: formatPrivateKey(credentials.privateKey as string),
privateKey: formatPemBlock(credentials.privateKey as string),
};
if (credentials.passphrase) {
+2 -2
View File
@@ -1,3 +1,4 @@
import { formatPemBlock } from '@n8n/utils';
import basicAuth from 'basic-auth';
import { rm } from 'fs/promises';
import jwt from 'jsonwebtoken';
@@ -15,7 +16,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
import { BlockList, isIPv6 } from 'node:net';
import { WebhookAuthorizationError } from './error';
import { formatPrivateKey } from '../../utils/utilities';
export type WebhookParameters = {
httpMethod: string | string[];
@@ -342,7 +342,7 @@ export async function validateWebhookAuthentication(
if (expectedAuth.keyType === 'passphrase') {
secretOrPublicKey = expectedAuth.secret;
} else {
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
secretOrPublicKey = formatPemBlock(expectedAuth.publicKey, true);
}
try {
+1
View File
@@ -924,6 +924,7 @@
"@n8n/di": "workspace:*",
"@n8n/errors": "workspace:*",
"@n8n/imap": "workspace:*",
"@n8n/utils": "workspace:*",
"@thednp/dommatrix": "^2.0.12",
"alasql": "4.4.0",
"amqplib": "0.10.6",
@@ -6,7 +6,6 @@ import {
addExecutionHints,
compareItems,
flattenKeys,
formatPrivateKey,
fuzzyCompare,
getResolvables,
keysToLowercase,
@@ -85,128 +84,6 @@ describe('Test wrapData', () => {
});
});
describe('Test formatPrivateKey', () => {
it('should format compact private PEM blocks with wrapped body lines', () => {
const compactKey = `-----BEGIN OPENSSH PRIVATE KEY-----${'A'.repeat(
130,
)}-----END OPENSSH PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
${'A'.repeat(64)}
${'A'.repeat(64)}
${'A'.repeat(2)}
-----END OPENSSH PRIVATE KEY-----`);
});
it('should format compact public PEM blocks with wrapped body lines', () => {
const compactKey = `-----BEGIN PUBLIC KEY-----${'B'.repeat(66)}-----END PUBLIC KEY-----`;
expect(formatPrivateKey(compactKey, true)).toBe(`-----BEGIN PUBLIC KEY-----
${'B'.repeat(64)}
${'B'.repeat(2)}
-----END PUBLIC KEY-----`);
});
it('should keep multiline PEM blocks unchanged', () => {
const multilineKey = `-----BEGIN OPENSSH PRIVATE KEY-----
ABC
-----END OPENSSH PRIVATE KEY-----`;
expect(formatPrivateKey(multilineKey)).toBe(multilineKey);
});
it('should return empty string for empty input', () => {
expect(formatPrivateKey('')).toBe('');
});
it('should format compact RSA PRIVATE KEY block', () => {
const compactKey = `-----BEGIN RSA PRIVATE KEY-----${'C'.repeat(64)}-----END RSA PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN RSA PRIVATE KEY-----
${'C'.repeat(64)}
-----END RSA PRIVATE KEY-----`);
});
it('should format compact EC PRIVATE KEY block', () => {
const compactKey = `-----BEGIN EC PRIVATE KEY-----${'D'.repeat(70)}-----END EC PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN EC PRIVATE KEY-----
${'D'.repeat(64)}
${'D'.repeat(6)}
-----END EC PRIVATE KEY-----`);
});
it('should format compact CERTIFICATE block', () => {
const compactCert = `-----BEGIN CERTIFICATE-----${'E'.repeat(128)}-----END CERTIFICATE-----`;
expect(formatPrivateKey(compactCert)).toBe(`-----BEGIN CERTIFICATE-----
${'E'.repeat(64)}
${'E'.repeat(64)}
-----END CERTIFICATE-----`);
});
it('should strip surrounding whitespace before formatting compact PEM', () => {
const compactKey = ` -----BEGIN OPENSSH PRIVATE KEY-----${'A'.repeat(
64,
)}-----END OPENSSH PRIVATE KEY----- `;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN OPENSSH PRIVATE KEY-----
${'A'.repeat(64)}
-----END OPENSSH PRIVATE KEY-----`);
});
it('should convert escaped \\n sequences in compact body to newlines', () => {
const compactKey = `-----BEGIN PRIVATE KEY-----\\n${'F'.repeat(64)}\\n${'F'.repeat(
32,
)}\\n-----END PRIVATE KEY-----`;
expect(formatPrivateKey(compactKey)).toBe(`-----BEGIN PRIVATE KEY-----
${'F'.repeat(64)}
${'F'.repeat(32)}
-----END PRIVATE KEY-----`);
});
it('should preserve compact certificate chain structure (chain guard)', () => {
const chain = `-----BEGIN CERTIFICATE-----${'A'.repeat(
10,
)}-----END CERTIFICATE----------BEGIN CERTIFICATE-----${'B'.repeat(
10,
)}-----END CERTIFICATE-----`;
expect(formatPrivateKey(chain)).toBe(chain);
});
it('should preserve multi-line certificate chain unchanged', () => {
const chain = `-----BEGIN CERTIFICATE-----
AAA
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
BBB
-----END CERTIFICATE-----`;
expect(formatPrivateKey(chain)).toBe(chain);
});
it('should not match when BEGIN/END labels differ', () => {
const mismatched = `-----BEGIN RSA PRIVATE KEY-----${'A'.repeat(
64,
)}-----END EC PRIVATE KEY-----`;
expect(formatPrivateKey(mismatched)).toBe(mismatched);
});
it('should keep a multiline encrypted PEM with Proc-Type/DEK-Info unchanged', () => {
const encrypted = `-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,1234567890ABCDEF
${'X'.repeat(64)}
-----END RSA PRIVATE KEY-----`;
expect(formatPrivateKey(encrypted)).toBe(encrypted);
});
});
describe('Test keysToLowercase', () => {
it('should convert keys to lowercase', () => {
const headers = {
-58
View File
@@ -281,64 +281,6 @@ export const keysToLowercase = <T>(headers: T) => {
}, {} as IDataObject);
};
const PEM_BODY_LINE_LENGTH = 64;
function formatCompactPem(privateKey: string, keyIsPublic: boolean): string | undefined {
const trimmed = privateKey.trim();
if ((trimmed.match(/-----BEGIN /g) ?? []).length !== 1) return undefined;
const labelPattern = keyIsPublic ? '[A-Z0-9 ]*PUBLIC KEY' : '[A-Z0-9 ]*PRIVATE KEY|CERTIFICATE';
const pemMatch = trimmed.match(
new RegExp(`^-----BEGIN (${labelPattern})-----([\\s\\S]*?)-----END \\1-----$`),
);
if (!pemMatch) return undefined;
const [, label, body] = pemMatch;
const normalizedBody = body.replace(/\\n/g, '\n').trim();
const formattedBody = /\s/.test(normalizedBody)
? normalizedBody.replace(/:\s+/g, ':').replace(/\s+/g, '\n')
: (normalizedBody.match(new RegExp(`.{1,${PEM_BODY_LINE_LENGTH}}`, 'g')) ?? []).join('\n');
return `-----BEGIN ${label}-----\n${formattedBody}\n-----END ${label}-----`;
}
/**
* Formats a private key by removing unnecessary whitespace and adding line breaks.
* @param privateKey - The private key to format.
* @returns The formatted private key.
*/
export function formatPrivateKey(privateKey: string, keyIsPublic = false): string {
let regex = /(PRIVATE KEY|CERTIFICATE)/;
if (keyIsPublic) {
regex = /(PUBLIC KEY)/;
}
if (!privateKey || /\n/.test(privateKey)) {
return privateKey;
}
const compactPem = formatCompactPem(privateKey, keyIsPublic);
if (compactPem !== undefined) {
return compactPem;
}
let formattedPrivateKey = '';
const parts = privateKey.split('-----').filter((item) => item !== '');
parts.forEach((part) => {
if (regex.test(part)) {
formattedPrivateKey += `-----${part}-----`;
} else {
const passRegex = /Proc-Type|DEK-Info/;
if (passRegex.test(part)) {
part = part.replace(/:\s+/g, ':');
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
} else {
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
}
}
});
return formattedPrivateKey;
}
/**
* @TECH_DEBT Explore replacing with handlebars
*/
+9
View File
@@ -1549,6 +1549,9 @@ importers:
packages/@n8n/client-oauth2:
dependencies:
'@n8n/utils':
specifier: workspace:*
version: link:../utils
axios:
specifier: 1.18.0
version: 1.18.0(debug@4.4.3)
@@ -2909,6 +2912,9 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/utils':
specifier: workspace:*
version: link:../utils
'@oracle/langchain-oracledb':
specifier: 0.2.0
version: 0.2.0(@langchain/core@1.1.41(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@langchain/textsplitters@1.0.1(@langchain/core@1.1.41(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))))(oracledb@6.10.0)
@@ -5443,6 +5449,9 @@ importers:
'@n8n/imap':
specifier: workspace:*
version: link:../@n8n/imap
'@n8n/utils':
specifier: workspace:*
version: link:../@n8n/utils
'@thednp/dommatrix':
specifier: ^2.0.12
version: 2.0.12