-
Notifications
You must be signed in to change notification settings - Fork 56
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
266 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
})(); |