diff --git a/README.md b/README.md index eb2ab2a..fc99c1f 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ await smpp.close(); // stop listening and close every live session | --- | --- | --- | | `host` / `port` | all interfaces / `2775` | Where to listen. Pass `0` for any free port. | | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | -| `tls` | `false` | `true`, or a `tls.TlsOptions` object with your certificate and key. | +| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `reassemblyTimeout` | `300000` | How long an incomplete multipart message is held. | diff --git a/src/server.ts b/src/server.ts index f3809b9..3ad369f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,15 @@ import type { LogInt } from '@larvit/log'; -import type { PduObject } from './pdu.ts'; +import type { PduObject, TlvInput } from './pdu.ts'; import type { Result } from './result.ts'; import type { Server as NetServer, Socket } from 'node:net'; -import type { TlsOptions } from 'node:tls'; +import type { Server as TlsServer, TlsOptions } from 'node:tls'; import { EventEmitter } from 'node:events'; import { Session } from './session.ts'; import { createServer as createNetServer } from 'node:net'; import { createServer as createTlsServer } from 'node:tls'; import { paramText } from './defs/types.ts'; import { silentLog } from './log.ts'; +import { tlvs } from './defs/tlvs.ts'; export type AuthenticateResult = { userData?: unknown } | boolean; @@ -44,6 +45,7 @@ const defaults = { }; const bindCommands = ['bind_receiver', 'bind_transceiver', 'bind_transmitter']; +const scInterfaceVersion = 0x34; /** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */ export class SmppServer extends EventEmitter { @@ -100,6 +102,25 @@ async function authenticate( return true; } +/** + * A peer that declared below 0x34 must not be sent optional parameters at all; above it, an ESME + * reads a missing sc_interface_version as this SMSC having none. + */ +function bindRespTlvs(pduObj: PduObject): Record | undefined { + const declared = pduObj.params.interface_version; + const definition = tlvs.sc_interface_version; + + if (!definition || typeof declared !== 'number' || declared < scInterfaceVersion) return undefined; + + return { + sc_interface_version: { + tagId: definition.id, + tagName: definition.tag, + tagValue: scInterfaceVersion, + }, + }; +} + /** * Handles everything a peer may send before it is bound. Returns true when it has answered, so the * session leaves the PDU alone. @@ -128,7 +149,7 @@ async function onRequest( } session.loggedIn = true; - await session.sendReturn(pduObj); + await session.sendReturn(pduObj, 'ESME_ROK', {}, bindRespTlvs(pduObj)); log.verbose('server - bound', { systemId: paramText(pduObj.params.system_id) }); return true; @@ -158,15 +179,33 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): server.emit('session', session); } +/** Node reports a rejected handshake as `tlsClientError`, which is never an `error` event. */ +function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer { + const listener = createTlsServer(tlsOptions); + + listener.on('tlsClientError', err => { + log.warn('server - client handshake failed', { message: err.message }); + }); + + return listener; +} + /** Starts listening for SMPP connections. Resolves once the socket is bound. */ export function server(options: ServerOptions = {}): Promise> { return new Promise(resolve => { const log = options.log ?? silentLog; const port = options.port ?? defaults.port; const useTls = options.tls !== undefined && options.tls !== false; - const listener = useTls - ? createTlsServer(typeof options.tls === 'object' ? options.tls : {}) - : createNetServer(); + const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; + + if (useTls && !tlsOptions) { + log.warn('server - tls without a certificate', { port }); + resolve({ err: new Error('Listening over TLS needs tls: { cert, key }') }); + + return; + } + + const listener = tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(); const smpp = new SmppServer(listener); listener.on(useTls ? 'secureConnection' : 'connection', (sock: Socket) => { diff --git a/test/session.test.ts b/test/session.test.ts index a012143..05a72a0 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { describe } from 'node:test'; +import type { PduObject } from '../src/pdu.ts'; import type { Session } from '../src/session.ts'; import type { Sms } from '../src/sms.ts'; import type { SmppServer } from '../src/server.ts'; import { client } from '../src/client.ts'; -import { isCommand } from '../src/pdu.ts'; +import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts'; import { server } from '../src/server.ts'; async function startServer(options: Parameters[0] = {}): Promise { @@ -25,6 +26,30 @@ function once(register: (resolve: (value: T) => void) => void): Promise { return new Promise(resolve => { register(resolve); }); } +/** Binds off a raw socket, which is the only way to declare a version the client cannot. */ +async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise { + const { buffer } = objToPdu({ + cmdName: 'bind_transceiver', + params: { interface_version: interfaceVersion, password: 'pass', system_id: 'user' }, + }); + + assert.ok(buffer); + + const sock = net.connect({ port: smpp.port }); + const response = await once(resolve => { + sock.on('connect', () => { sock.write(buffer); }); + sock.once('data', resolve); + }); + + sock.destroy(); + + const { pduObj } = pduToObj(response); + + assert.ok(pduObj); + + return pduObj; +} + describe('bind', () => { test('binds and unbinds against a server with no auth', async () => { const smpp = await startServer(); @@ -118,6 +143,33 @@ describe('bind', () => { await asFive.unbind(); await smpp.close(); }); + + test('tells a 3.4 peer the version it supports in the bind response', async () => { + const smpp = await startServer(); + const asThreeFour = await bindRaw(smpp, 0x34); + const asFive = await bindRaw(smpp, 0x50); + + assert.equal(asThreeFour.cmdName, 'bind_transceiver_resp'); + assert.equal(asThreeFour.cmdStatus, 'ESME_ROK'); + assert.deepEqual(asThreeFour.tlvs.sc_interface_version, { + tagId: 0x0210, + tagName: 'sc_interface_version', + tagValue: 0x34, + }); + assert.equal(asFive.tlvs.sc_interface_version?.tagValue, 0x34); + + await smpp.close(); + }); + + test('sends no optional parameters to a peer declaring less than 3.4', async () => { + const smpp = await startServer(); + const bound = await bindRaw(smpp, 0x00); + + assert.equal(bound.cmdStatus, 'ESME_ROK'); + assert.deepEqual(bound.tlvs, {}); + + await smpp.close(); + }); }); describe('sending', () => { diff --git a/test/tls.test.ts b/test/tls.test.ts new file mode 100644 index 0000000..dd16b2c --- /dev/null +++ b/test/tls.test.ts @@ -0,0 +1,214 @@ +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 { Log } from '@larvit/log'; +import { TLSSocket } from 'node:tls'; +import { client } from '../src/client.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(): Promise { + const { err, server: smpp } = await server({ + port: 0, + tls: { cert: certificate.cert, key: certificate.key }, + }); + + assert.equal(err, undefined); + assert.ok(smpp); + + return smpp; +} + +function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise(resolve => { register(resolve); }); +} + +describe('tls', () => { + test('binds over a verified handshake and delivers an SMS', async () => { + const smpp = await startServer(); + const incoming = once(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); + + 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 => { + received.smsId = 'tls-id'; + await received.sendResp(); + + 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(), {}); + await smpp.close(); + }); + + test('returns an error rather than throwing when the certificate is not trusted', async () => { + const smpp = await startServer(); + 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); + + await smpp.close(); + }); + + test('returns an error when the certificate does not cover the host', async () => { + const smpp = await startServer(); + 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); + + await smpp.close(); + }); + + 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 () => { + const warned = once(resolve => { + const log = new Log({ logLevel: 'warn', stderr: resolve, stdout: resolve }); + + void server({ log, port: 0, tls: { cert: certificate.cert, key: certificate.key } }) + .then(({ server: smpp }) => { + assert.ok(smpp); + + const sock = net.connect({ port: smpp.port }, () => { + sock.end('not a client hello'); + }); + + sock.on('close', () => { void smpp.close(); }); + sock.resume(); + }); + }); + + assert.match(await warned, /client handshake failed/); + }); +}); diff --git a/todo.md b/todo.md index 367d212..06517f0 100644 --- a/todo.md +++ b/todo.md @@ -5,7 +5,7 @@ rules there constrain every item below. ## Status -The rewrite is **feature complete and green**: 137 tests, lint and typecheck clean, verified on Node +The rewrite is **feature complete and green**: 144 tests, lint and typecheck clean, verified on Node 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. ```bash @@ -74,12 +74,6 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha - [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a response arrived" and the caller retries. Re-queueing them automatically would be friendlier but risks duplicate delivery, so it needs a decision before it is built. -- [ ] **The server never returns the `sc_interface_version` TLV.** The 3.4 spec has an ESME read its - absence from a bind response as "this SMSC does not support optional parameters", and ours - does send DLR TLVs. Returning it has to be conditional: a peer that declared below 0x34 must - not be sent optional parameters at all. -- [ ] **TLS is untested.** The code path is right (`tls.connect` / `tls.createServer`, options - passed through) but no test exercises a handshake. Needs a self-signed certificate fixture. - [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them end to end. The interop suite is the natural place. - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript