summaryrefslogtreecommitdiffstats
path: root/client.js
diff options
context:
space:
mode:
Diffstat (limited to 'client.js')
-rw-r--r--client.js156
1 files changed, 142 insertions, 14 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