diff --git a/package-lock.json b/package-lock.json index 708e41a7..7a740892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,18 +12,14 @@ "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", - "bech32": "2.0.0", "debug": "4.3.4", - "dotenv": "16.0.3", "express": "4.22.1", - "helmet": "6.0.1", "js-yaml": "4.1.1", "knex": "2.4.2", "pg": "8.9.0", "pg-query-stream": "4.3.0", "ramda": "0.28.0", "redis": "4.5.1", - "tor-control-ts": "^1.0.0", "ws": "^8.18.0", "zod": "^3.22.4" }, @@ -38,6 +34,7 @@ "@semantic-release/github": "8.1.0", "@semantic-release/npm": "13.1.5", "@semantic-release/release-notes-generator": "10.0.3", + "@types/accepts": "^1.3.7", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.7", @@ -70,6 +67,9 @@ "ts-node-dev": "^1.1.8", "typescript": "~5.7.3", "uuid": "^8.3.2" + }, + "engines": { + "node": ">=22.9" } }, "node_modules/@actions/core": { @@ -2840,6 +2840,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3621,12 +3631,6 @@ "node": ">=6.0.0" } }, - "node_modules/bech32": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", - "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", - "license": "MIT" - }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -5212,15 +5216,6 @@ "node": ">=8" } }, - "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6736,15 +6731,6 @@ "he": "bin/he" } }, - "node_modules/helmet": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.0.1.tgz", - "integrity": "sha512-8wo+VdQhTMVBMCITYZaGTbE4lvlthelPYSvoyNvk4RECTmrVjMerp9RfUOQXZWLvCcAn1pKj7ZRxK4lI9Alrcw==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -15872,12 +15858,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tor-control-ts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tor-control-ts/-/tor-control-ts-1.0.0.tgz", - "integrity": "sha512-uV+swAIQuH0QP+SJcQwlj2xrv0XqKa9V1HQOkU+NONR7/8+JM/4uIxzDJDsVtiK6cNq+x5Nt6deh3nx16XK1Yg==", - "license": "MIT" - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index 1d92e762..e524b014 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ ], "main": "src/index.ts", "scripts": { - "dev": "node -r ts-node/register src/index.ts", - "clean-db": "node -r ts-node/register src/clean-db.ts", + "dev": "node --env-file-if-exists=.env -r ts-node/register src/index.ts", + "clean-db": "node --env-file-if-exists=.env -r ts-node/register src/clean-db.ts", "clean": "rimraf ./{dist,.nyc_output,.test-reports,.coverage}", "build": "tsc --project tsconfig.build.json", "prestart": "npm run build", - "start": "cd dist && node src/index.js", + "start": "cd dist && node --env-file-if-exists=../.env src/index.js", "build:check": "npm run build -- --noEmit", "knip": "knip --config .knip.json --production --include files,dependencies --no-progress --reporter compact", "lint": "biome lint ./src ./test", @@ -38,7 +38,7 @@ "lint:fix": "npm run lint -- --write", "format": "biome format --write ./src ./test", "format:check": "biome format ./src ./test", - "import": "node -r ts-node/register src/import-events.ts", + "import": "node --env-file-if-exists=.env -r ts-node/register src/import-events.ts", "db:migrate": "knex migrate:latest", "db:migrate:rollback": "knex migrate:rollback", "db:seed": "knex seed:run", @@ -51,7 +51,7 @@ "test:load": "node -r ts-node/register ./scripts/security-load-test.ts", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", - "export": "node -r ts-node/register src/scripts/export-events.ts", + "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", "docker:compose:start": "./scripts/start", "docker:compose:stop": "./scripts/stop", "docker:compose:clean": "./scripts/clean", @@ -90,6 +90,7 @@ "@semantic-release/github": "8.1.0", "@semantic-release/npm": "13.1.5", "@semantic-release/release-notes-generator": "10.0.3", + "@types/accepts": "^1.3.7", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.7", @@ -123,22 +124,21 @@ "typescript": "~5.7.3", "uuid": "^8.3.2" }, + "engines": { + "node": ">=24.14.1" + }, "dependencies": { "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", - "bech32": "2.0.0", "debug": "4.3.4", - "dotenv": "16.0.3", "express": "4.22.1", - "helmet": "6.0.1", "js-yaml": "4.1.1", "knex": "2.4.2", "pg": "8.9.0", "pg-query-stream": "4.3.0", "ramda": "0.28.0", "redis": "4.5.1", - "tor-control-ts": "^1.0.0", "ws": "^8.18.0", "zod": "^3.22.4" }, diff --git a/src/clean-db.ts b/src/clean-db.ts index ab096172..0d176e97 100644 --- a/src/clean-db.ts +++ b/src/clean-db.ts @@ -1,11 +1,8 @@ import { createInterface } from 'readline' -import dotenv from 'dotenv' import { Knex } from 'knex' import { getMasterDbClient } from './database/client' -dotenv.config() - type CleanDbOptions = { all: boolean dryRun: boolean diff --git a/src/factories/web-app-factory.ts b/src/factories/web-app-factory.ts index e7e16e0c..f7c50681 100644 --- a/src/factories/web-app-factory.ts +++ b/src/factories/web-app-factory.ts @@ -1,5 +1,4 @@ import express from 'express' -import helmet from 'helmet' import { randomBytes } from 'crypto' import { createSettings } from './settings-factory' @@ -34,7 +33,11 @@ export const createWebApp = () => { 'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'], } - return helmet.contentSecurityPolicy({ directives })(req, res, next) + const csp = Object.entries(directives) + .map(([key, values]) => `${key} ${values.join(' ')}`) + .join('; ') + res.setHeader('Content-Security-Policy', csp) + return next() }) .use('/favicon.ico', express.static('./resources/favicon.ico')) .use('/css', express.static('./resources/css')) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 9c557d21..674d588c 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -1,6 +1,6 @@ +import accepts from 'accepts' import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' -import accepts from 'accepts' import { createSettings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' import { FeeSchedule } from '../../@types/settings' diff --git a/src/import-events.ts b/src/import-events.ts index e6392a75..9b51ca2d 100644 --- a/src/import-events.ts +++ b/src/import-events.ts @@ -2,10 +2,6 @@ import { extname, resolve } from 'path' import fs from 'fs' -import dotenv from 'dotenv' - -dotenv.config() - import { createEventBatchPersister, EventImportLineError, diff --git a/src/index.ts b/src/index.ts index 7d8ef98b..b7e3a64e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ import cluster from 'cluster' -import dotenv from 'dotenv' -dotenv.config() import { appFactory } from './factories/app-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' diff --git a/src/scripts/export-events.ts b/src/scripts/export-events.ts index 6893da6f..af119cb5 100644 --- a/src/scripts/export-events.ts +++ b/src/scripts/export-events.ts @@ -1,6 +1,4 @@ import 'pg-query-stream' -import dotenv from 'dotenv' -dotenv.config() import fs from 'fs' import path from 'path' diff --git a/src/tor/client.ts b/src/tor/client.ts index 6619ed1f..e1c10355 100644 --- a/src/tor/client.ts +++ b/src/tor/client.ts @@ -1,7 +1,7 @@ +import net from 'net' import { readFile, writeFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' -import { Tor } from 'tor-control-ts' import { createLogger } from '../factories/logger-factory' import { TorConfig } from '../@types/tor' @@ -20,7 +20,141 @@ export const createTorConfig = (): TorConfig => { } } -let client: Tor | undefined +type OnionResult = { ServiceID?: string; PrivateKey?: string } + +export class TorClient { + private socket: net.Socket | undefined + private readonly host: string + private readonly port: number + private readonly password: string + + constructor({ host, port, password }: { host?: string; port?: number; password?: string } = {}) { + this.host = host ?? 'localhost' + this.port = port ?? 9051 + this.password = password ?? '' + } + + connect(): Promise { + return new Promise((resolve, reject) => { + this.socket = net.connect({ host: this.host, port: this.port }) + this.socket.once('error', reject) + this.socket.once('data', (data) => { + if (/^250/.test(data.toString())) { resolve() } + else { reject(new Error(`Tor auth failed: ${data}`)) } + }) + this.socket.write(`AUTHENTICATE "${this.password}"\r\n`) + }) + } + + private isCompleteTorReply(buffer: string): boolean { + if (!buffer.endsWith('\r\n')) { + return false + } + + const lines = buffer.split('\r\n') + if (lines[lines.length - 1] === '') { + lines.pop() + } + + if (lines.length === 0) { + return false + } + + const firstLine = lines[0].match(/^(\d{3})([\s\-+])/) + if (!firstLine) { + return false + } + + const statusCode = firstLine[1] + let inDataBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (inDataBlock) { + if (line === '.') { + inDataBlock = false + } + continue + } + + const match = line.match(/^(\d{3})([\s\-+])/) + if (!match || match[1] !== statusCode) { + return false + } + + if (match[2] === ' ') { + return i === lines.length - 1 + } + + if (match[2] === '+') { + inDataBlock = true + } + } + + return false + } + + private sendCommand(command: string): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error('Not connected to Tor control port')) + return + } + + const socket = this.socket + let buf = '' + + const cleanup = () => { + socket.off('data', onData) + socket.off('error', onError) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + + const onData = (data: Buffer) => { + buf += data.toString() + if (!this.isCompleteTorReply(buf)) { + return + } + + cleanup() + if (/^250/.test(buf)) { resolve(buf) } + else { reject(new Error(buf.trim())) } + } + + socket.on('data', onData) + socket.on('error', onError) + socket.write(`${command}\r\n`) + }) + } + + async addOnion(port: number, host?: string, privateKey?: string | null): Promise { + const key = privateKey ?? 'NEW:BEST' + const portSpec = host !== undefined ? `${port},${host}:${port}` : `${port}` + const response = await this.sendCommand(`ADD_ONION ${key} Port=${portSpec}`) + + const result: OnionResult = {} + for (const line of response.split('\r\n')) { + const m = line.match(/^250[-\s](\w+)=(.+)$/) + if (m) { (result as Record)[m[1]] = m[2] } + } + if (result.ServiceID) { result.ServiceID += '.onion' } + if (!result.PrivateKey && privateKey) { result.PrivateKey = privateKey } + return result + } + + async quit(): Promise { + await this.sendCommand('QUIT').catch(() => undefined) + this.socket?.destroy() + this.socket = undefined + } +} + +let client: TorClient | undefined export const getTorClient = async () => { if (!client) { @@ -29,7 +163,7 @@ export const getTorClient = async () => { if (config.host !== undefined) { debug('connecting') - client = new Tor(config) + client = new TorClient(config) try { await client.connect() } catch (_error) { @@ -73,7 +207,7 @@ export const addOnion = async (port: number, host?: string): Promise => debug('saving private key to %s', path) await writeFile(path, hiddenService.PrivateKey, 'utf8') - return hiddenService.ServiceID + return hiddenService.ServiceID! } else { throw new Error(JSON.stringify(hiddenService)) } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index cf5cd6a6..da3cfc5e 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -14,11 +14,94 @@ import { propSatisfies, T, } from 'ramda' -import { bech32 } from 'bech32' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' import { User } from '../@types/user' +const BECH32_ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +const BECH32_ALPHABET_MAP: Record = {} +for (let i = 0; i < BECH32_ALPHABET.length; i++) { BECH32_ALPHABET_MAP[BECH32_ALPHABET[i]] = i } + +function bech32PolymodStep(pre: number): number { + const b = pre >> 25 + return (((pre & 0x1ffffff) << 5) ^ + (-((b >> 0) & 1) & 0x3b6a57b2) ^ + (-((b >> 1) & 1) & 0x26508e6d) ^ + (-((b >> 2) & 1) & 0x1ea119fa) ^ + (-((b >> 3) & 1) & 0x3d4233dd) ^ + (-((b >> 4) & 1) & 0x2a1462b3)) +} + +function bech32PrefixChk(prefix: string): number { + let chk = 1 + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i) + chk = bech32PolymodStep(chk) ^ (c >> 5) + } + chk = bech32PolymodStep(chk) + for (let i = 0; i < prefix.length; ++i) { + chk = bech32PolymodStep(chk) ^ (prefix.charCodeAt(i) & 0x1f) + } + return chk +} + +function bech32Convert(data: number[], inBits: number, outBits: number, pad: boolean): number[] { + let value = 0, bits = 0 + const maxV = (1 << outBits) - 1 + const maxAcc = (1 << (inBits + outBits - 1)) - 1 + const maxInput = (1 << inBits) - 1 + const result: number[] = [] + for (const byte of data) { + if (!Number.isInteger(byte) || byte < 0 || byte > maxInput) { + throw new Error(`Invalid value for ${inBits}-bit input: ${byte}`) + } + value = ((value << inBits) | byte) & maxAcc + bits += inBits + while (bits >= outBits) { + bits -= outBits + result.push((value >> bits) & maxV) + } + } + if (pad) { + if (bits > 0) { result.push((value << (outBits - bits)) & maxV) } + } else if (bits >= inBits || ((value << (outBits - bits)) & maxV) !== 0) { + throw new Error('Invalid bech32 padding') + } + return result +} + +function bech32Decode(str: string): { prefix: string; words: number[] } { + const lower = str.toLowerCase() + const split = lower.lastIndexOf('1') + if (split < 1 || split + 7 > str.length) { throw new Error(`Invalid bech32: ${str}`) } + const prefix = lower.slice(0, split) + const wordChars = lower.slice(split + 1) + let chk = bech32PrefixChk(prefix) + const words: number[] = [] + for (let i = 0; i < wordChars.length; ++i) { + const v = BECH32_ALPHABET_MAP[wordChars[i]] + if (v === undefined) { throw new Error(`Unknown bech32 character: ${wordChars[i]}`) } + chk = bech32PolymodStep(chk) ^ v + if (i + 6 < wordChars.length) { words.push(v) } + } + if (chk !== 1) { throw new Error('Invalid bech32 checksum') } + return { prefix, words } +} + +function bech32Encode(prefix: string, words: number[]): string { + prefix = prefix.toLowerCase() + let chk = bech32PrefixChk(prefix) + let result = prefix + '1' + for (const w of words) { + chk = bech32PolymodStep(chk) ^ w + result += BECH32_ALPHABET[w] + } + for (let i = 0; i < 6; ++i) { chk = bech32PolymodStep(chk) } + chk ^= 1 + for (let i = 0; i < 6; ++i) { result += BECH32_ALPHABET[(chk >> ((5 - i) * 5)) & 0x1f] } + return result +} + export const toJSON = (input: any) => JSON.stringify(input) export const toBuffer = (input: any) => Buffer.from(input, 'hex') @@ -61,19 +144,25 @@ export const fromDBUser = applySpec({ }) export const fromBech32 = (input: string) => { - const { prefix, words } = bech32.decode(input) - if (!input.startsWith(prefix)) { + const normalizedInput = input.toLowerCase() + + if (input !== normalizedInput && input !== input.toUpperCase()) { + throw new Error('Bech32 mixed-case input is invalid') + } + + const { prefix, words } = bech32Decode(input) + if (!normalizedInput.startsWith(prefix)) { throw new Error(`Bech32 invalid prefix: ${prefix}`) } - return Buffer.from(bech32.fromWords(words).slice(0, 32)).toString('hex') + return Buffer.from( + bech32Convert(words, 5, 8, false).slice(0, 32) + ).toString('hex') } -export const toBech32 = - (prefix: string) => - (input: string): string => { - return bech32.encode(prefix, bech32.toWords(Buffer.from(input, 'hex'))) - } +export const toBech32 = (prefix: string) => (input: string): string => { + return bech32Encode(prefix, bech32Convert(Array.from(Buffer.from(input, 'hex')), 8, 5, true)) +} export const toDate = (input: string | number) => new Date(input) diff --git a/test/unit/tor/client.spec.ts b/test/unit/tor/client.spec.ts new file mode 100644 index 00000000..e673190d --- /dev/null +++ b/test/unit/tor/client.spec.ts @@ -0,0 +1,202 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { EventEmitter } from 'events' +import net from 'net' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +const { expect } = chai + +import { TorClient } from '../../../src/tor/client' + +// Capture real implementations before onion.spec.ts (loaded later) overrides the prototype +const realConnect = TorClient.prototype.connect + +type MockSocket = EventEmitter & { write: Sinon.SinonStub; destroy: Sinon.SinonStub } + +function createMockSocket(): MockSocket { + return Object.assign(new EventEmitter(), { + write: Sinon.stub(), + destroy: Sinon.stub(), + }) +} + +describe('TorClient', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('isCompleteTorReply', () => { + let client: TorClient + let isComplete: (buf: string) => boolean + + beforeEach(() => { + client = new TorClient() + isComplete = (buf: string) => (client as any).isCompleteTorReply(buf) + }) + + it('returns false for empty string', () => { + expect(isComplete('')).to.be.false + }) + + it('returns false for partial reply without trailing CRLF', () => { + expect(isComplete('250 OK')).to.be.false + }) + + it('returns true for a complete single-line 250 OK reply', () => { + expect(isComplete('250 OK\r\n')).to.be.true + }) + + it('returns true for a complete single-line error reply', () => { + expect(isComplete('551 Error\r\n')).to.be.true + }) + + it('returns true for a complete multi-line reply ending with 250 OK', () => { + expect(isComplete('250-ServiceID=abc\r\n250 OK\r\n')).to.be.true + }) + + it('returns true for a multi-line ADD_ONION reply with PrivateKey', () => { + const response = '250-ServiceID=abcdefghij\r\n250-PrivateKey=RSA:xxx\r\n250 OK\r\n' + expect(isComplete(response)).to.be.true + }) + + it('returns false for incomplete multi-line reply (continuation only)', () => { + expect(isComplete('250-ServiceID=abc\r\n')).to.be.false + }) + + it('returns false for buffer ending mid-line', () => { + expect(isComplete('250-ServiceID=abc\r\n250 OK')).to.be.false + }) + + it('handles data block (250+) terminated by . then final 250 OK', () => { + const response = '250+data=\r\nsome content\r\n.\r\n250 OK\r\n' + expect(isComplete(response)).to.be.true + }) + + it('returns false for data block without terminating 250 OK', () => { + expect(isComplete('250+data=\r\nsome content\r\n.\r\n')).to.be.false + }) + }) + + describe('sendCommand', () => { + let client: TorClient + let mockSocket: MockSocket + + beforeEach(() => { + client = new TorClient() + mockSocket = createMockSocket() + ;(client as any).socket = mockSocket + }) + + it('resolves with full response on successful single-line 250 reply', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('data', Buffer.from('250 version=0.4\r\n')) + const result = await p + expect(result).to.equal('250 version=0.4\r\n') + }) + + it('rejects when socket is not connected', async () => { + ;(client as any).socket = undefined + await expect((client as any).sendCommand('GETINFO version')).to.be.rejectedWith('Not connected') + }) + + it('rejects on non-250 reply', async () => { + const p = (client as any).sendCommand('GETINFO unknown') + mockSocket.emit('data', Buffer.from('552 Unrecognized option\r\n')) + await expect(p).to.be.rejectedWith('552') + }) + + it('rejects on socket error during command', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('error', new Error('Connection reset')) + await expect(p).to.be.rejectedWith('Connection reset') + }) + + it('buffers fragmented TCP chunks until multi-line reply is complete', async () => { + const p = (client as any).sendCommand('ADD_ONION NEW:BEST Port=80') + mockSocket.emit('data', Buffer.from('250-ServiceID=abc\r\n')) + // not yet complete — continuation line only + mockSocket.emit('data', Buffer.from('250 PrivateKey=RSA:xxx\r\n')) + const result = await p + expect(result).to.include('ServiceID=abc') + expect(result).to.include('PrivateKey=RSA:xxx') + }) + + it('writes the command with CRLF to the socket', async () => { + const p = (client as any).sendCommand('QUIT') + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await p + expect(mockSocket.write.calledOnceWith('QUIT\r\n')).to.be.true + }) + + it('removes data and error listeners after resolution', async () => { + const p = (client as any).sendCommand('GETINFO version') + mockSocket.emit('data', Buffer.from('250 version=0.4\r\n')) + await p + expect(mockSocket.listenerCount('data')).to.equal(0) + expect(mockSocket.listenerCount('error')).to.equal(0) + }) + + it('removes data and error listeners after rejection', async () => { + const p = (client as any).sendCommand('GETINFO unknown') + mockSocket.emit('data', Buffer.from('552 Unrecognized option\r\n')) + await p.catch(() => undefined) + expect(mockSocket.listenerCount('data')).to.equal(0) + expect(mockSocket.listenerCount('error')).to.equal(0) + }) + }) + + describe('connect', () => { + let mockSocket: MockSocket + let savedConnect: typeof TorClient.prototype.connect + + beforeEach(() => { + // Temporarily restore the real connect for these tests (onion.spec.ts overrides the prototype) + savedConnect = TorClient.prototype.connect + TorClient.prototype.connect = realConnect + + mockSocket = createMockSocket() + sandbox.stub(net, 'connect').returns(mockSocket as any) + }) + + afterEach(() => { + TorClient.prototype.connect = savedConnect + }) + + it('resolves when control port responds with 250', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'test' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await expect(p).to.be.fulfilled + }) + + it('rejects when control port responds with non-250', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'wrong' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('515 Authentication failed\r\n')) + await expect(p).to.be.rejectedWith('Tor auth failed') + }) + + it('rejects on socket error during connect', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'test' }) + const p = client.connect() + mockSocket.emit('error', new Error('ECONNREFUSED')) + await expect(p).to.be.rejectedWith('ECONNREFUSED') + }) + + it('sends AUTHENTICATE command with the configured password', async () => { + const client = new TorClient({ host: 'localhost', port: 9051, password: 'secret' }) + const p = client.connect() + mockSocket.emit('data', Buffer.from('250 OK\r\n')) + await p + expect(mockSocket.write.calledOnceWith('AUTHENTICATE "secret"\r\n')).to.be.true + }) + }) +}) diff --git a/test/unit/tor/onion.spec.ts b/test/unit/tor/onion.spec.ts index 228c2984..c114f684 100644 --- a/test/unit/tor/onion.spec.ts +++ b/test/unit/tor/onion.spec.ts @@ -1,72 +1,50 @@ -import { addOnion, closeTorClient, createTorConfig, getTorClient } from '../../../src/tor/client' -import { hiddenService, Tor } from 'tor-control-ts' +import { addOnion, closeTorClient, createTorConfig, getTorClient, TorClient } from '../../../src/tor/client' import { expect } from 'chai' import fs from 'fs/promises' import { hostname } from 'os' import Sinon from 'sinon' -export function mockModule( - moduleToMock: T, - defaultMockValuesForMock: Partial<{ [K in keyof T]: T[K] }>, -) { - return (sandbox: Sinon.SinonSandbox, returnOverrides?: Partial<{ [K in keyof T]: T[K] }>): void => { - console.log('mockModule func') - const functions = Object.keys(moduleToMock) - const returns = returnOverrides || {} - console.log('mockModule func', functions) - functions.forEach((f) => { - console.log('f: ' + f) - sandbox.stub(moduleToMock, f).callsFake(returns[f] || defaultMockValuesForMock[f]) - }) - } -} - describe('onion', () => { - Tor.prototype.connect = async function () { - if (this === undefined) { - throw new Error() - } - const opts = (this as Tor)['opts'] as { - host: string - port: number - password: string - } - if (opts.host == hostname() && opts.port == 9051 && opts.password === 'nostr_ts_relay') { + TorClient.prototype.connect = async function () { + const h = (this as any).host as string + const p = (this as any).port as number + const pw = (this as any).password as string + if (h == hostname() && p == 9051 && pw === 'nostr_ts_relay') { return } else { - throw new Error() + throw new Error('Connection refused') } } - Tor.prototype.quit = async function () { + TorClient.prototype.quit = async function () { return } - Tor.prototype.addOnion = async function (port, host, privateKey) { - privateKey + TorClient.prototype.addOnion = async function (port: number, host?: string, privateKey?: string | null) { + void privateKey if (host) { const validHost = /[a-zA-Z]+(:[0-9]+)?/.test(host) if (validHost) { - return { host, port, ServiceID: 'pubKey', PrivateKey: 'privKey' } as hiddenService + return { ServiceID: 'pubKey', PrivateKey: 'privKey' } } else { - return { host, port, ServiceID: undefined, PrivateKey: undefined } as hiddenService + return { ServiceID: undefined, PrivateKey: undefined } } } else { - return { host, port, ServiceID: 'pubKey', PrivateKey: 'privKey' } as hiddenService + return { ServiceID: 'pubKey', PrivateKey: 'privKey' } } } let sandbox: Sinon.SinonSandbox const mock = function (sandbox: Sinon.SinonSandbox, readFail?: boolean, writeFail?: boolean) { sandbox.stub(fs, 'readFile').callsFake(async (path, options) => { - path - options + void path + void options if (readFail) { throw new Error() } return 'privKey' }) sandbox.stub(fs, 'writeFile').callsFake(async (path, options) => { - path - options + void path + void options if (writeFail) { throw new Error() } @@ -94,32 +72,31 @@ describe('onion', () => { expect(config).to.include({ host: 'localhost', port: 9051, password: 'test' }) }) it('tor connect fail', async () => { - //mockTor(sandbox) process.env.TOR_HOST = 'localhost' process.env.TOR_CONTROL_PORT = '9051' process.env.TOR_PASSWORD = 'nostr_ts_relay' - let client: Tor = undefined + let client: TorClient | undefined = undefined try { client = await getTorClient() - closeTorClient() - } catch (_error) {} + await closeTorClient() + } catch (_error) { + } expect(client).be.undefined }) it('tor connect success', async () => { - //mockTor(sandbox) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' process.env.TOR_PASSWORD = 'nostr_ts_relay' - let client: Tor = undefined + let client: TorClient | undefined = undefined try { client = await getTorClient() - closeTorClient() - } catch (_error) {} + await closeTorClient() + } catch (_error) { + } expect(client).be.not.undefined }) it('add onion connect fail', async () => { - //mockTor(sandbox) mock(sandbox) process.env.TOR_HOST = 'localhost' process.env.TOR_CONTROL_PORT = '9051' @@ -128,15 +105,13 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() - //domain = undefined + await closeTorClient() } catch (_error) { - domain + void _error } expect(domain).be.undefined }) it('add onion fail', async () => { - //mockTor(sandbox) mock(sandbox) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' @@ -146,14 +121,13 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80, '}') - closeTorClient() + await closeTorClient() } catch (_error) { - domain + void _error } expect(domain).be.undefined }) it('add onion write fail', async () => { - //mockTor(sandbox) mock(sandbox, false, true) process.env.TOR_HOST = hostname() process.env.TOR_CONTROL_PORT = '9051' @@ -162,10 +136,9 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() - //domain = undefined + await closeTorClient() } catch (_error) { - domain + void _error } console.log('domain: ' + domain) expect(domain).be.undefined @@ -179,9 +152,9 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { - domain + void _error } console.log('domain: ' + domain) expect(domain).be.not.undefined @@ -195,9 +168,9 @@ describe('onion', () => { let domain = undefined try { domain = await addOnion(80) - closeTorClient() + await closeTorClient() } catch (_error) { - domain + void _error } console.log('domain: ' + domain) expect(domain).be.not.undefined