Skip to content

Commit b8c1fc7

Browse files
committed
feat: add core express implementation with mTLS
Ticket: WP-4352
1 parent 89f60fc commit b8c1fc7

File tree

5 files changed

+498
-0
lines changed

5 files changed

+498
-0
lines changed

bin/enclaved-express

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env node
2+
3+
// TODO: Remove this unhandledRejection hook once BG-49996 is implemented.
4+
process.on('unhandledRejection', (reason, promise) => {
5+
console.error('----- Unhandled Rejection at -----');
6+
console.error(promise);
7+
console.error('----- Reason -----');
8+
console.error(reason);
9+
});
10+
11+
const { init } = require('../dist/src/enclavedApp');
12+
13+
if (require.main === module) {
14+
init().catch((err) => {
15+
console.log(`Fatal error: ${err.message}`);
16+
console.log(err.stack);
17+
});
18+
}

src/config.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @prettier
3+
*/
4+
5+
export enum TlsMode {
6+
DISABLED = 'disabled', // No TLS (plain HTTP)
7+
ENABLED = 'enabled', // TLS with server cert only
8+
MTLS = 'mtls' // TLS with both server and client certs
9+
}
10+
11+
export interface Config {
12+
port: number;
13+
bind: string;
14+
ipc?: string;
15+
debugNamespace?: string[];
16+
// TLS settings
17+
keyPath?: string;
18+
crtPath?: string;
19+
tlsKey?: string;
20+
tlsCert?: string;
21+
tlsMode: TlsMode;
22+
// mTLS settings
23+
mtlsRequestCert?: boolean;
24+
mtlsRejectUnauthorized?: boolean;
25+
mtlsAllowedClientFingerprints?: string[];
26+
// Other settings
27+
logFile?: string;
28+
timeout: number;
29+
keepAliveTimeout?: number;
30+
headersTimeout?: number;
31+
}
32+
33+
const defaultConfig: Config = {
34+
port: 3080,
35+
bind: 'localhost',
36+
timeout: 305 * 1000,
37+
logFile: '',
38+
tlsMode: TlsMode.ENABLED, // Default to TLS enabled
39+
mtlsRequestCert: false,
40+
mtlsRejectUnauthorized: false,
41+
};
42+
43+
function readEnvVar(name: string): string | undefined {
44+
if (process.env[name] !== undefined && process.env[name] !== '') {
45+
return process.env[name];
46+
}
47+
}
48+
49+
export function config(): Config {
50+
const envConfig: Partial<Config> = {
51+
port: Number(readEnvVar('MASTER_BITGO_EXPRESS_PORT')) || defaultConfig.port,
52+
bind: readEnvVar('MASTER_BITGO_EXPRESS_BIND') || defaultConfig.bind,
53+
ipc: readEnvVar('MASTER_BITGO_EXPRESS_IPC'),
54+
debugNamespace: (readEnvVar('MASTER_BITGO_EXPRESS_DEBUG_NAMESPACE') || '').split(',').filter(Boolean),
55+
// Basic TLS settings from MASTER_BITGO_EXPRESS
56+
keyPath: readEnvVar('MASTER_BITGO_EXPRESS_KEYPATH'),
57+
crtPath: readEnvVar('MASTER_BITGO_EXPRESS_CRTPATH'),
58+
tlsKey: readEnvVar('MASTER_BITGO_EXPRESS_TLS_KEY'),
59+
tlsCert: readEnvVar('MASTER_BITGO_EXPRESS_TLS_CERT'),
60+
// Determine TLS mode
61+
tlsMode: readEnvVar('MASTER_BITGO_EXPRESS_DISABLE_TLS') === 'true'
62+
? TlsMode.DISABLED
63+
: readEnvVar('MTLS_ENABLED') === 'true'
64+
? TlsMode.MTLS
65+
: TlsMode.ENABLED,
66+
// mTLS settings
67+
mtlsRequestCert: readEnvVar('MTLS_REQUEST_CERT') === 'true',
68+
mtlsRejectUnauthorized: readEnvVar('MTLS_REJECT_UNAUTHORIZED') === 'true',
69+
mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','),
70+
// Other settings
71+
logFile: readEnvVar('MASTER_BITGO_EXPRESS_LOGFILE'),
72+
timeout: Number(readEnvVar('MASTER_BITGO_EXPRESS_TIMEOUT')) || defaultConfig.timeout,
73+
keepAliveTimeout: Number(readEnvVar('MASTER_BITGO_EXPRESS_KEEP_ALIVE_TIMEOUT')),
74+
headersTimeout: Number(readEnvVar('MASTER_BITGO_EXPRESS_HEADERS_TIMEOUT')),
75+
};
76+
77+
// Support loading key/cert from file if keyPath/crtPath are set and tlsKey/tlsCert are not
78+
if (!envConfig.tlsKey && envConfig.keyPath) {
79+
try {
80+
envConfig.tlsKey = require('fs').readFileSync(envConfig.keyPath, 'utf-8');
81+
} catch (e) {
82+
const err = e instanceof Error ? e : new Error(String(e));
83+
throw new Error(`Failed to read TLS key from keyPath: ${err.message}`);
84+
}
85+
}
86+
if (!envConfig.tlsCert && envConfig.crtPath) {
87+
try {
88+
envConfig.tlsCert = require('fs').readFileSync(envConfig.crtPath, 'utf-8');
89+
} catch (e) {
90+
const err = e instanceof Error ? e : new Error(String(e));
91+
throw new Error(`Failed to read TLS certificate from crtPath: ${err.message}`);
92+
}
93+
}
94+
95+
return { ...defaultConfig, ...envConfig };
96+
}

src/enclavedApp.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* @prettier
3+
*/
4+
import * as express from 'express';
5+
import * as path from 'path';
6+
import debug from 'debug';
7+
import * as https from 'https';
8+
import * as http from 'http';
9+
import * as morgan from 'morgan';
10+
import * as fs from 'fs';
11+
import * as timeout from 'connect-timeout';
12+
import * as bodyParser from 'body-parser';
13+
import * as _ from 'lodash';
14+
import { SSL_OP_NO_TLSv1 } from 'constants';
15+
16+
import { Config, config, TlsMode } from './config';
17+
import * as routes from './routes';
18+
19+
const debugLogger = debug('enclaved:express');
20+
const pjson = require('../package.json');
21+
22+
/**
23+
* Set up the logging middleware provided by morgan
24+
*
25+
* @param app
26+
* @param config
27+
*/
28+
function setupLogging(app: express.Application, config: Config): void {
29+
// Set up morgan for logging, with optional logging into a file
30+
let middleware;
31+
if (config.logFile) {
32+
// create a write stream (in append mode)
33+
const accessLogPath = path.resolve(config.logFile);
34+
const accessLogStream = fs.createWriteStream(accessLogPath, { flags: 'a' });
35+
/* eslint-disable-next-line no-console */
36+
console.log('Log location: ' + accessLogPath);
37+
// setup the logger
38+
middleware = morgan('combined', { stream: accessLogStream });
39+
} else {
40+
middleware = morgan('combined');
41+
}
42+
43+
app.use(middleware);
44+
morgan.token('remote-user', function (req: express.Request) {
45+
return (req as any).clientCert ? (req as any).clientCert.subject.CN : 'unknown';
46+
});
47+
}
48+
49+
/**
50+
* Create a startup function which will be run upon server initialization
51+
*
52+
* @param config
53+
* @param baseUri
54+
* @return {Function}
55+
*/
56+
export function startup(config: Config, baseUri: string): () => void {
57+
return function () {
58+
/* eslint-disable no-console */
59+
console.log('BitGo-Enclaved-Express running');
60+
console.log(`Base URI: ${baseUri}`);
61+
console.log(`TLS Mode: ${config.tlsMode}`);
62+
console.log(`mTLS Enabled: ${config.tlsMode === TlsMode.MTLS}`);
63+
console.log(`Request Client Cert: ${config.mtlsRequestCert}`);
64+
console.log(`Reject Unauthorized: ${config.mtlsRejectUnauthorized}`);
65+
/* eslint-enable no-console */
66+
};
67+
}
68+
69+
function isTLS(config: Config): boolean {
70+
const { keyPath, crtPath, tlsKey, tlsCert, tlsMode } = config;
71+
console.log('TLS Configuration:', {
72+
tlsMode,
73+
hasKeyPath: Boolean(keyPath),
74+
hasCrtPath: Boolean(crtPath),
75+
hasTlsKey: Boolean(tlsKey),
76+
hasTlsCert: Boolean(tlsCert),
77+
});
78+
if (tlsMode === TlsMode.DISABLED) return false;
79+
return Boolean((keyPath && crtPath) || (tlsKey && tlsCert));
80+
}
81+
82+
async function createHttpsServer(app: express.Application, config: Config): Promise<https.Server> {
83+
const { keyPath, crtPath, tlsKey, tlsCert, tlsMode, mtlsRequestCert, mtlsRejectUnauthorized } = config;
84+
let key: string;
85+
let cert: string;
86+
if (tlsKey && tlsCert) {
87+
key = tlsKey;
88+
cert = tlsCert;
89+
console.log('Using TLS key and cert from environment variables');
90+
} else if (keyPath && crtPath) {
91+
const privateKeyPromise = require('fs').promises.readFile(keyPath, 'utf8');
92+
const certificatePromise = require('fs').promises.readFile(crtPath, 'utf8');
93+
[key, cert] = await Promise.all([privateKeyPromise, certificatePromise]);
94+
console.log(`Using TLS key and cert from files: ${keyPath}, ${crtPath}`);
95+
} else {
96+
throw new Error('Failed to get TLS key and certificate');
97+
}
98+
99+
const httpsOptions: https.ServerOptions = {
100+
secureOptions: SSL_OP_NO_TLSv1,
101+
key,
102+
cert,
103+
// Add mTLS options if in mTLS mode
104+
requestCert: tlsMode === TlsMode.MTLS && mtlsRequestCert,
105+
rejectUnauthorized: tlsMode === TlsMode.MTLS && mtlsRejectUnauthorized,
106+
};
107+
108+
const server = https.createServer(httpsOptions, app);
109+
110+
// Add middleware to validate client certificate fingerprints if in mTLS mode
111+
if (tlsMode === TlsMode.MTLS && config.mtlsAllowedClientFingerprints?.length) {
112+
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
113+
const clientCert = (req as any).socket?.getPeerCertificate();
114+
if (!clientCert) {
115+
return res.status(403).json({ error: 'Client certificate required' });
116+
}
117+
118+
const fingerprint = clientCert.fingerprint256?.replace(/:/g, '').toUpperCase();
119+
if (!fingerprint || !config.mtlsAllowedClientFingerprints?.includes(fingerprint)) {
120+
return res.status(403).json({ error: 'Invalid client certificate fingerprint' });
121+
}
122+
123+
// Store client certificate info for logging
124+
(req as any).clientCert = clientCert;
125+
next();
126+
});
127+
}
128+
129+
return server;
130+
}
131+
132+
function createHttpServer(app: express.Application): http.Server {
133+
return http.createServer(app);
134+
}
135+
136+
export async function createServer(config: Config, app: express.Application): Promise<https.Server | http.Server> {
137+
const server = isTLS(config) ? await createHttpsServer(app, config) : createHttpServer(app);
138+
if (config.keepAliveTimeout !== undefined) {
139+
server.keepAliveTimeout = config.keepAliveTimeout;
140+
}
141+
if (config.headersTimeout !== undefined) {
142+
server.headersTimeout = config.headersTimeout;
143+
}
144+
return server;
145+
}
146+
147+
export function createBaseUri(config: Config): string {
148+
const { bind, port } = config;
149+
const tls = isTLS(config);
150+
const isStandardPort = (port === 80 && !tls) || (port === 443 && tls);
151+
return `http${tls ? 's' : ''}://${bind}${!isStandardPort ? ':' + port : ''}`;
152+
}
153+
154+
/**
155+
* Create error handling middleware
156+
*/
157+
function errorHandler() {
158+
return function (err: any, req: express.Request, res: express.Response, _next: express.NextFunction) {
159+
debugLogger('Error: ' + (err && err.message ? err.message : String(err)));
160+
const statusCode = err && err.status ? err.status : 500;
161+
const result = {
162+
error: err && err.message ? err.message : String(err),
163+
name: err && err.name ? err.name : 'Error',
164+
code: err && err.code ? err.code : undefined,
165+
version: pjson.version,
166+
};
167+
return res.status(statusCode).json(result);
168+
};
169+
}
170+
171+
/**
172+
* Create and configure the express application
173+
*/
174+
export function app(cfg: Config): express.Application {
175+
debugLogger('app is initializing');
176+
177+
const app = express();
178+
179+
setupLogging(app, cfg);
180+
debugLogger('logging setup');
181+
182+
const { debugNamespace } = cfg;
183+
184+
// enable specified debug namespaces
185+
if (_.isArray(debugNamespace)) {
186+
for (const ns of debugNamespace) {
187+
if (ns && !debug.enabled(ns)) {
188+
debug.enable(ns);
189+
}
190+
}
191+
}
192+
193+
// Be more robust about accepting URLs with double slashes
194+
app.use(function replaceUrlSlashes(req: express.Request, res: express.Response, next: express.NextFunction) {
195+
req.url = req.url.replace(/\/{2,}/g, '/');
196+
next();
197+
});
198+
199+
// Set timeout
200+
app.use(timeout(cfg.timeout));
201+
202+
// Add body parser
203+
app.use(bodyParser.json({ limit: '20mb' }));
204+
205+
// Setup routes
206+
routes.setupRoutes(app);
207+
208+
// Add error handler
209+
app.use(errorHandler());
210+
211+
return app;
212+
}
213+
214+
// Add prepareIpc function
215+
async function prepareIpc(ipcSocketFilePath: string) {
216+
if (process.platform === 'win32') {
217+
throw new Error(`IPC option is not supported on platform ${process.platform}`);
218+
}
219+
try {
220+
const stat = fs.statSync(ipcSocketFilePath);
221+
if (!stat.isSocket()) {
222+
throw new Error('IPC socket is not actually a socket');
223+
}
224+
fs.unlinkSync(ipcSocketFilePath);
225+
} catch (e: any) {
226+
if (e.code !== 'ENOENT') {
227+
throw e;
228+
}
229+
}
230+
}
231+
232+
export async function init(): Promise<void> {
233+
const cfg = config();
234+
const expressApp = app(cfg);
235+
const server = await createServer(cfg, expressApp);
236+
const { port, bind, ipc } = cfg;
237+
const baseUri = createBaseUri(cfg);
238+
239+
if (ipc) {
240+
await prepareIpc(ipc);
241+
server.listen(ipc, startup(cfg, baseUri));
242+
} else {
243+
server.listen(port, bind, startup(cfg, baseUri));
244+
}
245+
}

0 commit comments

Comments
 (0)