diff --git a/packages/@n8n/utils/src/format-pem-block.test.ts b/packages/@n8n/utils/src/format-pem-block.test.ts index e7d48ee1896..bd17db91bf4 100644 --- a/packages/@n8n/utils/src/format-pem-block.test.ts +++ b/packages/@n8n/utils/src/format-pem-block.test.ts @@ -1,3 +1,4 @@ +import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { formatPemBlock } from './format-pem-block'; @@ -117,6 +118,47 @@ ${'X'.repeat(64)} expect(formatPemBlock(encrypted)).toBe(encrypted); }); + it('should restore RFC 1421 headers in legacy encrypted RSA PEM keys', () => { + // What the SSL Certificates credential stores after a multi-line legacy + // encrypted RSA key is pasted into the single-line "Private Key" input: + // newlines are collapsed to spaces, so the blank line separating the + // Proc-Type/DEK-Info headers from the body becomes two consecutive spaces. + const flattened = + '-----BEGIN RSA PRIVATE KEY----- ' + + 'Proc-Type: 4,ENCRYPTED ' + + 'DEK-Info: AES-256-CBC,A4F349D0CD99508CA625518C9D671B68 ' + // double space = blank line + 'Pr9ZjzHxUr4HuhWspQ1vQHIgriYbTzbLdbXoWH/n6ABBRTocD3WO5/JFf83jZJzo ' + + 'yGnbXk6DK1JScbTHPYT6IuBqfDpQGB8FfFCZuANLwYtBZTFqVKdrsEHwZzGb0hSK ' + + '-----END RSA PRIVATE KEY-----'; + + // OpenSSL 3 needs the blank line after the headers to pick a decoder for the + // block, and ssh2 needs the space after each header colon to read the cipher + // name; either one missing fails before the passphrase is ever consulted. + expect(formatPemBlock(flattened)).toBe(`-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,A4F349D0CD99508CA625518C9D671B68 + +Pr9ZjzHxUr4HuhWspQ1vQHIgriYbTzbLdbXoWH/n6ABBRTocD3WO5/JFf83jZJzo +yGnbXk6DK1JScbTHPYT6IuBqfDpQGB8FfFCZuANLwYtBZTFqVKdrsEHwZzGb0hSK +-----END RSA PRIVATE KEY-----`); + }); + + it('should keep a flattened legacy encrypted RSA key loadable by OpenSSL', () => { + const passphrase = 'passphrase'; + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem', cipher: 'aes-256-cbc', passphrase }, + }); + const flattened = privateKey.trim().replace(/\n/g, ' '); + const formatted = formatPemBlock(flattened); + + expect(() => createPrivateKey({ key: formatted, passphrase })).not.toThrow(); + // ssh2 is not a dependency here, so pin the header shape its parser relies on: + // it slices the DEK-Info value past a fixed ": " offset. + expect(formatted).toMatch(/\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,[0-9A-F]+\n\n/); + }); + it('should collapse Proc-Type/DEK-Info headers on the fallback path', () => { // Mismatched labels force the fallback formatter, where a body chunk carrying // the encrypted-key headers exercises the Proc-Type/DEK-Info branch. diff --git a/packages/@n8n/utils/src/format-pem-block.ts b/packages/@n8n/utils/src/format-pem-block.ts index d61732c8603..eed7b0ec981 100644 --- a/packages/@n8n/utils/src/format-pem-block.ts +++ b/packages/@n8n/utils/src/format-pem-block.ts @@ -14,7 +14,17 @@ function formatCompactPem(pem: string, isPublic: boolean): string | 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 + .replace(/:\s+/g, ':') + .replace(/\s+/g, '\n') + // Restore RFC 1421 shape for a legacy encrypted key's headers. Both halves + // are load-bearing: OpenSSL 3 matches no decoder without the blank line + // before the body, and ssh2 reads the DEK-Info value at a fixed offset that + // assumes a single space after the colon. + .replace( + /^(?:(?:Proc-Type|DEK-Info):\S+\n)+/, + (headers) => `${headers.replace(/^(Proc-Type|DEK-Info):/gm, '$1: ')}\n`, + ) : (normalizedBody.match(new RegExp(`.{1,${PEM_BODY_LINE_LENGTH}}`, 'g')) ?? []).join('\n'); return `-----BEGIN ${label}-----\n${formattedBody}\n-----END ${label}-----`;