11import * as _ from 'lodash' ;
22import * as fs from 'fs/promises' ;
33import { v4 as uuid } from "uuid" ;
4- import * as forge from 'node-forge' ;
54
5+ import * as x509 from '@peculiar/x509' ;
6+ import * as asn1X509 from '@peculiar/asn1-x509' ;
7+ import * as asn1Schema from '@peculiar/asn1-schema' ;
8+
9+ import * as forge from 'node-forge' ;
610const { asn1, pki, md, util } = forge ;
711
12+ const crypto = globalThis . crypto ;
13+
814export type CAOptions = ( CertDataOptions | CertPathOptions ) ;
915
1016export interface CertDataOptions extends BaseCAOptions {
@@ -50,6 +56,23 @@ export type GeneratedCertificate = {
5056 ca : string
5157} ;
5258
59+ const SUBJECT_NAME_MAP : { [ key : string ] : string } = {
60+ commonName : "CN" ,
61+ organizationName : "O" ,
62+ organizationalUnitName : "OU" ,
63+ countryName : "C" ,
64+ localityName : "L" ,
65+ stateOrProvinceName : "ST" ,
66+ domainComponent : "DC" ,
67+ serialNumber : "2.5.4.5"
68+ } ;
69+
70+ function arrayBufferToPem ( buffer : ArrayBuffer , label : string ) : string {
71+ const base64 = Buffer . from ( buffer ) . toString ( 'base64' ) ;
72+ const lines = base64 . match ( / .{ 1 , 64 } / g) || [ ] ;
73+ return `-----BEGIN ${ label } -----\n${ lines . join ( '\n' ) } \n-----END ${ label } -----\n` ;
74+ }
75+
5376/**
5477 * Generate a CA certificate for mocking HTTPS.
5578 *
@@ -68,123 +91,118 @@ export async function generateCACertificate(options: {
6891 } ,
6992 bits ?: number ,
7093 nameConstraints ?: {
94+ /**
95+ * Array of permitted domains
96+ */
7197 permitted ?: string [ ]
7298 }
7399} = { } ) {
74- options = _ . defaults ( { } , options , {
100+ options = {
75101 bits : 2048 ,
76- } ) ;
77-
78- const subjectOptions = _ . defaults ( { } , options . subject , {
79- // These subject fields are required for a fully valid CA cert that will be
80- // accepted when imported anywhere:
81- commonName : 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY' ,
82- organizationName : 'Mockttp' ,
83- countryName : 'XX' , // ISO-3166-1 alpha-2 'unknown country' code
84- } ) ;
102+ ...options ,
103+ subject : {
104+ commonName : 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY' ,
105+ organizationName : 'Mockttp' ,
106+ countryName : 'XX' , // ISO-3166-1 alpha-2 'unknown country' code
107+ ...options . subject
108+ } ,
109+ } ;
85110
86- const keyPair = await new Promise < forge . pki . rsa . KeyPair > ( ( resolve , reject ) => {
87- pki . rsa . generateKeyPair ( { bits : options . bits } , ( error , keyPair ) => {
88- if ( error ) reject ( error ) ;
89- else resolve ( keyPair ) ;
90- } ) ;
91- } ) ;
111+ // We use RSA for now for maximum compatibility
112+ const keyAlgorithm = {
113+ name : "RSASSA-PKCS1-v1_5" ,
114+ modulusLength : options . bits ,
115+ publicExponent : new Uint8Array ( [ 1 , 0 , 1 ] ) , // Standard 65537 fixed value
116+ hash : "SHA-256"
117+ } ;
92118
93- const cert = pki . createCertificate ( ) ;
94- cert . publicKey = keyPair . publicKey ;
95- cert . serialNumber = generateSerialNumber ( ) ;
119+ const keyPair = await crypto . subtle . generateKey (
120+ keyAlgorithm ,
121+ true , // Key should be extractable to be exportable
122+ [ "sign" , "verify" ]
123+ ) as CryptoKeyPair ;
124+
125+ // Baseline requirements set a specific order for standard CA fields:
126+ const orderedKeys = [ "countryName" , "organizationName" , "organizationalUnitName" , "commonName" ] ;
127+ const subjectNameParts : x509 . JsonNameParams = [ ] ;
128+
129+ for ( const key of orderedKeys ) {
130+ const value = options . subject ! [ key ] ;
131+ if ( ! value ) continue ;
132+ const mappedKey = SUBJECT_NAME_MAP [ key ] || key ;
133+ subjectNameParts . push ( { [ mappedKey ] : [ value ] } ) ;
134+ }
135+ for ( const key in options . subject ) {
136+ if ( orderedKeys . includes ( key ) ) continue ; // Already added above
137+ const value = options . subject [ key ] ! ;
138+ const mappedKey = SUBJECT_NAME_MAP [ key ] || key ;
139+ subjectNameParts . push ( { [ mappedKey ] : [ value ] } ) ;
140+ }
141+ const subjectDistinguishedName = new x509 . Name ( subjectNameParts ) . toString ( ) ;
96142
97- cert . validity . notBefore = new Date ( ) ;
143+ const notBefore = new Date ( ) ;
98144 // Make it valid for the last 24h - helps in cases where clocks slightly disagree
99- cert . validity . notBefore . setDate ( cert . validity . notBefore . getDate ( ) - 1 ) ;
100-
101- cert . validity . notAfter = new Date ( ) ;
102- // Valid for the next year by default.
103- cert . validity . notAfter . setFullYear ( cert . validity . notAfter . getFullYear ( ) + 1 ) ;
104-
105- cert . setSubject ( Object . entries ( subjectOptions ) . map ( ( [ key , value ] ) => ( {
106- name : key ,
107- value : value
108- } ) ) ) ;
109-
110- const extensions : any [ ] = [
111- { name : 'basicConstraints' , cA : true , critical : true } ,
112- { name : 'keyUsage' , keyCertSign : true , digitalSignature : true , nonRepudiation : true , cRLSign : true , critical : true } ,
113- { name : 'subjectKeyIdentifier' } ,
145+ notBefore . setDate ( notBefore . getDate ( ) - 1 ) ;
146+
147+ const notAfter = new Date ( ) ;
148+ // Valid for the next 10 years by default (BR sets an 8 year minimum)
149+ notAfter . setFullYear ( notAfter . getFullYear ( ) + 10 ) ;
150+
151+ const extensions : x509 . Extension [ ] = [
152+ new x509 . BasicConstraintsExtension (
153+ true , // cA = true
154+ undefined , // We don't set any path length constraint (should we? Not required by BR)
155+ true
156+ ) ,
157+ new x509 . KeyUsagesExtension (
158+ x509 . KeyUsageFlags . keyCertSign |
159+ x509 . KeyUsageFlags . digitalSignature |
160+ x509 . KeyUsageFlags . cRLSign ,
161+ true
162+ ) ,
163+ await x509 . SubjectKeyIdentifierExtension . create ( keyPair . publicKey as CryptoKey , false ) ,
164+ await x509 . AuthorityKeyIdentifierExtension . create ( keyPair . publicKey as CryptoKey , false )
114165 ] ;
166+
115167 const permittedDomains = options . nameConstraints ?. permitted || [ ] ;
116- if ( permittedDomains . length > 0 ) {
117- extensions . push ( {
118- critical : true ,
119- id : '2.5.29.30' ,
120- name : 'nameConstraints' ,
121- value : generateNameConstraints ( {
122- permitted : permittedDomains ,
123- } ) ,
124- } )
168+ if ( permittedDomains . length > 0 ) {
169+ const permittedSubtrees = permittedDomains . map ( domain => {
170+ const generalName = new asn1X509 . GeneralName ( { dNSName : domain } ) ;
171+ return new asn1X509 . GeneralSubtree ( { base : generalName } ) ;
172+ } ) ;
173+ const nameConstraints = new asn1X509 . NameConstraints ( {
174+ permittedSubtrees : new asn1X509 . GeneralSubtrees ( permittedSubtrees )
175+ } ) ;
176+ extensions . push ( new x509 . Extension (
177+ asn1X509 . id_ce_nameConstraints ,
178+ true ,
179+ asn1Schema . AsnConvert . serialize ( nameConstraints ) )
180+ ) ;
125181 }
126- cert . setExtensions ( extensions ) ;
127182
128- // Self-issued too
129- cert . setIssuer ( cert . subject . attributes ) ;
183+ const certificate = await x509 . X509CertificateGenerator . create ( {
184+ serialNumber : generateSerialNumber ( ) ,
185+ subject : subjectDistinguishedName ,
186+ issuer : subjectDistinguishedName , // Self-signed
187+ notBefore,
188+ notAfter,
189+ signingAlgorithm : keyAlgorithm ,
190+ publicKey : keyPair . publicKey as CryptoKey ,
191+ signingKey : keyPair . privateKey as CryptoKey ,
192+ extensions
193+ } ) ;
130194
131- // Self-sign the certificate - we're the root
132- cert . sign ( keyPair . privateKey , md . sha256 . create ( ) ) ;
195+ const privateKeyBuffer = await crypto . subtle . exportKey ( "pkcs8" , keyPair . privateKey as CryptoKey ) ;
196+ const privateKeyPem = arrayBufferToPem ( privateKeyBuffer , "RSA PRIVATE KEY" ) ;
197+ const certificatePem = certificate . toString ( "pem" ) ;
133198
134199 return {
135- key : pki . privateKeyToPem ( keyPair . privateKey ) ,
136- cert : pki . certificateToPem ( cert )
200+ key : privateKeyPem ,
201+ cert : certificatePem
137202 } ;
138203}
139204
140205
141- type GenerateNameConstraintsInput = {
142- /**
143- * Array of permitted domains
144- */
145- permitted ?: string [ ] ;
146- } ;
147-
148- /**
149- * Generate name constraints in conformance with
150- * [RFC 5280 § 4.2.1.10](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.10)
151- */
152- function generateNameConstraints (
153- input : GenerateNameConstraintsInput
154- ) : forge . asn1 . Asn1 {
155- const domainsToSequence = ( ips : string [ ] ) =>
156- ips . map ( ( domain ) => {
157- return asn1 . create ( asn1 . Class . UNIVERSAL , asn1 . Type . SEQUENCE , true , [
158- asn1 . create (
159- asn1 . Class . CONTEXT_SPECIFIC ,
160- 2 ,
161- false ,
162- util . encodeUtf8 ( domain )
163- ) ,
164- ] ) ;
165- } ) ;
166-
167- const permittedAndExcluded : forge . asn1 . Asn1 [ ] = [ ] ;
168-
169- if ( input . permitted && input . permitted . length > 0 ) {
170- permittedAndExcluded . push (
171- asn1 . create (
172- asn1 . Class . CONTEXT_SPECIFIC ,
173- 0 ,
174- true ,
175- domainsToSequence ( input . permitted )
176- )
177- ) ;
178- }
179-
180- return asn1 . create (
181- asn1 . Class . UNIVERSAL ,
182- asn1 . Type . SEQUENCE ,
183- true ,
184- permittedAndExcluded
185- ) ;
186- }
187-
188206export function generateSPKIFingerprint ( certPem : PEM ) {
189207 let cert = pki . certificateFromPem ( certPem . toString ( 'utf8' ) ) ;
190208 return util . encode64 (
0 commit comments