diff --git a/CLAUDE.md b/CLAUDE.md index 99448f9..b79bfdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ request and the merge queue. | `api/controllers/general/` | The generic CRUD and DataTables endpoints | | `api/controllers/pages/` | Page controllers for the EJS views | | `api/helpers/` | Shared helpers: forms, DataTables, image locks and stream, search | -| `api/hooks/` | `fog-version`, `plugins` (loads `plugins.json`), `watchdog` | +| `api/hooks/` | `fog-version`, `plugins` (loads `plugins.json`), `watchdog`, `agent-tls` (the fog-agent mTLS listener) | | `plugins//` | Plugins: `ad`, `printer`, `snapin`, `wol` | | `tools/setup/` | The production installer (`index.js`) and the dev setup (`dev.js`) | | `views/` | EJS layouts and pages | diff --git a/api/controllers/agent/enroll.js b/api/controllers/agent/enroll.js index 66e22ca..4bb3651 100644 --- a/api/controllers/agent/enroll.js +++ b/api/controllers/agent/enroll.js @@ -21,20 +21,11 @@ * request is the one that is signed. No session: the route is public, as boot. */ const net = require('net'); -const {leafInfo} = require('../../lib/agent-pki'); +const {leafInfo, jsonBody, MAX_CSR, text, version} = require('../../lib/agent-pki'); const RETRY_AFTER = 300; const MAC = /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i; -// The field limits of the 1.6 columns, so a public caller cannot store more. -const text = (value, max) => (typeof value === 'string' ? value.slice(0, max) : ''); - -// Update::normalize: trim, and strip one leading v or V. -const version = (value) => { - const v = text(value, 50).trim(); - return /^[vV]/.test(v) ? v.slice(1) : v; -}; - // The valid MACs in the Host `macs` form (12 lower-case hex digits), no repeats. const macsOf = (list) => { const out = []; @@ -45,10 +36,6 @@ const macsOf = (list) => { return out; }; -// A CSR is about 500 characters (ECDSA P-256) and an RSA-4096 CSR about 1800. -// 16384 leaves room for any real key, and stops a caller from making the -// public route parse and store a huge value. -const MAX_CSR = 16384; const MAX_MACS = 32; // The caps for the stored identity fields and the enroll token. No real // firmware string or minted token (48 hex) comes near them. @@ -86,7 +73,7 @@ module.exports = { const req = this.req; const res = this.res; const Enrollment = sails.models.agentenrollment; - const body = (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) ? req.body : {}; + const body = jsonBody(req); const pending = (row) => res.status(202).json({status: 'pending', reason: row.reason, retry_after: RETRY_AFTER}); const issued = (result) => res.status(200).json({ diff --git a/api/controllers/agent/poll.js b/api/controllers/agent/poll.js new file mode 100644 index 0000000..ffe4d4d --- /dev/null +++ b/api/controllers/agent/poll.js @@ -0,0 +1,54 @@ +/** + * POST /agent/v1/poll (minimal; contract 1.6) + * + * The heartbeat of an enrolled fog-agent. The policy agentPrincipal proved the + * certificate and set req.agentHost. It writes agentCheckin (now) and, when the + * body has `agent_version`, agentVersion (normalized as at enroll). It answers + * 200 with the 1.6 shape. The `state` object is required: without it a fresh + * agent logs "poll: the revision moved but the server sent no state". With an + * empty capability list it reconciles nothing and sends no /result. + * + * It returns no tasks, payloads or facts requests; those are later briefs. + * This route writes no audit row. + */ +const {jsonBody, version} = require('../../lib/agent-pki'); + +// The 1.6 default of FOG_CLIENT_CHECKIN_TIME, in seconds. +const DEFAULT_POLL_INTERVAL = 60; + +module.exports = { + friendlyName: 'Agent poll', + description: 'Record the check-in of a fog-agent and answer with the minimal poll state.', + exits: { + success: {description: 'The answer is in the response.'} + }, + fn: async function () { + const req = this.req; + const res = this.res; + const host = req.agentHost; + const body = jsonBody(req); + + const set = {agentCheckin: Date.now()}; + const agentVersion = version(body.agent_version); + if (agentVersion) { set.agentVersion = agentVersion; } + await sails.models.host.updateOne({id: host.id}).set(set); + + const setting = await sails.models.setting.findOne({name: 'FOG_CLIENT_CHECKIN_TIME'}); + // An integer, because the Go agent's PollInterval is an int and fails to + // decode 90.5. 1.6 casts with (int) and passes 0 or a negative number on: + // the agent then keeps its own default. A missing or non-numeric value + // gives 60. + const interval = parseInt(setting && setting.value, 10); + + return res.status(200).json({ + status: 'ok', + protocol: 1, + host: {id: host.agentId, name: host.name}, + revision: '', + state: {revision: '', capabilities: []}, + poll_interval: Number.isNaN(interval) ? DEFAULT_POLL_INTERVAL : interval, + // ISO 8601 with an offset, as 1.6 sends it (Route.php:3207, format('c')). + server_time: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') + }); + } +}; diff --git a/api/controllers/agent/renew.js b/api/controllers/agent/renew.js new file mode 100644 index 0000000..a9f39e5 --- /dev/null +++ b/api/controllers/agent/renew.js @@ -0,0 +1,71 @@ +/** + * POST /agent/v1/renew + * + * An enrolled fog-agent sends a CSR for its own key and gets a new certificate + * (contract 1.3). The policy agentPrincipal proved the old certificate and set + * req.agentHost. The same agent binary works against FOG 1.6, so the statuses, + * the JSON keys and the strings are 1.6's: + * + * 200 {status: issued, host_id, certificate_pem, not_after} + * 400 {status: error, error} the CSR is bad, or it is for another key + * 503 {status: error, error} agent certificate issuance is not configured + * + * The fingerprint does not change and the old certificate stays valid: the + * binding is the key. It writes the 1.6 audit row (Enrollment.php:581-595): + * agent.enroll, authSource agent, "certificate renewed to , key <16>". + * The actor is (system), as in 1.6: an agent request has no signed-in user. + */ +const {MAX_CSR, jsonBody, leafInfo} = require('../../lib/agent-pki'); + +module.exports = { + friendlyName: 'Agent renew', + description: 'Sign a new certificate for the key of an enrolled fog-agent.', + exits: { + success: {description: 'The answer is in the response.'} + }, + fn: async function () { + const req = this.req; + const res = this.res; + const host = req.agentHost; + const body = jsonBody(req); + const fail = (status, error) => res.status(status).json({status: 'error', error}); + + const csr = (typeof body.csr_pem === 'string' && body.csr_pem.length <= MAX_CSR) ? body.csr_pem : ''; + let fingerprint; + try { + await sails.helpers.agent.verifyCsr(csr); + fingerprint = sails.helpers.agent.keyFingerprint(csr); + } catch (unused) { + return fail(400, 'csr_pem is not a certificate request'); + } + if (fingerprint !== host.agentFingerprint) { + return fail(400, 'the request is not for the key this certificate proved'); + } + + let signed; + try { + signed = await sails.helpers.agent.signCsr.with({csrPem: csr, hostId: String(host.agentId)}); + } catch (err) { + sails.log.warn(`agent renew: signing for host ${host.id} failed: ${err.exit || err.message}`); + return fail(503, 'agent certificate issuance is not configured'); + } + const {notAfter} = leafInfo(signed.leafPem); + const updated = await sails.models.host.updateOne({id: host.id}).set({agentNotAfter: notAfter, agentCheckin: Date.now()}); + await sails.helpers.audit.record.with({req, row: { + type: 'agent.enroll', + subjectType: 'host', + subjectId: host.id, + subjectLabel: host.name, + renderable: true, + outcome: 'allowed', + text: `certificate renewed to ${updated.agentNotAfter}, key ${fingerprint.slice(0, 16)}`, + authSource: 'agent' + }}); + return res.status(200).json({ + status: 'issued', + host_id: host.agentId, + certificate_pem: signed.leafPem + signed.chainPem, + not_after: updated.agentNotAfter + }); + } +}; diff --git a/api/hooks/agent-tls/index.js b/api/hooks/agent-tls/index.js new file mode 100644 index 0000000..3777c15 --- /dev/null +++ b/api/hooks/agent-tls/index.js @@ -0,0 +1,123 @@ +/** + * agent-tls hook: the HTTPS listener of the fog-agent (mTLS). + * + * After Sails lifts, it starts an HTTPS server on custom.agentTlsPort (default + * 8443; 0 turns it off). The server runs the same Express app as the main + * port, so every route answers on both ports and an agent uses one base URL, + * https://:, for ca.cert.pem, enroll, poll and renew. It closes the + * server when Sails lowers. + * + * TLS: the server certificate is pki/server.pem (made by api/lib/agent-pki-files.js, + * signed by the root). The server REQUESTS a client certificate and never + * requires one (rejectUnauthorized false), so a browser is never asked for one. + * `ca` is the agent CA, then the root: Node sends their subjects as the + * CertificateRequest list, and Go's client sends its certificate only if one of + * them issued it (contract 5.1). The policy agentPrincipal re-verifies the + * certificate in code (contract 5.4); the TLS verdict is not trusted. + * + * At lift, before it listens, the hook calls ensureServerCert with + * custom.agentTlsSans. It makes server.pem and server.key from the existing + * root when they are missing, and makes them again when the SAN set changed, + * fewer than 30 days remain, or the root no longer verifies them. It never + * makes a root or an agent CA: a new agent CA orphans every enrolled agent, + * so only setup (npm run setup, npm run setup:dev) makes one. When root.pem or + * agent-ca.pem is missing, or the root does not verify the agent CA, the hook + * logs one warning that names npm run setup and starts no listener. When the + * server certificate cannot be made (root.key missing), it logs one warning. + * It then starts the listener with the existing server.pem and server.key, or + * starts none when either is missing. + */ +const fs = require('fs'); +const https = require('https'); +const path = require('path'); +// The module object, not a destructured function: a test replaces +// ensureServerCert on it to hold the lift at the await. +const pkiFiles = require('../../lib/agent-pki-files'); + +/** + * Start the agent listener on `app`. Returns the server, or null when `port` + * is 0 or the PKI files are missing (one warning in that case). A port that is + * in use logs one warning and fog-node keeps running without the listener; the + * server then emits 'error' once, before 'listening'. + */ +const startAgentListener = (app, {port, pkiDir}) => { + if (!port) { return null; } + const notStarted = (err) => sails.log.warn(`Agent listener not started: cannot listen on port ${port} (${err.code || err.message}). fog-node runs without it.`); + let server; + try { + const read = (name) => fs.readFileSync(path.join(pkiDir, name)); + server = https.createServer({ + key: read('server.key'), + cert: read('server.pem'), + ca: [read('agent-ca.pem'), read('root.pem')], + requestCert: true, + rejectUnauthorized: false, + minVersion: 'TLSv1.2' + }, app); + } catch (err) { + sails.log.warn(`Agent listener not started: the PKI files in ${pkiDir} are missing or unreadable (${err.code || err.message}).`); + return null; + } + // A listen failure (EADDRINUSE) arrives as an 'error' after listen() returns + // and before 'listening'. Once it listens, an error is only logged. + server.once('error', notStarted); + server.once('listening', () => { + server.removeListener('error', notStarted); + server.on('error', (err) => sails.log.warn(`Agent listener error: ${err.code || err.message}`)); + }); + try { + server.listen(port); + } catch (err) { + notStarted(err); + return null; + } + return server; +}; + +module.exports = function defineAgentTlsHook(sails) { + return { + initialize: function () { + const hook = this; + hook.server = null; + // 'lower' can fire while 'lifted' awaits ensureServerCert. Without this + // flag the listener would start after the lower and keep the port. + let lowered = false; + sails.on('lifted', async () => { + const {agentTlsPort: port, agentTlsSans: sans, pkiDir} = sails.config.custom; + if (!port) { return; } + try { + await pkiFiles.ensureServerCert(pkiDir, {sans}); + } catch (err) { + if (err.code === 'E_AGENT_PKI_MISSING') { + sails.log.warn(`Agent listener not started: ${err.message} Run npm run setup to make the agent CAs.`); + return; + } + // Without a server pair there is nothing to listen with: stop here, so + // startAgentListener does not log a second warning. + const pair = ['server.pem', 'server.key'].every((name) => fs.existsSync(path.join(pkiDir, name))); + sails.log.warn(`Agent listener${pair ? '' : ' not started'}: the server certificate could not be checked or made: ${err.message}`); + if (!pair) { return; } + } + if (lowered) { return; } + const server = startAgentListener(sails.hooks.http.app, {port, pkiDir}); + hook.server = server; + // A failed listen leaves no listener: do not keep the server. + if (server) { + const clear = () => { if (hook.server === server) { hook.server = null; } }; + server.once('error', clear); + server.once('listening', () => server.removeListener('error', clear)); + } + }); + sails.on('lower', () => { + lowered = true; + if (!hook.server) { return; } + hook.server.close(); + // Keep-alive connections would hold the server open. + hook.server.closeAllConnections(); + hook.server = null; + }); + } + }; +}; + +module.exports.startAgentListener = startAgentListener; diff --git a/api/lib/agent-pki-files.js b/api/lib/agent-pki-files.js new file mode 100644 index 0000000..c909342 --- /dev/null +++ b/api/lib/agent-pki-files.js @@ -0,0 +1,302 @@ +/** + * The fog-agent certificate authorities: a root ("FOG Server CA") and the + * agent intermediate ("FOG Agent CA") that signs the agent client certificates. + * The parameters match FOG 1.6 (lib/common/functions.sh), so one agent binary + * works against 1.6 and fog-node. + * + * Files in the PKI folder (custom.pkiDir): + * root.key, root.pem the root CA + * agent-ca.key, agent-ca.pem the agent CA, issued by the root + * agent-ca-bundle.pem agent CA, then root + * server.key, server.pem the certificate of the agent listener + * (api/hooks/agent-tls), issued by the root + * The folder is made with mode 0o700 and each key file ends at 0o600, also + * when it overwrites an older file. Windows ignores these modes. + * + * This is a plain module, not a Sails helper. The setup tools + * (tools/setup/pki.js, ensurePki) and the agent-tls hook (ensureServerCert, at + * lift) both use it, so it lives in api/lib and not in tools/. + * + * Certificates use @peculiar/x509 (it also builds and verifies CSRs). It signs + * through WebCrypto, where an imported RSA key carries its hash. So each key + * is imported with the hash of the certificate it signs: SHA-512 for the root + * and the agent CA (makeRoot, makeAgentCa), SHA-256 for the server certificate + * (makeServer imports the root key with SHA-256). + * The shared crypto pieces are in api/lib/agent-pki.js. + */ +const crypto = require('crypto'); +const fs = require('fs'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const x509 = require('@peculiar/x509'); +const shared = require('./agent-pki'); + +const DAYS = 10950; +// The server certificate of the agent listener: 825 days, and it is made again +// when fewer than 30 days remain. +const SERVER_DAYS = 825; +const SERVER_RENEW_DAYS = 30; +const SERVER_AUTH = '1.3.6.1.5.5.7.3.1'; + +const newKey = () => crypto.generateKeyPairSync('rsa', {modulusLength: 4096}).privateKey; +const keyPem = (key) => key.export({type: 'pkcs8', format: 'pem'}); + +const makeRoot = async () => { + const key = newKey(); + const keys = await shared.importPair(key, 'SHA-512'); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: shared.randomSerial(), + name: shared.subjectOf('FOG Server CA', 'FOG Root CA'), + notBefore: new Date(), + notAfter: new Date(Date.now() + DAYS * shared.DAY_MS), + signingAlgorithm: shared.RSA, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + return {key: keyPem(key), cert: cert.toString('pem')}; +}; + +const makeAgentCa = async (rootKeyPem, rootCertPem) => { + const key = newKey(); + const keys = await shared.importPair(key, 'SHA-512'); + // The root key signs this certificate, so it is imported with SHA-512. + const rootKeys = await shared.importPair(crypto.createPrivateKey(rootKeyPem), 'SHA-512'); + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: shared.randomSerial(), + subject: shared.subjectOf('FOG Agent CA', 'FOG Agent'), + issuer: new x509.X509Certificate(rootCertPem).subjectName, + notBefore: new Date(), + notAfter: new Date(Date.now() + DAYS * shared.DAY_MS), + signingAlgorithm: shared.RSA, + publicKey: keys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), + new x509.ExtendedKeyUsageExtension([shared.CLIENT_AUTH], false) + ] + }); + return {key: keyPem(key), cert: cert.toString('pem')}; +}; + +const exists = (file) => fs.existsSync(file); + +// The names the server certificate must carry when no list is configured: +// localhost, this host's name, and every non-internal interface address. The +// agent pins the root and Go checks the URL host against these (contract 4.4). +const defaultSans = () => { + const out = ['localhost', '127.0.0.1', os.hostname()]; + for (const list of Object.values(os.networkInterfaces())) { + for (const entry of list || []) { + if (!entry.internal) out.push(entry.address); + } + } + return out; +}; + +// The form in which two SAN entries compare equal: a name lower-cased, an IP +// address in its canonical spelling (the URL parser compresses an IPv6 address +// the same way for any input spelling). The type prefix keeps a name apart. +const sanKey = (value) => { + const text = String(value).trim().toLowerCase().split('%')[0]; + return net.isIP(text) === 6 ? `ip:${new URL(`http://[${text}]`).hostname}` : `${net.isIP(text) ? 'ip' : 'dns'}:${text}`; +}; + +// A SAN list without empty entries or repeats. The first spelling stays. +const cleanSans = (list) => { + const seen = new Set(); + return list.map((v) => String(v).trim()).filter((v) => { + if (!v || seen.has(sanKey(v))) return false; + seen.add(sanKey(v)); + return true; + }); +}; + +// The SAN entries of a certificate, as a sorted list of sanKey values. +const sanKeysOf = (certPem) => { + const san = new x509.X509Certificate(certPem).getExtension(x509.SubjectAlternativeNameExtension); + return (san ? san.names.items : []).map((n) => sanKey(n.value)).sort(); +}; + +// The server certificate: RSA 2048, signed directly by the root (no +// intermediate). The subject CN is the first SAN. +const makeServer = async (rootKeyPem, rootCertPem, sans) => { + const key = crypto.generateKeyPairSync('rsa', {modulusLength: 2048}).privateKey; + const keys = await shared.importPair(key, 'SHA-256'); + const rootKeys = await shared.importPair(crypto.createPrivateKey(rootKeyPem), 'SHA-256'); + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: shared.randomSerial(), + subject: new x509.Name([{CN: [sans[0]]}, {O: ['FOG Project']}]), + issuer: new x509.X509Certificate(rootCertPem).subjectName, + notBefore: new Date(), + notAfter: new Date(Date.now() + SERVER_DAYS * shared.DAY_MS), + signingAlgorithm: shared.RSA, + publicKey: keys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.ExtendedKeyUsageExtension([SERVER_AUTH], false), + // An IP address is an iPAddress entry and a name a dNSName entry. For an IP + // host, Go checks only the IP entries. + new x509.SubjectAlternativeNameExtension(sans.map((value) => ({type: net.isIP(value) ? 'ip' : 'dns', value}))) + ] + }); + return {key: keyPem(key), cert: cert.toString('pem')}; +}; + +// Must the server certificate be made again? It must when a file is missing, +// the SAN set differs from `wanted` (a list of sanKey values), fewer than +// SERVER_RENEW_DAYS remain, or the root no longer verifies it (a new root). +const serverStale = (certFile, keyFile, rootPem, wantedKeys) => { + if (!exists(certFile) || !exists(keyFile)) return true; + try { + const pem = fs.readFileSync(certFile, 'utf8'); + if (JSON.stringify(sanKeysOf(pem)) !== JSON.stringify(wantedKeys)) return true; + if (Date.parse(new crypto.X509Certificate(pem).validTo) - Date.now() < SERVER_RENEW_DAYS * shared.DAY_MS) return true; + return !shared.verifies(pem, rootPem); + } catch (unused) { + return true; + } +}; + +// The mode option applies only to a new file, so chmod makes an overwritten key 0600 too. +// Windows has no POSIX modes: there chmod may fail, and that is fine. +const writeKey = (file, pem) => { + fs.writeFileSync(file, pem, {mode: 0o600}); + try { + fs.chmodSync(file, 0o600); + } catch (err) { + if (process.platform !== 'win32') throw err; + } +}; +// toString('pem') has no final newline. Without it the bundle would run the +// agent CA's END line into the root's BEGIN line. +const writeCert = (file, pem) => fs.writeFileSync(file, pem.endsWith('\n') ? pem : pem + '\n'); + +// UTC YYYYMMDDHHMMSS: the suffix of a file that a new one replaces. +const stamp = () => new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); + +const {fingerprintOf} = shared; + +// An error that the setup tools recognize by its code. +const rootError = (code, message) => Object.assign(new Error(message), {code}); +// Is it one of the two guard errors? The tools print its message and stop. +const isRootError = (err) => Boolean(err) && (err.code === 'E_AGENT_ROOT_MISSING' || err.code === 'E_AGENT_ROOT_MISMATCH'); + +// The SAN list of opts.sans, or the default list when it is unset or empty. +const sansOf = (opts) => cleanSans(opts.sans && opts.sans.length ? opts.sans : defaultSans()); + +// The server certificate step of ensurePki and ensureServerCert: the rules of +// the server.pem item of ensurePki below. +const ensureServer = async (dir, rootPem, sans) => { + const f = (name) => path.join(dir, name); + if (serverStale(f('server.pem'), f('server.key'), rootPem, sans.map(sanKey).sort())) { + if (!exists(f('root.key'))) { + throw new Error(`${f('server.pem')} is missing or out of date, and ${f('root.key')} is missing, so the server certificate cannot be made.`); + } + const suffix = stamp(); + for (const name of ['server.pem', 'server.key']) { + if (exists(f(name))) fs.renameSync(f(name), f(`${name}.${suffix}`)); + } + const server = await makeServer(fs.readFileSync(f('root.key'), 'utf8'), rootPem, sans); + writeKey(f('server.key'), server.key); + writeCert(f('server.pem'), server.cert); + } +}; + +/** + * Make the CA files that are missing. As 1.6: a certificate file is what + * "exists" means. + * - root.pem missing: make root.key and root.pem. An existing root.key is + * renamed with a timestamp first. An existing root.pem is never re-made, + * even when root.key is missing. + * - agent-ca.pem missing, or not verifying against root.pem: re-mint the agent + * CA with root.key. The old agent-ca.pem and agent-ca.key get a timestamp + * suffix first. A re-mint with root.key missing throws. + * - agent-ca-bundle.pem is brought up to date on every call. It is written + * only when its text differs, so a second call changes no file. + * - server.pem and server.key (the agent listener) are made when missing. They + * are made again, the old pair renamed with a timestamp, when the SAN set + * differs from opts.sans, fewer than 30 days remain, or the root no longer + * verifies the certificate. Names compare in lower case and IP addresses in + * canonical form, in any order. Without opts.sans the default list applies + * (defaultSans). A re-issue with root.key missing throws. + * - opts.expectedRoot (a fingerprint256 string) guards the root. A missing + * root.pem throws E_AGENT_ROOT_MISSING, and a root.pem with another + * fingerprint throws E_AGENT_ROOT_MISMATCH. Both throw before any file is + * written. Without it, the rules above apply unchanged. + * Returns {rootFingerprint}, the fingerprint of root.pem after the call. + */ +const ensurePki = async (dir, opts = {}) => { + const {expectedRoot} = opts; + const sans = sansOf(opts); + const f = (name) => path.join(dir, name); + if (expectedRoot) { + if (!exists(f('root.pem'))) { + throw rootError('E_AGENT_ROOT_MISSING', `The agent root ${f('root.pem')} is missing, but the database records the root ${expectedRoot}.`); + } + const onDisk = fingerprintOf(fs.readFileSync(f('root.pem'), 'utf8')); + if (onDisk !== expectedRoot) { + throw rootError('E_AGENT_ROOT_MISMATCH', `The agent root ${f('root.pem')} has the fingerprint ${onDisk}, but the database records the root ${expectedRoot}.`); + } + } + fs.mkdirSync(dir, {recursive: true, mode: 0o700}); + + if (!exists(f('root.pem'))) { + if (exists(f('root.key'))) fs.renameSync(f('root.key'), f(`root.key.${stamp()}`)); + const root = await makeRoot(); + writeKey(f('root.key'), root.key); + writeCert(f('root.pem'), root.cert); + } + const rootPem = fs.readFileSync(f('root.pem'), 'utf8'); + + if (!exists(f('agent-ca.pem')) || !shared.verifies(fs.readFileSync(f('agent-ca.pem'), 'utf8'), rootPem)) { + if (!exists(f('root.key'))) { + throw new Error(`${f('agent-ca.pem')} is missing or not issued by ${f('root.pem')}, and ${f('root.key')} is missing, so the agent CA cannot be re-made.`); + } + const suffix = stamp(); + for (const name of ['agent-ca.pem', 'agent-ca.key']) { + if (exists(f(name))) fs.renameSync(f(name), f(`${name}.${suffix}`)); + } + const ca = await makeAgentCa(fs.readFileSync(f('root.key'), 'utf8'), rootPem); + writeKey(f('agent-ca.key'), ca.key); + writeCert(f('agent-ca.pem'), ca.cert); + } + + // Both files end in a newline (writeCert), so the join is clean. + const bundle = fs.readFileSync(f('agent-ca.pem'), 'utf8') + rootPem; + if (!exists(f('agent-ca-bundle.pem')) || fs.readFileSync(f('agent-ca-bundle.pem'), 'utf8') !== bundle) { + writeCert(f('agent-ca-bundle.pem'), bundle); + } + + await ensureServer(dir, rootPem, sans); + return {rootFingerprint: fingerprintOf(rootPem)}; +}; + +/** + * The lift-time step of the agent-tls hook: server.pem and server.key only, + * under the ensurePki rules (made when missing; made again, the old pair + * renamed, when the SAN set differs from opts.sans, fewer than 30 days remain, + * or the root no longer verifies it). It never makes or renames a root or an + * agent CA: a new agent CA orphans every enrolled agent, so only setup makes + * one. When root.pem or agent-ca.pem is missing, or root.pem does not verify + * agent-ca.pem, it throws E_AGENT_PKI_MISSING before any file is written. + */ +const ensureServerCert = async (dir, opts = {}) => { + const sans = sansOf(opts); + const f = (name) => path.join(dir, name); + const rootPem = exists(f('root.pem')) ? fs.readFileSync(f('root.pem'), 'utf8') : ''; + if (!rootPem || !exists(f('agent-ca.pem')) || !shared.verifies(fs.readFileSync(f('agent-ca.pem'), 'utf8'), rootPem)) { + throw rootError('E_AGENT_PKI_MISSING', `The agent root ${f('root.pem')} or the agent CA ${f('agent-ca.pem')} is missing, or the root does not verify the agent CA.`); + } + await ensureServer(dir, rootPem, sans); +}; + +module.exports = {ensurePki, ensureServerCert, defaultSans, cleanSans, rootError, isRootError, stamp, exists}; diff --git a/api/lib/agent-pki.js b/api/lib/agent-pki.js index b2a1fc5..dc2a32f 100644 --- a/api/lib/agent-pki.js +++ b/api/lib/agent-pki.js @@ -68,15 +68,36 @@ const AGENT_CN_PREFIX = 'fog-agent host '; const agentCn = (hostId) => `${AGENT_CN_PREFIX}${hostId}`; const AGENT_CN_RX = new RegExp(`^CN=${AGENT_CN_PREFIX}(\\d+)$`, 'm'); +// A date as UTC "YYYY-MM-DD HH:MM:SS", the 1.6 format of not_after. +const utc = (date) => date.toISOString().slice(0, 19).replace('T', ' '); + // The host number and expiry of an agent leaf. `agentId` is null when the CN -// is not an agent CN. `notAfter` is UTC "YYYY-MM-DD HH:MM:SS", the 1.6 format. +// is not an agent CN. `notAfter` is in the utc format. const leafInfo = (pem) => { const leaf = new crypto.X509Certificate(pem); const cn = AGENT_CN_RX.exec(leaf.subject); return { agentId: cn ? Number(cn[1]) : null, - notAfter: new Date(Date.parse(leaf.validTo)).toISOString().slice(0, 19).replace('T', ' ') + notAfter: utc(new Date(Date.parse(leaf.validTo))) }; }; -module.exports = {DAY_MS, CLIENT_AUTH, RSA, ROOT_SETTING, ROOT_SETTING_DESCRIPTION, fingerprintOf, isCsrPem, randomSerial, subjectOf, importPair, verifies, agentCn, leafInfo}; +// The JSON body of an agent request as an object, or {} for anything else. +const jsonBody = (req) => ((req.body && typeof req.body === 'object' && !Array.isArray(req.body)) ? req.body : {}); + +// The agent request fields that enroll, renew and poll share. +// A CSR is about 500 characters (ECDSA P-256) and an RSA-4096 CSR about 1800. +// 16384 leaves room for any real key, and stops a caller from making a route +// parse and store a huge value. +const MAX_CSR = 16384; + +// The field limits of the 1.6 columns, so a caller cannot store more. +const text = (value, max) => (typeof value === 'string' ? value.slice(0, max) : ''); + +// Update::normalize: trim, and strip one leading v or V. +const version = (value) => { + const v = text(value, 50).trim(); + return /^[vV]/.test(v) ? v.slice(1) : v; +}; + +module.exports = {DAY_MS, CLIENT_AUTH, RSA, ROOT_SETTING, ROOT_SETTING_DESCRIPTION, MAX_CSR, fingerprintOf, isCsrPem, randomSerial, subjectOf, importPair, verifies, agentCn, leafInfo, jsonBody, text, version}; diff --git a/api/policies/agentPrincipal.js b/api/policies/agentPrincipal.js new file mode 100644 index 0000000..ae1d29c --- /dev/null +++ b/api/policies/agentPrincipal.js @@ -0,0 +1,68 @@ +/** + * agentPrincipal: who is this fog-agent? (contract 1.5, 5.4, 5.5) + * + * The agent proves itself with its client certificate on the agent listener + * (api/hooks/agent-tls). This policy decides, in code, and does not trust the + * TLS verdict (`req.socket.authorized`): + * 1. A peer certificate arrived on the TLS socket. + * 2. The leaf was issued by the agent CA, the agent CA by the root, now is in + * the dates of all three, the leaf has the clientAuth usage and is not a CA. + * 3. The fingerprint of the leaf's public key binds to one host that is not + * pending (Host.agentFingerprint). + * On success `req.agentHost` is that host. On failure it answers 401 with the + * FOG 1.6 body, and only `unknown_certificate` makes the agent enroll again: + * no_client_certificate steps 1 and 2 + * unknown_certificate no live, non-pending host has the key + * ambiguous_certificate more than one has it + * server_error the database failed + * + * The 1.6 definitions of "verifies" are in api/lib/agent-pki.js (`verifies`). + */ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const pki = require('../lib/agent-pki'); + +module.exports = async (req, res, proceed) => { + const deny = (reason) => res.status(401).json({status: 'unauthorized', reason, error: 'client certificate required'}); + + // 1. The certificate of the peer, from the TLS socket of the agent listener. + const socket = req.socket; + const peer = socket && socket.encrypted && socket.getPeerCertificate ? socket.getPeerCertificate(true) : null; + if (!peer || !peer.raw) { return deny('no_client_certificate'); } + + // 2. The chain, the dates, the purpose. + let leaf; + try { + leaf = new crypto.X509Certificate(peer.raw); + const dir = sails.config.custom.pkiDir; + const caPem = fs.readFileSync(path.join(dir, 'agent-ca.pem'), 'utf8'); + const rootPem = fs.readFileSync(path.join(dir, 'root.pem'), 'utf8'); + if (!pki.verifies(leaf.raw, caPem, true) || !pki.verifies(caPem, rootPem)) { + return deny('no_client_certificate'); + } + // 1.6 runs openssl_x509_checkpurpose, which checks the dates of every + // certificate in the chain (Principal.php:123). So the agent CA and the + // root must be in their dates too, not only the leaf. + const now = Date.now(); + for (const cert of [leaf, new crypto.X509Certificate(caPem), new crypto.X509Certificate(rootPem)]) { + if (now < Date.parse(cert.validFrom) || now > Date.parse(cert.validTo)) { + return deny('no_client_certificate'); + } + } + } catch (unused) { + return deny('no_client_certificate'); + } + + // 3. The host that the key is bound to. + let hosts; + try { + hosts = await sails.models.host.find({agentFingerprint: sails.helpers.agent.keyFingerprint(leaf.toString()), pending: false}).limit(2); + } catch (unused) { + return deny('server_error'); + } + if (hosts.length === 0) { return deny('unknown_certificate'); } + if (hosts.length > 1) { return deny('ambiguous_certificate'); } + req.agentHost = hosts[0]; + return proceed(); +}; diff --git a/config/custom.js b/config/custom.js index b73c6f4..ff2253e 100644 --- a/config/custom.js +++ b/config/custom.js @@ -19,6 +19,11 @@ module.exports.custom = { // override it (the setup tools carry that override forward). pkiDir: path.join(__dirname, '..', 'pki'), + // The HTTPS port of the fog-agent listener (api/hooks/agent-tls). It asks the + // agent for its client certificate (mTLS) and serves the same routes as the + // main port. 0 turns the listener off. Agents use https://:. + agentTlsPort: 8443, + // Image store: one folder per image -> //.img. // Single source of truth for the deploy streamer, dashboard/hwinfo disk usage, // and the image-store scan. Matches FOG's storageLocation (default /images). diff --git a/config/policies.js b/config/policies.js index d6585d3..034f8f2 100644 --- a/config/policies.js +++ b/config/policies.js @@ -93,6 +93,11 @@ module.exports.policies = { // So is the enroll route: the agent has no session (it proves its key by the CSR) 'agent/enroll': true, + // Renew and poll prove the agent by its client certificate. Named one by one: + // agent/* would also catch agent/enroll, agent/ca-file and the admin actions. + 'agent/renew': 'agentPrincipal', + 'agent/poll': 'agentPrincipal', + // System info 'system/info': true, 'system/bandwidth': true, diff --git a/config/routes.js b/config/routes.js index 7ce3e7b..692213c 100644 --- a/config/routes.js +++ b/config/routes.js @@ -115,6 +115,9 @@ module.exports.routes = { // fog-agent enrollment (public, no session: the agent has no certificate yet). // Agent-facing, so it follows the FOG 1.6 wire format, not fog-node's API. 'POST /agent/v1/enroll': {action: 'agent/enroll', csrf: false}, + // fog-agent renew and poll: mTLS on the agent listener (policy agentPrincipal). + 'POST /agent/v1/renew': {action: 'agent/renew', csrf: false}, + 'POST /agent/v1/poll': {action: 'agent/poll', csrf: false}, // Host Views 'GET /hosts': {action: 'pages/list/hosts'}, diff --git a/config/session.js b/config/session.js index 15d19aa..9ec60a5 100644 --- a/config/session.js +++ b/config/session.js @@ -41,8 +41,11 @@ module.exports.session = { * https://sailsjs.com/config/session * * * ***************************************************************************/ - // isSessionDisabled: function (req){ - // return !!req.path.match(req._sails.LOOKS_LIKE_ASSET_RX); - // }, + // The Sails default (assets), plus the fog-agent paths: an agent keeps no + // cookies, so without this every poll would create a Redis session. + isSessionDisabled: function (req){ + return !!req.path.match(req._sails.LOOKS_LIKE_ASSET_RX) || + req.path.startsWith('/agent/v1/'); + }, }; diff --git a/docs/adr/0003-audit-trail.md b/docs/adr/0003-audit-trail.md index 97cd45b..21ce8e2 100644 --- a/docs/adr/0003-audit-trail.md +++ b/docs/adr/0003-audit-trail.md @@ -21,8 +21,13 @@ part of 7): adds the change table `AuditChange`. - A machine-originated write gets a row with a synthetic actor, `(system)`, and an empty `permission`, which means that no authorization ran (1.6 decision 4). - Only the fog-agent enrollment route does this so far. The imaging boot and task - paths come later. + Only the fog-agent enrollment route and the fog-agent renew route do this so + far. The renew row has the authSource `agent`; the enrollment rows have + `anonymous` (1.6 does the same). The imaging boot and task paths come later. +- An `agentPrincipal` 401 on `/agent/v1/` (a missing, wrong, expired or unknown + client certificate) writes no `denied` row, on purpose. 1.6 audits no agent + authentication failure: `Principal::verify` and the gate in `Route.php` call + no `Audit::record`. The poll route writes no row either, as in 1.6. - `outcome` defaults to `unknown`, so an incomplete row never claims `allowed`. The other values are `allowed`, `denied`, `failed` and `partial` (1.6 decision 11). @@ -45,7 +50,7 @@ The events: |---|---| | `auth.login`, `auth.login.failed`, `auth.logout`, `auth.token.rejected` | `auth/login`, `auth/logout`, `policies/isLoggedIn` | | `access.api`, `access.page` | the request seam, `api/hooks/audit` | -| `agent.enroll`, `agent.token` | the enrollment route, the admin decision and the token routes, with the 1.6 texts | +| `agent.enroll`, `agent.token` | the enrollment route, the renew route, the admin decision and the token routes, with the 1.6 texts | A failed sign-in stores the attempted name and no reason, to avoid user enumeration (1.6 stores none either). @@ -86,7 +91,7 @@ enumeration (1.6 stores none either). - The `audit.view` and `audit.manage` permissions, and the Audit Log page. - Retention, and the rule that shrinking the trail writes a row first (1.6 decision 10). -- The machine paths other than the agent enrollment route. +- The machine paths other than the agent enrollment and renew routes. ## Consequences diff --git a/docs/install.md b/docs/install.md index f37aff6..0ff851f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -133,6 +133,40 @@ new name has a timestamp suffix. Setup then makes new CAs. Setup records the new fingerprint. You must then re-enroll every fog-agent by hand with the new `ca.cert.pem`. +### Agent port + +fog-node runs a second, HTTPS listener for fog-agent. It uses the port in +`custom.agentTlsPort` in `config/local.js`. The default is 8443. The agent +proves itself with its client certificate on this port (mTLS), so it works on +any OS with no proxy setup. The listener serves the same routes as the main +port. An agent uses one base URL for everything: `https://:`, for +example `https://fogserver:8443`. + +To change the port, run `npm run setup` again, or edit `custom.agentTlsPort` +and restart fog-node. The value `0` turns the listener off. The answers file +key is `server.agentTlsPort`. If the port is in use, fog-node logs a warning +that names the port and runs without the listener. + +The listener serves `pki/server.pem`, which the root certificate `pki/root.pem` +signed. An agent pins the root, and checks the host in the URL against the names +and addresses in `server.pem`. Setup writes that list to `custom.agentTlsSans`: +`localhost`, this computer's name and addresses, and the host of each browser +URL. If agents use another name, add it to `custom.agentTlsSans`. + +At each start, fog-node checks the server certificate only. It makes +`pki/server.pem` and `pki/server.key` from the existing root when they are +missing. It makes them again when the list changes, fewer than 30 days remain, +or the root no longer verifies them. It renames the old pair with a timestamp +suffix. This needs `pki/root.key`. Without it, fog-node logs one warning. It +then starts the listener with the existing `server.pem` and `server.key`. If +either is missing, it runs without the listener. + +A start never makes a root or an agent CA. A new agent CA would cut off every +enrolled agent, so only `npm run setup` (or `npm run setup:dev`) makes one. If +`pki/root.pem` or `pki/agent-ca.pem` is missing, or the root does not verify +the agent CA, fog-node logs one warning that names `npm run setup`. It then +runs without the listener. + ### Install without prompts To script an install, put the answers in a JSON file and run diff --git a/eslint.config.js b/eslint.config.js index fce8dbe..6e90f1f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,7 +44,7 @@ const rules = { // are still reported. 'camelcase': ['warn', {'properties':'always', 'allow': [ '^default_iface$', '^(tx|rx)_(bytes|errors|dropped|sec)$', '^grants_list$', - '^(retry_after|host_id|certificate_pem|not_after|csr_pem|agent_version)$', + '^(retry_after|host_id|certificate_pem|not_after|csr_pem|agent_version|poll_interval|server_time)$', '^(system_uuid|system_serial|board_serial|chassis_asset|smbios_version)$' ]}], 'comma-style': ['warn', 'last'], diff --git a/test/helpers/agent-pki.test.js b/test/helpers/agent-pki.test.js index 383f942..c25bba1 100644 --- a/test/helpers/agent-pki.test.js +++ b/test/helpers/agent-pki.test.js @@ -4,8 +4,9 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const x509 = require('@peculiar/x509'); -const {ensurePki, setupPki} = require('../../tools/setup/pki'); -const {DAY_MS, ROOT_SETTING} = require('../../api/lib/agent-pki'); +const {ensurePki} = require('../../api/lib/agent-pki-files'); +const {setupPki} = require('../../tools/setup/pki'); +const {DAY_MS, ROOT_SETTING, importPair} = require('../../api/lib/agent-pki'); const support = require('../support/pki'); // The fog-agent PKI (the CAs, CSR signing and the key fingerprint), against the @@ -58,6 +59,10 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const fixtureDir = support.useFixture(); + // A copy of the fixture keeps its server certificate only if ensurePki gets + // the fixture's SAN list (support.SANS). + const ensure = (dir, opts = {}) => ensurePki(dir, Object.assign({sans: support.SANS}, opts)); + // Run fn against a private copy of the fixture, then remove it. const withCopy = async (fn) => { const dir = support.copy(fixtureDir()); @@ -82,11 +87,11 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const sign = (csrPem, hostId = '42') => sails.helpers.agent.signCsr.with({csrPem, hostId}); describe('ensurePki::', () => { - it('makes the five files, and the keys are 0600 (not on Windows)', () => { - const names = ['root.key', 'root.pem', 'agent-ca.key', 'agent-ca.pem', 'agent-ca-bundle.pem']; + it('makes the seven files, and the keys are 0600 (not on Windows)', () => { + const names = ['root.key', 'root.pem', 'agent-ca.key', 'agent-ca.pem', 'agent-ca-bundle.pem', 'server.key', 'server.pem']; assert.deepStrictEqual(fs.readdirSync(fixtureDir()).sort(), names.sort()); if (process.platform !== 'win32') { - for (const k of ['root.key', 'agent-ca.key']) { + for (const k of ['root.key', 'agent-ca.key', 'server.key']) { assert.strictEqual(fs.statSync(path.join(fixtureDir(),k)).mode & 0o777, 0o600, k); } } @@ -137,7 +142,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI return [n, fs.statSync(p).mtimeMs, fs.readFileSync(p, 'utf8')]; }); const first = snap(); - await ensurePki(dir); + await ensure(dir); assert.deepStrictEqual(snap(), first); })); @@ -146,7 +151,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const oldCa = fs.readFileSync(path.join(dir, 'agent-ca.pem'), 'utf8'); fs.unlinkSync(path.join(dir, 'agent-ca.pem')); fs.unlinkSync(path.join(dir, 'agent-ca.key')); - await ensurePki(dir); + await ensure(dir); assert.strictEqual(fs.readFileSync(path.join(dir, 'root.pem'), 'utf8'), rootBefore); const ca = cert(path.join(dir, 'agent-ca.pem')); assert.ok(ca.checkIssued(cert(path.join(dir, 'root.pem')))); @@ -159,7 +164,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const foreign = fs.readFileSync(path.join(other, 'agent-ca.pem'), 'utf8'); fs.writeFileSync(path.join(dir, 'agent-ca.pem'), foreign); const oldKey = fs.readFileSync(path.join(dir, 'agent-ca.key'), 'utf8'); - await ensurePki(dir); + await ensure(dir); const names = fs.readdirSync(dir); const stamped = (base) => names.filter((n) => new RegExp(`^${base}\\.\\d{14}$`).test(n)); assert.strictEqual(stamped('agent-ca\\.pem').length, 1); @@ -177,7 +182,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const oldKey = fs.readFileSync(path.join(dir, 'root.key'), 'utf8'); const oldRoot = fs.readFileSync(path.join(dir, 'root.pem'), 'utf8'); fs.unlinkSync(path.join(dir, 'root.pem')); - await ensurePki(dir); + await ensure(dir); const kept = fs.readdirSync(dir).filter((n) => /^root\.key\.\d{14}$/.test(n)); assert.strictEqual(kept.length, 1); assert.strictEqual(fs.readFileSync(path.join(dir, kept[0]), 'utf8'), oldKey); @@ -190,11 +195,104 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI it('throws on a re-mint when root.key is missing, and never re-makes an existing root.pem', () => withCopy(async (dir) => { const rootBefore = fs.readFileSync(path.join(dir, 'root.pem'), 'utf8'); fs.unlinkSync(path.join(dir, 'root.key')); - await ensurePki(dir); + await ensure(dir); assert.strictEqual(fs.readFileSync(path.join(dir, 'root.pem'), 'utf8'), rootBefore); fs.unlinkSync(path.join(dir, 'agent-ca.pem')); - await assert.rejects(() => ensurePki(dir), /root\.key/); + await assert.rejects(() => ensure(dir), /root\.key/); })); + + describe('server certificate::', () => { + const server = (dir) => cert(path.join(dir, 'server.pem')); + const stamped = (dir, base) => fs.readdirSync(dir).filter((n) => new RegExp(`^${base}\\.\\d{14}$`).test(n)); + const snap = (dir) => fs.readdirSync(dir).sort().map((n) => [n, fs.readFileSync(path.join(dir, n), 'utf8')]); + + it('makes the server certificate RSA 2048, SHA-256, 825 days, CA false, serverAuth, with dNSName and iPAddress SANs, signed directly by the root', () => { + const c = server(fixtureDir()); + const root = cert(path.join(fixtureDir(), 'root.pem')); + assert.strictEqual(c.subject.split('\n').join(','), 'CN=localhost,O=FOG Project'); + assert.strictEqual(c.issuer, root.subject); + assert.ok(c.checkIssued(root) && c.verify(root.publicKey)); + assert.strictEqual(keyBits(c), 2048); + assert.strictEqual(days(c), 825); + assert.strictEqual(hashOf(c), 'SHA-256'); + assert.strictEqual(c.ca, false); + const bc = ext(c, x509.BasicConstraintsExtension); + assert.ok(bc.critical && !bc.ca); + const usage = ext(c, x509.KeyUsagesExtension); + assert.ok(usage.critical); + assert.strictEqual(usage.usages, x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment); + assert.deepStrictEqual(Array.from(ext(c, x509.ExtendedKeyUsageExtension).usages), ['1.3.6.1.5.5.7.3.1']); + // A name is a dNSName entry and an address an iPAddress entry. + assert.strictEqual(c.subjectAltName, 'DNS:localhost, IP Address:127.0.0.1'); + // The key belongs to the certificate. + const key = crypto.createPrivateKey(fs.readFileSync(path.join(fixtureDir(), 'server.key'))); + assert.ok(crypto.createPublicKey(key).equals(c.publicKey)); + }); + + it('lists the default names without a SAN list', () => withCopy(async (dir) => { + fs.unlinkSync(path.join(dir, 'server.pem')); + await ensurePki(dir); + const san = server(dir).subjectAltName; + assert.ok(san.includes('DNS:localhost') && san.includes('IP Address:127.0.0.1')); + assert.ok(san.toLowerCase().includes(os.hostname().toLowerCase())); + })); + + it('makes the pair again, and renames the old one, when the SAN set changes', () => withCopy(async (dir) => { + const oldPem = fs.readFileSync(path.join(dir, 'server.pem'), 'utf8'); + const oldKey = fs.readFileSync(path.join(dir, 'server.key'), 'utf8'); + await ensure(dir, {sans: [...support.SANS, 'fog.example']}); + assert.strictEqual(server(dir).subjectAltName, 'DNS:localhost, IP Address:127.0.0.1, DNS:fog.example'); + assert.ok(server(dir).checkIssued(cert(path.join(dir, 'root.pem')))); + assert.strictEqual(fs.readFileSync(path.join(dir, stamped(dir, 'server\\.pem')[0]), 'utf8'), oldPem); + assert.strictEqual(fs.readFileSync(path.join(dir, stamped(dir, 'server\\.key')[0]), 'utf8'), oldKey); + assert.strictEqual(stamped(dir, 'server\\.pem').length, 1); + })); + + it('keeps the pair when only the order, the case or the IPv6 spelling changes', () => withCopy(async (dir) => { + await ensure(dir, {sans: ['localhost', '127.0.0.1', '::1', 'Fog.Example']}); + const before = snap(dir); + await ensure(dir, {sans: ['fog.example', '0:0:0:0:0:0:0:1', 'LOCALHOST', '127.0.0.1']}); + assert.deepStrictEqual(snap(dir), before); + // A different address is a different set. + await ensure(dir, {sans: ['localhost', '127.0.0.1', '::2', 'fog.example']}); + assert.strictEqual(stamped(dir, 'server\\.pem').length, 1); + })); + + it('makes the pair again when fewer than 30 days remain', () => withCopy(async (dir) => { + // A server certificate for the same names, signed by the root, valid 10 more days. + const rootKeys = await importPair(crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'root.key'))), 'SHA-256'); + const keys = await importPair(crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'server.key'))), 'SHA-256'); + const near = await x509.X509CertificateGenerator.create({ + serialNumber: '01', + subject: 'CN=localhost, O=FOG Project', + issuer: new x509.X509Certificate(fs.readFileSync(path.join(dir, 'root.pem'), 'utf8')).subjectName, + notBefore: new Date(Date.now() - DAY_MS), + notAfter: new Date(Date.now() + 10 * DAY_MS), + signingAlgorithm: {name: 'RSASSA-PKCS1-v1_5'}, + publicKey: keys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [new x509.SubjectAlternativeNameExtension([{type: 'dns', value: 'localhost'}, {type: 'ip', value: '127.0.0.1'}])] + }); + fs.writeFileSync(path.join(dir, 'server.pem'), near.toString('pem') + '\n'); + await ensure(dir); + assert.strictEqual(stamped(dir, 'server\\.pem').length, 1); + assert.ok(days(server(dir)) === 825 && Date.parse(server(dir).validTo) - Date.now() > 800 * DAY_MS); + })); + + it('makes the pair again under a new root', () => withCopy(async (dir) => { + fs.unlinkSync(path.join(dir, 'root.pem')); + await ensure(dir); + assert.ok(server(dir).checkIssued(cert(path.join(dir, 'root.pem')))); + assert.ok(server(dir).verify(cert(path.join(dir, 'root.pem')).publicKey)); + })); + + it('throws on a re-issue when root.key is missing', () => withCopy(async (dir) => { + fs.unlinkSync(path.join(dir, 'root.key')); + await ensure(dir); + fs.unlinkSync(path.join(dir, 'server.pem')); + await assert.rejects(() => ensure(dir), /root\.key/); + })); + }); }); describe('signCsr::', () => { @@ -283,7 +381,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI }); const recorded = async () => (await Setting.findOne({name: ROOT_SETTING})).value; const setup = (pkiDir, extra = {}) => setupPki(Object.assign({ - pkiDir, newRoot: false, settings: sails.models.setting, log: () => {}, command: 'setup' + pkiDir, newRoot: false, settings: sails.models.setting, log: () => {}, command: 'setup', sans: support.SANS }, extra)); // Run fn with an empty temp folder, then remove it. const withEmpty = async (fn) => { @@ -300,7 +398,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI it('ensurePki with the matching fingerprint changes no file and returns it', () => withCopy(async (dir) => { const before = snapshot(dir); const expectedRoot = support.rootOf(dir); - assert.deepStrictEqual(await ensurePki(dir, {expectedRoot}), {rootFingerprint: expectedRoot}); + assert.deepStrictEqual(await ensure(dir, {expectedRoot}), {rootFingerprint: expectedRoot}); assert.deepStrictEqual(snapshot(dir), before); })); @@ -321,7 +419,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI })); it('ensurePki with no expectedRoot makes the missing root as before', () => withEmpty(async (dir) => { - const {rootFingerprint} = await ensurePki(dir); + const {rootFingerprint} = await ensure(dir); assert.strictEqual(rootFingerprint, support.rootOf(dir)); })); @@ -357,7 +455,7 @@ N6M/cmtQjgdJ8gIhAOBb4zKxR50Hh3m3NWDCPQK/OG3cmj8QTzCFV8oY7LuI const lines = []; const {rootFingerprint} = await setup(dir, {newRoot: true, log: (l) => lines.push(l)}); assert.deepStrictEqual(fs.readdirSync(dir).sort(), - ['agent-ca-bundle.pem', 'agent-ca.key', 'agent-ca.pem', 'root.key', 'root.pem']); + ['agent-ca-bundle.pem', 'agent-ca.key', 'agent-ca.pem', 'root.key', 'root.pem', 'server.key', 'server.pem']); assert.strictEqual(rootFingerprint, support.rootOf(dir)); assert.notStrictEqual(rootFingerprint, support.rootOf(fixtureDir())); assert.strictEqual(await recorded(), rootFingerprint); diff --git a/test/hooks/agent-tls.test.js b/test/hooks/agent-tls.test.js new file mode 100644 index 0000000..3d1621d --- /dev/null +++ b/test/hooks/agent-tls.test.js @@ -0,0 +1,293 @@ +const assert = require('assert'); +const crypto = require('crypto'); +const EventEmitter = require('events'); +const fs = require('fs'); +const https = require('https'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const tls = require('tls'); +const defineAgentTlsHook = require('../../api/hooks/agent-tls'); +const pkiFiles = require('../../api/lib/agent-pki-files'); +const {startAgentListener} = defineAgentTlsHook; +const agentCsr = require('../support/agent-csr'); +const support = require('../support/pki'); +const {freePort} = require('../support/port'); + +// startAgentListener (api/hooks/agent-tls): the HTTPS listener of the +// fog-agent. The routes that run on it are in test/integration/agent-mtls.test.js. +describe('Agent listener::', function () { + this.timeout(60000); + + const fixtureDir = support.useFixture(); + const rootPem = () => fs.readFileSync(path.join(fixtureDir(), 'root.pem')); + // A request handler that reports what the TLS socket knows of the peer. + const app = (req, res) => { + const peer = req.socket.getPeerCertificate(); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({authorized: req.socket.authorized, peer: Boolean(peer && peer.raw)})); + }; + + const listening = (server) => (server.listening ? Promise.resolve() : new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + })); + const stop = (server) => new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + + // Run fn and return the sails.log.warn lines it wrote. With `late`, wait for + // a warning that arrives after fn returns (a listen error does). + const warnings = async (fn, late = false) => { + const saved = sails.log.warn; + const lines = []; + sails.log.warn = (...args) => lines.push(args.join(' ')); + try { + const result = await fn(); + for (let i = 0; late && i < 40 && !lines.length; i++) { await new Promise((resolve) => setTimeout(resolve, 50)); } + return {result, lines}; + } finally { + sails.log.warn = saved; + } + }; + + const get = (port, options = {}) => new Promise((resolve, reject) => { + const req = https.request(Object.assign({host: '127.0.0.1', port, path: '/', ca: rootPem(), agent: false}, options), (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(JSON.parse(data))); + }); + req.on('error', reject); + req.end(); + }); + + it('returns null for port 0, and logs nothing', async () => { + const {result, lines} = await warnings(() => startAgentListener(app, {port: 0, pkiDir: fixtureDir()})); + assert.strictEqual(result, null); + assert.deepStrictEqual(lines, []); + }); + + it('returns null and logs one warning when the PKI is missing', async () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'fog-pki-empty-')); + try { + const port = await freePort(); + const {result, lines} = await warnings(() => startAgentListener(app, {port, pkiDir: empty})); + assert.strictEqual(result, null); + assert.strictEqual(lines.length, 1); + assert.ok(lines[0].includes(empty), lines[0]); + } finally { + fs.rmSync(empty, {recursive: true, force: true}); + } + }); + + it('logs one warning that names the port, and does not throw, when the port is busy', async () => { + const busy = net.createServer(); + await new Promise((resolve) => busy.listen(0, resolve)); + const {port} = busy.address(); + let server; + try { + const {result, lines} = await warnings(() => { + server = startAgentListener(app, {port, pkiDir: fixtureDir()}); + return server; + }, true); + assert.ok(result, 'the server object is returned'); + assert.strictEqual(lines.length, 1); + assert.ok(lines[0].includes(String(port)) && lines[0].includes('EADDRINUSE'), lines[0]); + assert.strictEqual(server.listening, false); + } finally { + await new Promise((resolve) => busy.close(resolve)); + } + }); + + // The hook itself, driven by a fake sails: an EventEmitter with the config, + // log and hooks.http.app that the hook reads. Each test calls the 'lifted' + // handler directly, so it can await the lift. + describe('the hook at lift::', () => { + // The fixture's SAN list (test/support/pki.js), so a real lift-time check + // leaves the read-only fixture as it is. + const fakeSails = (custom) => { + const fake = new EventEmitter(); + fake.config = {custom: Object.assign({agentTlsSans: support.SANS}, custom)}; + fake.lines = []; + fake.log = {warn: (...args) => fake.lines.push(args.join(' '))}; + fake.hooks = {http: {app}}; + return fake; + }; + const initHook = (fake) => { + const hook = defineAgentTlsHook(fake); + hook.initialize(); + return hook; + }; + const liftHandler = (fake) => fake.listeners('lifted')[0]; + // Run fn with pkiFiles.ensureServerCert replaced by stub, and put it back. + const stubbed = async (stub, fn) => { + const saved = pkiFiles.ensureServerCert; + pkiFiles.ensureServerCert = stub; + try { + return await fn(); + } finally { + pkiFiles.ensureServerCert = saved; + } + }; + // Close a server the hook kept, whether or not it is listening yet. + const discard = async (server) => { + if (!server) return; + await listening(server).catch(() => {}); + if (server.listening) await stop(server); + }; + + it('starts no listener when lower fires while the lift awaits the server certificate', async () => { + const port = await freePort(); + const fake = fakeSails({agentTlsPort: port, pkiDir: fixtureDir()}); + const hook = initHook(fake); + let release; + await stubbed(() => new Promise((resolve) => { release = resolve; }), async () => { + const lift = liftHandler(fake)(); + fake.emit('lower'); + release(); + await lift; + }); + const kept = hook.server; + await discard(kept); + // Boolean asserts: a failing deepEqual would print the server, key included. + assert.ok(kept === null, 'the hook started a listener after the lower'); + }); + + it('drops the server after a failed listen', async () => { + const busy = net.createServer(); + await new Promise((resolve) => busy.listen(0, resolve)); + const fake = fakeSails({agentTlsPort: busy.address().port, pkiDir: fixtureDir()}); + const hook = initHook(fake); + try { + const {lines} = await warnings(() => stubbed(async () => {}, () => liftHandler(fake)()), true); + assert.ok(lines.length === 1 && lines[0].includes('EADDRINUSE'), lines.join('\n')); + assert.ok(hook.server === null, 'the hook kept a server that failed to listen'); + } finally { + await new Promise((resolve) => busy.close(resolve)); + } + }); + + it('keeps a newer server when an older one fails to listen after it was replaced', async () => { + // Two lifts in one tick: the first on a busy port, the second on a free + // one. The first server's listen error arrives after the second server + // is hook.server, and must not clear it. + const busy = net.createServer(); + await new Promise((resolve) => busy.listen(0, resolve)); + const freeOne = await freePort(); + const fake = fakeSails({agentTlsPort: busy.address().port, pkiDir: fixtureDir()}); + const hook = initHook(fake); + let second; + try { + await warnings(() => stubbed(async () => {}, async () => { + const first = liftHandler(fake)(); + fake.config.custom = Object.assign({}, fake.config.custom, {agentTlsPort: freeOne}); + const next = liftHandler(fake)(); + await Promise.all([first, next]); + second = hook.server; + }), true); + assert.ok(second, 'the second lift made a server'); + assert.ok(hook.server === second, 'the older server\'s listen error cleared the newer server'); + } finally { + await discard(second); + await new Promise((resolve) => busy.close(resolve)); + } + }); + + it('makes no agent CA and starts no listener when agent-ca.pem is missing', async () => { + const dir = support.copy(fixtureDir()); + try { + fs.rmSync(path.join(dir, 'agent-ca.pem')); + const before = fs.readdirSync(dir).sort(); + const fake = fakeSails({agentTlsPort: await freePort(), pkiDir: dir}); + const hook = initHook(fake); + const {lines} = await warnings(() => liftHandler(fake)()); + const kept = hook.server; + await discard(kept); + assert.deepStrictEqual(fs.readdirSync(dir).sort(), before, 'the lift changed the pki folder'); + assert.ok(kept === null, 'the hook started a listener'); + assert.strictEqual(fake.lines.length, 1, fake.lines.join('\n')); + assert.ok(fake.lines[0].includes('npm run setup'), fake.lines[0]); + assert.deepStrictEqual(lines, []); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('logs one warning and starts no listener when root.key and the server pair are missing', async () => { + const dir = support.copy(fixtureDir()); + try { + for (const name of ['root.key', 'server.pem', 'server.key']) fs.rmSync(path.join(dir, name)); + const fake = fakeSails({agentTlsPort: await freePort(), pkiDir: dir}); + const hook = initHook(fake); + // lines: startAgentListener's warnings (global sails); fake.lines: the hook's. + const {lines} = await warnings(() => liftHandler(fake)()); + const kept = hook.server; + await discard(kept); + assert.ok(kept === null, 'the hook started a listener'); + const all = [...fake.lines, ...lines]; + assert.strictEqual(all.length, 1, all.join('\n')); + assert.ok(all[0].includes('root.key'), all[0]); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + }); + + describe('listening::', () => { + let server; + let port; + before(async () => { + port = await freePort(); + server = startAgentListener(app, {port, pkiDir: fixtureDir()}); + assert.ok(server, 'the listener starts'); + await listening(server); + }); + after(() => stop(server)); + + it('serves server.pem, which verifies against the root and names localhost and 127.0.0.1', async () => { + // tls.connect checks the host (an IP here, so the iPAddress entry) against the SAN. + const socket = tls.connect({host: '127.0.0.1', port, ca: rootPem()}); + await new Promise((resolve, reject) => { socket.once('secureConnect', resolve); socket.once('error', reject); }); + try { + assert.strictEqual(socket.authorized, true); + const served = new crypto.X509Certificate(socket.getPeerCertificate().raw); + const file = new crypto.X509Certificate(fs.readFileSync(path.join(fixtureDir(), 'server.pem'))); + assert.strictEqual(served.fingerprint256, file.fingerprint256); + assert.strictEqual(served.subjectAltName, 'DNS:localhost, IP Address:127.0.0.1'); + assert.ok(['TLSv1.2', 'TLSv1.3'].includes(socket.getProtocol())); + } finally { + socket.destroy(); + } + }); + + it('requests a client certificate and never requires one', async () => { + // A browser sends none: the request is answered, with no peer certificate. + assert.deepStrictEqual(await get(port), {authorized: false, peer: false}); + }); + + it('trusts a certificate from the agent CA, and passes it on to the request', async () => { + const c = await agentCsr.newCsr(); + const {leafPem, chainPem} = await sails.helpers.agent.signCsr.with({csrPem: c.pem, hostId: '7'}); + const answer = await get(port, {cert: leafPem + chainPem, key: agentCsr.keyPem(c.keys)}); + assert.deepStrictEqual(answer, {authorized: true, peer: true}); + }); + + it('passes on a certificate from another CA, not authorized, for the policy to refuse', async () => { + const other = await support.foreign(); + const c = await agentCsr.newCsr(); + const saved = sails.config.custom.pkiDir; + let issued; + await support.setRoot(support.rootOf(other)); + sails.config.custom.pkiDir = other; + try { + issued = await sails.helpers.agent.signCsr.with({csrPem: c.pem, hostId: '7'}); + } finally { + sails.config.custom.pkiDir = saved; + await support.setRoot(support.rootOf(fixtureDir())); + } + const answer = await get(port, {cert: issued.leafPem + issued.chainPem, key: agentCsr.keyPem(c.keys)}); + assert.deepStrictEqual(answer, {authorized: false, peer: true}); + }); + }); +}); diff --git a/test/integration/agent-mtls.test.js b/test/integration/agent-mtls.test.js new file mode 100644 index 0000000..97527e9 --- /dev/null +++ b/test/integration/agent-mtls.test.js @@ -0,0 +1,414 @@ +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const path = require('path'); +const redis = require('redis'); +const supertest = require('supertest'); +const x509 = require('@peculiar/x509'); +const pki = require('../../api/lib/agent-pki'); +const agentCsr = require('../support/agent-csr'); +const support = require('../support/pki'); + +// The agent listener and the routes that need the client certificate: the +// principal policy (contract 1.5, 5.4), renew (1.3) and the minimal poll (1.6). +// The requests are Node https requests with a client certificate, to the port +// that test/lifecycle.test.js gives the lift as custom.agentTlsPort. The +// wire contract is FOG 1.6's, so the statuses, keys and strings are exact. +describe('Agent mTLS::', function () { + this.timeout(120000); + + const fixtureDir = support.useFixture(); + const app = () => sails.hooks.http.app; + const AJAX = ['X-Requested-With', 'XMLHttpRequest']; + // Every name this file creates starts with PREFIX, which starts with one of + // the PREFIXES in test/lifecycle.test.js, so the audit rows are removable. + const RUN = crypto.randomBytes(3).toString('hex'); + const PREFIX = 'mcm' + RUN; + let seq = 0; + const uniq = () => PREFIX + (seq++); + const mac = () => '02:' + crypto.randomBytes(5).toString('hex').match(/../g).join(':'); + const bare = (m) => m.replace(/[:-]/g, ''); + const Host = () => sails.models.host; + const fingerprints = []; + const read = (name) => fs.readFileSync(path.join(fixtureDir(), name), 'utf8'); + + // A request to the agent listener, with or without a client certificate. + const mtls = (method, urlPath, {cert, key, body, headers = {}} = {}) => new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = https.request({ + host: '127.0.0.1', + port: sails.config.custom.agentTlsPort, + path: urlPath, + method, + ca: read('root.pem'), + cert, + key, + agent: false, + headers: Object.assign(payload === undefined ? {} : {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload)}, headers) + }, (res) => { + let text = ''; + res.on('data', (chunk) => { text += chunk; }); + res.on('end', () => { + let json; + try { json = JSON.parse(text); } catch (unused) { json = undefined; } + resolve({status: res.statusCode, headers: res.headers, text, json}); + }); + }); + req.on('error', reject); + req.end(payload); + }); + const poll = (a, body = {}) => mtls('POST', '/agent/v1/poll', {cert: a.cert, key: a.key, body}); + const renew = (a, body) => mtls('POST', '/agent/v1/renew', {cert: a.cert, key: a.key, body}); + + // The 401 body of contract 1.5, exact. + const unauthorized = (reason) => ({status: 'unauthorized', reason, error: 'client certificate required'}); + const assert401 = (res, reason) => { + assert.strictEqual(res.status, 401); + assert.match(res.headers['content-type'], /^application\/json/); + assert.deepStrictEqual(res.json, unauthorized(reason)); + }; + + async function newCsr() { + const c = await agentCsr.newCsr(); + fingerprints.push(c.fingerprint); + return c; + } + const enrollBody = (c, m) => ({ + protocol: 1, + agent_version: 'v0.0.1', + os: 'linux', + arch: 'amd64', + hostname: uniq(), + identity: {system_uuid: '', system_serial: '', board_serial: '', chassis_asset: '', macs: m ? [m] : []}, + csr_pem: c.pem + }); + + // Signs in as a browser does (see agent-admin.test.js). + async function signIn(username, password) { + const agent = supertest.agent(app()); + let res = await agent.get('/csrfToken').expect(200); + await agent.post('/login').type('form').set('Accept', 'text/html') + .send({username, password, _csrf: res.body._csrf}) + .expect(302).expect('Location', '/'); + res = await agent.get('/csrfToken').expect(200); + return {agent, csrf: res.body._csrf}; + } + + let admin; + + // An enrolled agent, through the routes: enroll (pending), the admin approve, + // enroll again (the certificate). Returns {c, host, cert, key, agentId}. + async function enrolled() { + const m = mac(); + const host = await Host().create({name: uniq(), macs: [bare(m)], pending: false}).fetch(); + const c = await newCsr(); + const first = await mtls('POST', '/agent/v1/enroll', {body: enrollBody(c, m)}); + assert.strictEqual(first.status, 202, first.text); + const row = await sails.models.agentenrollment.findOne({fingerprint: c.fingerprint}); + await admin.agent.post(`/api/v1/agent/enrollment/${row.id}/approve`).set(...AJAX).expect(200); + const got = await mtls('POST', '/agent/v1/enroll', {body: enrollBody(c, m)}); + assert.strictEqual(got.status, 200, got.text); + return {c, host, cert: got.json.certificate_pem, key: agentCsr.keyPem(c.keys), agentId: got.json.host_id}; + } + + // A leaf for `publicKey` signed by the fixture's agent CA with the given + // dates and extended key usage, then the agent CA: the shape signCsr gives. + async function forgeLeaf(publicKey, {notBefore, notAfter, eku = pki.CLIENT_AUTH}) { + const caPem = read('agent-ca.pem'); + const caKeys = await pki.importPair(crypto.createPrivateKey(read('agent-ca.key')), 'SHA-256'); + const leaf = await x509.X509CertificateGenerator.create({ + serialNumber: pki.randomSerial(), + subject: pki.subjectOf(pki.agentCn('99'), 'FOG Agent'), + issuer: new x509.X509Certificate(caPem).subjectName, + notBefore, + notAfter, + signingAlgorithm: pki.RSA, + publicKey, + signingKey: caKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), + new x509.ExtendedKeyUsageExtension([eku], false) + ] + }); + return leaf.toString('pem') + '\n' + caPem; + } + + // The number of Redis session keys: the sails session store prefix is "sess:". + const sessionCount = () => new Promise((resolve, reject) => { + const client = redis.createClient(sails.config.session.url); + client.on('error', reject); + client.keys('sess:*', (err, keys) => { + client.quit(); + return err ? reject(err) : resolve(keys.length); + }); + }); + + const hostOf = (id) => Host().findOne({id}); + + async function cleanup() { + const rows = await sails.models.agentenrollment.find({fingerprint: fingerprints}); + await Host().destroy({id: rows.map((r) => r.host).filter(Boolean)}); + await Host().destroy({name: {startsWith: PREFIX}}); + await sails.models.agentenrollment.destroy({fingerprint: fingerprints}); + } + + before(async () => { + // The hook starts the listener after the lift, so wait for it. + for (let i = 0; i < 100 && !(sails.hooks['agent-tls'].server && sails.hooks['agent-tls'].server.listening); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(sails.hooks['agent-tls'].server && sails.hooks['agent-tls'].server.listening, 'the agent listener is up'); + await cleanup(); + admin = await signIn('mochatestadmin', 'mochatestadmin'); + }); + after(cleanup); + + describe('the listener::', () => { + it('gives a browser-style request with no client certificate the login page', async () => { + const res = await mtls('GET', '/login', {headers: {Accept: 'text/html'}}); + assert.strictEqual(res.status, 200); + assert.match(res.text, /name="password"/); + }); + + it('serves a certificate that verifies against the root, with the right SAN entry types', async () => { + // https.request above verified it against root.pem for 127.0.0.1 (an IP entry). + const served = new crypto.X509Certificate(read('server.pem')); + assert.strictEqual(served.subjectAltName, 'DNS:localhost, IP Address:127.0.0.1'); + assert.ok(served.verify(new crypto.X509Certificate(read('root.pem')).publicKey)); + }); + }); + + describe('sessions::', () => { + it('a poll and an asset request create no session, and a login request does', async () => { + const a = await enrolled(); + const before = await sessionCount(); + const res = await poll(a); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers['set-cookie'], undefined, 'no cookie'); + const asset = await supertest(app()).get('/styles/importer.css'); + assert.strictEqual(asset.headers['set-cookie'], undefined, 'no cookie for an asset'); + assert.strictEqual(await sessionCount(), before, 'no new sess: key'); + // The counter works: a page that needs a session adds one. + await supertest(app()).get('/csrfToken').expect(200); + assert.strictEqual(await sessionCount(), before + 1); + }); + }); + + describe('agentPrincipal::', () => { + it('no_client_certificate: no certificate', async () => { + assert401(await mtls('POST', '/agent/v1/poll', {body: {}}), 'no_client_certificate'); + }); + + it('no_client_certificate: not on the agent listener (the main port)', async () => { + const res = await supertest(app()).post('/agent/v1/poll').send({}); + assert.strictEqual(res.status, 401); + assert.deepStrictEqual(res.body, unauthorized('no_client_certificate')); + }); + + it('no_client_certificate: a certificate from another CA', async () => { + const other = await support.foreign(); + const c = await newCsr(); + const saved = sails.config.custom.pkiDir; + let issued; + await support.setRoot(support.rootOf(other)); + sails.config.custom.pkiDir = other; + try { + issued = await sails.helpers.agent.signCsr.with({csrPem: c.pem, hostId: '98'}); + } finally { + sails.config.custom.pkiDir = saved; + await support.setRoot(support.rootOf(fixtureDir())); + } + // A host is bound to the key, so only the issuer can be the reason. + await Host().create({name: uniq(), macs: [bare(mac())], pending: false, agentFingerprint: c.fingerprint}); + assert401(await poll({cert: issued.leafPem + issued.chainPem, key: agentCsr.keyPem(c.keys)}), 'no_client_certificate'); + }); + + it('no_client_certificate: an expired leaf', async () => { + const c = await newCsr(); + await Host().create({name: uniq(), macs: [bare(mac())], pending: false, agentFingerprint: c.fingerprint}); + const cert = await forgeLeaf(c.keys.publicKey, {notBefore: new Date(Date.now() - 10 * pki.DAY_MS), notAfter: new Date(Date.now() - pki.DAY_MS)}); + assert401(await poll({cert, key: agentCsr.keyPem(c.keys)}), 'no_client_certificate'); + }); + + it('no_client_certificate: a leaf that is valid but is not for clientAuth', async () => { + const c = await newCsr(); + await Host().create({name: uniq(), macs: [bare(mac())], pending: false, agentFingerprint: c.fingerprint}); + const cert = await forgeLeaf(c.keys.publicKey, { + notBefore: new Date(Date.now() - pki.DAY_MS), notAfter: new Date(Date.now() + pki.DAY_MS), eku: '1.3.6.1.5.5.7.3.1' + }); + assert401(await poll({cert, key: agentCsr.keyPem(c.keys)}), 'no_client_certificate'); + // The control: the same leaf with clientAuth is accepted. + const good = await forgeLeaf(c.keys.publicKey, {notBefore: new Date(Date.now() - pki.DAY_MS), notAfter: new Date(Date.now() + pki.DAY_MS)}); + assert.strictEqual((await poll({cert: good, key: agentCsr.keyPem(c.keys)})).status, 200); + }); + + it('unknown_certificate: an issued key whose host was deleted', async () => { + const a = await enrolled(); + assert.strictEqual((await poll(a)).status, 200); + await Host().destroy({id: a.host.id}); + assert401(await poll(a), 'unknown_certificate'); + }); + + it('unknown_certificate: a host that is still pending', async () => { + const a = await enrolled(); + await Host().updateOne({id: a.host.id}).set({pending: true}); + assert401(await poll(a), 'unknown_certificate'); + }); + + it('ambiguous_certificate: two hosts with the same fingerprint', async () => { + const a = await enrolled(); + await Host().create({name: uniq(), macs: [bare(mac())], pending: false, agentFingerprint: a.c.fingerprint}); + assert401(await poll(a), 'ambiguous_certificate'); + }); + + it('server_error: the host lookup fails', async () => { + const a = await enrolled(); + const model = Host(); + const find = model.find; + model.find = () => { throw new Error('database down'); }; + try { + assert401(await poll(a), 'server_error'); + } finally { + model.find = find; + } + }); + + it('guards renew the same way', async () => { + assert401(await mtls('POST', '/agent/v1/renew', {body: {csr_pem: 'x'}}), 'no_client_certificate'); + }); + }); + + describe('POST /agent/v1/poll', () => { + it('answers 200 with the 1.6 shape, and writes the check-in and the version', async () => { + const a = await enrolled(); + await Host().updateOne({id: a.host.id}).set({agentCheckin: 1, agentVersion: 'old'}); + const res = await poll(a, {agent_version: 'v1.2.3'}); + assert.strictEqual(res.status, 200); + const {server_time: serverTime, poll_interval: interval, ...rest} = res.json; + assert.deepStrictEqual(rest, { + status: 'ok', + protocol: 1, + host: {id: a.agentId, name: a.host.name}, + revision: '', + state: {revision: '', capabilities: []} + }); + assert.strictEqual(typeof interval, 'number'); + // 1.6 format('c'): 2026-11-10T17:06:09+00:00. + assert.match(serverTime, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\+00:00$/); + const after = await hostOf(a.host.id); + assert.ok(after.agentCheckin > Date.now() - 60000); + assert.strictEqual(after.agentVersion, '1.2.3'); + // No version in the body leaves the version as it is. + assert.strictEqual((await poll(a, {})).status, 200); + assert.strictEqual((await hostOf(a.host.id)).agentVersion, '1.2.3'); + }); + + it('takes poll_interval from FOG_CLIENT_CHECKIN_TIME, and defaults to 60', async () => { + const a = await enrolled(); + const name = 'FOG_CLIENT_CHECKIN_TIME'; + const saved = await sails.models.setting.findOne({name}); + try { + await sails.models.setting.destroy({name}); + assert.strictEqual((await poll(a)).json.poll_interval, 60); + await sails.models.setting.create({name, description: 'mocha', value: '45'}); + assert.strictEqual((await poll(a)).json.poll_interval, 45); + // The Go agent's PollInterval is an int and fails to decode 90.5. + await sails.models.setting.update({name}).set({value: '90.5'}); + const fractional = await poll(a); + assert.strictEqual(fractional.status, 200); + assert.strictEqual(fractional.json.poll_interval, 90); + assert.ok(Number.isInteger(fractional.json.poll_interval)); + assert.match(fractional.text, /"poll_interval":\s*90\s*[,}]/, 'the JSON number has no fraction'); + await sails.models.setting.update({name}).set({value: 'soon'}); + assert.strictEqual((await poll(a)).json.poll_interval, 60); + } finally { + await sails.models.setting.destroy({name}); + if (saved) { await sails.models.setting.create(saved); } + } + }); + }); + + describe('POST /agent/v1/renew', () => { + const certs = (pem) => pem.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----\n?/g); + + it('same key: 200 with a new leaf for the same key; both certificates still authenticate', async () => { + const a = await enrolled(); + await Host().updateOne({id: a.host.id}).set({agentNotAfter: '2000-01-01 00:00:00', agentCheckin: 1}); + const res = await renew(a, {csr_pem: a.c.pem}); + assert.strictEqual(res.status, 200, res.text); + assert.deepStrictEqual(Object.keys(res.json).sort(), ['certificate_pem', 'host_id', 'not_after', 'status']); + assert.strictEqual(res.json.status, 'issued'); + assert.strictEqual(res.json.host_id, a.agentId); + assert.match(res.json.not_after, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/); + + const pems = certs(res.json.certificate_pem); + assert.strictEqual(pems.length, 2, 'the leaf, then the agent CA'); + const leaf = new crypto.X509Certificate(pems[0]); + const old = new crypto.X509Certificate(certs(a.cert)[0]); + assert.notStrictEqual(leaf.serialNumber, old.serialNumber, 'a new leaf'); + assert.ok(leaf.subject.split('\n').includes(`CN=fog-agent host ${a.agentId}`)); + assert.strictEqual(sails.helpers.agent.keyFingerprint(pems[0]), a.c.fingerprint); + assert.ok(pki.verifies(pems[0], pems[1], true)); + assert.strictEqual(pems[1], read('agent-ca.pem')); + + const after = await hostOf(a.host.id); + assert.strictEqual(after.agentNotAfter, res.json.not_after); + assert.notStrictEqual(after.agentNotAfter, '2000-01-01 00:00:00'); + assert.ok(after.agentCheckin > Date.now() - 60000); + assert.strictEqual(after.agentFingerprint, a.c.fingerprint, 'the fingerprint does not change'); + + // The 1.6 audit row (Enrollment.php:581-595): exactly one, written by the agent. + const rows = await AuditLog.find({subjectId: a.host.id, text: {startsWith: 'certificate renewed'}}); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].text, `certificate renewed to ${res.json.not_after}, key ${a.c.fingerprint.slice(0, 16)}`); + assert.strictEqual(rows[0].authSource, 'agent'); + assert.deepStrictEqual( + [rows[0].type, rows[0].subjectType, rows[0].subjectLabel, rows[0].renderable, rows[0].outcome, rows[0].createdBy], + ['agent.enroll', 'host', a.host.name, true, 'allowed', '(system)'] + ); + const total = await AuditLog.count({subjectId: a.host.id}); + + // The old certificate stays valid, and so does the new one. + assert.strictEqual((await poll(a)).status, 200); + assert.strictEqual((await poll({cert: res.json.certificate_pem, key: a.key})).status, 200); + // A poll writes no row (1.6 has none). + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.strictEqual(await AuditLog.count({subjectId: a.host.id}), total); + }); + + it('a different key: 400 with the exact error, and the host is unchanged', async () => { + const a = await enrolled(); + const other = await agentCsr.newCsr(); + const before = await hostOf(a.host.id); + const res = await renew(a, {csr_pem: other.pem}); + assert.strictEqual(res.status, 400); + assert.deepStrictEqual(res.json, {status: 'error', error: 'the request is not for the key this certificate proved'}); + const after = await hostOf(a.host.id); + assert.deepStrictEqual([after.agentNotAfter, after.agentFingerprint], [before.agentNotAfter, before.agentFingerprint]); + }); + + it('a bad CSR: 400 with the exact error', async () => { + const a = await enrolled(); + const bad = {status: 'error', error: 'csr_pem is not a certificate request'}; + for (const body of [{}, {csr_pem: 'not a csr'}, {csr_pem: agentCsr.forge(a.c.pem)}, {csr_pem: 7}]) { + const res = await renew(a, body); + assert.strictEqual(res.status, 400); + assert.deepStrictEqual(res.json, bad); + } + }); + + it('signing not configured: 503 with the exact error', async () => { + const a = await enrolled(); + await support.setRoot('00:00'); + try { + const res = await renew(a, {csr_pem: a.c.pem}); + assert.strictEqual(res.status, 503); + assert.deepStrictEqual(res.json, {status: 'error', error: 'agent certificate issuance is not configured'}); + } finally { + await support.setRoot(support.rootOf(fixtureDir())); + } + }); + }); +}); diff --git a/test/lifecycle.test.js b/test/lifecycle.test.js index 2b0ffc6..5f6f54a 100644 --- a/test/lifecycle.test.js +++ b/test/lifecycle.test.js @@ -1,4 +1,6 @@ const sails = require('sails'); +const support = require('./support/pki'); +const {freePort} = require('./support/port'); // The names the suite creates. Every test names its users, hosts, tokens, // attempted sign-in names and agent hostnames with one of the PREFIXES, and @@ -11,15 +13,31 @@ const PREFIXES = ['mocha', 'mc', 'mpa', 'mph', 'testmochauser']; // When the run started: only a row at or after it is the suite's. let startedAt; +// The agent listener (api/hooks/agent-tls) needs a PKI and a free port. 8443 is +// often taken (a dev server, test:ui, test:install), so the suite asks the OS +// for a free port (test/support/port.js). +let pkiDir; +let agentTlsPort; + +// The shared PKI fixture is built before the lift, so the lift-time +// ensureServerCert (with the same SAN list) changes nothing in the read-only +// fixture. RSA-4096 generation is slow, hence the long timeout. +before(async function() { + this.timeout(180000); + startedAt = Date.now(); + pkiDir = await support.fixture(); + agentTlsPort = await freePort(); +}); + before(function(done) { this.timeout(11000); - startedAt = Date.now(); sails.lift({ // These were PORT=1338 and sails_hooks__grunt=false in the npm script. // That syntax fails in Windows cmd, so the options live here instead. port: 1338, hooks: {grunt: false}, - log: {level: 'debug'} + log: {level: 'debug'}, + custom: {pkiDir, agentTlsPort, agentTlsSans: support.SANS} }, (err) => { if (err) return done(err); Role.findOrCreate( diff --git a/test/setup/server-defaults.test.js b/test/setup/server-defaults.test.js index af0d4ad..5c569be 100644 --- a/test/setup/server-defaults.test.js +++ b/test/setup/server-defaults.test.js @@ -1,5 +1,5 @@ const assert = require('assert'); -const {splitList, isWebUrl, toOrigins, defaultUrls, defaultImageStore} = require('../../tools/setup/lib/server-defaults'); +const {splitList, isWebUrl, toOrigins, urlHosts, defaultUrls, defaultImageStore} = require('../../tools/setup/lib/server-defaults'); describe('Installer server defaults ::', () => { it('splits a comma list and drops empty entries', () => { @@ -36,6 +36,11 @@ describe('Installer server defaults ::', () => { ['http://fogserver:1337', 'http://localhost:1337', 'http://10.0.0.5:1337', 'http://192.168.1.9:1337']); }); + // The server certificate of the agent listener names the host of each URL. + it('takes the host of each URL, with no port and no IPv6 brackets', () => { + assert.deepStrictEqual(urlHosts(['http://fog.example:1337', 'https://10.0.0.5', 'http://[::1]:1337']), ['fog.example', '10.0.0.5', '::1']); + }); + // The macOS root volume is read-only, so /images cannot be created there. it('picks an image directory that each OS can create', () => { assert.strictEqual(defaultImageStore('win32'), 'C:\\fog\\images'); diff --git a/test/support/agent-csr.js b/test/support/agent-csr.js index 46defe1..12f8e4b 100644 --- a/test/support/agent-csr.js +++ b/test/support/agent-csr.js @@ -24,4 +24,7 @@ const forge = (pem) => { return `-----BEGIN CERTIFICATE REQUEST-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE REQUEST-----\n`; }; -module.exports = {newKey, csrFor, newCsr, forge}; +// The private key of a key pair as PKCS#8 PEM, for https.request({key}). +const keyPem = (keys) => crypto.KeyObject.from(keys.privateKey).export({type: 'pkcs8', format: 'pem'}); + +module.exports = {newKey, csrFor, newCsr, forge, keyPem}; diff --git a/test/support/pki.js b/test/support/pki.js index a5f6354..9082e47 100644 --- a/test/support/pki.js +++ b/test/support/pki.js @@ -1,7 +1,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const {ensurePki} = require('../../tools/setup/pki'); +const {ensurePki} = require('../../api/lib/agent-pki-files'); const {ROOT_SETTING, ROOT_SETTING_DESCRIPTION, fingerprintOf} = require('../../api/lib/agent-pki'); // One PKI for the whole mocha run. RSA-4096 generation is slow, so the tests @@ -12,14 +12,20 @@ let shared; // root. Made on first use only, for the same reason. let foreignPki; -const make = (prefix) => { +const make = (prefix, sans) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - return ensurePki(dir).then(() => dir); + return ensurePki(dir, {sans}).then(() => dir); }; -// The shared PKI folder (a promise of its path). The first call makes it. +// The SAN list of the fixture's server certificate. test/lifecycle.test.js +// gives the lift this list as custom.agentTlsSans, so the lift-time +// ensureServerCert changes nothing in the fixture. +const SANS = ['localhost', '127.0.0.1']; + +// The shared PKI folder (a promise of its path). The first call makes it. Its +// server certificate names SANS only. const fixture = () => { - if (!shared) shared = make('fog-pki-fixture-'); + if (!shared) shared = make('fog-pki-fixture-', SANS); return shared; }; @@ -78,4 +84,4 @@ const copy = (dir) => { return to; }; -module.exports = {fixture, foreign, useFixture, copy, rootOf, setRoot}; +module.exports = {SANS, fixture, foreign, useFixture, copy, rootOf, setRoot}; diff --git a/test/support/port.js b/test/support/port.js new file mode 100644 index 0000000..0c714d7 --- /dev/null +++ b/test/support/port.js @@ -0,0 +1,15 @@ +const net = require('net'); + +// A free TCP port: listen on port 0, read the port the OS chose, close. +// 8443 and other fixed ports are often taken (a dev server, test:ui, +// test:install), so the tests that need a listener ask the OS instead. +const freePort = () => new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.on('error', reject); + probe.listen(0, () => { + const {port} = probe.address(); + probe.close(() => resolve(port)); + }); +}); + +module.exports = {freePort}; diff --git a/tools/setup/dev.js b/tools/setup/dev.js index 5a455af..146e418 100644 --- a/tools/setup/dev.js +++ b/tools/setup/dev.js @@ -87,6 +87,11 @@ writeIfMissing(localCfg, 'config/local.js', `module.exports = { } } }, + // The fog-agent HTTPS port (api/hooks/agent-tls). The certificate names are + // the default list: localhost, this computer and its addresses. + custom: { + agentTlsPort: 8443 + }, datastores: { fogdb: { adapter: 'sails-mongo', diff --git a/tools/setup/index.js b/tools/setup/index.js index f76e97e..0aad389 100644 --- a/tools/setup/index.js +++ b/tools/setup/index.js @@ -12,6 +12,8 @@ const schema = require('./lib/schema'); const seedDemo = require('./seed-demo'); const secure = require('./lib/secure'); const pki = require('./pki'); +const {cleanSans, defaultSans} = require('../../api/lib/agent-pki-files'); +const {splitList, urlHosts} = require('./lib/server-defaults'); const async = require('async'); const CLI = require('clui'); const Spinner = CLI.Spinner; @@ -165,6 +167,9 @@ async.waterfall([ return next(`config/local.js exists but cannot be read, so its pkiDir cannot be kept. Fix or remove it, then re-run: ${err.message}`); } payload.pkiDir = pkiOverride || pki.pkiDirFor(config.appPath); + // The server certificate of the agent listener names this computer and + // the host of each URL that the installer was given. + payload.agentTlsSans = cleanSans([...defaultSans(), ...urlHosts(splitList(payload.server.urls))]); const pkiLines = pkiOverride ? `, pkiDir: '${jsStr(pkiOverride)}'` : ''; // A re-run also keeps an http.trustProxy that the old local.js set: behind @@ -219,7 +224,11 @@ async.waterfall([ onlyAllowOrigins: [${payload.server.origins.map((o) => `'${jsStr(o)}'`).join(', ')}] },${httpLines} custom: { - imageStorePath: '${jsStr(payload.server.imageStorePath)}'${pkiLines} + imageStorePath: '${jsStr(payload.server.imageStorePath)}', + // The fog-agent HTTPS port (0 = off) and the names and addresses in the + // certificate that it serves. Re-run npm run setup to change them. + agentTlsPort: ${payload.server.agentTlsPort}, + agentTlsSans: [${payload.agentTlsSans.map((s) => `'${jsStr(s)}'`).join(', ')}]${pkiLines} }, datastores: { fogdb: { @@ -269,7 +278,8 @@ async.waterfall([ newRoot: process.argv.includes('--new-agent-root'), settings: sails.models.setting, log: console.log, - command: 'setup' + command: 'setup', + sans: payload.agentTlsSans }).then( () => { console.log(`Agent CA files are in ${payload.pkiDir}`); diff --git a/tools/setup/lib/inquire.js b/tools/setup/lib/inquire.js index c03ffdc..acecb74 100644 --- a/tools/setup/lib/inquire.js +++ b/tools/setup/lib/inquire.js @@ -178,6 +178,17 @@ module.exports = { return `fog-node cannot write to that directory: ${err.message}`; } } + }, + { + name: 'agentTlsPort', + type: 'number', + message: 'Enter the HTTPS port for fog-agent (mTLS), or 0 to turn it off:', + default: 8443, + required: true, // See the database port question. + validate: (value) => { + if (!Number.isInteger(value) || value < 0 || value > 65535) return 'You must enter a port number from 0 to 65535'; + return true; + } } ]; prompt('server', questions).then((answers) => { diff --git a/tools/setup/lib/server-defaults.js b/tools/setup/lib/server-defaults.js index 500acec..ba953df 100644 --- a/tools/setup/lib/server-defaults.js +++ b/tools/setup/lib/server-defaults.js @@ -19,6 +19,11 @@ const isWebUrl = (value) => { // repeats. const toOrigins = (urls) => [...new Set(urls.map((v) => new URL(v).origin))]; +// The host of each URL, for the server certificate of the agent listener. The +// URL parser keeps the brackets of an IPv6 host; the certificate lists the +// address alone. +const urlHosts = (urls) => urls.map((v) => new URL(v).hostname.replace(/^\[|\]$/g, '')); + // This computer's name, localhost, and each external IPv4 address, on // fog-node's default port. FOG admins often browse by IP, so the IPs are in // the default. Node 18.0-18.3 reports `family` as the number 4. @@ -36,4 +41,4 @@ const defaultImageStore = (platform = process.platform) => { return '/images'; }; -module.exports = {splitList, isWebUrl, toOrigins, defaultUrls, defaultImageStore}; +module.exports = {splitList, isWebUrl, toOrigins, urlHosts, defaultUrls, defaultImageStore}; diff --git a/tools/setup/pki.js b/tools/setup/pki.js index 3f33775..0cbbbf9 100644 --- a/tools/setup/pki.js +++ b/tools/setup/pki.js @@ -1,159 +1,16 @@ /** - * The fog-agent certificate authorities: a root ("FOG Server CA") and the - * agent intermediate ("FOG Agent CA") that signs the agent client certificates. - * The parameters match FOG 1.6 (lib/common/functions.sh), so one agent binary - * works against 1.6 and fog-node. - * - * Files in the PKI folder (custom.pkiDir): - * root.key, root.pem the root CA - * agent-ca.key, agent-ca.pem the agent CA, issued by the root - * agent-ca-bundle.pem agent CA, then root - * The folder is made with mode 0o700 and each key file ends at 0o600, also - * when it overwrites an older file. Windows ignores these modes. - * - * Certificates use @peculiar/x509 (it also builds and verifies CSRs). It signs - * through WebCrypto, where an imported RSA key carries its hash. So the root - * key is imported with SHA-512 and the agent CA key with SHA-256. - * The shared crypto pieces are in api/lib/agent-pki.js. + * The setup step for the fog-agent certificate authorities. Making the CA and + * server certificate files (ensurePki) is in api/lib/agent-pki-files.js, + * because the agent-tls hook uses it at lift too. This file adds the root + * guard, the Setting that records the root, and the pkiDir lookup. */ -const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const x509 = require('@peculiar/x509'); const shared = require('../../api/lib/agent-pki'); +const files = require('../../api/lib/agent-pki-files'); -const DAYS = 10950; - -const newKey = () => crypto.generateKeyPairSync('rsa', {modulusLength: 4096}).privateKey; -const keyPem = (key) => key.export({type: 'pkcs8', format: 'pem'}); - -const makeRoot = async () => { - const key = newKey(); - const keys = await shared.importPair(key, 'SHA-512'); - const cert = await x509.X509CertificateGenerator.createSelfSigned({ - serialNumber: shared.randomSerial(), - name: shared.subjectOf('FOG Server CA', 'FOG Root CA'), - notBefore: new Date(), - notAfter: new Date(Date.now() + DAYS * shared.DAY_MS), - signingAlgorithm: shared.RSA, - keys, - extensions: [ - new x509.BasicConstraintsExtension(true, undefined, true), - new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), - await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) - ] - }); - return {key: keyPem(key), cert: cert.toString('pem')}; -}; - -const makeAgentCa = async (rootKeyPem, rootCertPem) => { - const key = newKey(); - const keys = await shared.importPair(key, 'SHA-512'); - // The root key signs this certificate, so it is imported with SHA-512. - const rootKeys = await shared.importPair(crypto.createPrivateKey(rootKeyPem), 'SHA-512'); - const cert = await x509.X509CertificateGenerator.create({ - serialNumber: shared.randomSerial(), - subject: shared.subjectOf('FOG Agent CA', 'FOG Agent'), - issuer: new x509.X509Certificate(rootCertPem).subjectName, - notBefore: new Date(), - notAfter: new Date(Date.now() + DAYS * shared.DAY_MS), - signingAlgorithm: shared.RSA, - publicKey: keys.publicKey, - signingKey: rootKeys.privateKey, - extensions: [ - new x509.BasicConstraintsExtension(true, 0, true), - new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), - await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), - new x509.ExtendedKeyUsageExtension([shared.CLIENT_AUTH], false) - ] - }); - return {key: keyPem(key), cert: cert.toString('pem')}; -}; - -const exists = (file) => fs.existsSync(file); -// The mode option applies only to a new file, so chmod makes an overwritten key 0600 too. -// Windows has no POSIX modes: there chmod may fail, and that is fine. -const writeKey = (file, pem) => { - fs.writeFileSync(file, pem, {mode: 0o600}); - try { - fs.chmodSync(file, 0o600); - } catch (err) { - if (process.platform !== 'win32') throw err; - } -}; -// toString('pem') has no final newline. Without it the bundle would run the -// agent CA's END line into the root's BEGIN line. -const writeCert = (file, pem) => fs.writeFileSync(file, pem.endsWith('\n') ? pem : pem + '\n'); - -// UTC YYYYMMDDHHMMSS: the suffix of a file that a new one replaces. -const stamp = () => new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); - -const {fingerprintOf, ROOT_SETTING, ROOT_SETTING_DESCRIPTION} = shared; - -// An error that the setup tools recognize by its code. -const rootError = (code, message) => Object.assign(new Error(message), {code}); -// Is it one of the two guard errors? The tools print its message and stop. -const isRootError = (err) => Boolean(err) && (err.code === 'E_AGENT_ROOT_MISSING' || err.code === 'E_AGENT_ROOT_MISMATCH'); - -/** - * Make the CA files that are missing. As 1.6: a certificate file is what - * "exists" means. - * - root.pem missing: make root.key and root.pem. An existing root.key is - * renamed with a timestamp first. An existing root.pem is never re-made, - * even when root.key is missing. - * - agent-ca.pem missing, or not verifying against root.pem: re-mint the agent - * CA with root.key. The old agent-ca.pem and agent-ca.key get a timestamp - * suffix first. A re-mint with root.key missing throws. - * - agent-ca-bundle.pem is brought up to date on every call. It is written - * only when its text differs, so a second call changes no file. - * - opts.expectedRoot (a fingerprint256 string) guards the root. A missing - * root.pem throws E_AGENT_ROOT_MISSING, and a root.pem with another - * fingerprint throws E_AGENT_ROOT_MISMATCH. Both throw before any file is - * written. Without it, the rules above apply unchanged. - * Returns {rootFingerprint}, the fingerprint of root.pem after the call. - */ -const ensurePki = async (dir, opts = {}) => { - const {expectedRoot} = opts; - const f = (name) => path.join(dir, name); - if (expectedRoot) { - if (!exists(f('root.pem'))) { - throw rootError('E_AGENT_ROOT_MISSING', `The agent root ${f('root.pem')} is missing, but the database records the root ${expectedRoot}.`); - } - const onDisk = fingerprintOf(fs.readFileSync(f('root.pem'), 'utf8')); - if (onDisk !== expectedRoot) { - throw rootError('E_AGENT_ROOT_MISMATCH', `The agent root ${f('root.pem')} has the fingerprint ${onDisk}, but the database records the root ${expectedRoot}.`); - } - } - fs.mkdirSync(dir, {recursive: true, mode: 0o700}); - - if (!exists(f('root.pem'))) { - if (exists(f('root.key'))) fs.renameSync(f('root.key'), f(`root.key.${stamp()}`)); - const root = await makeRoot(); - writeKey(f('root.key'), root.key); - writeCert(f('root.pem'), root.cert); - } - const rootPem = fs.readFileSync(f('root.pem'), 'utf8'); - - if (!exists(f('agent-ca.pem')) || !shared.verifies(fs.readFileSync(f('agent-ca.pem'), 'utf8'), rootPem)) { - if (!exists(f('root.key'))) { - throw new Error(`${f('agent-ca.pem')} is missing or not issued by ${f('root.pem')}, and ${f('root.key')} is missing, so the agent CA cannot be re-made.`); - } - const suffix = stamp(); - for (const name of ['agent-ca.pem', 'agent-ca.key']) { - if (exists(f(name))) fs.renameSync(f(name), f(`${name}.${suffix}`)); - } - const ca = await makeAgentCa(fs.readFileSync(f('root.key'), 'utf8'), rootPem); - writeKey(f('agent-ca.key'), ca.key); - writeCert(f('agent-ca.pem'), ca.cert); - } - - // Both files end in a newline (writeCert), so the join is clean. - const bundle = fs.readFileSync(f('agent-ca.pem'), 'utf8') + rootPem; - if (!exists(f('agent-ca-bundle.pem')) || fs.readFileSync(f('agent-ca-bundle.pem'), 'utf8') !== bundle) { - writeCert(f('agent-ca-bundle.pem'), bundle); - } - return {rootFingerprint: fingerprintOf(rootPem)}; -}; +const {ensurePki, isRootError, rootError, stamp, exists} = files; +const {ROOT_SETTING, ROOT_SETTING_DESCRIPTION} = shared; /** * The setup step for the agent CAs. It records the root fingerprint in the @@ -162,10 +19,11 @@ const ensurePki = async (dir, opts = {}) => { * - newRoot: true after --new-agent-root. With a recorded root, the four CA * files that exist get a timestamp suffix, so ensurePki makes new CAs. * - command: 'setup' or 'setup:dev', the npm script that the messages name. + * - sans: the SAN list of the server certificate (ensurePki). Unset: the default list. * Returns {rootFingerprint}. A refusal throws an error that keeps its code * (E_AGENT_ROOT_MISSING or E_AGENT_ROOT_MISMATCH) and has the full message. */ -const setupPki = async ({pkiDir, newRoot, settings, log, command}) => { +const setupPki = async ({pkiDir, newRoot, settings, log, command, sans}) => { const record = await settings.findOne({name: ROOT_SETTING}); const recorded = record ? record.value : undefined; const replace = Boolean(newRoot && recorded); @@ -180,7 +38,7 @@ const setupPki = async ({pkiDir, newRoot, settings, log, command}) => { let result; try { - result = await ensurePki(pkiDir, {expectedRoot: replace ? undefined : recorded}); + result = await ensurePki(pkiDir, {expectedRoot: replace ? undefined : recorded, sans}); } catch (err) { if (!isRootError(err)) throw err; throw rootError(err.code, `${err.message}\n` + @@ -213,4 +71,4 @@ const overrideDir = (appPath) => { // The folder the setup tools use: the local.js override, else /pki as config/custom.js. const pkiDirFor = (appPath) => overrideDir(appPath) || path.join(appPath, 'pki'); -module.exports = {ensurePki, setupPki, isRootError, overrideDir, pkiDirFor}; +module.exports = {setupPki, isRootError, overrideDir, pkiDirFor};