Verified Commit 1eea468f authored by Sebastiaan Deckers's avatar Sebastiaan Deckers 馃悜

feat: TLS credentials from Core API with Auth0 JWT

parent 4948bcba
Pipeline #59775676 failed with stage
in 1 minute and 42 seconds
......@@ -24,7 +24,7 @@ If a valid URI is specified, the server proxies all ACME challenge requests to t
Proxying requires the server to do more work than a redirect, but it is transparent to the CA. This allows use of ports other than `80` and `443`, to which Boulder is restricted.
This string is prepended to the challenge request's URL path, so omit any trailing shash to avoid duplication. Both HTTP and HTTPS can be used, depending on the scheme (`http:` vs `https:`).
This string is prepended to the challenge request's URL path, so omit any trailing shash to avoid duplication. Both HTTP and HTTPS can be used, depending on the scheme (`http:` or `https:`).
Example:
......@@ -72,6 +72,78 @@ If a valid path is specified, the server responds to challenges by mapping to st
}
```
## `core`
Communicate with a backend API as a coordination service for multiple server instances. Used to retrieve hosted site configurations (TLS certificates, etc).
See: [@commonshost/core](https://gitlab.com/commonshost/core)
Example:
```js
{
core: {
origin: 'https://example.net:4433',
auth0: {
issuer: 'https://example.auth0.com/',
audience: 'https://example.net/',
clientId: 'QYvq8geQNBJlAf3FDTyd',
clientSecret: 'FOzMGZYZxm...pkgf6RLQmx'
}
}
}
```
### `core.origin`
Default: `''`
The [URL protocol scheme, hostname and port](https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/origin) of the Core API.
### `core.auth0`
Settings to use Auth0 as the authentication provider for communication with the Core API.
See: [Auth0 - How to implement the Client Credentials Grant](https://auth0.com/docs/api-auth/tutorials/client-credentials)
#### `core.auth0.accessToken`
Default: `''`
Example: `'eyJhbGciOiJIUzI...'`
The [JSON Web Token (JWT)](https://jwt.io) to use for authentication with the Core API. If left blank, will be generated using the `audience`, `clientId`, `clientSecret`, `issuer`, and `scopes` settings.
#### `core.auth0.audience`
Default: `''`
Provided by Auth0. The **Identifier** value on the [Settings](https://manage.auth0.com/#/apis) tab for the API.
#### `core.auth0.clientId`
Default: `''`
Provided by Auth0. See: https://tools.ietf.org/html/rfc6749#section-2.3.1
#### `core.auth0.clientSecret`
Default: `''`
Provided by Auth0. See: https://tools.ietf.org/html/rfc6749#section-2.3.1
#### `core.auth0.issuer`
Default: `''`
Provided by Auth0. The Auth0 domain with a `https://` prefix and a `/` suffix: `https://YOUR_DOMAIN/`.
#### `core.auth0.scope`
Default: `''`
See the Core API documentation for available scopes. Typically set to `'global_read'` for read access to multiple sites.
## `doh`
Default: `false`
......@@ -80,23 +152,23 @@ DNS over HTTPS (DoH) support. Set to `true` for defaults (local DNS resolver) or
### `doh.protocol`
Default: `udp4`
Default: `'udp4'`
Can be either `udp4` or `udp6` to indicate whether to connect to the resolver over IPv4 or IPv6 respectively.
Can be either `'udp4'` or `'udp6'` to indicate whether to connect to the resolver over IPv4 or IPv6 respectively.
### `doh.localAddress`
Default: `0.0.0.0` (IPv4) or `::0` (IPv6)
Default: `'0.0.0.0'` (IPv4) or `'::0'` (IPv6)
The UDP socket is bound to this address.
Use a loopback IP address (`''` empty string, `localhost`, `127.0.0.1`, or `::1`) to only accept local DNS resolver responses.
Use a loopback IP address (`''` empty string, `'localhost'`, `'127.0.0.1'`, or `'::1'`) to only accept local DNS resolver responses.
Use a wildcard IP address (`0.0.0.0` or `::0`) to accept remote DNS resolver responses.
Use a wildcard IP address (`'0.0.0.0'` or `'::0'`) to accept remote DNS resolver responses.
### `doh.resolverAddress`
Default: `127.0.0.1` (IPv4) or `::1` (IPv6)
Default: `'127.0.0.1'` (IPv4) or `'::1'` (IPv6)
The IP address of the DNS resolver. Queries are sent via UDP.
......@@ -120,26 +192,26 @@ Default: `false`
[Gopher over HTTPS](https://gopher.commons.host) (GoH) support. Set to `true` or an object to enable.
## `goh.allowHTTP1`
### `goh.allowHTTP1`
Default: `false`
If `false` only HTTP/2 (or later) clients are accepted. Set to `true` to also accept requests from HTTP/1 clients.
## `goh.unsafeAllowNonStandardPort`
### `goh.unsafeAllowNonStandardPort`
Default: `false`
If `false` the relay is restricted to the standard Gopher port `70`. If `true` the middleware accepts URLs with any port number. Allowing any port is potentially unsafe and not recommended. The middleware does not validate the response, effectively becoming an open TCP/IP proxy.
## `goh.unsafeAllowPrivateAddress`
### `goh.unsafeAllowPrivateAddress`
Default: `false`
If `false` connection attempts to any private IPv4 or IPv6 address are denied. This is important for security when operating a public GoH service to avoid exposing LAN hosts to malicious external users. Set to `true` to allow connections to remote hosts with private IP addresses.
## `goh.timeout`
### `goh.timeout`
Default: `10000`
......@@ -306,13 +378,13 @@ The domain name is provided by the SNI extension of TLS or a default fallback do
The method to look up TLS credentials for a domain. Possible values:
- `api`: Use the Commons Host Core API. Used when operating as a CDN.
- `'core'`: Use the Commons Host Core API. For operating as a CDN edge server. See the `core` settings.
- `file`: Look up using a file path. Used in local development.
- `'file'`: Look up using a file path. Used in local development or simple static web server.
#### `loader: 'api'`
#### `loader: 'core'`
*Not yet implemented.*
Retrieve TLS private keys, public certificates, and certificate authority (CA) chains from the Core API.
#### `loader: 'file'`
......@@ -345,30 +417,40 @@ If no fallback exists on launch, a self-signed certificate and key pair is gener
Default: `'~/.commonshost/key.pem'`
Loader: `'file'`
A string of the path to a PEM file containing the secret key.
### `tls.fallbackCert`
Default: `'~/.commonshost/cert.pem'`
Loader: `'file'`
A string of the path to a PEM file containing the public certificate.
### `tls.fallbackCa`
Default: `[]`
An array of strings containing the path to all files in the certificate chain.
Loader: `'file'`
An array of strings of the paths to all files containing the certificate authority (CA) chain.
### `tls.storePath`
Default: `''`
Loader: `'file'`
Directory where keys and certificates are stored.
### `tls.keyPath`
Default: `'$store/$domain/key.pem'`
Loader: `'file'`
Path to the domain-specific key file.
May contain substitution variables `$store` and `$domain`.
......@@ -377,6 +459,8 @@ May contain substitution variables `$store` and `$domain`.
Default: `'$store/$domain/cert.pem'`
Loader: `'file'`
Path to the domain-specific certificate file.
May contain substitution variables `$store` and `$domain`.
......
......@@ -123,31 +123,33 @@ async function load ({
result.acme.webroot = resolve(cwd, result.acme.webroot)
}
if (result.tls.storePath !== '') {
result.tls.storePath = resolve(cwd, result.tls.storePath)
}
if (result.tls.loader === 'file') {
if (result.tls.storePath !== '') {
result.tls.storePath = resolve(cwd, result.tls.storePath)
}
if (result.placeholder.hostNotFound !== '') {
result.placeholder.hostNotFound =
resolve(cwd, result.placeholder.hostNotFound)
}
if (result.placeholder.hostNotFound !== '') {
result.placeholder.hostNotFound =
resolve(cwd, result.placeholder.hostNotFound)
}
if (result.tls.fallbackKey === '' && result.tls.fallbackCert === '') {
const key = join(userHome, '.commonshost/key.pem')
const cert = join(userHome, '.commonshost/cert.pem')
if (fileExists(key) && fileExists(cert)) {
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else if (generateCertificate === true) {
log.info(
'馃攼 Generating a private TLS certificate.\n' +
' Confirm to add as a trusted certificate to your key chain.'
)
await keygen({ key, cert })
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else {
throw new Error('Missing a private key & public certificate pair.')
if (result.tls.fallbackKey === '' && result.tls.fallbackCert === '') {
const key = join(userHome, '.commonshost/key.pem')
const cert = join(userHome, '.commonshost/cert.pem')
if (fileExists(key) && fileExists(cert)) {
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else if (generateCertificate === true) {
log.info(
'馃攼 Generating a private TLS certificate.\n' +
' Confirm to add as a trusted certificate to your key chain.'
)
await keygen({ key, cert })
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else {
throw new Error('Missing a private key & public certificate pair.')
}
}
}
......
......@@ -22,11 +22,12 @@ function getCommonname (servername, options) {
function getLoader (loader) {
switch (loader) {
case 'api':
case 'core':
return tlsSniApiWorker
case 'file':
default:
return tlsSniFile
default:
throw new Error('Invalid TLS loader')
}
}
......
const fetch = require('node-fetch')
let expiration = 0
let token
module.exports.getAccessToken =
async function getAccessToken ({ core: { auth0: options } }) {
if (options.accessToken) return options.accessToken
if (Date.now() < expiration) return token
const url = options.issuer + 'oauth/token'
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
audience: options.audience,
scope: ['global_read'].join(' '),
client_id: options.clientId,
client_secret: options.clientSecret
})
})
const data = await response.json()
if (!('access_token' in data)) {
throw new Error(`Failed to get a token: ${data.error_description}`)
}
token = data.access_token
expiration = Date.now() + data.expires_in
return token
}
module.exports.tlsSniApiMaster =
async function tlsSniApiMaster (message, worker, options) {
const { servername } = message
try {
// TODO: Retrieve key & cert from Core API
const key = undefined
const cert = undefined
worker.send({ type: 'tls-credentials-response', servername, key, cert })
} catch (error) {
console.error(error)
worker.send({ type: 'tls-credentials-response', servername, error })
const fetch = require('node-fetch')
const { getAccessToken } = require('./getAccessToken')
module.exports.tlsSniApiMaster = (options) => {
const apiRequests = new Map()
return async function tlsSniApiMaster ({ servername }, worker) {
if (apiRequests.has(servername)) {
apiRequests.get(servername).add(worker)
return
}
apiRequests.set(servername, new Set([worker]))
const domain = encodeURIComponent(servername)
const url = `${options.core.origin}/sites/${domain}/certificate`
let message
try {
const headers = {
'authorization': `Bearer ${await getAccessToken(options)}`,
'accept': 'application/json'
}
const response = await fetch(url, { headers })
const { key, cert, ca } = await response.json()
if (!key || !cert) throw new Error('Missing TLS credentials')
message = { type: 'tls-credentials-response', servername, key, cert, ca }
} catch (error) {
message = { type: 'tls-credentials-response', servername, error }
} finally {
const workers = apiRequests.get(servername)
apiRequests.delete(servername)
for (const worker of workers) {
worker.send(message)
}
}
}
}
......@@ -14,12 +14,6 @@ module.exports.tlsSniApiWorker = (options) => {
}
})
function deserialiseBuffer (raw) {
if (!raw || raw instanceof Buffer) return raw
if (raw.type === 'Buffer') return Buffer.from(raw.data)
if (Array.isArray(raw)) return raw.map(deserialiseBuffer)
}
return async function getSecureContext (servername) {
let secureContext = tlsCache.get(servername)
if (!secureContext) {
......@@ -32,18 +26,18 @@ module.exports.tlsSniApiWorker = (options) => {
message.type === 'tls-credentials-response' &&
message.servername === servername
) {
if ('error' in message) {
reject(message.error)
} else {
process.off('message', onMessage)
tlsRequests.delete(servername)
const secureContext = createSecureContext({
key: deserialiseBuffer(message.key),
cert: deserialiseBuffer(message.cert),
ca: deserialiseBuffer(message.ca)
})
tlsCache.set(servername, secureContext)
resolve(secureContext)
process.off('message', onMessage)
tlsRequests.delete(servername)
try {
if ('error' in message) {
throw message.error
} else {
const secureContext = createSecureContext(message)
tlsCache.set(servername, secureContext)
resolve(secureContext)
}
} catch (error) {
reject(error)
}
}
})
......
......@@ -202,11 +202,14 @@ module.exports.Master = class Master {
while (workers.length < maxWorkerCount) {
const worker = cluster.fork()
workers.push(worker)
worker.on('message', async (message) => {
if (message.type === 'tls-credentials-request') {
await tlsSniApiMaster(message, worker, this.options)
}
})
if (this.options.tls.loader === 'core') {
const tlsSniApiHandler = tlsSniApiMaster(this.options)
worker.on('message', async (message) => {
if (message.type === 'tls-credentials-request') {
await tlsSniApiHandler(message, worker)
}
})
}
worker.once('exit', (code, signal) => {
if (code) {
this.log.error(`Worker exit: code ${code}, signal ${signal}`)
......
const test = require('blue-tape')
const { Master } = require('..')
const { join } = require('path')
const { connect } = require('tls')
const { createServer } = require('http')
const { once } = require('events')
const { promises: { readFile } } = require('fs')
const userHome = require('user-home')
let core
test('start mock Core API', async (t) => {
core = createServer(async (request, response) => {
console.log('[core]', request.method, request.url)
t.is(request.method, 'GET')
t.is(request.url, '/sites/localhost/certificate')
t.is(request.headers['authorization'], 'Bearer access-token')
const cert = join(userHome, '.commonshost/cert.pem')
const key = join(userHome, '.commonshost/key.pem')
const body = {
servername: 'localhost',
ca: [],
cert: await readFile(cert, 'utf8'),
key: await readFile(key, 'utf8')
}
response.end(JSON.stringify(body))
})
core.listen()
await once(core, 'listening')
})
let issuer
test('start mock JWT issuer', async (t) => {
issuer = createServer(async (request, response) => {
console.log('[issuer]', request.method, request.url)
t.is(request.method, 'POST')
t.is(request.url, '/oauth/token')
const body = []
for await (const chunk of request) body.push(chunk)
t.deepEqual(JSON.parse(Buffer.concat(body)), {
grant_type: 'client_credentials',
audience: 'https://example.net/',
scope: 'global_read',
client_id: 'client-id',
client_secret: 'client-secret'
})
response.end(JSON.stringify({ access_token: 'access-token' }))
})
issuer.listen()
await once(issuer, 'listening')
})
let master
test('start server', async (t) => {
const cwd = join(__dirname, 'fixtures')
const options = {
tls: { loader: 'core' },
core: {
origin: `http://localhost:${core.address().port}`,
auth0: {
issuer: `http://localhost:${issuer.address().port}/`,
audience: 'https://example.net/',
clientId: 'client-id',
clientSecret: 'client-secret'
}
}
}
master = new Master({ cwd, options })
await master.listen()
})
test(`Connect a TLS client with SNI`, async (t) => {
const client = connect({
port: 8443,
servername: 'localhost',
rejectUnauthorized: false
})
await once(client, 'secureConnect')
client.end()
await once(client, 'close')
})
test('stop mock JWT issuer', async (t) => issuer.close())
test('stop mock Core API', async (t) => core.close())
test('stop server', async (t) => master.close())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment