Skip to content

Commit 9ae3273

Browse files
committed
feat(pem): add parsePem to extract multiple DER blocks with labels
PEM files can contain multiple blocks (e.g., certificate chains). This helper parses all blocks and returns their labels and DER data.
1 parent b49cdd4 commit 9ae3273

File tree

3 files changed

+179
-1
lines changed

3 files changed

+179
-1
lines changed

src/helpers/pem.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { describe, expect, it } from "vitest";
66

7-
import { derToPem, getPemLabel, isPem, normalizePem, pemToDer } from "./pem";
7+
import { derToPem, getPemLabel, isPem, normalizePem, parsePem, pemToDer } from "./pem";
88

99
describe("PEM utilities", () => {
1010
// Sample DER data (just random bytes for testing)
@@ -164,6 +164,113 @@ describe("PEM utilities", () => {
164164
});
165165
});
166166

167+
describe("parsePem", () => {
168+
it("parses a single PEM block", () => {
169+
const pem = derToPem(sampleDer, "CERTIFICATE");
170+
const blocks = parsePem(pem);
171+
172+
expect(blocks).toHaveLength(1);
173+
expect(blocks[0].label).toBe("CERTIFICATE");
174+
expect(blocks[0].der).toEqual(sampleDer);
175+
});
176+
177+
it("parses multiple PEM blocks", () => {
178+
const der1 = new Uint8Array([0x01, 0x02, 0x03]);
179+
const der2 = new Uint8Array([0x04, 0x05, 0x06]);
180+
const der3 = new Uint8Array([0x07, 0x08, 0x09]);
181+
182+
const pem = [
183+
derToPem(der1, "CERTIFICATE"),
184+
derToPem(der2, "CERTIFICATE"),
185+
derToPem(der3, "PRIVATE KEY"),
186+
].join("\n");
187+
188+
const blocks = parsePem(pem);
189+
190+
expect(blocks).toHaveLength(3);
191+
expect(blocks[0]).toEqual({ label: "CERTIFICATE", der: der1 });
192+
expect(blocks[1]).toEqual({ label: "CERTIFICATE", der: der2 });
193+
expect(blocks[2]).toEqual({ label: "PRIVATE KEY", der: der3 });
194+
});
195+
196+
it("handles different label types", () => {
197+
const labels = [
198+
"CERTIFICATE",
199+
"PUBLIC KEY",
200+
"PRIVATE KEY",
201+
"RSA PRIVATE KEY",
202+
"EC PRIVATE KEY",
203+
"ENCRYPTED PRIVATE KEY",
204+
];
205+
206+
const pem = labels.map(label => derToPem(sampleDer, label)).join("\n");
207+
208+
const blocks = parsePem(pem);
209+
210+
expect(blocks).toHaveLength(labels.length);
211+
for (let i = 0; i < labels.length; i++) {
212+
expect(blocks[i].label).toBe(labels[i]);
213+
expect(blocks[i].der).toEqual(sampleDer);
214+
}
215+
});
216+
217+
it("returns empty array for non-PEM string", () => {
218+
expect(parsePem("not a pem")).toEqual([]);
219+
expect(parsePem("")).toEqual([]);
220+
});
221+
222+
it("handles PEM with extra whitespace and text between blocks", () => {
223+
const der1 = new Uint8Array([0x01, 0x02]);
224+
const der2 = new Uint8Array([0x03, 0x04]);
225+
226+
const pem = `
227+
Some header text
228+
${derToPem(der1, "CERTIFICATE")}
229+
Intermediate text here
230+
${derToPem(der2, "PRIVATE KEY")}
231+
Footer text
232+
`;
233+
234+
const blocks = parsePem(pem);
235+
236+
expect(blocks).toHaveLength(2);
237+
expect(blocks[0]).toEqual({ label: "CERTIFICATE", der: der1 });
238+
expect(blocks[1]).toEqual({ label: "PRIVATE KEY", der: der2 });
239+
});
240+
241+
it("throws on mismatched BEGIN/END labels", () => {
242+
const pem = `-----BEGIN CERTIFICATE-----
243+
AQID
244+
-----END PRIVATE KEY-----`;
245+
246+
expect(() => parsePem(pem)).toThrow(/label mismatch.*BEGIN CERTIFICATE.*END PRIVATE KEY/);
247+
});
248+
249+
it("handles empty PEM blocks", () => {
250+
const pem = `-----BEGIN CERTIFICATE-----
251+
-----END CERTIFICATE-----`;
252+
253+
const blocks = parsePem(pem);
254+
255+
expect(blocks).toHaveLength(1);
256+
expect(blocks[0].label).toBe("CERTIFICATE");
257+
expect(blocks[0].der).toEqual(new Uint8Array(0));
258+
});
259+
260+
it("handles trailing text after last block", () => {
261+
const pem = `${derToPem(sampleDer, "CERTIFICATE")}
262+
Some trailing text here
263+
-----BEGIN ORPHAN-----
264+
AQID`;
265+
266+
const blocks = parsePem(pem);
267+
268+
// Should only parse the complete block, ignoring the incomplete one
269+
expect(blocks).toHaveLength(1);
270+
expect(blocks[0].label).toBe("CERTIFICATE");
271+
});
272+
});
273+
167274
describe("round-trip", () => {
168275
it("preserves data through multiple round-trips", () => {
169276
let data: Uint8Array = sampleDer;

src/helpers/pem.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,71 @@
77

88
import { base64 } from "@scure/base";
99

10+
/**
11+
* A single PEM block parsed from a PEM file.
12+
*/
13+
export interface PemBlock {
14+
/** The label from the BEGIN statement (e.g., "CERTIFICATE", "PRIVATE KEY") */
15+
label: string;
16+
/** The DER-encoded binary data */
17+
der: Uint8Array;
18+
}
19+
20+
/**
21+
* Parse a PEM file into a series of DER blocks with their types.
22+
*
23+
* PEM files can contain multiple blocks (e.g., certificate chains). This function
24+
* extracts each block along with its label from the BEGIN statement.
25+
*
26+
* @param pem - The PEM-encoded string (may contain multiple blocks)
27+
* @returns Array of PEM blocks, each containing a label and DER data
28+
* @throws {Error} if the PEM format is invalid (mismatched labels or missing markers)
29+
*
30+
* @example
31+
* ```typescript
32+
* const blocks = parsePem(pemString);
33+
* // [
34+
* // { label: "CERTIFICATE", der: Uint8Array(...) },
35+
* // { label: "CERTIFICATE", der: Uint8Array(...) },
36+
* // { label: "PRIVATE KEY", der: Uint8Array(...) }
37+
* // ]
38+
* ```
39+
*/
40+
export function parsePem(pem: string): PemBlock[] {
41+
const blocks: PemBlock[] = [];
42+
43+
// Match all BEGIN...END blocks with their content
44+
// The regex captures: (1) label from BEGIN, (2) base64 content, (3) label from END
45+
const blockRegex = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END ([A-Z0-9 ]+)-----/g;
46+
47+
let match: RegExpExecArray | null;
48+
49+
while ((match = blockRegex.exec(pem)) !== null) {
50+
const beginLabel = match[1];
51+
const content = match[2];
52+
const endLabel = match[3];
53+
54+
// Validate that BEGIN and END labels match
55+
if (beginLabel !== endLabel) {
56+
throw new Error(`PEM label mismatch: BEGIN ${beginLabel} but END ${endLabel}`);
57+
}
58+
59+
// Remove all whitespace from base64 content
60+
const b64 = content.replace(/\s/g, "");
61+
62+
// Skip empty blocks
63+
if (b64.length === 0) {
64+
blocks.push({ label: beginLabel, der: new Uint8Array(0) });
65+
continue;
66+
}
67+
68+
const der = base64.decode(b64);
69+
blocks.push({ label: beginLabel, der });
70+
}
71+
72+
return blocks;
73+
}
74+
1075
/**
1176
* Convert DER (binary) bytes to PEM format.
1277
*

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ export {
188188
type TextLine,
189189
} from "./api/drawing";
190190

191+
// ─────────────────────────────────────────────────────────────────────────────
192+
// Helpers
193+
// ─────────────────────────────────────────────────────────────────────────────
194+
195+
export { parsePem, type PemBlock } from "./helpers/pem";
196+
191197
// ─────────────────────────────────────────────────────────────────────────────
192198
// Annotations
193199
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)