219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import net from 'node:net';
|
|
import test, { describe } from 'node:test';
|
|
import type { Sms } from '../src/sms.ts';
|
|
import type { SmppServer } from '../src/server.ts';
|
|
import type { TestContext } from 'node:test';
|
|
import { Log } from '@larvit/log';
|
|
import { TLSSocket } from 'node:tls';
|
|
import { client } from '../src/client.ts';
|
|
import { closeAfter } from './teardown.ts';
|
|
import { generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
import { server } from '../src/server.ts';
|
|
|
|
const host = 'localhost';
|
|
|
|
const oids = {
|
|
basicConstraints: Buffer.from('551d13', 'hex'),
|
|
commonName: Buffer.from('550403', 'hex'),
|
|
ecdsaWithSha256: Buffer.from('2a8648ce3d040302', 'hex'),
|
|
subjectAltName: Buffer.from('551d11', 'hex'),
|
|
};
|
|
|
|
function derLength(length: number): Buffer {
|
|
if (length < 0x80) return Buffer.from([length]);
|
|
|
|
const bytes: number[] = [];
|
|
|
|
for (let rest = length; rest > 0; rest = Math.floor(rest / 0x100)) {
|
|
bytes.unshift(rest % 0x100);
|
|
}
|
|
|
|
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
}
|
|
|
|
function der(tag: number, ...parts: Buffer[]): Buffer {
|
|
const body = Buffer.concat(parts);
|
|
|
|
return Buffer.concat([Buffer.from([tag]), derLength(body.length), body]);
|
|
}
|
|
|
|
function derBoolean(value: boolean): Buffer {
|
|
return der(0x01, Buffer.from([value ? 0xff : 0x00]));
|
|
}
|
|
|
|
function derName(commonName: string): Buffer {
|
|
return der(0x30, der(0x31, der(0x30,
|
|
der(0x06, oids.commonName),
|
|
der(0x0c, Buffer.from(commonName, 'utf8')),
|
|
)));
|
|
}
|
|
|
|
function derExtension(id: Buffer, critical: boolean, value: Buffer): Buffer {
|
|
return der(0x30, der(0x06, id), ...(critical ? [derBoolean(true)] : []), der(0x04, value));
|
|
}
|
|
|
|
function derUtcTime(date: Date): Buffer {
|
|
const text = date.toISOString().replace(/[-:T]/g, '').replace(/\.\d{3}/, '').slice(2);
|
|
|
|
return der(0x17, Buffer.from(text, 'ascii'));
|
|
}
|
|
|
|
function toPem(label: string, contents: Buffer): string {
|
|
const lines = contents.toString('base64').match(/.{1,64}/g) ?? [];
|
|
|
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
|
}
|
|
|
|
// Built here rather than shelled out or committed: the image has no openssl, and a fixture key in a
|
|
// public repository leaks.
|
|
function createCertificate(): { cert: string; key: string } {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
const algorithm = der(0x30, der(0x06, oids.ecdsaWithSha256));
|
|
const serial = randomBytes(8);
|
|
const now = Date.now();
|
|
|
|
serial.writeUInt8((serial.readUInt8(0) & 0x3f) | 0x40, 0);
|
|
|
|
// RFC 5280 TBSCertificate — field order is wire order, never sort it.
|
|
const tbs = der(0x30,
|
|
der(0xa0, der(0x02, Buffer.from([0x02]))),
|
|
der(0x02, serial),
|
|
algorithm,
|
|
derName(host),
|
|
der(0x30, derUtcTime(new Date(now - 60_000)), derUtcTime(new Date(now + 3_600_000))),
|
|
derName(host),
|
|
publicKey.export({ format: 'der', type: 'spki' }),
|
|
der(0xa3, der(0x30,
|
|
derExtension(oids.basicConstraints, true, der(0x30, derBoolean(true))),
|
|
derExtension(oids.subjectAltName, false, der(0x30, der(0x82, Buffer.from(host, 'ascii')))),
|
|
)),
|
|
);
|
|
const certificate = der(0x30,
|
|
tbs,
|
|
algorithm,
|
|
der(0x03, Buffer.from([0x00]), sign('sha256', tbs, privateKey)),
|
|
);
|
|
const key = privateKey.export({ format: 'pem', type: 'pkcs8' });
|
|
|
|
return {
|
|
cert: toPem('CERTIFICATE', certificate),
|
|
key: typeof key === 'string' ? key : key.toString('utf8'),
|
|
};
|
|
}
|
|
|
|
const certificate = createCertificate();
|
|
|
|
async function startServer(t: TestContext): Promise<SmppServer> {
|
|
const { err, server: smpp } = await server({
|
|
port: 0,
|
|
tls: { cert: certificate.cert, key: certificate.key },
|
|
});
|
|
|
|
assert.equal(err, undefined);
|
|
assert.ok(smpp);
|
|
closeAfter(t, smpp);
|
|
|
|
return smpp;
|
|
}
|
|
|
|
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
|
return new Promise<T>(resolve => { register(resolve); });
|
|
}
|
|
|
|
describe('tls', () => {
|
|
test('binds over a verified handshake and delivers an SMS', async t => {
|
|
const smpp = await startServer(t);
|
|
const incoming = once<Sms>(resolve => {
|
|
smpp.on('session', session => session.on('sms', resolve));
|
|
});
|
|
const { err, session } = await client({
|
|
host,
|
|
port: smpp.port,
|
|
tls: { ca: certificate.cert },
|
|
});
|
|
|
|
assert.equal(err, undefined);
|
|
assert.ok(session);
|
|
assert.ok(session.loggedIn);
|
|
closeAfter(t, session);
|
|
|
|
const sock = session.sock;
|
|
|
|
assert.ok(sock instanceof TLSSocket);
|
|
assert.ok(sock.authorized);
|
|
assert.equal(sock.getPeerCertificate().subject.CN, host);
|
|
|
|
const [sms, sent] = await Promise.all([
|
|
incoming.then(async received => {
|
|
await received.sendResp({ smsId: 'tls-id' });
|
|
|
|
return received;
|
|
}),
|
|
session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }),
|
|
]);
|
|
|
|
assert.equal(sms.message, 'hello over tls');
|
|
assert.equal(sms.to, '46709771337');
|
|
assert.equal(sent.err, undefined);
|
|
assert.deepEqual(sent.smsIds, ['tls-id']);
|
|
|
|
assert.deepEqual(await session.unbind(), {});
|
|
});
|
|
|
|
test('returns an error rather than throwing when the certificate is not trusted', async t => {
|
|
const smpp = await startServer(t);
|
|
const { err, session } = await client({ host, port: smpp.port, tls: {} });
|
|
|
|
assert.ok(err instanceof Error);
|
|
assert.match(err.message, /self.signed certificate/);
|
|
assert.equal(session, undefined);
|
|
});
|
|
|
|
test('returns an error when the certificate does not cover the host', async t => {
|
|
const smpp = await startServer(t);
|
|
const { err, session } = await client({
|
|
host: '127.0.0.1',
|
|
port: smpp.port,
|
|
tls: { ca: certificate.cert },
|
|
});
|
|
|
|
assert.ok(err instanceof Error);
|
|
assert.match(err.message, /altnames/);
|
|
assert.equal(session, undefined);
|
|
});
|
|
|
|
test('refuses to listen over tls without a certificate', async () => {
|
|
const { err, server: smpp } = await server({ port: 0, tls: true });
|
|
|
|
assert.ok(err instanceof Error);
|
|
assert.equal(smpp, undefined);
|
|
});
|
|
|
|
test('logs a handshake the server turned away', async t => {
|
|
let onWarning: ((message: string) => void) | undefined;
|
|
const warned = once<string>(resolve => { onWarning = resolve; });
|
|
const log = new Log({
|
|
logLevel: 'warn',
|
|
stderr: message => onWarning?.(message),
|
|
stdout: message => onWarning?.(message),
|
|
});
|
|
const { err, server: smpp } = await server({
|
|
log,
|
|
port: 0,
|
|
tls: { cert: certificate.cert, key: certificate.key },
|
|
});
|
|
|
|
assert.equal(err, undefined);
|
|
assert.ok(smpp);
|
|
closeAfter(t, smpp);
|
|
|
|
const sock = net.connect({ port: smpp.port }, () => { sock.end('not a client hello'); });
|
|
|
|
t.after(() => { sock.destroy(); });
|
|
sock.resume();
|
|
|
|
assert.match(await warned, /client handshake failed/);
|
|
});
|
|
});
|