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));

debug(`Generating private key for ${ domain }`);
let domainKeyPath = pathForDomain(domain, 'private-key.key');
debug(`Generating private key for ${domains}`);
let domainKeyPath = pathForDomain(domains, '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, `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, `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
24 changes: 19 additions & 5 deletions src/constants.ts
Expand Up @@ -16,29 +16,43 @@ export const configDir = applicationConfigPath('devcert');
export const configPath: (...pathSegments: string[]) => string = path.join.bind(path, configDir);

export const domainsDir = configPath('domains');
export const pathForDomain: (domain: string, ...pathSegments: string[]) => string = path.join.bind(path, domainsDir)
export const pathForDomain: (domain: string | string[], ...pathSegments: string[]) => string = path.join.bind(path, domainsDir)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this can be string[] here, since path.join() itself only accepts an arg array of string types.

In this context ,path.join.bind(path, domainsDir) basically becomes

(domain, ...pathSegments) => path.join(domainsDir, domain, ...pathSegments)

This is what I get in a node shell:

> configDir = '/test'
'/test'
> configPath = path.join.bind(path, configDir)
[Function: bound join]
> domainDir = configPath('domains')
'\\test\\domains'
> pathForDomain = path.join.bind(path, domainDir)
[Function: bound join]
> pathForDomain(['localhost', 'test.localhost'], 'private-key.key')
Thrown:
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received type object
    at validateString (internal/validators.js:125:11)
    at Object.join (path.js:427:7)

Copy link
Contributor

@Js-Brecht Js-Brecht Mar 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I think that using path.join.bind() like this is kind of an obscure usage, and can lead to confusion. I know it took me a moment when I first looked at it, and this is the only place I've ever seen it used.

@zetlen how would you feel about these changing to simple wrapper functions, instead of bound path.join functions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops - I had figured that out while testing, forgot to push - the fixed version is now here.


export const caVersionFile = configPath('devcert-ca-version');
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, `private-key.key`);
let domainCertPath = pathForDomain(domains, `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, `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