1
1
import * as _ from 'lodash' ;
2
2
import * as fs from 'fs/promises' ;
3
3
import { v4 as uuid } from "uuid" ;
4
- import * as forge from 'node-forge' ;
5
4
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' ;
6
10
const { asn1, pki, md, util } = forge ;
7
11
12
+ const crypto = globalThis . crypto ;
13
+
8
14
export type CAOptions = ( CertDataOptions | CertPathOptions ) ;
9
15
10
16
export interface CertDataOptions extends BaseCAOptions {
@@ -50,6 +56,23 @@ export type GeneratedCertificate = {
50
56
ca : string
51
57
} ;
52
58
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
+
53
76
/**
54
77
* Generate a CA certificate for mocking HTTPS.
55
78
*
@@ -68,123 +91,118 @@ export async function generateCACertificate(options: {
68
91
} ,
69
92
bits ?: number ,
70
93
nameConstraints ?: {
94
+ /**
95
+ * Array of permitted domains
96
+ */
71
97
permitted ?: string [ ]
72
98
}
73
99
} = { } ) {
74
- options = _ . defaults ( { } , options , {
100
+ options = {
75
101
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
+ } ;
85
110
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
+ } ;
92
118
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 ( ) ;
96
142
97
- cert . validity . notBefore = new Date ( ) ;
143
+ const notBefore = new Date ( ) ;
98
144
// 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 )
114
165
] ;
166
+
115
167
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
+ ) ;
125
181
}
126
- cert . setExtensions ( extensions ) ;
127
182
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
+ } ) ;
130
194
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" ) ;
133
198
134
199
return {
135
- key : pki . privateKeyToPem ( keyPair . privateKey ) ,
136
- cert : pki . certificateToPem ( cert )
200
+ key : privateKeyPem ,
201
+ cert : certificatePem
137
202
} ;
138
203
}
139
204
140
205
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
-
188
206
export function generateSPKIFingerprint ( certPem : PEM ) {
189
207
let cert = pki . certificateFromPem ( certPem . toString ( 'utf8' ) ) ;
190
208
return util . encode64 (
0 commit comments