Skip to content

Commit 1abdff0

Browse files
authored
Merge pull request #123 from jaredwray/claude/fix-https-credential-validation-VfVAk
feat: adding mock certificate support for ssl testing
2 parents e68c72f + c1cafcd commit 1abdff0

5 files changed

Lines changed: 933 additions & 1 deletion

File tree

src/certificate.ts

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
import * as crypto from "node:crypto";
2+
import * as fsPromises from "node:fs/promises";
3+
4+
export type CertificateOptions = {
5+
/**
6+
* Common Name for the certificate subject. Defaults to 'localhost'.
7+
*/
8+
commonName?: string;
9+
/**
10+
* Subject Alternative Names. Defaults to [dns:localhost, ip:127.0.0.1, ip:::1].
11+
*/
12+
altNames?: Array<
13+
{ type: "dns"; value: string } | { type: "ip"; value: string }
14+
>;
15+
/**
16+
* Number of days the certificate is valid. Defaults to 365.
17+
*/
18+
validityDays?: number;
19+
/**
20+
* RSA key size in bits. Defaults to 2048.
21+
*/
22+
keySize?: number;
23+
};
24+
25+
export type CertificateResult = {
26+
/**
27+
* PEM-encoded X.509 certificate.
28+
*/
29+
cert: string;
30+
/**
31+
* PEM-encoded PKCS#8 private key.
32+
*/
33+
key: string;
34+
};
35+
36+
export type CertificateFileOptions = CertificateOptions & {
37+
/**
38+
* File path to write the certificate PEM.
39+
*/
40+
certPath: string;
41+
/**
42+
* File path to write the private key PEM.
43+
*/
44+
keyPath: string;
45+
};
46+
47+
// --- ASN.1 DER encoding helpers ---
48+
49+
function encodeLength(length: number): Buffer {
50+
if (length < 0x80) {
51+
return Buffer.from([length]);
52+
}
53+
54+
const bytes: number[] = [];
55+
let temp = length;
56+
while (temp > 0) {
57+
bytes.unshift(temp & 0xff);
58+
temp >>= 8;
59+
}
60+
61+
return Buffer.from([0x80 | bytes.length, ...bytes]);
62+
}
63+
64+
function encodeTlv(tag: number, value: Buffer): Buffer {
65+
return Buffer.concat([Buffer.from([tag]), encodeLength(value.length), value]);
66+
}
67+
68+
function encodeSequence(...elements: Buffer[]): Buffer {
69+
const content = Buffer.concat(elements);
70+
return encodeTlv(0x30, content);
71+
}
72+
73+
function encodeSet(...elements: Buffer[]): Buffer {
74+
const content = Buffer.concat(elements);
75+
return encodeTlv(0x31, content);
76+
}
77+
78+
function encodeInteger(value: Buffer | number): Buffer {
79+
let buf: Buffer;
80+
if (typeof value === "number") {
81+
if (value === 0) {
82+
buf = Buffer.from([0]);
83+
} else {
84+
const bytes: number[] = [];
85+
let temp = value;
86+
while (temp > 0) {
87+
bytes.unshift(temp & 0xff);
88+
temp >>= 8;
89+
}
90+
91+
// Add leading zero if high bit is set (to keep it positive)
92+
if (bytes[0] & 0x80) {
93+
bytes.unshift(0);
94+
}
95+
96+
buf = Buffer.from(bytes);
97+
}
98+
} else {
99+
// Ensure positive representation
100+
if (value.length > 0 && value[0] & 0x80) {
101+
buf = Buffer.concat([Buffer.from([0]), value]);
102+
} else {
103+
buf = value;
104+
}
105+
}
106+
107+
return encodeTlv(0x02, buf);
108+
}
109+
110+
function encodeBitString(value: Buffer): Buffer {
111+
// Prepend unused-bits byte (0)
112+
const content = Buffer.concat([Buffer.from([0]), value]);
113+
return encodeTlv(0x03, content);
114+
}
115+
116+
function encodeOctetString(value: Buffer): Buffer {
117+
return encodeTlv(0x04, value);
118+
}
119+
120+
function encodeOid(oid: number[]): Buffer {
121+
const bytes: number[] = [];
122+
// First two components are encoded as 40 * first + second
123+
bytes.push(40 * oid[0] + oid[1]);
124+
125+
for (let i = 2; i < oid.length; i++) {
126+
let component = oid[i];
127+
if (component < 128) {
128+
bytes.push(component);
129+
} else {
130+
const encoded: number[] = [];
131+
encoded.push(component & 0x7f);
132+
component >>= 7;
133+
while (component > 0) {
134+
encoded.push((component & 0x7f) | 0x80);
135+
component >>= 7;
136+
}
137+
138+
encoded.reverse();
139+
bytes.push(...encoded);
140+
}
141+
}
142+
143+
return encodeTlv(0x06, Buffer.from(bytes));
144+
}
145+
146+
function encodeUtf8String(value: string): Buffer {
147+
return encodeTlv(0x0c, Buffer.from(value, "utf8"));
148+
}
149+
150+
/**
151+
* Encode a date as ASN.1 time. Uses UTCTime (tag 0x17) for years 1950-2049
152+
* and GeneralizedTime (tag 0x18) for years >= 2050, per RFC 5280 Section 4.1.2.5.
153+
*/
154+
function encodeTime(date: Date): Buffer {
155+
const fullYear = date.getUTCFullYear();
156+
const timePart = `${pad2(date.getUTCMonth() + 1)}${pad2(date.getUTCDate())}${pad2(date.getUTCHours())}${pad2(date.getUTCMinutes())}${pad2(date.getUTCSeconds())}Z`;
157+
158+
if (fullYear >= 2050) {
159+
// GeneralizedTime: 4-digit year
160+
const str = `${fullYear}${timePart}`;
161+
return encodeTlv(0x18, Buffer.from(str, "ascii"));
162+
}
163+
164+
// UTCTime: 2-digit year
165+
const str = `${pad2(fullYear % 100)}${timePart}`;
166+
return encodeTlv(0x17, Buffer.from(str, "ascii"));
167+
}
168+
169+
function encodeContextSpecific(tag: number, value: Buffer): Buffer {
170+
return encodeTlv(0xa0 | tag, value);
171+
}
172+
173+
function pad2(n: number): string {
174+
return n.toString().padStart(2, "0");
175+
}
176+
177+
// --- OID constants ---
178+
179+
// 1.2.840.113549.1.1.11 = sha256WithRSAEncryption
180+
const OID_SHA256_WITH_RSA = [1, 2, 840, 113549, 1, 1, 11];
181+
// 2.5.4.3 = commonName
182+
const OID_COMMON_NAME = [2, 5, 4, 3];
183+
// 2.5.29.17 = subjectAltName
184+
const OID_SUBJECT_ALT_NAME = [2, 5, 29, 17];
185+
186+
// --- X.509 certificate builder ---
187+
188+
function buildAlgorithmIdentifier(): Buffer {
189+
return encodeSequence(
190+
encodeOid(OID_SHA256_WITH_RSA),
191+
encodeTlv(0x05, Buffer.alloc(0)),
192+
); // NULL
193+
}
194+
195+
function buildName(cn: string): Buffer {
196+
const rdn = encodeSet(
197+
encodeSequence(encodeOid(OID_COMMON_NAME), encodeUtf8String(cn)),
198+
);
199+
return encodeSequence(rdn);
200+
}
201+
202+
function buildValidity(notBefore: Date, notAfter: Date): Buffer {
203+
return encodeSequence(encodeTime(notBefore), encodeTime(notAfter));
204+
}
205+
206+
function encodeIpAddress(ip: string): Buffer {
207+
// Handle IPv4
208+
if (ip.includes(".")) {
209+
const parts = ip.split(".").map(Number);
210+
return Buffer.from(parts);
211+
}
212+
213+
// Handle IPv6
214+
const expanded = expandIpv6(ip);
215+
const buf = Buffer.alloc(16);
216+
const groups = expanded.split(":");
217+
for (let i = 0; i < 8; i++) {
218+
const val = Number.parseInt(groups[i], 16);
219+
buf.writeUInt16BE(val, i * 2);
220+
}
221+
222+
return buf;
223+
}
224+
225+
function expandIpv6(ip: string): string {
226+
// Handle :: expansion
227+
if (ip.includes("::")) {
228+
const [left, right] = ip.split("::");
229+
const leftGroups = left ? left.split(":") : [];
230+
const rightGroups = right ? right.split(":") : [];
231+
const missing = 8 - leftGroups.length - rightGroups.length;
232+
const middle = Array.from({ length: missing }).fill("0000") as string[];
233+
const allGroups = [...leftGroups, ...middle, ...rightGroups];
234+
return allGroups.map((g) => g.padStart(4, "0")).join(":");
235+
}
236+
237+
return ip
238+
.split(":")
239+
.map((g) => g.padStart(4, "0"))
240+
.join(":");
241+
}
242+
243+
function buildSubjectAltNameExtension(
244+
altNames: Array<
245+
{ type: "dns"; value: string } | { type: "ip"; value: string }
246+
>,
247+
): Buffer {
248+
const names: Buffer[] = [];
249+
for (const alt of altNames) {
250+
if (alt.type === "dns") {
251+
// Context-specific tag [2] for dNSName (IA5String implicit)
252+
names.push(encodeTlv(0x82, Buffer.from(alt.value, "ascii")));
253+
} else {
254+
// Context-specific tag [7] for iPAddress
255+
names.push(encodeTlv(0x87, encodeIpAddress(alt.value)));
256+
}
257+
}
258+
259+
const sanValue = encodeSequence(...names);
260+
261+
// Extension: OID, critical=false (omitted), extnValue as OCTET STRING
262+
return encodeSequence(
263+
encodeOid(OID_SUBJECT_ALT_NAME),
264+
encodeOctetString(sanValue),
265+
);
266+
}
267+
268+
function buildExtensions(
269+
altNames: Array<
270+
{ type: "dns"; value: string } | { type: "ip"; value: string }
271+
>,
272+
): Buffer {
273+
const extensions = encodeSequence(buildSubjectAltNameExtension(altNames));
274+
// Extensions are context-specific [3] EXPLICIT
275+
return encodeContextSpecific(3, extensions);
276+
}
277+
278+
function buildTbsCertificate(
279+
serialNumber: Buffer,
280+
issuerCn: string,
281+
notBefore: Date,
282+
notAfter: Date,
283+
subjectCn: string,
284+
publicKeyDer: Buffer,
285+
altNames: Array<
286+
{ type: "dns"; value: string } | { type: "ip"; value: string }
287+
>,
288+
): Buffer {
289+
const version = encodeContextSpecific(0, encodeInteger(2)); // v3
290+
const serial = encodeInteger(serialNumber);
291+
const signatureAlgorithm = buildAlgorithmIdentifier();
292+
const issuer = buildName(issuerCn);
293+
const validity = buildValidity(notBefore, notAfter);
294+
const subject = buildName(subjectCn);
295+
const extensions = buildExtensions(altNames);
296+
297+
return encodeSequence(
298+
version,
299+
serial,
300+
signatureAlgorithm,
301+
issuer,
302+
validity,
303+
subject,
304+
publicKeyDer, // SubjectPublicKeyInfo (already DER-encoded from crypto)
305+
extensions,
306+
);
307+
}
308+
309+
function derToPem(der: Buffer, label: string): string {
310+
const base64 = der.toString("base64");
311+
const lines: string[] = [];
312+
for (let i = 0; i < base64.length; i += 64) {
313+
lines.push(base64.slice(i, i + 64));
314+
}
315+
316+
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
317+
}
318+
319+
// --- Public API ---
320+
321+
/**
322+
* Generate a self-signed certificate using only Node.js built-in crypto.
323+
* Returns PEM-encoded certificate and private key strings.
324+
*/
325+
export function generateCertificate(
326+
options?: CertificateOptions,
327+
): CertificateResult {
328+
const commonName = options?.commonName ?? "localhost";
329+
const validityDays = options?.validityDays ?? 365;
330+
const keySize = options?.keySize ?? 2048;
331+
const altNames = options?.altNames ?? [
332+
{ type: "dns" as const, value: "localhost" },
333+
{ type: "ip" as const, value: "127.0.0.1" },
334+
{ type: "ip" as const, value: "::1" },
335+
];
336+
337+
// Generate RSA key pair
338+
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
339+
modulusLength: keySize,
340+
publicKeyEncoding: { type: "spki", format: "der" },
341+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
342+
});
343+
344+
// Build dates
345+
const notBefore = new Date();
346+
const notAfter = new Date();
347+
notAfter.setDate(notAfter.getDate() + validityDays);
348+
349+
// Generate random serial number (16 bytes, positive)
350+
const serialNumber = crypto.randomBytes(16);
351+
// Ensure positive by clearing the high bit
352+
serialNumber[0] &= 0x7f;
353+
354+
// Build TBS certificate
355+
const tbsCertificate = buildTbsCertificate(
356+
serialNumber,
357+
commonName,
358+
notBefore,
359+
notAfter,
360+
commonName,
361+
publicKey,
362+
altNames,
363+
);
364+
365+
// Sign the TBS certificate
366+
const signer = crypto.createSign("SHA256");
367+
signer.update(tbsCertificate);
368+
const signature = signer.sign(privateKey);
369+
370+
// Build the full certificate
371+
const certificate = encodeSequence(
372+
tbsCertificate,
373+
buildAlgorithmIdentifier(),
374+
encodeBitString(signature),
375+
);
376+
377+
return {
378+
cert: derToPem(certificate, "CERTIFICATE"),
379+
key: privateKey,
380+
};
381+
}
382+
383+
/**
384+
* Generate a self-signed certificate and write PEM files to disk.
385+
* Returns the same CertificateResult as generateCertificate().
386+
*/
387+
export async function generateCertificateFiles(
388+
options: CertificateFileOptions,
389+
): Promise<CertificateResult> {
390+
const result = generateCertificate(options);
391+
await fsPromises.writeFile(options.certPath, result.cert, "utf8");
392+
await fsPromises.writeFile(options.keyPath, result.key, "utf8");
393+
return result;
394+
}

0 commit comments

Comments
 (0)