fix: Restore legacy PEM headers when reformatting single-line keys (#37088)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
This commit is contained in:
n8n-cat-bot[bot]
2026-08-28 07:39:26 +00:00
committed by GitHub
parent 3fe4b79eef
commit 044a90d368
2 changed files with 53 additions and 1 deletions
@@ -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.
+11 -1
View File
@@ -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}-----`;