diff --git a/package.json b/package.json index 1457c183cc..0a5b077f81 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@hyperledger-labs/cactus", "private": true, "scripts": { + "run-ci": "./tools/ci.sh", "configure": "lerna clean --yes && lerna bootstrap && npm-run-all build generate-api-server-config", "generate-api-server-config": "node ./tools/generate-api-server-config.js", "start:api-server": "node ./packages/cactus-cmd-api-server/dist/lib/main/typescript/cmd/cactus-api.js --config-file=.config.json", diff --git a/packages/cactus-cmd-api-server/package-lock.json b/packages/cactus-cmd-api-server/package-lock.json index eac2ae8f41..53582ece5e 100644 --- a/packages/cactus-cmd-api-server/package-lock.json +++ b/packages/cactus-cmd-api-server/package-lock.json @@ -77,6 +77,15 @@ "@types/serve-static": "*" } }, + "@types/express-http-proxy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@types/express-http-proxy/-/express-http-proxy-1.6.1.tgz", + "integrity": "sha512-FjuKVtGaT3ccHD7uFr7vKDsn3shEEc/Upo2YnVsTfoDPuUbCV/GIsinG7gbrkzcIYELqh+8hYmn/rEfqMQA/9g==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/express-serve-static-core": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.7.tgz", @@ -115,6 +124,15 @@ "integrity": "sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ==", "dev": true }, + "@types/node-forge": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-0.9.3.tgz", + "integrity": "sha512-2ARlg50tba1Ps3Jg/D416LEWo9TxVACfuZLNy8GvLiggndLxxfUBz8OyeZZsE9JIF6r8AOJrcaKS3O/5NVhQlA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.2.tgz", @@ -475,6 +493,11 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -527,6 +550,31 @@ "vary": "~1.1.2" } }, + "express-http-proxy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-1.6.0.tgz", + "integrity": "sha512-7Re6Lepg96NA2wiv7DC5csChAScn4K76/UgYnC71XiITCT1cgGTJUGK6GS0pIixudg3Fbx3Q6mmEW3mZv5tHFQ==", + "requires": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "express-openapi-validator": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-3.10.0.tgz", @@ -828,6 +876,11 @@ "fetch-blob": "^1.0.5" } }, + "node-forge": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + }, "node-gyp-build": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.1.tgz", diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index 9a4cf5a805..3f8da66987 100755 --- a/packages/cactus-cmd-api-server/package.json +++ b/packages/cactus-cmd-api-server/package.json @@ -74,10 +74,12 @@ "convict-format-with-validator": "6.0.0", "cors": "2.8.5", "express": "4.17.1", + "express-http-proxy": "1.6.0", "express-openapi-validator": "3.10.0", "joi": "14.3.1", "js-sha3": "0.8.0", "node-fetch": "3.0.0-beta.4", + "node-forge": "0.9.1", "secp256k1": "4.0.0", "semver": "7.3.2", "sha3": "2.1.2", @@ -89,8 +91,10 @@ "@types/convict": "5.2.1", "@types/cors": "2.8.6", "@types/express": "4.17.6", + "@types/express-http-proxy": "1.6.1", "@types/joi": "14.3.4", "@types/multer": "1.4.2", + "@types/node-forge": "0.9.3", "@types/secp256k1": "3.5.3", "@types/semver": "7.3.1", "@types/uuid": "7.0.2" diff --git a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts index bc3bea5a76..72b1dd89de 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts @@ -1,6 +1,11 @@ import path from "path"; -import { Server } from "http"; import { gte } from "semver"; +import { AddressInfo } from "net"; +import tls from "tls"; +import { Server, createServer } from "http"; +import { Server as SecureServer } from "https"; +import { createServer as createSecureServer } from "https"; +import expressHttpProxy from "express-http-proxy"; import express, { Express, Request, @@ -29,14 +34,16 @@ import { Servers } from "./common/servers"; export interface IApiServerConstructorOptions { pluginRegistry?: PluginRegistry; + httpServerApi?: Server | SecureServer; + httpServerCockpit?: Server | SecureServer; config: ICactusApiServerConfig; } export class ApiServer { private readonly log: Logger; private pluginRegistry: PluginRegistry | undefined; - private httpServerApi: Server | null = null; - private httpServerCockpit: Server | null = null; + private readonly httpServerApi: Server | SecureServer; + private readonly httpServerCockpit: Server | SecureServer; constructor(public readonly options: IApiServerConstructorOptions) { if (!options) { @@ -46,21 +53,71 @@ export class ApiServer { throw new Error(`ApiServer#ctor options.config was falsy`); } + LoggerProvider.setLogLevel(options.config.logLevel); + + if (this.options.httpServerApi) { + this.httpServerApi = this.options.httpServerApi; + } else if (this.options.config.apiTlsEnabled) { + this.httpServerApi = createSecureServer({ + key: this.options.config.apiTlsKeyPem, + cert: this.options.config.apiTlsCertPem, + }); + } else { + this.httpServerApi = createServer(); + } + + if (this.options.httpServerCockpit) { + this.httpServerCockpit = this.options.httpServerCockpit; + } else if (this.options.config.cockpitTlsEnabled) { + this.httpServerCockpit = createSecureServer({ + key: this.options.config.cockpitTlsKeyPem, + cert: this.options.config.cockpitTlsCertPem, + }); + } else { + this.httpServerCockpit = createServer(); + } + this.log = LoggerProvider.getOrCreate({ label: "api-server", level: options.config.logLevel, }); } - async start(): Promise { + async start(): Promise { this.checkNodeVersion(); + const tlsMaxVersion = this.options.config.tlsDefaultMaxVersion; + this.log.info("Setting tls.DEFAULT_MAX_VERSION to %s...", tlsMaxVersion); + tls.DEFAULT_MAX_VERSION = tlsMaxVersion; + try { - await this.startCockpitFileServer(); - await this.startApiServer(); + const { cockpitTlsEnabled, apiTlsEnabled } = this.options.config; + const addressInfoCockpit = await this.startCockpitFileServer(); + const addressInfoApi = await this.startApiServer(); + + { + const { apiHost: host } = this.options.config; + const { port } = addressInfoApi; + const protocol = apiTlsEnabled ? "https:" : "http:"; + const httpUrl = `${protocol}//${host}:${port}`; + this.log.info(`Cactus API reachable ${httpUrl}`); + } + + { + const { cockpitHost: host } = this.options.config; + const { port } = addressInfoCockpit; + const protocol = cockpitTlsEnabled ? "https:" : "http:"; + const httpUrl = `${protocol}//${host}:${port}`; + this.log.info(`Cactus Cockpit reachable ${httpUrl}`); + } + + return { addressInfoCockpit, addressInfoApi }; } catch (ex) { - this.log.error(`Failed to start ApiServer: ${ex.stack}`); + const errorMessage = `Failed to start ApiServer: ${ex.stack}`; + this.log.error(errorMessage); this.log.error(`Attempting shutdown...`); await this.shutdown(); + this.log.info(`Server shut down OK`); + throw new Error(errorMessage); } } @@ -83,11 +140,11 @@ export class ApiServer { } } - public getHttpServerApi(): Server | null { + public getHttpServerApi(): Server | SecureServer { return this.httpServerApi; } - public getHttpServerCockpit(): Server | null { + public getHttpServerCockpit(): Server | SecureServer { return this.httpServerCockpit; } @@ -106,12 +163,12 @@ export class ApiServer { public async initPluginRegistry(): Promise { const registry = new PluginRegistry({ plugins: [] }); - + const { logLevel } = this.options.config; this.log.info(`Instantiated empty registry, invoking plugin factories...`); for (const pluginImport of this.options.config.plugins) { const { packageName, options } = pluginImport; this.log.info(`Creating plugin from package: ${packageName}`, options); - const pluginOptions = { ...options, pluginRegistry: registry }; + const pluginOptions = { ...options, logLevel, pluginRegistry: registry }; const { createPluginFactory } = await import(packageName); const pluginFactory: PluginFactory< ICactusPlugin, @@ -126,7 +183,9 @@ export class ApiServer { public async shutdown(): Promise { this.log.info(`Shutting down API server ...`); + const registry = await this.getOrInitPluginRegistry(); + const webServicesShutdown = registry .getPlugins() .filter((pluginInstance) => isIPluginWebService(pluginInstance)) @@ -151,7 +210,7 @@ export class ApiServer { } } - async startCockpitFileServer(): Promise { + async startCockpitFileServer(): Promise { const cockpitWwwRoot = this.options.config.cockpitWwwRoot; this.log.info(`wwwRoot: ${cockpitWwwRoot}`); @@ -161,29 +220,70 @@ export class ApiServer { const resolvedIndexHtml = path.resolve(resolvedWwwRoot + "/index.html"); this.log.info(`resolvedIndexHtml: ${resolvedIndexHtml}`); + const cockpitCorsDomainCsv = this.options.config.cockpitCorsDomainCsv; + const allowedDomains = cockpitCorsDomainCsv.split(","); + const corsMiddleware = this.createCorsMiddleware(allowedDomains); + + const { + apiHost, + apiPort, + cockpitApiProxyRejectUnauthorized: rejectUnauthorized, + } = this.options.config; + const protocol = this.options.config.apiTlsEnabled ? "https:" : "http:"; + const apiHttpUrl = `${protocol}//${apiHost}:${apiPort}`; + + const apiProxyMiddleware = expressHttpProxy(apiHttpUrl, { + // preserve the path whatever it was. Without this the proxy just uses / + proxyReqPathResolver: (srcReq) => srcReq.originalUrl, + + proxyReqOptDecorator: (proxyReqOpts, srcReq) => { + const { originalUrl: thePath } = srcReq; + const srcHost = srcReq.header("host"); + const { host: destHostname, port: destPort } = proxyReqOpts; + const destHost = `${destHostname}:${destPort}`; + this.log.debug(`PROXY ${srcHost} => ${destHost} :: ${thePath}`); + + // make sure self signed certs are accepted if it was configured as such by the user + (proxyReqOpts as any).rejectUnauthorized = rejectUnauthorized; + return proxyReqOpts; + }, + }); + const app: Express = express(); + app.use("/api/v*", apiProxyMiddleware); app.use(compression()); + app.use(corsMiddleware); app.use(express.static(resolvedWwwRoot)); app.get("/*", (_, res) => res.sendFile(resolvedIndexHtml)); const cockpitPort: number = this.options.config.cockpitPort; const cockpitHost: string = this.options.config.cockpitHost; - await new Promise((resolve, reject) => { - this.httpServerCockpit = app.listen(cockpitPort, cockpitHost, () => { - const httpUrl = `http://${cockpitHost}:${cockpitPort}`; - this.log.info(`Cactus Cockpit UI reachable ${httpUrl}`); - resolve({ cockpitPort }); + if (!this.httpServerCockpit.listening) { + await new Promise((resolve, reject) => { + this.httpServerCockpit.once("error", reject); + this.httpServerCockpit.once("listening", resolve); + this.httpServerCockpit.listen(cockpitPort, cockpitHost); }); - this.httpServerCockpit.on("error", (err: any) => reject(err)); - }); + } + this.httpServerCockpit.on("request", app); + + // the address() method returns a string for unix domain sockets and null + // if the server is not listening but we don't car about any of those cases + // so the casting here should be safe. Famous last words... I know. + const addressInfo = this.httpServerCockpit.address() as AddressInfo; + this.log.info(`Cactus Cockpit net.AddressInfo`, addressInfo); + + return addressInfo; } - async startApiServer(): Promise { + async startApiServer(): Promise { const app: Application = express(); app.use(compression()); - const corsMiddleware = this.createCorsMiddleware(); + const apiCorsDomainCsv = this.options.config.apiCorsDomainCsv; + const allowedDomains = apiCorsDomainCsv.split(","); + const corsMiddleware = this.createCorsMiddleware(allowedDomains); app.use(corsMiddleware); app.use(bodyParser.json({ limit: "50mb" })); @@ -211,25 +311,31 @@ export class ApiServer { return (pluginInstance as IPluginWebService).installWebServices(app); }); - await Promise.all(webServicesInstalled); - this.log.info(`Installed ${webServicesInstalled.length} web services OK`); + const endpoints2D = await Promise.all(webServicesInstalled); + this.log.info(`Installed ${webServicesInstalled.length} web service(s) OK`); + + const endpoints = endpoints2D.reduce((acc, val) => acc.concat(val), []); + endpoints.forEach((ep) => this.log.info(`Endpoint={path=${ep.getPath()}}`)); const apiPort: number = this.options.config.apiPort; const apiHost: string = this.options.config.apiHost; - this.log.info(`Binding Cactus API to port ${apiPort}...`); - await new Promise((resolve, reject) => { - const httpServerApi = app.listen(apiPort, apiHost, () => { - const address: any = httpServerApi.address(); - this.log.info(`Successfully bound API to port ${apiPort}`, { address }); - if (address && address.port) { - resolve({ port: address.port }); - } else { - resolve({ port: apiPort }); - } + + if (!this.httpServerApi.listening) { + await new Promise((resolve, reject) => { + this.httpServerApi.once("error", reject); + this.httpServerApi.once("listening", resolve); + this.httpServerApi.listen(apiPort, apiHost); }); - this.httpServerApi = httpServerApi; - this.httpServerApi.on("error", (err) => reject(err)); - }); + } + this.httpServerApi.on("request", app); + + // the address() method returns a string for unix domain sockets and null + // if the server is not listening but we don't car about any of those cases + // so the casting here should be safe. Famous last words... I know. + const addressInfo = this.httpServerApi.address() as AddressInfo; + this.log.info(`Cactus API net.AddressInfo`, addressInfo); + + return addressInfo; } createOpenApiValidator(): OpenApiValidator { @@ -240,23 +346,24 @@ export class ApiServer { }); } - createCorsMiddleware(): RequestHandler { - const apiCorsDomainCsv = this.options.config.apiCorsDomainCsv; - const allowedDomains = apiCorsDomainCsv.split(","); - const allDomainsAllowed = allowedDomains.includes("*"); - - const corsOptions: CorsOptions = { - origin: (origin: string | undefined, callback) => { - if ( - allDomainsAllowed || - (origin && allowedDomains.indexOf(origin) !== -1) - ) { - callback(null, true); - } else { - callback(new Error(`CORS not allowed for Origin "${origin}".`)); - } - }, + createCorsMiddleware(allowedDomains: string[]): RequestHandler { + const allDomainsOk = allowedDomains.includes("*"); + + const corsOptionsDelegate = (req: Request, callback: any) => { + const origin = req.header("Origin"); + const isDomainOk = origin && allowedDomains.includes(origin); + // this.log.debug("CORS %j %j %s", allDomainsOk, isDomainOk, req.originalUrl); + + let corsOptions; + if (allDomainsOk) { + corsOptions = { origin: "*" }; // reflect (enable) the all origins in the CORS response + } else if (isDomainOk) { + corsOptions = { origin }; // reflect (enable) the requested origin in the CORS response + } else { + corsOptions = { origin: false }; // disable CORS for this request + } + callback(null, corsOptions); // callback expects two parameters: error and options }; - return cors(corsOptions); + return cors(corsOptionsDelegate); } } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts index 15d3cbf625..3a77fffadf 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts @@ -1,4 +1,6 @@ import { randomBytes } from "crypto"; +import { SecureVersion } from "tls"; +import { existsSync, readFileSync } from "fs"; import convict, { Schema, Config, SchemaObj } from "convict"; import { ipaddress } from "convict-format-with-validator"; import secp256k1 from "secp256k1"; @@ -9,6 +11,7 @@ import { LogLevelDesc, } from "@hyperledger/cactus-common"; import { FORMAT_PLUGIN_ARRAY } from "./convict-plugin-array-format"; +import { SelfSignedPkiGenerator, IPki } from "./self-signed-pki-generator"; convict.addFormat(FORMAT_PLUGIN_ARRAY); convict.addFormat(ipaddress); @@ -22,12 +25,25 @@ export interface ICactusApiServerOptions { configFile: string; cactusNodeId: string; logLevel: LogLevelDesc; + tlsDefaultMaxVersion: SecureVersion; cockpitHost: string; cockpitPort: number; + cockpitCorsDomainCsv: string; + cockpitApiProxyRejectUnauthorized: boolean; + cockpitTlsEnabled: boolean; + cockpitMtlsEnabled: boolean; cockpitWwwRoot: string; + cockpitTlsCertPem: string; + cockpitTlsKeyPem: string; + cockpitTlsClientCaPem: string; apiHost: string; apiPort: number; apiCorsDomainCsv: string; + apiTlsEnabled: boolean; + apiMtlsEnabled: boolean; + apiTlsCertPem: string; + apiTlsKeyPem: string; + apiTlsClientCaPem: string; plugins: IPluginImport[]; publicKey: string; privateKey: string; @@ -119,6 +135,23 @@ export class ConfigService { env: "MIN_NODE_VERSION", arg: "min-node-version", }, + tlsDefaultMaxVersion: { + doc: + "Sets the DEFAULT_MAX_VERSION property of the built-in tls module of NodeJS. " + + "Only makes a difference on NOdeJS 10 and older where TLS v1.3 is turned off by default. " + + "Newer NodeJS versions ship with TLS v1.3 enabled.", + format: (version: string) => { + ConfigService.formatNonBlankString(version); + const versions = ["TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1"]; + if (!versions.includes(version)) { + const msg = `OK TLS versions ${versions.join(",")} Got: ${version}`; + throw new Error(msg); + } + }, + default: "TLSv1.3", + env: "TLS_DEFAULT_MAX_VERSION", + arg: "tls-default-max-version", + }, cockpitHost: { doc: "The host to bind the Cockpit webserver to. Secure default is: 127.0.0.1. Use 0.0.0.0 to bind for any host.", @@ -143,6 +176,70 @@ export class ConfigService { default: "packages/cactus-cmd-api-server/node_modules/@hyperledger/cactus-cockpit/www/", }, + cockpitCorsDomainCsv: { + doc: + "The Comma seperated list of domains to allow Cross Origin Resource Sharing from when " + + "serving static file requests. The wildcard (*) character is supported to allow CORS for any and all domains", + format: "*", + env: "COCKPIT_CORS_DOMAIN_CSV", + arg: "cockpit-cors-domain-csv", + default: "", + }, + cockpitTlsEnabled: { + doc: + "Enable TLS termination on the server. Useful if you do not have/want to " + + "have a reverse proxy or load balancer doing the SSL/TLS termination in your environment.", + format: Boolean, + env: "COCKPIT_TLS_ENABLED", + arg: "cockpit-tls-enabled", + default: true, + }, + cockpitApiProxyRejectUnauthorized: { + doc: + "When false: accept self signed certificates while proxying from the cockpit host " + + "to the API host. Acceptable for development environments, never use it in production.", + format: Boolean, + env: "COCKPIT_API_PROXY_REJECT_UNAUTHORIZED", + arg: "cockpit-api-proxy-reject-unauthorized", + default: true, + }, + cockpitMtlsEnabled: { + doc: + "Enable mTLS so that only clients presenting valid TLS certificate of " + + "their own will be able to connect to the cockpit", + format: Boolean, + env: "COCKPIT_MTLS_ENABLED", + arg: "cockpit-mtls-enabled", + default: true, + }, + cockpitTlsCertPem: { + doc: + "Either the file path to the cert file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_CERT_PEM", + arg: "cockpit-tls-cert-pem", + default: null as any, + }, + cockpitTlsKeyPem: { + sensitive: true, + doc: + "Either the file path to the key file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_KEY_PEM", + arg: "cockpit-tls-key-pem", + default: null as any, + }, + cockpitTlsClientCaPem: { + doc: + "Either the client cert file pat or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_CLIENT_CA_PEM", + arg: "cockpit-tls-client-ca-pem", + default: null as any, + }, apiHost: { doc: "The host to bind the API to. Secure default is: 127.0.0.1. Use 0.0.0.0 to bind for any host.", @@ -168,6 +265,52 @@ export class ConfigService { arg: "api-cors-domain-csv", default: "", }, + apiTlsEnabled: { + doc: + "Enable TLS termination on the server. Useful if you do not have/want to " + + "have a reverse proxy or load balancer doing the SSL/TLS termination in your environment.", + format: Boolean, + env: "API_TLS_ENABLED", + arg: "api-tls-enabled", + default: true, + }, + apiMtlsEnabled: { + doc: + "Enable mTLS so that only clients presenting valid TLS certificate of " + + "their own will be able to connect to the web APIs", + format: Boolean, + env: "API_MTLS_ENABLED", + arg: "api-mtls-enabled", + default: true, + }, + apiTlsCertPem: { + doc: + "Either the file path to the cert file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_CERT_PEM", + arg: "api-tls-cert-pem", + default: null as any, + }, + apiTlsClientCaPem: { + doc: + "Either the client cert file pat or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_CLIENT_CA_PEM", + arg: "api-tls-client-ca-pem", + default: null as any, + }, + apiTlsKeyPem: { + sensitive: true, + doc: + "Either the file path to the key file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_KEY_PEM", + arg: "api-tls-key-pem", + default: null as any, + }, publicKey: { doc: "Public key of this Cactus node (the API server)", env: "PUBLIC_KEY", @@ -210,6 +353,15 @@ export class ConfigService { } } + private static filePathOrFileContents(value: string) { + ConfigService.formatNonBlankString(value); + if (existsSync(value)) { + return readFileSync(value); + } else { + return value; + } + } + /** * Remaps the example config returned by `newExampleConfig()` into a similar object whose keys are the designated * environment variable names. As an example it returns something like this: @@ -264,39 +416,73 @@ export class ConfigService { const privateKey = Buffer.from(privateKeyBytes).toString("hex"); const publicKey = Buffer.from(publicKeyBytes).toString("hex"); - return { - plugins: [ - { - packageName: "@hyperledger/cactus-plugin-kv-storage-memory", - options: {}, - }, - { - packageName: "@hyperledger/cactus-plugin-keychain-memory", - options: {}, - }, - { - packageName: "@hyperledger/cactus-plugin-web-service-consortium", - options: { - privateKey: "some-fake-key", - }, + const apiTlsEnabled: boolean = (schema.apiTlsEnabled as SchemaObj).default; + const apiHost = (schema.apiHost as SchemaObj).default; + const apiPort = (schema.apiPort as SchemaObj).default; + + // const apiProtocol = apiTlsEnabled ? "https:" : "http"; + // const apiBaseUrl = `${apiProtocol}//${apiHost}:${apiPort}`; + + const cockpitTlsEnabled: boolean = (schema.cockpitTlsEnabled as SchemaObj) + .default; + const cockpitHost = (schema.cockpitHost as SchemaObj).default; + const cockpitPort = (schema.cockpitPort as SchemaObj).default; + + // const cockpitProtocol = cockpitTlsEnabled ? "https:" : "http"; + // const cockpitBaseUrl = `${cockpitProtocol}//${cockpitHost}:${cockpitPort}`; + + const pkiGenerator = new SelfSignedPkiGenerator(); + const pkiServer: IPki = pkiGenerator.create("localhost"); + // const pkiClient: IPki = pkiGenerator.create("localhost", pkiServer); + + const plugins = [ + { + packageName: "@hyperledger/cactus-plugin-kv-storage-memory", + options: {}, + }, + { + packageName: "@hyperledger/cactus-plugin-keychain-memory", + options: {}, + }, + { + packageName: "@hyperledger/cactus-plugin-web-service-consortium", + options: { + privateKey, }, - ], + }, + ]; + + return { configFile: ".config.json", cactusNodeId: uuidV4(), logLevel: "debug", minNodeVersion: (schema.minNodeVersion as SchemaObj).default, - publicKey, - privateKey, + tlsDefaultMaxVersion: "TLSv1.3", + apiHost, + apiPort, apiCorsDomainCsv: (schema.apiCorsDomainCsv as SchemaObj).default, - apiHost: (schema.apiHost as SchemaObj).default, - apiPort: (schema.apiPort as SchemaObj).default, - cockpitHost: (schema.cockpitHost as SchemaObj).default, - cockpitPort: (schema.cockpitPort as SchemaObj).default, + apiMtlsEnabled: false, + cockpitApiProxyRejectUnauthorized: true, + apiTlsEnabled, + apiTlsCertPem: pkiServer.certificatePem, + apiTlsKeyPem: pkiServer.privateKeyPem, + apiTlsClientCaPem: "-", // API mTLS is off so this will not crash the server + cockpitHost, + cockpitPort, cockpitWwwRoot: (schema.cockpitWwwRoot as SchemaObj).default, + cockpitCorsDomainCsv: (schema.cockpitCorsDomainCsv as SchemaObj).default, + cockpitMtlsEnabled: false, + cockpitTlsEnabled, + cockpitTlsCertPem: pkiServer.certificatePem, + cockpitTlsKeyPem: pkiServer.privateKeyPem, + cockpitTlsClientCaPem: "-", // Cockpit mTLS is off so this will not crash the server + publicKey, + privateKey, keychainSuffixPublicKey: (schema.keychainSuffixPublicKey as SchemaObj) .default, keychainSuffixPrivateKey: (schema.keychainSuffixPrivateKey as SchemaObj) .default, + plugins, }; } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts b/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts new file mode 100644 index 0000000000..eaa8f63ab5 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts @@ -0,0 +1,159 @@ +import { pki, md } from "node-forge"; +import { v4 as uuidV4 } from "uuid"; +import { Strings } from "@hyperledger/cactus-common"; + +export type ForgeKeyPair = pki.rsa.KeyPair; +export type ForgePrivateKey = pki.rsa.PrivateKey; +export type ForgeCertificate = pki.Certificate; +export type ForgeCertificateField = pki.CertificateField; + +/** + * PKI as in public key infrastructure and x509 certificates. + */ +export interface IPki { + keyPair: ForgeKeyPair; + certificate: ForgeCertificate; + certificatePem: string; + privateKeyPem: string; +} + +/** + * Do not use this for anything in a production deployment. It's meant as a helper + * class for development and testing purposes (enhancing developer experience). + * + * Secure by default is one of our core design principles and it's much harder to + * enforce/implement that it sounds when you also do not want to ruin the ease + * of use of the software. Dynamically pre-provisioning PKI is notoriously + * complicated and error prone to the average user/developer. + * + */ +export class SelfSignedPkiGenerator { + public create(commonName: string, parent?: IPki): IPki { + const keyPair: pki.rsa.KeyPair = pki.rsa.generateKeyPair(4096); + const privateKeyPem: string = pki.privateKeyToPem(keyPair.privateKey); + const certificate = pki.createCertificate(); + + this.configureCertificateParameters(keyPair, certificate, commonName); + if (parent) { + certificate.setIssuer(parent.certificate.subject.attributes); + certificate.publicKey = keyPair.publicKey; + // certificate.privateKey = keyPair.privateKey; + certificate.sign(parent.keyPair.privateKey, md.sha512.create()); + + if (!parent.certificate.verify(certificate)) { + throw new Error("Could not verify newly generated certificate"); + } + } else { + certificate.sign(keyPair.privateKey, md.sha512.create()); + } + + const certificatePem = pki.certificateToPem(certificate); + return { keyPair, certificate, certificatePem, privateKeyPem }; + } + + public configureCertificateParameters( + keyPair: pki.rsa.KeyPair, + certificate: pki.Certificate, + commonName: string + ): pki.Certificate { + // 20 octets max for serial numbers of certs as per the standard + const serialNumber = Strings.replaceAll(uuidV4(), "-", "").substring(0, 19); + certificate.serialNumber = serialNumber; + certificate.publicKey = keyPair.publicKey; + certificate.privateKey = keyPair.privateKey; + certificate.validity.notBefore = new Date(); + certificate.validity.notAfter = new Date(); + + const nextYear = certificate.validity.notBefore.getFullYear() + 1; + certificate.validity.notAfter.setFullYear(nextYear); + + const certificateFields: ForgeCertificateField[] = [ + { + shortName: "CN", + name: "commonName", + value: commonName, + }, + { + name: "countryName", + value: "Universe", + }, + { + shortName: "ST", + value: "Milky Way", + }, + { + shortName: "L", + name: "localityName", + value: "Planet Earth", + }, + { + shortName: "O", + name: "organizationName", + value: "Hyperledger", + }, + { + shortName: "OU", + value: "Cactus", + }, + { + name: "unstructuredName", + value: "Cactus Dummy Self Signed Certificates", + }, + ]; + + certificate.setSubject(certificateFields); + + certificate.setIssuer(certificateFields); + + certificate.setExtensions([ + { + name: "basicConstraints", + cA: true, + }, + { + name: "keyUsage", + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true, + }, + { + name: "extKeyUsage", + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true, + }, + { + name: "nsCertType", + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true, + }, + { + name: "subjectAltName", + altNames: [ + { + type: 6, // URI + value: "localhost", + }, + { + type: 7, // IP + ip: "127.0.0.1", + }, + ], + }, + { + name: "subjectKeyIdentifier", + }, + ]); + + return certificate; + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts index 5b8641b08d..3f3b6f1051 100755 --- a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts @@ -3,3 +3,11 @@ export { ConfigService, ICactusApiServerOptions, } from "./config/config-service"; +export { + SelfSignedPkiGenerator, + ForgeCertificateField, + ForgeCertificate, + ForgeKeyPair, + ForgePrivateKey, + IPki, +} from "./config/self-signed-pki-generator"; diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts new file mode 100644 index 0000000000..f4ea21cda6 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts @@ -0,0 +1,142 @@ +// tslint:disable-next-line: no-var-requires +const tap = require("tap"); +import { AddressInfo } from "net"; +import { TLSSocket } from "tls"; +import { + Server, + createServer, + request, + RequestOptions, + ServerOptions, +} from "https"; +import { + SelfSignedPkiGenerator, + IPki, +} from "../../../../../main/typescript/public-api"; +import { Logger, LoggerProvider } from "@hyperledger/cactus-common"; + +const log: Logger = LoggerProvider.getOrCreate({ + label: "test-generates-working-certificates", + level: "TRACE", +}); + +tap.test("works with HTTPS NodeJS module", async (assert: any) => { + assert.ok(SelfSignedPkiGenerator, "class present on API surface"); + + const generator = new SelfSignedPkiGenerator(); + assert.ok(generator, "Instantiated SelfSignedCertificateGenerator OK."); + + const serverCert: IPki = generator.create("localhost"); + assert.ok(serverCert, "serverCert truthy"); + assert.ok(serverCert.certificatePem, "serverCert.certificatePem truthy"); + assert.ok(serverCert.privateKeyPem, "serverCert.privateKeyPem truthy"); + assert.ok(serverCert.certificate, "serverCert.certificate truthy"); + assert.ok(serverCert.keyPair, "serverCert.keyPair truthy"); + + // make sure the client cert has a different common name otherwise they collide and everything breaks in this test + const clientCert: IPki = generator.create("client.localhost", serverCert); + assert.ok(clientCert, "clientCert truthy"); + assert.ok(clientCert.certificatePem, "clientCert.certificatePem truthy"); + assert.ok(clientCert.privateKeyPem, "clientCert.privateKeyPem truthy"); + assert.ok(clientCert.certificate, "clientCert.certificate truthy"); + assert.ok(clientCert.keyPair, "clientCert.keyPair truthy"); + assert.ok( + serverCert.certificate.verify(clientCert.certificate), + "Server cert verified client cert OK" + ); + + const serverOptions: ServerOptions = { + key: serverCert.privateKeyPem, + cert: serverCert.certificatePem, + + ca: [serverCert.certificatePem], + + rejectUnauthorized: true, + requestCert: true, + }; + + const MESSAGE = "hello world\n"; + + const server: Server = await new Promise((resolve, reject) => { + const listener = (aRequest: any, aResponse: any) => { + aResponse.writeHead(200); + aResponse.end(MESSAGE); + }; + const aServer: Server = createServer(serverOptions, listener); + aServer.once("tlsClientError", (err: Error) => + log.error("tlsClientError: %j", err) + ); + aServer.on("keylog", (data: Buffer, tlsSocket: TLSSocket) => { + log.debug("keylog:tlsSocket.address(): %j", tlsSocket.address()); + log.debug("keylog:data: %j", data.toString("utf-8")); + }); + aServer.on("OCSPRequest", (...args: any[]) => + log.debug("OCSPRequest: %j", args) + ); + aServer.on("secureConnection", (tlsSocket: TLSSocket) => + log.debug("secureConnection: tlsSocket.address() %j", tlsSocket.address()) + ); + + aServer.once("listening", () => resolve(aServer)); + aServer.listen(0, "localhost"); + assert.tearDown(() => aServer.close()); + }); + + assert.ok(server, "HTTPS Server object truthy"); + assert.ok(server.listening, "HTTPS Server is indeed listening"); + + const addressInfo = server.address() as AddressInfo; + assert.ok(addressInfo, "HTTPS Server provided truthy AddressInfo"); + assert.ok(addressInfo.port, "HTTPS Server provided truthy AddressInfo.port"); + log.debug("AddressInfo for test HTTPS server: %j", addressInfo); + + const response = await new Promise((resolve, reject) => { + const requestOptions: RequestOptions = { + protocol: "https:", + host: addressInfo.address, + port: addressInfo.port, + path: "/", + method: "GET", + + // IMPORTANT: + // Without this self signed certs are rejected because they are not part of a chain with a trusted root CA + // By declaring our certificate here we tell the HTTPS client to assume that our certificate is a trusted one. + // This is fine for a test case because we don't want thid party dependencies on test execution. + ca: [serverCert.certificatePem], + rejectUnauthorized: true, + + // We present the server with the client's certificate to put the "mutual" in mTLS for real. + key: clientCert.privateKeyPem, + cert: clientCert.certificatePem, + }; + + const req = request(requestOptions, (res) => { + res.setEncoding("utf8"); + + let body = ""; + + res.on("data", (chunk) => { + body = body + chunk; + }); + + res.on("end", () => { + if (res.statusCode !== 200) { + reject(`HTTPS request failed. Status code: ${res.statusCode}`); + } else { + resolve(body); + } + }); + }); + + req.on("error", (error) => { + log.error("Failed to send request: ", error); + reject(error); + }); + req.end(); + }); + + assert.ok(response, "Server response truthy"); + assert.equal(response, MESSAGE, `Server responded with "${MESSAGE}"`); + + assert.end(); +}); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts new file mode 100644 index 0000000000..ae46b3e4e0 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts @@ -0,0 +1,102 @@ +// tslint:disable-next-line: no-var-requires +const tap = require("tap"); +import { AddressInfo } from "net"; +import { Server, createServer, request, RequestOptions } from "https"; +import { + SelfSignedPkiGenerator, + IPki, +} from "../../../../../main/typescript/public-api"; +import { Logger, LoggerProvider } from "@hyperledger/cactus-common"; + +const log: Logger = LoggerProvider.getOrCreate({ + label: "test-generates-working-certificates", + level: "TRACE", +}); + +tap.test("works with HTTPS NodeJS module", async (assert: any) => { + assert.ok(SelfSignedPkiGenerator, "class present on API surface"); + + const generator = new SelfSignedPkiGenerator(); + assert.ok(generator, "Instantiated SelfSignedCertificateGenerator OK."); + const serverCertData: IPki = generator.create("localhost"); + assert.ok(serverCertData, "Returned cert data truthy"); + assert.ok(serverCertData.certificatePem, "certData.certificatePem truthy"); + assert.ok(serverCertData.privateKeyPem, "certData.privateKeyPem truthy"); + assert.ok(serverCertData.certificate, "certData.certificate truthy"); + assert.ok(serverCertData.keyPair, "certData.keyPair truthy"); + + const serverOptions = { + key: serverCertData.privateKeyPem, + cert: serverCertData.certificatePem, + }; + + const MESSAGE = "hello world\n"; + + const server: Server = await new Promise((resolve, reject) => { + const listener = (aRequest: any, aResponse: any) => { + aResponse.writeHead(200); + aResponse.end(MESSAGE); + }; + const aServer: Server = createServer(serverOptions, listener); + aServer.once("tlsClientError", (err: Error) => { + log.error("tlsClientError: %j", err); + reject(err); + }); + aServer.once("listening", () => resolve(aServer)); + aServer.listen(0, "localhost"); + assert.tearDown(() => aServer.close()); + }); + + assert.ok(server, "HTTPS Server object truthy"); + assert.ok(server.listening, "HTTPS Server is indeed listening"); + + const addressInfo = server.address() as AddressInfo; + assert.ok(addressInfo, "HTTPS Server provided truthy AddressInfo"); + assert.ok(addressInfo.port, "HTTPS Server provided truthy AddressInfo.port"); + log.debug("AddressInfo for test HTTPS server: %j", addressInfo); + + const response = await new Promise((resolve, reject) => { + const requestOptions: RequestOptions = { + protocol: "https:", + host: addressInfo.address, + port: addressInfo.port, + path: "/", + method: "GET", + + // IMPORTANT: + // Without this self signed certs are rejected because they are not part of a chain with a trusted root CA + // By declaring our certificate here we tell the HTTPS client to assume that our certificate is a trusted one. + // This is fine for a test case because we don't want thid party dependencies on test execution. + ca: serverCertData.certificatePem, + }; + + const req = request(requestOptions, (res) => { + res.setEncoding("utf8"); + + let body = ""; + + res.on("data", (chunk) => { + body = body + chunk; + }); + + res.on("end", () => { + if (res.statusCode !== 200) { + reject(`HTTPS request failed. Status code: ${res.statusCode}`); + } else { + resolve(body); + } + }); + }); + + req.on("error", (error) => { + log.error("Failed to send request: ", error); + reject(error); + }); + req.end(); + }); + + assert.ok(response, "Server response truthy"); + assert.equal(response, MESSAGE, `Server responded with "${MESSAGE}"`); + + assert.end(); +}); diff --git a/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts b/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts index 0c6d407b0f..28637a6948 100644 --- a/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts +++ b/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts @@ -44,6 +44,7 @@ tap.test( const cactusApiServerOptions: ICactusApiServerOptions = configService.newExampleConfig(); cactusApiServerOptions.configFile = ""; cactusApiServerOptions.apiCorsDomainCsv = "*"; + cactusApiServerOptions.apiTlsEnabled = false; cactusApiServerOptions.apiPort = 0; const config = configService.newExampleConfigConvict( cactusApiServerOptions @@ -87,9 +88,11 @@ tap.test( const httpServer = apiServer.getHttpServerApi(); const addressInfo: any = httpServer?.address(); log.debug(`AddressInfo: `, addressInfo); - const CACTUS_API_HOST = `http://${addressInfo.address}:${addressInfo.port}`; + const protocol = config.get("apiTlsEnabled") ? "https:" : "http:"; + const basePath = `${protocol}//${addressInfo.address}:${addressInfo.port}`; + log.debug(`SDK base path: %s`, basePath); - const configuration = new Configuration({ basePath: CACTUS_API_HOST }); + const configuration = new Configuration({ basePath }); const api = new DefaultApi(configuration); // 7. Issue an API call to the API server via the SDK verifying that the SDK and the API server both work @@ -107,7 +110,7 @@ tap.test( contractJsonArtifact: HelloWorldContractJson, }; const pluginId = ledgerConnectorQuorum.getId(); - const url = `${CACTUS_API_HOST}/api/v1/plugins/${pluginId}/contract/deploy`; + const url = `${basePath}/api/v1/plugins/${pluginId}/contract/deploy`; // 9. Deploy smart contract by issuing REST API call // TODO: Make this part of the SDK so that manual request assembly is not required. Should plugins have their own SDK? const response2 = await axios.post(url, bodyObject, {}); diff --git a/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts b/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts index cc13fa3425..0f14d90371 100644 --- a/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts +++ b/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts @@ -57,6 +57,7 @@ tap.test( cactusApiServerOptions.configFile = ""; cactusApiServerOptions.apiCorsDomainCsv = "*"; cactusApiServerOptions.apiPort = 0; + cactusApiServerOptions.apiTlsEnabled = false; const config = configService.newExampleConfigConvict( cactusApiServerOptions );