Skip to content

Commit 08edde8

Browse files
committed
Allow importing CA keys in both PKCS#8 & PKCS#1 formats
Unfortunately it seems node-forge has been exporting to PKCS#1 up until now, which is not well supported. We now export PKCS#8, but we still want to handle all the existing keys out there, so we need to include this as a fallback (and long-term too, it may be useful to support a wider range of key inputs for custom Mockttp use cases).
1 parent cbf7236 commit 08edde8

File tree

9 files changed

+130
-27
lines changed

9 files changed

+130
-27
lines changed

src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,15 @@ export type { ServerMockedEndpoint } from "./server/mocked-endpoint";
7474
export {
7575
generateCACertificate,
7676
generateSPKIFingerprint
77-
} from './util/tls';
77+
} from './util/certificates';
7878

7979
// Export various referenced utility types:
8080
export type {
8181
CAOptions,
8282
PEM,
8383
CertDataOptions,
8484
CertPathOptions
85-
} from './util/tls';
85+
} from './util/certificates';
8686
export type { CachedDns, DnsLookupFunction } from './util/dns';
8787
export type { Serialized, SerializedValue } from './serialization/serialization';
8888

src/mockttp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { stripIndent } from "common-tags";
22
import * as cors from 'cors';
33

4-
import type { CAOptions } from './util/tls';
4+
import type { CAOptions } from './util/certificates';
55

66
import { RequestRuleBuilder } from "./rules/requests/request-rule-builder";
77
import { WebSocketRuleBuilder } from "./rules/websockets/websocket-rule-builder";

src/server/http-combo-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { URLPattern } from "urlpattern-polyfill";
1919

2020
import { Destination, TlsHandshakeFailure } from '../types';
21-
import { getCA } from '../util/tls';
21+
import { getCA } from '../util/certificates';
2222
import { shouldPassThrough } from '../util/server-utils';
2323
import { getDestination } from '../util/url';
2424
import {

src/util/tls.ts renamed to src/util/certificates.ts

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import * as x509 from '@peculiar/x509';
77
import * as asn1X509 from '@peculiar/asn1-x509';
88
import * as asn1Schema from '@peculiar/asn1-schema';
99

10+
// Import for PKCS#8 structure
11+
import { PrivateKeyInfo } from '@peculiar/asn1-pkcs8';
12+
1013
const crypto = globalThis.crypto;
1114

1215
export type CAOptions = (CertDataOptions | CertPathOptions);
@@ -71,11 +74,50 @@ function arrayBufferToPem(buffer: ArrayBuffer, label: string): string {
7174
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
7275
}
7376

77+
// OID for rsaEncryption - used to wrap PKCS#1 keys into PKCS#8 below:
78+
const rsaEncryptionOid = "1.2.840.113549.1.1.1";
79+
7480
async function pemToCryptoKey(pem: string) {
75-
const derKey = x509.PemConverter.decodeFirst(pem);
81+
// The PEM might be PKCS#8 ("BEGIN PRIVATE KEY") or PKCS#1 ("BEGIN
82+
// RSA PRIVATE KEY"). We want to transparently accept both, but
83+
// we can only import PKCS#8, so we detect & convert if required.
84+
85+
const keyData = x509.PemConverter.decodeFirst(pem);
86+
let pkcs8KeyData: ArrayBuffer;
87+
88+
try {
89+
// Try to parse the PEM as PKCS#8 PrivateKeyInfo - if it works,
90+
// we can just use it directly as-is:
91+
asn1Schema.AsnConvert.parse(keyData, PrivateKeyInfo);
92+
pkcs8KeyData = keyData;
93+
} catch (e: any) {
94+
// If parsing as PKCS#8 fails, assume it's PKCS#1 (RSAPrivateKey)
95+
// and proceed to wrap it as an RSA key in a PrivateKeyInfo structure.
96+
const rsaPrivateKeyDer = keyData;
97+
98+
try {
99+
const privateKeyInfo = new PrivateKeyInfo({
100+
version: 0,
101+
privateKeyAlgorithm: new asn1X509.AlgorithmIdentifier({
102+
algorithm: rsaEncryptionOid
103+
}),
104+
privateKey: new asn1Schema.OctetString(rsaPrivateKeyDer)
105+
});
106+
pkcs8KeyData = asn1Schema.AsnConvert.serialize(privateKeyInfo);
107+
} catch (conversionError: any) {
108+
throw new Error(
109+
`Unsupported or malformed key format. Failed to parse as PKCS#8 with ${
110+
e.message || e.toString()
111+
} and failed to convert to PKCS#1 with ${
112+
conversionError.message || conversionError.toString()
113+
}`
114+
);
115+
}
116+
}
117+
76118
return await crypto.subtle.importKey(
77-
"pkcs8",
78-
derKey,
119+
"pkcs8", // N.b, pkcs1 is not supported, which is why we need the above
120+
pkcs8KeyData,
79121
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
80122
true, // Extractable
81123
["sign"]
@@ -243,7 +285,10 @@ export async function getCA(options: CAOptions): Promise<CA> {
243285
throw new Error('Unrecognized https options: you need to provide either a keyPath & certPath, or a key & cert.')
244286
}
245287

246-
return new CA(certOptions);
288+
const caCert = new x509.X509Certificate(certOptions.cert.toString());
289+
const caKey = await pemToCryptoKey(certOptions.key.toString());
290+
291+
return new CA(caCert, caKey, options);
247292
}
248293

249294
// We share a single keypair across all certificates in this process, and
@@ -261,20 +306,22 @@ const KEY_PAIR_ALGO = {
261306
publicExponent: new Uint8Array([1, 0, 1])
262307
};
263308

264-
export class CA {
265-
private caCert: x509.X509Certificate;
266-
private caKey: Promise<CryptoKey>;
267-
private options: CertDataOptions;
309+
export type { CA };
310+
311+
class CA {
312+
private options: BaseCAOptions;
268313

269314
private certCache: { [domain: string]: GeneratedCertificate };
270315

271-
constructor(options: CertDataOptions) {
272-
this.caKey = pemToCryptoKey(options.key.toString());
273-
this.caCert = new x509.X509Certificate(options.cert.toString());
316+
constructor(
317+
private caCert: x509.X509Certificate,
318+
private caKey: CryptoKey,
319+
options?: BaseCAOptions
320+
) {
274321
this.certCache = {};
275322
this.options = options ?? {};
276323

277-
const keyLength = options.keyLength || 2048;
324+
const keyLength = this.options.keyLength || 2048;
278325

279326
if (!KEY_PAIR || KEY_PAIR.length < keyLength) {
280327
// If we have no key, or not a long enough one, generate one.
@@ -376,7 +423,7 @@ export class CA {
376423
notAfter,
377424
signingAlgorithm: KEY_PAIR_ALGO,
378425
publicKey: leafKeyPair.publicKey,
379-
signingKey: await this.caKey,
426+
signingKey: this.caKey,
380427
extensions
381428
});
382429

test/ca.spec.ts renamed to test/certificates.spec.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
nodeOnly
1313
} from "./test-utils";
1414

15-
import { CA, generateCACertificate, generateSPKIFingerprint } from '../src/util/tls';
15+
import { getCA, CA, generateCACertificate, generateSPKIFingerprint } from '../src/util/certificates';
1616

1717
const validateLintSiteCertResults = (cert: string, results: any[]) => {
1818
// We don't worry about warnings
@@ -50,7 +50,7 @@ nodeOnly(() => {
5050
});
5151

5252
it("can generate a certificate for a domain", async () => {
53-
const ca = new CA({ key: await caKey, cert: await caCert, keyLength: 2048 });
53+
const ca = await getCA({ key: await caKey, cert: await caCert, keyLength: 2048 });
5454

5555
const { cert, key } = await ca.generateCertificate('localhost')
5656

@@ -65,7 +65,7 @@ nodeOnly(() => {
6565
});
6666

6767
it("can calculate the SPKI fingerprint for a certificate", async () => {
68-
const ca = new CA({ key: await caKey, cert: await caCert, keyLength: 2048 });
68+
const ca = await getCA({ key: await caKey, cert: await caCert, keyLength: 2048 });
6969

7070
const { cert } = await ca.generateCertificate('localhost');
7171

@@ -75,6 +75,15 @@ nodeOnly(() => {
7575
expect(caFingerprint).not.to.equal(certFingerprint);
7676
});
7777

78+
it("can use a PKCS#1 RSA private key as a CA", async () => {
79+
// We only need these for backward compatibility, but it is generally good practice to
80+
// be able to handle this properly, and very convenient if you currently have one.
81+
await getCA({
82+
keyPath: path.join(__dirname, 'fixtures', 'ca-pkcs1.key'),
83+
certPath: path.join(__dirname, 'fixtures', 'ca-pkcs1.pem'),
84+
});
85+
});
86+
7887
describe("with a constrained CA", () => {
7988
let constrainedCA: CA;
8089
let constrainedCaCert: string;
@@ -99,7 +108,7 @@ nodeOnly(() => {
99108
nameConstraints: { permitted: ["example.com"] },
100109
});
101110
constrainedCaCert = rootCa.cert;
102-
constrainedCA = new CA(rootCa);
111+
constrainedCA = await getCA(rootCa);
103112
});
104113

105114
it("can generate a valid certificate for a domain included in a constrained CA", async () => {
@@ -166,7 +175,7 @@ nodeOnly(() => {
166175

167176
it("should generate a CA certificate that can be used to create domain certificates", async () => {
168177
const caCertificate = await caCertificatePromise;
169-
const ca = new CA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 1024 });
178+
const ca = await getCA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 1024 });
170179

171180
const { cert, key } = await ca.generateCertificate('localhost');
172181

@@ -204,7 +213,7 @@ nodeOnly(() => {
204213
this.timeout(5000); // Large cert + remote request can make this slow
205214

206215
const caCertificate = await caCertificatePromise;
207-
const ca = new CA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 2048 });
216+
const ca = await getCA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 2048 });
208217

209218
const { cert } = await ca.generateCertificate('httptoolkit.com');
210219

@@ -237,7 +246,7 @@ nodeOnly(() => {
237246
this.timeout(10_000); // Large cert + remote request can make this slow
238247

239248
const caCertificate = await caCertificatePromise;
240-
const ca = new CA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 2048 });
249+
const ca = await getCA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 2048 });
241250

242251
const { cert } = await ca.generateCertificate('under_score.httptoolkit.com');
243252

test/fixtures/ca-pkcs1.key

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-----BEGIN RSA PRIVATE KEY-----
2+
MIIEogIBAAKCAQEA78UsLr+Es1VmCiD1gDQPAzetgSbqPWFx27h4+CP5t86l1dqU
3+
jb+6lwi+vKOehyaW2LfkwJpB9XhY4Uc7hMNKhvp8mRJZ765IjQwlN05E0PlsmvuC
4+
cVUpg/f6MBOkKnRPRfAuV2SQ1ESrjcLzjVGEOOAkOb4hGaYOs+n2V/noWpE2qTuW
5+
yjhlMajwgXm3gjWaxGNo2ETYBobqF5bYoBgdrX8yfrlzz8yrv8SL1Pds+j+EeTfJ
6+
uYubrRs5AYUzmckY5AZy95nyzwo/gMvQMLT72SrxyZEuY4M5GDwOSklek9jsgWeG
7+
AMjdmMkn58v50Fu8QEfHCiml0c9KYu7AA0NKYQIDAQABAoIBACGvZonpy7OjLYsC
8+
PuNkTPCjh5tzs2hssoGPLOWVrk1huaAuDX1NoKH770oSxaCQrhQj374kXdxl7Dy4
9+
FHdONX/5nHwn3R1Ec7pnKKpYs2fVhunOKHESKRs6gTp0Y/W38fQuLmCGRyefPyfT
10+
KgLiXyxbZxN6r+1JzFp/5eVNvZRNeUvB0moNhLdeL5AXqz8wdD5lDH4Wijj2x/XV
11+
vMvdgCXMU4ZavPXx8gGt45AV9DKGxwKaihTxIFHjVwE6a2MkcScnENNNJcjN2lcx
12+
i+NJc0JF3PcM2MpgfEzTwAuRJXCTgJpBrh+HOKGNJsOZZwNNLuqXttJCzhgJli8O
13+
J04FwB0CgYEA/B9Adrm1VQlA9AZbhziIz+yn+Ipjl8iqVB85zDQlqIOqIlwWl2Av
14+
DXzQHQv6xVZ+qViKZu75saEB3hpAW/jjUB3Yr9ZeOfJLjf9qqtpHL8789yxgYLZV
15+
nGCVkJJ8I6z4lzqSV341WvOug5Uj3Cl58savdqABjMe4+Y/u7K7a2TUCgYEA83VI
16+
z/NDi0AFiJlrBBqcXnA8pdgkZz5ssmytPvsPk0KUJvNXRguJhGLkDKMW4VV4rODy
17+
4JW47FVxKB3/IIeCmwNFuRwwBWiVCehQTRvygxUVGDT/7Zitxw3wcJl4DUMXKHE0
18+
lMZlGcmt96p28Ob/+e4TspIw7f73qZQBq+J1Pf0CgYA/zIi7gnLoedb96T3wEdZN
19+
B03aDur01cqETwKJMgj1g5vh5fuxgp1lhFgFgaqLlT+oCse7FG4LDPs0AOCYZXrg
20+
Iy1aEZz2fOGxpRiQ83tgFbjds1UQJBO15pryYp8XSAX//spZaY6G3XMjyPGyHKzH
21+
So/MLOE3hrLANDP5oKWwoQKBgA3YWqawz+OIZYgqKWLXXJMmUNyhmRhh/W58LbIK
22+
Nw2J2MEKT6lFGLH1qcBqOe/gm3dBZ66L9TNDfKbqZcrkyfGk0v8+8iAsnWUt1/s6
23+
nqUDuupggTly2FccnNLad3BRo2yCW5omWjOIO6N9BzyV9nKxLl+WXr/6Iv161EmB
24+
HGfFAoGAbh0ksL/JqKe/1loOcMJhj5RdSnAsAAZl7A+4m1mTZuyOxmM2LGsYQg1v
25+
hIiVycyF1BHZn8kFFHSw4faW5FcAvZNmHGwIHLQYLbUk4VNlHM7jihsDmvA4ABH8
26+
ioyjJNBAUiokO75HfkNI5LxDZyEagnQqfVCYL6qvzIov/pq8ZJU=
27+
-----END RSA PRIVATE KEY-----

test/fixtures/ca-pkcs1.pem

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDTzCCAjegAwIBAgIRCpNHMwVuHkN8kDPxTfXWZNowDQYJKoZIhvcNAQELBQAw
3+
QTEYMBYGA1UEAxMPSFRUUCBUb29sa2l0IENBMQswCQYDVQQGEwJYWDEYMBYGA1UE
4+
ChMPSFRUUCBUb29sa2l0IENBMB4XDTI1MDYxMTEzNDExNVoXDTI2MDYxMjEzNDEx
5+
NVowQTEYMBYGA1UEAxMPSFRUUCBUb29sa2l0IENBMQswCQYDVQQGEwJYWDEYMBYG
6+
A1UEChMPSFRUUCBUb29sa2l0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
7+
CgKCAQEA78UsLr+Es1VmCiD1gDQPAzetgSbqPWFx27h4+CP5t86l1dqUjb+6lwi+
8+
vKOehyaW2LfkwJpB9XhY4Uc7hMNKhvp8mRJZ765IjQwlN05E0PlsmvuCcVUpg/f6
9+
MBOkKnRPRfAuV2SQ1ESrjcLzjVGEOOAkOb4hGaYOs+n2V/noWpE2qTuWyjhlMajw
10+
gXm3gjWaxGNo2ETYBobqF5bYoBgdrX8yfrlzz8yrv8SL1Pds+j+EeTfJuYubrRs5
11+
AYUzmckY5AZy95nyzwo/gMvQMLT72SrxyZEuY4M5GDwOSklek9jsgWeGAMjdmMkn
12+
58v50Fu8QEfHCiml0c9KYu7AA0NKYQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
13+
MA4GA1UdDwEB/wQEAwIBxjAdBgNVHQ4EFgQUKs/5Gl7a4AWsCGwQhHCwqRI5mFgw
14+
DQYJKoZIhvcNAQELBQADggEBAHd+Kx9d/dvdm20s3J2hMaKeHznVknA2l6FNmLVo
15+
nMqYKSNWez8SvtfZMDJYcI7p9150CIzMyh5RBYMFSeH/raKDJnDn3rtEthujoNdC
16+
5q4UnjOkx1QdoPmt74XxIjTuP5eD3+30kYEI/3+ryBhLUx9SGsL4jucfP3m4Psb/
17+
TM3iL5U14KZp/qE9ChRVl8u8ARH8mEnUp9QW7K9rwLUbfmpn1qudOoswfTlepp7i
18+
+YrBXD49TYPbvZDRXmLdlijjAym6CyeI1VwTnOKidmvZobTLS2sUtktYHxtQMfpN
19+
y0zgFDU0S1yLoZZrwwjOWsTTW2qHVAv2P4LOEQZGNm9kolw=
20+
-----END CERTIFICATE-----

test/integration/proxying/https-proxying.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
DestroyableServer,
1919
ignoreNetworkError
2020
} from "../../test-utils";
21-
import { CA } from "../../../src/util/tls";
21+
import { getCA } from "../../../src/util/certificates";
2222
import { streamToBuffer } from "../../../src/util/buffer-utils";
2323

2424
const INITIAL_ENV = _.cloneDeep(process.env);
@@ -320,7 +320,7 @@ nodeOnly(() => {
320320
beforeEach(async () => {
321321
const caKey = await fs.readFile('./test/fixtures/test-ca.key');
322322
const caCert = await fs.readFile('./test/fixtures/test-ca.pem');
323-
const ca = new CA({ key: caKey.toString(), cert: caCert.toString(), keyLength: 1024 });
323+
const ca = await getCA({ key: caKey.toString(), cert: caCert.toString(), keyLength: 1024 });
324324

325325
const cert = await ca.generateCertificate('localhost');
326326

test/integration/websockets.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
makeDestroyable,
2020
delay
2121
} from '../test-utils';
22-
import { getCA } from '../../src/util/tls';
22+
import { getCA } from '../../src/util/certificates';
2323
import { pairFlatRawHeaders } from '../../src/util/header-utils';
2424

2525
browserOnly(() => {

0 commit comments

Comments
 (0)