Skip to content

Commit

Permalink
Example for on-demand tls-alpn-01
Browse files Browse the repository at this point in the history
  • Loading branch information
nmorsman committed Feb 1, 2024
1 parent b34134a commit a109129
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 0 deletions.
44 changes: 44 additions & 0 deletions examples/tls-alpn-01/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# tls-alpn-01

Responding to `tls-alpn-01` challenges using Node.js is a bit more involved than the other two challenge types, and requires a proxy (f.ex. [Nginx](https://nginx.org) or [HAProxy](https://www.haproxy.org)) in front of the Node.js service. The reason for this is that `tls-alpn-01` is solved by responding to the ACME challenge using self-signed certificates with an ALPN extension containing the challenge response.

Since we don't want users of our application to be served with these self-signed certificates, we need to split the HTTPS traffic into two different Node.js backends - one that only serves ALPN certificates for challenge responses, and the other for actual end-user traffic that serves certificates retrieved from the ACME provider. As far as I *(library author)* know, routing HTTPS traffic based on ALPN protocol can not be done purely using Node.js.

The end result should look something like this:

```text
Nginx or HAProxy (0.0.0.0:443)
*inspect requests SSL ALPN protocol*
If ALPN == acme-tls/1
-> Node.js ALPN responder (127.0.0.1:4444)
Else
-> Node.js HTTPS server (127.0.0.1:4443)
```

Example proxy configuration:

* [haproxy.cfg](haproxy.cfg) *(requires HAProxy >= v1.9.1)*
* [nginx.conf](nginx.conf) *(requires [ngx_stream_ssl_preread_module](https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html))*

Big thanks to [acme.sh](/~https://github.com/acmesh-official/acme.sh) and [dehydrated](/~https://github.com/dehydrated-io/dehydrated) for doing the legwork and providing Nginx and HAProxy config examples.

## How it works

When solving `tls-alpn-01` challenges, you prove ownership of a domain name by serving a specially crafted certificate over HTTPS. The ACME authority provides the client with a token that is placed into the certificates `id-pe-acmeIdentifier` extension along with a thumbprint of your account key.

Once the order is finalized, the ACME authority will verify by sending HTTPS requests to your domain with the `acme-tls/1` ALPN protocol, indicating to the server that it should serve the challenge response certificate. If the `id-pe-acmeIdentifier` extension contains the correct payload, the challenge is valid.

## Pros and cons

* Challenge must be satisfied using port 443 (HTTPS)
* Useful in instances where port 80 is unavailable
* Can not be used to issue wildcard certificates
* More complex than `http-01`, can not be solved purely using Node.js
* If using multiple web servers, all of them need to respond with the correct certificate

## External links

* [https://letsencrypt.org/docs/challenge-types/#tls-alpn-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01)
* [/~https://github.com/dehydrated-io/dehydrated/blob/master/docs/tls-alpn.md](/~https://github.com/dehydrated-io/dehydrated/blob/master/docs/tls-alpn.md)
* [/~https://github.com/acmesh-official/acme.sh/wiki/TLS-ALPN-without-downtime](/~https://github.com/acmesh-official/acme.sh/wiki/TLS-ALPN-without-downtime)
* [https://datatracker.ietf.org/doc/html/rfc8737](https://datatracker.ietf.org/doc/html/rfc8737)
23 changes: 23 additions & 0 deletions examples/tls-alpn-01/haproxy.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
##
# HTTPS listener
# - Send to ALPN responder port 4444 if protocol is acme-tls/1
# - Default to HTTPS backend port 4443
##

frontend https
mode tcp
bind :443
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
use_backend alpnresp if { req.ssl_alpn acme-tls/1 }
default_backend https

# Default HTTPS backend
backend https
mode tcp
server https 127.0.0.1:4443

# ACME tls-alpn-01 responder backend
backend alpnresp
mode tcp
server acmesh 127.0.0.1:4444
19 changes: 19 additions & 0 deletions examples/tls-alpn-01/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
##
# HTTPS server
# - Send to ALPN responder port 4444 if protocol is acme-tls/1
# - Default to HTTPS backend port 4443
##

stream {
map $ssl_preread_alpn_protocols $tls_port {
~\bacme-tls/1\b 4444;
default 4443;
}

server {
listen 443;
listen [::]:443;
proxy_pass 127.0.0.1:$tls_port;
ssl_preread on;
}
}
180 changes: 180 additions & 0 deletions examples/tls-alpn-01/tls-alpn-01.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Example using tls-alpn-01 challenge to generate certificates on-demand
*/

const fs = require('fs');
const path = require('path');
const https = require('https');
const tls = require('tls');
const acme = require('./../../');

const HTTPS_SERVER_PORT = 4443;
const ALPN_RESPONDER_PORT = 4444;
const VALID_DOMAINS = ['example.com', 'example.org'];
const FALLBACK_KEY = fs.readFileSync(path.join(__dirname, '..', 'fallback.key'));
const FALLBACK_CERT = fs.readFileSync(path.join(__dirname, '..', 'fallback.crt'));

const pendingDomains = {};
const alpnResponses = {};
const certificateStore = {};

function log(m) {
process.stdout.write(`${(new Date()).toISOString()} ${m}\n`);
}


/**
* On-demand certificate generation using tls-alpn-01
*/

async function getCertOnDemand(client, servername, attempt = 0) {
/* Invalid domain */
if (!VALID_DOMAINS.includes(servername)) {
throw new Error(`Invalid domain: ${servername}`);
}

/* Certificate exists */
if (servername in certificateStore) {
return certificateStore[servername];
}

/* Waiting on certificate order to go through */
if (servername in pendingDomains) {
if (attempt >= 10) {
throw new Error(`Gave up waiting on certificate for ${servername}`);
}

await new Promise((resolve) => { setTimeout(resolve, 1000); });
return getCertOnDemand(client, servername, (attempt + 1));
}

/* Create CSR */
log(`Creating CSR for ${servername}`);
const [key, csr] = await acme.crypto.createCsr({
commonName: servername
});

/* Order certificate */
log(`Ordering certificate for ${servername}`);
const cert = await client.auto({
csr,
email: 'test@example.com',
termsOfServiceAgreed: true,
challengePriority: ['tls-alpn-01'],
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
alpnResponses[authz.identifier.value] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization);
},
challengeRemoveFn: (authz) => {
delete alpnResponses[authz.identifier.value];
}
});

/* Done, store certificate */
log(`Certificate for ${servername} created successfully`);
certificateStore[servername] = [key, cert];
delete pendingDomains[servername];
return certificateStore[servername];
}


/**
* Main
*/

(async () => {
try {
/**
* Initialize ACME client
*/

log('Initializing ACME client');
const client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.staging,
accountKey: await acme.crypto.createPrivateKey()
});


/**
* ALPN responder
*/

const alpnResponder = https.createServer({
/* Fallback cert */
key: FALLBACK_KEY,
cert: FALLBACK_CERT,

/* Allow acme-tls/1 ALPN protocol */
ALPNProtocols: ['acme-tls/1'],

/* Serve ALPN certificate based on servername */
SNICallback: async (servername, cb) => {
try {
log(`Handling ALPN SNI request for ${servername}`);
if (!Object.keys(alpnResponses).includes(servername)) {
throw new Error(`No ALPN certificate found for ${servername}`);
}

/* Serve ALPN challenge response */
log(`Found ALPN certificate for ${servername}, serving secure context`);
cb(null, tls.createSecureContext({
key: alpnResponses[servername][0],
cert: alpnResponses[servername][1]
}));
}
catch (e) {
log(`[ERROR] ${e.message}`);
cb(e.message);
}
}
});

/* Terminate once TLS handshake has been established */
alpnResponder.on('secureConnection', (socket) => {
socket.end();
});

alpnResponder.listen(ALPN_RESPONDER_PORT, () => {
log(`ALPN responder listening on port ${ALPN_RESPONDER_PORT}`);
});


/**
* HTTPS server
*/

const requestListener = (req, res) => {
log(`HTTP 200 ${req.headers.host}${req.url}`);
res.writeHead(200);
res.end('Hello world\n');
};

const httpsServer = https.createServer({
/* Fallback cert */
key: FALLBACK_KEY,
cert: FALLBACK_CERT,

/* Serve certificate based on servername */
SNICallback: async (servername, cb) => {
try {
log(`Handling SNI request for ${servername}`);
const [key, cert] = await getCertOnDemand(client, servername);

log(`Found certificate for ${servername}, serving secure context`);
cb(null, tls.createSecureContext({ key, cert }));
}
catch (e) {
log(`[ERROR] ${e.message}`);
cb(e.message);
}
}
}, requestListener);

httpsServer.listen(HTTPS_SERVER_PORT, () => {
log(`HTTPS server listening on port ${HTTPS_SERVER_PORT}`);
});
}
catch (e) {
log(`[FATAL] ${e.message}`);
process.exit(1);
}
})();

0 comments on commit a109129

Please sign in to comment.