From 08fd9ae7bcb5fcd8647948844042d673fdf67172 Mon Sep 17 00:00:00 2001 From: Meisam Seyed Aliroteh Date: Thu, 30 May 2024 15:39:51 -0700 Subject: [PATCH 1/4] feat: add CryptoUtils to generate self-signed cert --- .yarnrc.txt | 1 + messages/crypto-utils.md | 11 ++ package.json | 4 +- src/common/CryptoUtils.ts | 150 +++++++++++++++++++++++++++ test/unit/common/CryptoUtils.test.ts | 73 +++++++++++++ yarn.lock | 43 +++++++- 6 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 .yarnrc.txt create mode 100644 messages/crypto-utils.md create mode 100644 src/common/CryptoUtils.ts create mode 100644 test/unit/common/CryptoUtils.test.ts diff --git a/.yarnrc.txt b/.yarnrc.txt new file mode 100644 index 0000000..9bc2065 --- /dev/null +++ b/.yarnrc.txt @@ -0,0 +1 @@ +network-timeout 600000 \ No newline at end of file diff --git a/messages/crypto-utils.md b/messages/crypto-utils.md new file mode 100644 index 0000000..37fbeff --- /dev/null +++ b/messages/crypto-utils.md @@ -0,0 +1,11 @@ +# error:invalidKeySize + +Invalid value for argument 'keySize'. It must be between 2048 and 16384. + +# error:invalidValidityDate + +Invalid value for argument 'validity'. It must be between 1 and 825 days in the future. + +# error:invalidValidityNumber + +Invalid value for argument 'validity'. It must be between 1 and 825. diff --git a/package.json b/package.json index d75928c..30b6278 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "@salesforce/sf-plugins-core": "^9.0.10", "ajv": "^8.13.0", "chalk": "^5.3.0", - "listr2": "^8.2.1" + "listr2": "^8.2.1", + "node-forge": "^1.3.1" }, "devDependencies": { "@oclif/plugin-command-snapshot": "^5.1.9", @@ -49,6 +50,7 @@ "@salesforce/dev-scripts": "^9.1.1", "@salesforce/ts-sinon": "1.4.19", "@types/node": "^20.12.11", + "@types/node-forge": "^1.3.11", "eslint": "^8.57.0", "eslint-plugin-sf-plugin": "^1.18.3", "oclif": "^4.10.9", diff --git a/src/common/CryptoUtils.ts b/src/common/CryptoUtils.ts new file mode 100644 index 0000000..3c33e85 --- /dev/null +++ b/src/common/CryptoUtils.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import forge from 'node-forge'; +import { Messages } from '@salesforce/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); + +const messages = Messages.loadMessages('@salesforce/lwc-dev-mobile-core', 'crypto-utils'); + +export type PEMCertificate = { + certificate: string; + key: string; +}; + +export class CryptoUtils { + /** + * Generates a self-signed certificated using the provided parameters. + * + * @param {string} hostname the hostname to use. Defaults to LOCALHOST. + * @param {number} keySize the size for the private key in bits, which must + * be between 2048 and 16384. Defaults to 4096. + * @param {number | Date} validity the validity length for the certificate. + * It can either be a number (representing the number of days from today) or + * it can be a specific date (which has to be at least 24 hours from now). + * Defaults to 365 days. The maximum length cannot be more than 825 days in the + * future to meet Apple guidelines: https://support.apple.com/en-gb/103769 + * @returns the generated certificate and key. + */ + public static generateSelfSignedCert( + hostname: string = 'localhost', + keySize: number = 4096, + validity: number | Date = 365 + ): PEMCertificate { + if (keySize < 2048 || keySize > 16_384) { + throw new Error(messages.getMessage('error:invalidKeySize')); + } + + if (validity instanceof Date) { + const millisecondsInOneDay = 24 * 60 * 60 * 1000; + const diff = validity.getTime() - new Date().getTime(); + if (diff < millisecondsInOneDay || diff > 825 * millisecondsInOneDay) { + throw new Error(messages.getMessage('error:invalidValidityDate')); + } + } else if (validity < 1 || validity > 825) { + throw new Error(messages.getMessage('error:invalidValidityNumber')); + } + + const keys = forge.pki.rsa.generateKeyPair(keySize); + const cert = forge.pki.createCertificate(); + + const startDate = new Date(); + let endDate = new Date(); + if (validity instanceof Date) { + endDate = validity; + } else { + endDate.setDate(startDate.getDate() + validity); + } + + cert.publicKey = keys.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = startDate; + cert.validity.notAfter = endDate; + + const attrs = [ + { + name: 'commonName', + value: hostname + }, + { + name: 'countryName', + value: 'US' + }, + { + shortName: 'ST', + value: 'California' + }, + { + name: 'localityName', + value: 'San Francisco' + }, + { + name: 'organizationName', + value: 'Example Inc.' + }, + { + shortName: 'OU', + value: 'Test' + } + ]; + + cert.setSubject(attrs); + cert.setIssuer(attrs); + + // Add the subjectAltName and ExtendedKeyUsage extensions, which are required by Apple + cert.setExtensions([ + { + name: 'basicConstraints', + cA: true + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true + }, + { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true + }, + { + name: 'subjectAltName', + altNames: [ + { + type: 2, // DNS + value: hostname + }, + { + type: 7, // IP + value: '127.0.0.1' + }, + { + type: 7, // IP + value: '10.0.2.2' + }, + { + type: 7, // IP (IPv6 loopback) + value: '::1' + } + ] + } + ]); + + cert.sign(keys.privateKey, forge.md.sha256.create()); + + const pemCert = forge.pki.certificateToPem(cert); + const pemKey = forge.pki.privateKeyToPem(keys.privateKey); + + return { certificate: pemCert, key: pemKey }; + } +} diff --git a/test/unit/common/CryptoUtils.test.ts b/test/unit/common/CryptoUtils.test.ts new file mode 100644 index 0000000..b40cfc9 --- /dev/null +++ b/test/unit/common/CryptoUtils.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { expect } from 'chai'; +import { Messages } from '@salesforce/core'; +import { CryptoUtils } from '../../../src/common/CryptoUtils.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); + +describe('CryptoUtils tests', () => { + const messages = Messages.loadMessages('@salesforce/lwc-dev-mobile-core', 'crypto-utils'); + + it('generateSelfSignedCert throws on invalid key size', async () => { + const expectedErrorMsg = messages.getMessage('error:invalidKeySize'); + + try { + CryptoUtils.generateSelfSignedCert('MyHostName', 1024); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + + try { + CryptoUtils.generateSelfSignedCert('MyHostName', 20000); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + }); + + it('generateSelfSignedCert throws on invalid validity as number of days', async () => { + const expectedErrorMsg = messages.getMessage('error:invalidValidityNumber'); + + try { + CryptoUtils.generateSelfSignedCert('MyHostName', 2048, 0); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + + try { + CryptoUtils.generateSelfSignedCert('MyHostName', 2048, 826); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + }); + + it('generateSelfSignedCert throws on invalid validity as specific date', async () => { + const expectedErrorMsg = messages.getMessage('error:invalidValidityDate'); + + try { + const expiryTooShort = new Date(); + expiryTooShort.setHours(expiryTooShort.getHours() + 8); // less than 24 hours + CryptoUtils.generateSelfSignedCert('MyHostName', 2048, expiryTooShort); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + + try { + const expiryTooLong = new Date(); + expiryTooLong.setDate(expiryTooLong.getDate() + 826); // more than 825 days in the future + CryptoUtils.generateSelfSignedCert('MyHostName', 2048, expiryTooLong); + } catch (error) { + expect(error).to.be.an('error').with.property('message', expectedErrorMsg); + } + }); + + it('generateSelfSignedCert succeeds to generate a certificate and key for localhost', async () => { + const cert = CryptoUtils.generateSelfSignedCert(); + expect(cert.certificate.startsWith('-----BEGIN CERTIFICATE-----')).to.be.true; + expect(cert.key.startsWith('-----BEGIN RSA PRIVATE KEY-----')).to.be.true; + }).timeout(10000); // increase timeout for this test +}); diff --git a/yarn.lock b/yarn.lock index 4895831..8aacb8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2166,6 +2166,13 @@ dependencies: "@types/node" "*" +"@types/node-forge@^1.3.11": + version "1.3.11" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" + integrity sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^20.10.7", "@types/node@^20.12.11", "@types/node@^20.12.7": version "20.12.11" resolved "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz" @@ -5589,6 +5596,11 @@ node-fetch@^2.6.1, node-fetch@^2.6.9: dependencies: whatwg-url "^5.0.0" +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-preload@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz" @@ -6760,7 +6772,16 @@ srcset@^5.0.0: resolved "https://registry.npmjs.org/srcset/-/srcset-5.0.1.tgz" integrity sha512-/P1UYbGfJVlxZag7aABNRrulEXAwCSDo7fklafOQrantuPTDmYgijJMks2zusPCVzgW9+4P69mq7w6pYuZpgxw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6829,7 +6850,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7375,7 +7403,7 @@ workerpool@6.2.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7393,6 +7421,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From b29d50f0c79347e391322912d73f5fe28410d55d Mon Sep 17 00:00:00 2001 From: Meisam Seyed Aliroteh Date: Fri, 31 May 2024 09:53:57 -0700 Subject: [PATCH 2/4] feat: update to produce DER cert as well --- src/common/CryptoUtils.ts | 61 +++++++++++++++------------- test/unit/common/CryptoUtils.test.ts | 6 ++- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/common/CryptoUtils.ts b/src/common/CryptoUtils.ts index 3c33e85..dd54b3d 100644 --- a/src/common/CryptoUtils.ts +++ b/src/common/CryptoUtils.ts @@ -12,8 +12,10 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/lwc-dev-mobile-core', 'crypto-utils'); export type PEMCertificate = { - certificate: string; - key: string; + derCertificate: string; + pemCertificate: string; + pemPrivateKey: string; + pemPublicKey: string; }; export class CryptoUtils { @@ -66,30 +68,12 @@ export class CryptoUtils { cert.validity.notAfter = endDate; const attrs = [ - { - name: 'commonName', - value: hostname - }, - { - name: 'countryName', - value: 'US' - }, - { - shortName: 'ST', - value: 'California' - }, - { - name: 'localityName', - value: 'San Francisco' - }, - { - name: 'organizationName', - value: 'Example Inc.' - }, - { - shortName: 'OU', - value: 'Test' - } + { name: 'commonName', value: hostname }, + { name: 'countryName', value: 'US' }, + { shortName: 'ST', value: 'California' }, + { name: 'localityName', value: 'San Francisco' }, + { name: 'organizationName', value: 'Salesforce Inc.' }, + { shortName: 'OU', value: 'LocalDevPreview' } ]; cert.setSubject(attrs); @@ -117,6 +101,16 @@ export class CryptoUtils { emailProtection: true, timeStamping: true }, + { + name: 'nsCertType', + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true + }, { name: 'subjectAltName', altNames: [ @@ -137,14 +131,25 @@ export class CryptoUtils { value: '::1' } ] + }, + { + name: 'subjectKeyIdentifier' } ]); cert.sign(keys.privateKey, forge.md.sha256.create()); const pemCert = forge.pki.certificateToPem(cert); - const pemKey = forge.pki.privateKeyToPem(keys.privateKey); + const privateKey = forge.pki.privateKeyToPem(keys.privateKey); + const publicKey = forge.pki.publicKeyToPem(keys.publicKey); + + const derCert = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); - return { certificate: pemCert, key: pemKey }; + return { + derCertificate: derCert, + pemCertificate: pemCert, + pemPrivateKey: privateKey, + pemPublicKey: publicKey + }; } } diff --git a/test/unit/common/CryptoUtils.test.ts b/test/unit/common/CryptoUtils.test.ts index b40cfc9..2fe3a9f 100644 --- a/test/unit/common/CryptoUtils.test.ts +++ b/test/unit/common/CryptoUtils.test.ts @@ -67,7 +67,9 @@ describe('CryptoUtils tests', () => { it('generateSelfSignedCert succeeds to generate a certificate and key for localhost', async () => { const cert = CryptoUtils.generateSelfSignedCert(); - expect(cert.certificate.startsWith('-----BEGIN CERTIFICATE-----')).to.be.true; - expect(cert.key.startsWith('-----BEGIN RSA PRIVATE KEY-----')).to.be.true; + expect(cert.derCertificate).not.to.be.null; + expect(cert.pemCertificate.startsWith('-----BEGIN CERTIFICATE-----')).to.be.true; + expect(cert.pemPublicKey.startsWith('-----BEGIN PUBLIC KEY-----')).to.be.true; + expect(cert.pemPrivateKey.startsWith('-----BEGIN RSA PRIVATE KEY-----')).to.be.true; }).timeout(10000); // increase timeout for this test }); From 5d5e02f8e7eea18c625e0f3aeed83431a54f9f77 Mon Sep 17 00:00:00 2001 From: Meisam Seyed Aliroteh Date: Fri, 31 May 2024 16:48:21 -0700 Subject: [PATCH 3/4] chore: address PR feedback --- src/common/CryptoUtils.ts | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/common/CryptoUtils.ts b/src/common/CryptoUtils.ts index dd54b3d..4d0437b 100644 --- a/src/common/CryptoUtils.ts +++ b/src/common/CryptoUtils.ts @@ -11,7 +11,7 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/lwc-dev-mobile-core', 'crypto-utils'); -export type PEMCertificate = { +export type SSLCertificateData = { derCertificate: string; pemCertificate: string; pemPrivateKey: string; @@ -36,7 +36,7 @@ export class CryptoUtils { hostname: string = 'localhost', keySize: number = 4096, validity: number | Date = 365 - ): PEMCertificate { + ): SSLCertificateData { if (keySize < 2048 || keySize > 16_384) { throw new Error(messages.getMessage('error:invalidKeySize')); } @@ -70,10 +70,7 @@ export class CryptoUtils { const attrs = [ { name: 'commonName', value: hostname }, { name: 'countryName', value: 'US' }, - { shortName: 'ST', value: 'California' }, - { name: 'localityName', value: 'San Francisco' }, - { name: 'organizationName', value: 'Salesforce Inc.' }, - { shortName: 'OU', value: 'LocalDevPreview' } + { name: 'organizationName', value: 'Salesforce Inc.' } ]; cert.setSubject(attrs); @@ -87,29 +84,12 @@ export class CryptoUtils { }, { name: 'keyUsage', - keyCertSign: true, digitalSignature: true, - nonRepudiation: true, - keyEncipherment: true, - dataEncipherment: true + keyEncipherment: true }, { - name: 'extKeyUsage', - serverAuth: true, - clientAuth: true, - codeSigning: true, - emailProtection: true, - timeStamping: true - }, - { - name: 'nsCertType', - client: true, - server: true, - email: true, - objsign: true, - sslCA: true, - emailCA: true, - objCA: true + name: 'extKeyUsage', // Needed by iOS (see https://support.apple.com/en-gb/103769) + serverAuth: true }, { name: 'subjectAltName', @@ -131,9 +111,6 @@ export class CryptoUtils { value: '::1' } ] - }, - { - name: 'subjectKeyIdentifier' } ]); From 24ee194e68aebdaa325d2c35285136660050f78d Mon Sep 17 00:00:00 2001 From: Meisam Seyed Aliroteh Date: Mon, 3 Jun 2024 10:34:17 -0700 Subject: [PATCH 4/4] chore: change organization name --- src/common/CryptoUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/CryptoUtils.ts b/src/common/CryptoUtils.ts index 4d0437b..7bfd9c4 100644 --- a/src/common/CryptoUtils.ts +++ b/src/common/CryptoUtils.ts @@ -70,7 +70,7 @@ export class CryptoUtils { const attrs = [ { name: 'commonName', value: hostname }, { name: 'countryName', value: 'US' }, - { name: 'organizationName', value: 'Salesforce Inc.' } + { name: 'organizationName', value: 'Salesforce Local Development Server — Self-Signed Cert' } ]; cert.setSubject(attrs);