Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Allow multiple Subject Alternative Name (SAN) extensions #52

Merged
merged 11 commits into from Jul 14, 2021
92 changes: 92 additions & 0 deletions README.md
Expand Up @@ -97,6 +97,98 @@ The `certutil` tooling is installed in OS-specific ways:
so devcert will simply fallback to the wizard approach for Firefox outlined
above)

## Multiple domains (SAN)
If you are developing a multi-tenant app or have many apps locally, you can generate a security
certificate using `devcert` to also use the [Subject Alternative Name](https://en.wikipedia.org/wiki/Subject_Alternative_Name)
extension, just pass an array of domains instead.

```js
let ssl = await devcert.certificateFor([
'localhost',
'local.api.example.com',
'local.example.com',
'local.auth.example.com'
]);
https.createServer(ssl, app).listen(3000);
```

## Docker and local development
If you are developing with Docker, one option is to install `devcert` into a base folder in your home directory and
generate certificates for all of your local Docker projects. See comments and caveats in [this issue](https://github.com/davewasmer/devcert/issues/17).

While not elegant, you only really need to do this as often as you add new domains locally, which is probably not very often.

The general script would look something like:

```js
// example: make a directory in home directory such as ~/devcert-util
// ~/devcert-util/generate.js
const fs = require('fs');
const devcert = require('devcert');

// or if its just one domain - devcert.certificateFor('local.example.com')
devcert.certificateFor([
'localhost',
'local.api.example.com',
'local.example.com',
'local.auth.example.com'
])
.then(({key, cert}) => {
fs.writeFileSync('./certs/tls.key', key);
fs.writeFileSync('./certs/tls.cert', cert);
})
.catch(console.error);
```

An easy way to use the files generated from above script is to copy the `~/devcert-util/certs` folder into your Docker projects:
```
# local-docker-project-root/
🗀 certs/
🗎 tls.key
🗎 tls.cert
```

And add this line to your `.gitignore`:
```
certs/
```

These two files can now easily be used by any project, be it Node.js or something else.

In Node, within Docker, simply load the copied certificate files into your https server:
```js
const fs = require('fs');
const Express = require('express');
const app = new Express();
https
.createServer({
key: fs.readFileSync('./certs/tls.key'),
cert: fs.readFileSync('./certs/tls.cert')
}, app)
.listen(3000);
```

Also works with webpack dev server or similar technologies:
```js
// webpack.config.js
const fs = require('fs');

module.exports = {
//...
devServer: {
contentBase: join(__dirname, 'dist'),
host: '0.0.0.0',
public: 'local.api.example.com',
port: 3000,
publicPath: '/',
https: {
key: fs.readFileSync('./certs/tls.key'),
cert: fs.readFileSync('./certs/tls.cert')
}
}
};
```

## How it works

When you ask for a development certificate, devcert will first check to see
Expand Down
Expand Up @@ -21,5 +21,4 @@ subjectAltName = @subject_alt_names
subjectKeyIdentifier = hash

[ subject_alt_names ]
DNS.1 = <%= domain %>
DNS.2 = *.<%= domain %>
<%= subjectAltNames %>
3 changes: 1 addition & 2 deletions openssl-configurations/domain-certificates.conf
Expand Up @@ -35,5 +35,4 @@ extendedKeyUsage = serverAuth
subjectAltName = @subject_alt_names

[ subject_alt_names ]
DNS.1 = <%= domain %>
DNS.2 = *.<%= domain %>
<%= subjectAltNames %>
26 changes: 13 additions & 13 deletions src/certificates.ts
Expand Up @@ -15,25 +15,25 @@ const debug = createDebug('devcert:certificates');
* individual domain certificates are signed by the devcert root CA (which was
* added to the OS/browser trust stores), they are trusted.
*/
export default async function generateDomainCertificate(domain: string): Promise<void> {
mkdirp(pathForDomain(domain));
export default async function generateDomainCertificate(domains: string[]): Promise<void> {
mkdirp(pathForDomain(domains[0]));

debug(`Generating private key for ${ domain }`);
let domainKeyPath = pathForDomain(domain, 'private-key.key');
debug(`Generating private key for ${domains}`);
let domainKeyPath = pathForDomain(domains[0], 'private-key.key');
generateKey(domainKeyPath);

debug(`Generating certificate signing request for ${ domain }`);
let csrFile = pathForDomain(domain, `certificate-signing-request.csr`);
withDomainSigningRequestConfig(domain, (configpath) => {
openssl(`req -new -config "${ configpath }" -key "${ domainKeyPath }" -out "${ csrFile }"`);
debug(`Generating certificate signing request for ${domains}`);
let csrFile = pathForDomain(domains[0], `certificate-signing-request.csr`);
withDomainSigningRequestConfig(domains, (configpath) => {
openssl(`req -new -config "${configpath}" -key "${domainKeyPath}" -out "${csrFile}"`);
});

debug(`Generating certificate for ${ domain } from signing request and signing with root CA`);
let domainCertPath = pathForDomain(domain, `certificate.crt`);
debug(`Generating certificate for ${domains} from signing request and signing with root CA`);
let domainCertPath = pathForDomain(domains[0], `certificate.crt`);

await withCertificateAuthorityCredentials(({ caKeyPath, caCertPath }) => {
withDomainCertificateConfig(domain, (domainCertConfigPath) => {
openssl(`ca -config "${ domainCertConfigPath }" -in "${ csrFile }" -out "${ domainCertPath }" -keyfile "${ caKeyPath }" -cert "${ caCertPath }" -days 825 -batch`)
await withCertificateAuthorityCredentials(({caKeyPath, caCertPath}) => {
withDomainCertificateConfig(domains, (domainCertConfigPath) => {
openssl(`ca -config "${domainCertConfigPath}" -in "${csrFile}" -out "${domainCertPath}" -keyfile "${caKeyPath}" -cert "${caCertPath}" -days 825 -batch`)
});
});
}
Expand Down
22 changes: 18 additions & 4 deletions src/constants.ts
Expand Up @@ -23,22 +23,36 @@ export const opensslSerialFilePath = configPath('certificate-authority', 'serial
export const opensslDatabaseFilePath = configPath('certificate-authority', 'index.txt');
export const caSelfSignConfig = path.join(__dirname, '../openssl-configurations/certificate-authority-self-signing.conf');

export function withDomainSigningRequestConfig(domain: string, cb: (filepath: string) => void) {
function generatesubjectAltNames(domains: string[]): string {
return domains
.reduce((dnsEntries, domain) =>
dnsEntries.concat([
`DNS.${dnsEntries.length + 1} = ${domain}`,
`DNS.${dnsEntries.length + 2} = *.${domain}`,
]), [] as string[])
.join("\r\n");
}

export function withDomainSigningRequestConfig(domains: string[], cb: (filepath: string) => void) {
const domain = domains[0];
const subjectAltNames = generatesubjectAltNames(domains);
let tmpFile = mktmp();
let source = readFile(path.join(__dirname, '../openssl-configurations/domain-certificate-signing-requests.conf'), 'utf-8');
let template = makeTemplate(source);
let result = template({ domain });
let result = template({domain, subjectAltNames});
writeFile(tmpFile, eol.auto(result));
cb(tmpFile);
rm(tmpFile);
}

export function withDomainCertificateConfig(domain: string, cb: (filepath: string) => void) {
export function withDomainCertificateConfig(domains: string[], cb: (filepath: string) => void) {
const domain = domains[0];
const subjectAltNames = generatesubjectAltNames(domains);
let tmpFile = mktmp();
let source = readFile(path.join(__dirname, '../openssl-configurations/domain-certificates.conf'), 'utf-8');
let template = makeTemplate(source);
let result = template({
domain,
subjectAltNames,
serialFile: opensslSerialFilePath,
databaseFile: opensslDatabaseFilePath,
domainDir: pathForDomain(domain)
Expand Down
19 changes: 11 additions & 8 deletions src/index.ts
Expand Up @@ -64,8 +64,9 @@ type IReturnData<O extends Options = {}> = (IDomainData) & (IReturnCa<O>) & (IRe
* If `options.getCaPath` is true, return value will include the ca certificate path
* as { caPath: string }
*/
export async function certificateFor<O extends Options>(domain: string, options: O = {} as O): Promise<IReturnData<O>> {
debug(`Certificate requested for ${ domain }. Skipping certutil install: ${ Boolean(options.skipCertutilInstall) }. Skipping hosts file: ${ Boolean(options.skipHostsFile) }`);
export async function certificateFor<O extends Options>(requestedDomains: string | string[], options: O = {} as O): Promise<IReturnData<O>> {
const domains = Array.isArray(requestedDomains) ? requestedDomains : [requestedDomains]
debug(`Certificate requested for ${domains}. Skipping certutil install: ${Boolean(options.skipCertutilInstall)}. Skipping hosts file: ${Boolean(options.skipHostsFile)}`);

if (options.ui) {
Object.assign(UI, options.ui);
Expand All @@ -79,8 +80,8 @@ export async function certificateFor<O extends Options>(domain: string, options:
throw new Error('OpenSSL not found: OpenSSL is required to generate SSL certificates - make sure it is installed and available in your PATH');
}

let domainKeyPath = pathForDomain(domain, `private-key.key`);
let domainCertPath = pathForDomain(domain, `certificate.crt`);
let domainKeyPath = pathForDomain(domains[0], `private-key.key`);
let domainCertPath = pathForDomain(domains[0], `certificate.crt`);

if (!exists(rootCAKeyPath)) {
debug('Root CA is not installed yet, so it must be our first run. Installing root CA ...');
Expand All @@ -90,13 +91,15 @@ export async function certificateFor<O extends Options>(domain: string, options:
await ensureCACertReadable(options);
}

if (!exists(pathForDomain(domain, `certificate.crt`))) {
debug(`Can't find certificate file for ${ domain }, so it must be the first request for ${ domain }. Generating and caching ...`);
await generateDomainCertificate(domain);
if (!exists(pathForDomain(domains[0], `certificate.crt`))) {
debug(`Can't find certificate file for ${domains}, so it must be the first request for ${domains}. Generating and caching ...`);
await generateDomainCertificate(domains);
}

if (!options.skipHostsFile) {
await currentPlatform.addDomainToHostFileIfMissing(domain);
domains.forEach(async (domain) => {
await currentPlatform.addDomainToHostFileIfMissing(domain);
})
}

debug(`Returning domain certificate`);
Expand Down