mirror of
https://github.com/certd/certd.git
synced 2026-05-18 06:17:31 +08:00
🔱: [acme] sync upgrade with 4 commits [trident-sync]
Example for on-demand tls-alpn-01 Example disclaimer, fallback cert Replace CircleCI with GitHub Actions
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user