diff options
| author | Jannis R <mail@jannisr.de> | 2020-05-03 01:36:16 +0200 |
|---|---|---|
| committer | Jannis R <mail@jannisr.de> | 2020-05-03 01:36:16 +0200 |
| commit | fbf45404d5810def49b47609cb2a70a09374bc58 (patch) | |
| tree | ff8dc729031b1472fe7496b59e978817fd2b9709 | |
| parent | c6a417420e4e82611fdf940d9b7403b8479f8dfc (diff) | |
TOFU client certificates, part 1
| -rw-r--r-- | client.js | 156 | ||||
| -rw-r--r-- | connect.js | 4 | ||||
| -rw-r--r-- | examples/client.js | 22 | ||||
| -rw-r--r-- | examples/server.js | 6 | ||||
| -rw-r--r-- | lib/response.js | 18 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | server.js | 29 |
7 files changed, 215 insertions, 22 deletions
@@ -1,13 +1,18 @@ 'use strict' +const pem = require('pem') const {parse: parseUrl} = require('url') +const whilst = require('async/whilst') +const waterfall = require('async/waterfall') const connect = require('./connect') const createParser = require('./lib/response-parser') const { DEFAULT_PORT, ALPN_ID, } = require('./lib/util') -const {CODES} = require('./lib/statuses') +const {CODES, MESSAGES} = require('./lib/statuses') + +const HOUR = 60 * 60 * 1000 const _request = (pathOrUrl, opt, cb) => { connect(opt, (err, socket) => { @@ -50,6 +55,56 @@ const _request = (pathOrUrl, opt, cb) => { }) } +// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 +// > Transient certificates are limited in scope to a particular domain. +// > Transient certificates MUST NOT be reused across different domains. +// > +// > Transient certificates MUST be permanently deleted when the matching +// > server issues a response with a status code of 21 (see Appendix 1 +// > below). +// > +// > Transient certificates MUST be permanently deleted when the client +// > process terminates. +// > +// > Transient certificates SHOULD be permanently deleted after not having +// > been used for more than 24 hours. +const certs = new Map() +const defaultClientCertStore = { + get: (host, cb) => { + // reuse? + if (certs.has(host)) { + const {tCreated, cert, key} = certs.get(host) + if ((Date.now() - tCreated) <= 24 * HOUR) { + return cb(null, {tCreated, cert, key}) + } + certs.delete(host) // expired + } + + // generate new + const tCreated = Date.now() + pem.createCertificate({ + days: 1, selfSigned: true + }, (err, {certificate: cert, clientKey: key}) => { + if (err) return cb(err) + + certs.set(host, {tCreated, cert, key}) + return cb(null, {tCreated, cert, key}) + }) + }, + delete: (host, cb) => { + const has = certs.has(host) + if (has) certs.delete(host) + cb(null, has) + }, +} + +const errFromStatusCode = (res, msg = null) => { + const err = new Error(msg || MESSAGES[res.statusCode] || 'unknown error') + err.statusCode = res.statusCode + err.res = res + return err +} + const sendGeminiRequest = (pathOrUrl, opt, cb) => { if (typeof pathOrUrl !== 'string' || !pathOrUrl) { throw new Error('pathOrUrl must be a string & not empty') @@ -60,11 +115,23 @@ const sendGeminiRequest = (pathOrUrl, opt, cb) => { } const { followRedirects, - cert, key, passphrase, + useClientCerts, + letUserConfirmClientCertUsage, + clientCertStore, tlsOpt, } = { followRedirects: false, - cert: null, key: null, passphrase: null, + // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 + // > Interactive clients for human users MUST inform users that such a + // > session has been requested and require the user to approve + // > generation of such a certificate. Transient certificates MUST NOT + // > be generated automatically. + // > + // > Transient certificates are limited in scope to a particular domain. + // > Transient certificates MUST NOT be reused across different domains. + useClientCerts: false, + letUserConfirmClientCertUsage: null, + clientCertStore: defaultClientCertStore, tlsOpt: {}, ...opt, } @@ -74,33 +141,94 @@ const sendGeminiRequest = (pathOrUrl, opt, cb) => { const port = target.port || DEFAULT_PORT const reqOpt = { hostname, port, - cert, key, passphrase, tlsOpt, } - let onRes = cb + const chain = [ + cb => _request(pathOrUrl, reqOpt, cb), + ] + if (followRedirects) { // todo: prevent endless redirects - onRes = (err, res) => { - if (err) return cb(err) - - if ( + const followRedirects = (res, cb) => { + const checkRedirect = cb => cb(null, ( res.statusCode === CODES.REDIRECT_TEMPORARY || res.statusCode === CODES.REDIRECT_PERMANENT - ) { + )) + const followRedirect = (cb) => { const newTarget = parseUrl(res.meta) _request(res.meta, { ...reqOpt, host: newTarget.hostname || hostname, port: newTarget.port || port, - }, onRes) - } else { - cb(null, res) + }, (err, newRes) => { + if (err) return cb(err) + res = newRes + cb(null, res) + }) } + whilst(checkRedirect, followRedirect, cb) + } + chain.push(followRedirects) + } + + if (useClientCerts) { + if (typeof letUserConfirmClientCertUsage !== 'function') { + throw new Error('letUserConfirmClientCertUsage must be a function') + } + if (!clientCertStore) throw new Error('invalid clientCertStore') + if (typeof clientCertStore.get !== 'function') { + throw new Error('clientCertStore.get must be a function') + } + if (typeof clientCertStore.delete !== 'function') { + throw new Error('clientCertStore.delete must be a function') + } + + const handleClientAuth = (res, cb) => { + // report server-sent errors + // > The contents of <META> may provide additional information + // > on certificate requirements or the reason a certificate + // > was rejected. + const reason = res.meta + if ( + res.statusCode === CODES.CERTIFICATE_NOT_ACCEPTED || + res.statusCode === CODES.FUTURE_CERT_REJECTED || + res.statusCode === CODES.EXPIRED_CERT_REJECTED + ) return cb(errFromStatusCode(res, reason)) + + if ( + res.statusCode !== CODES.CLIENT_CERT_REQUIRED && + res.statusCode !== CODES.TRANSIENT_CERT_REQUESTED && + res.statusCode !== CODES.AUTHORISED_CERT_REQUIRED + ) return cb(null, res) + + // handle server-sent client cert prompt + letUserConfirmClientCertUsage({ + host: hostname + ':' + port, + reason, + }, (confirmed) => { + if (confirmed !== true) { + const err = new Error('server request client cert, but user rejected') + err.res = res + return cb(err) + } + + clientCertStore.get(hostname + ':' + port, (err, {cert, key}) => { + if (err) return cb(err) + + _request(pathOrUrl, { + ...reqOpt, + cert, key, + }, cb) + }) + }) } + chain.push(handleClientAuth) } - _request(pathOrUrl, reqOpt, onRes) + // redirects after server-sent client cert requests don't work yet + // todo: run chain in a loop + waterfall(chain, cb) } module.exports = sendGeminiRequest @@ -15,10 +15,12 @@ const connectToGeminiServer = (opt, cb) => { const { hostname, port, + cert, key, passphrase, tlsOpt, } = { hostname: '127.0.0.1', port: DEFAULT_PORT, + cert: null, key: null, passphrase: null, tlsOpt: {}, // todo: TOFU via isTrustedCertificate() ...opt, @@ -28,7 +30,7 @@ const connectToGeminiServer = (opt, cb) => { ALPNProtocols: [ALPN_ID], minVersion: MIN_TLS_VERSION, hostname, port, - // todo: cert, key, passphrase + cert, key, passphrase, ...tlsOpt, }) diff --git a/examples/client.js b/examples/client.js index 49e38d0..737b381 100644 --- a/examples/client.js +++ b/examples/client.js @@ -1,7 +1,28 @@ 'use strict' +const {createInterface} = require('readline') const {request} = require('..') +// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 +// > Interactive clients for human users MUST inform users that such a session +// > has been requested and require the user to approve generation of such a +// > certificate. Transient certificates MUST NOT be generated automatically. +const letUserConfirmClientCertUsage = ({host, reason}, cb) => { + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + history: 0, + }) + prompt.question([ + `Send client cert to ${host}?`, + reason ? ` Server says: "${reason}".` : '', + ' y/n > ' + ].join(''), (confirmed) => { + prompt.close() + cb(confirmed === 'y' || confirmed === 'Y') + }) +} + const onError = (err) => { console.error(err) process.exit(1) @@ -9,6 +30,7 @@ const onError = (err) => { request('/bar', { followRedirects: true, + useClientCerts: true, letUserConfirmClientCertUsage, tlsOpt: { rejectUnauthorized: false, }, diff --git a/examples/server.js b/examples/server.js index 2012c42..d9eb587 100644 --- a/examples/server.js +++ b/examples/server.js @@ -7,7 +7,13 @@ const { } = require('..') const onRequest = (req, res) => { + console.log('request', req.url) + if (req.clientFingerprint) console.log('client fingerprint:', req.clientFingerprint) + if (req.path === '/foo') { + if (!req.clientFingerprint) { + return res.requestTransientClientCert('/foo is secret!') + } res.write('foo') res.end('!') } else if (req.path === '/bar') { diff --git a/lib/response.js b/lib/response.js index ad27c75..442cea0 100644 --- a/lib/response.js +++ b/lib/response.js @@ -47,19 +47,25 @@ const createResponse = () => { res.statusCode = CODES.SUCCESS res.meta = '' res.mimeType = null - - // API res.sendHeader = sendHeader + + // convenience API res.prompt = (promptMsg) => { if (typeof promptMsg !== 'string') { throw new Error('invalid promptMsg') } - sendHeader(10, promptMsg) - res.push(null) + sendHeader(CODES.INPUT, promptMsg) } res.gone = () => { - sendHeader(51) - res.push(null) + sendHeader(CODES.GONE) + } + res.requestTransientClientCert = (reason) => { + if (typeof reason !== 'string') throw new Error('invalid reason') + sendHeader(CODES.TRANSIENT_CERT_REQUESTED, reason) + } + res.requestAuthorizedClientCert = (reason) => { + if (typeof reason !== 'string') throw new Error('invalid reason') + sendHeader(CODES.AUTHORISED_CERT_REQUIRED, reason) } // todo: redirect(), serverUnavailable(), slowDown(), badRequest() diff --git a/package.json b/package.json index 3ad9064..27299e1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "node": ">=12" }, "dependencies": { + "async": "^3.2.0", + "pem": "^1.14.4" }, "devDependencies": { "create-cert": "^1.0.6" @@ -22,6 +22,18 @@ const createGeminiServer = (opt = {}, onRequest) => { } const onConnection = (socket) => { + if ( + socket.authorizationError && + // allow self-signed certs + socket.authorizationError !== 'SELF_SIGNED_CERT_IN_CHAIN' && + socket.authorizationError !== 'DEPTH_ZERO_SELF_SIGNED_CERT' && + socket.authorizationError !== 'UNABLE_TO_GET_ISSUER_CERT' + ) { + socket.destroy(new Error(socket.authorizationError)) + return; + } + const clientCert = socket.getPeerCertificate() + const req = createParser() socket.pipe(req) socket.once('error', (err) => { @@ -43,6 +55,9 @@ const createGeminiServer = (opt = {}, onRequest) => { req.url = header.url const url = new URL(header.url, 'http://foo/') req.path = url.pathname + if (clientCert && clientCert.fingerprint) { + req.clientFingerprint = clientCert.fingerprint + } // todo: req.abort(), req.destroy() // prepare res @@ -63,7 +78,19 @@ const createGeminiServer = (opt = {}, onRequest) => { const server = createTlsServer({ ALPNProtocols: [ALPN_ID], minVersion: MIN_TLS_VERSION, - requestCert: !!alwaysRequireClientCert, + // > Usually the server specifies in the Server Hello message if a + // > client certificate is needed/wanted. + // > Does anybody know if it is possible to perform an authentication + // > via client cert if the server does not request it? + // + // > The client won't send a certificate unless the server asks for it + // > with a `Certificate Request` message (see the standard, section + // > 7.4.4). If the server does not ask for a certificate, the sending + // > of a `Certificate` and a `CertificateVerify` message from the + // > client is likely to imply an immediate termination from the server + // > (with an unexpected_message alert). + // https://security.stackexchange.com/a/36101 + requestCert: true, // > Gemini requests typically will be made without a client // > certificate being sent to the server. If a requested resource // > is part of a server-side application which requires persistent |
