diff --git a/src/common/utils/decode-node-payload/decode-servername.util.ts b/src/common/utils/decode-node-payload/decode-servername.util.ts new file mode 100644 index 0000000..1917e03 --- /dev/null +++ b/src/common/utils/decode-node-payload/decode-servername.util.ts @@ -0,0 +1,32 @@ +import { hkdfSync, timingSafeEqual } from 'node:crypto'; + +const HKDF_INFO = 'rw-v1'; +const TLDS = ['com', 'net', 'org', 'io', 'dev', 'app']; + +export function deriveSni(caCertPem: string, jwtPublicKey: string): string { + const canon = (pem: string) => + pem.replace(/-----[^-]+-----/g, '').replace(/[^A-Za-z0-9+/=]/g, ''); + + const ikm = Buffer.concat([ + Buffer.from(canon(jwtPublicKey), 'utf8'), + Buffer.from(canon(caCertPem), 'utf8'), + ]); + + const okm = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), HKDF_INFO, 22)); + const host = okm.subarray(0, 16).toString('hex'); + return `${host}.${okm.subarray(16, 21).toString('hex')}.${TLDS[okm[21] % TLDS.length]}`; +} + +export function makeSniVerifier( + caCertPem: string, + jwtPublicKey: string, +): (servername: string) => boolean { + const expected = Buffer.from(deriveSni(caCertPem, jwtPublicKey)); + + return (servername: string): boolean => { + if (!servername) return false; + const got = Buffer.from(servername); + if (got.length !== expected.length) return false; + return timingSafeEqual(got, expected); + }; +} diff --git a/src/main.ts b/src/main.ts index f2b0c86..744f539 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,7 @@ import { Server } from 'https'; import morgan from 'morgan'; import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston'; import { ZodValidationPipe } from 'nestjs-zod'; -import { SecureVersion } from 'node:tls'; +import { createSecureContext, SecureContext, SecureVersion } from 'node:tls'; import { createLogger } from 'winston'; import * as winston from 'winston'; @@ -19,6 +19,10 @@ import { NestFactory } from '@nestjs/core'; import { NotFoundExceptionFilter } from '@common/exception'; import { acquireInstanceLock } from '@common/utils/acquire-instance-lock'; import { parseNodePayload } from '@common/utils/decode-node-payload'; +import { + deriveSni, + makeSniVerifier, +} from '@common/utils/decode-node-payload/decode-servername.util'; import { customLogFilter } from '@common/utils/filter-logs'; import { getDuplicateInstanceMessage } from '@common/utils/get-duplicate-instance-message'; import { getStartMessage } from '@common/utils/get-start-message'; @@ -55,14 +59,24 @@ async function bootstrap(): Promise { const nodePayload = parseNodePayload(); - const httpsOptions: { minVersion?: SecureVersion } & HttpsOptions = { + const realCtx: SecureContext = createSecureContext({ key: nodePayload.nodeKeyPem, cert: nodePayload.nodeCertPem, ca: [nodePayload.caCertPem], + minVersion: 'TLSv1.3', + }); + + logger.info(`Expected SNI: ${deriveSni(nodePayload.caCertPem, nodePayload.jwtPublicKey)}`); + + const verifySni = makeSniVerifier(nodePayload.caCertPem, nodePayload.jwtPublicKey); + + const httpsOptions: { minVersion?: SecureVersion } & HttpsOptions = { + SNICallback: (servername: string, cb: (err: Error | null, ctx?: SecureContext) => void) => + verifySni(servername) ? cb(null, realCtx) : cb(new Error('unknown sni')), requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3', - }; + } as { minVersion?: SecureVersion } & HttpsOptions; const app = await NestFactory.create(AppModule, { httpsOptions,