Skip to content

Commit

Permalink
feat: add CryptoUtils to generate self-signed cert
Browse files Browse the repository at this point in the history
  • Loading branch information
maliroteh-sf committed May 30, 2024
1 parent 01850d6 commit 08fd9ae
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 4 deletions.
1 change: 1 addition & 0 deletions .yarnrc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
network-timeout 600000
11 changes: 11 additions & 0 deletions messages/crypto-utils.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@
"@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",
"@salesforce/cli-plugins-testkit": "^5.3.4",
"@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",
Expand Down
150 changes: 150 additions & 0 deletions src/common/CryptoUtils.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
73 changes: 73 additions & 0 deletions test/unit/common/CryptoUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
43 changes: 40 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -6829,7 +6850,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", [email protected], 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"

[email protected], 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==
Expand Down Expand Up @@ -7375,7 +7403,7 @@ [email protected]:
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==
Expand All @@ -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"
Expand Down

0 comments on commit 08fd9ae

Please sign in to comment.