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..7bfd9c4 --- /dev/null +++ b/src/common/CryptoUtils.ts @@ -0,0 +1,132 @@ +/* + * 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 SSLCertificateData = { + derCertificate: string; + pemCertificate: string; + pemPrivateKey: string; + pemPublicKey: 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 + ): SSLCertificateData { + 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' }, + { name: 'organizationName', value: 'Salesforce Local Development Server — Self-Signed Cert' } + ]; + + 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', + digitalSignature: true, + keyEncipherment: true + }, + { + name: 'extKeyUsage', // Needed by iOS (see https://support.apple.com/en-gb/103769) + serverAuth: 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 privateKey = forge.pki.privateKeyToPem(keys.privateKey); + const publicKey = forge.pki.publicKeyToPem(keys.publicKey); + + const derCert = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); + + 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 new file mode 100644 index 0000000..2fe3a9f --- /dev/null +++ b/test/unit/common/CryptoUtils.test.ts @@ -0,0 +1,75 @@ +/* + * 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.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 +}); 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"