summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--client.js156
-rw-r--r--connect.js4
-rw-r--r--examples/client.js22
-rw-r--r--examples/server.js6
-rw-r--r--lib/response.js18
-rw-r--r--package.json2
-rw-r--r--server.js29
7 files changed, 215 insertions, 22 deletions
diff --git a/client.js b/client.js
index 2edede7..a15bc3b 100644
--- a/client.js
+++ b/client.js
@@ -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
diff --git a/connect.js b/connect.js
index c606703..bca3d20 100644
--- a/connect.js
+++ b/connect.js
@@ -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"
diff --git a/server.js b/server.js
index b077b84..285b63b 100644
--- a/server.js
+++ b/server.js
@@ -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