From effd574f4ef432e140e53fb3aa6a937a2ec49ae3 Mon Sep 17 00:00:00 2001 From: Zaid Humaimidi Date: Tue, 27 Feb 2024 11:41:03 -0800 Subject: [PATCH] XMPP connection request --- lib/api-functions.ts | 51 ++++- lib/config.ts | 3 + lib/connection-request.ts | 85 +++++++ lib/debug.ts | 34 +++ lib/xmpp-client.ts | 466 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 lib/xmpp-client.ts diff --git a/lib/api-functions.ts b/lib/api-functions.ts index 2249fca..33da2d6 100644 --- a/lib/api-functions.ts +++ b/lib/api-functions.ts @@ -29,12 +29,16 @@ import { import { httpConnectionRequest, udpConnectionRequest, + xmppConnectionRequest, } from "./connection-request.ts"; import { Expression, Task } from "./types.ts"; import { evaluate } from "./common/expression/util.ts"; import { hashPassword } from "./auth.ts"; import { flattenDevice } from "./ui/db.ts"; import { ResourceLockedError } from "./common/errors.ts"; +import * as config from "../lib/config.ts"; + +const XMPP_CONFIGURED = !!config.get("XMPP_JID"); export async function connectionRequest( deviceId: string, @@ -46,7 +50,12 @@ export async function connectionRequest( device = flattenDevice(res); } - let connectionRequestUrl, udpConnectionRequestAddress, username, password; + let connectionRequestUrl, + udpConnectionRequestAddress, + stunEnable, + connReqJabberId, + username, + password; if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) { connectionRequestUrl = (device[ @@ -57,6 +66,12 @@ export async function connectionRequest( "InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress" ] || {} ).value || [""])[0]; + stunEnable = (( + device["InternetGatewayDevice.ManagementServer.STUNEnable"] || {} + ).value || [""])[0]; + connReqJabberId = (( + device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || {} + ).value || [""])[0]; username = (( device[ "InternetGatewayDevice.ManagementServer.ConnectionRequestUsername" @@ -74,6 +89,10 @@ export async function connectionRequest( udpConnectionRequestAddress = (( device["Device.ManagementServer.UDPConnectionRequestAddress"] || {} ).value || [""])[0]; + stunEnable = ((device["Device.ManagementServer.STUNEnable"] || {}) + .value || [""])[0]; + connReqJabberId = ((device["Device.ManagementServer.ConnReqJabberID"] || {}) + .value || [""])[0]; username = (( device["Device.ManagementServer.ConnectionRequestUsername"] || {} ).value || [""])[0]; @@ -148,7 +167,7 @@ export async function connectionRequest( const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback); let udpProm = Promise.resolve(false); - if (udpConnectionRequestAddress) { + if (udpConnectionRequestAddress && +stunEnable) { try { const u = new URL("udp://" + udpConnectionRequestAddress); udpProm = udpConnectionRequest( @@ -167,14 +186,26 @@ export async function connectionRequest( } } - const status = await httpConnectionRequest( - connectionRequestUrl, - authExp, - CONNECTION_REQUEST_ALLOW_BASIC_AUTH, - CONNECTION_REQUEST_TIMEOUT, - debug, - deviceId, - ); + let status; + + if (connReqJabberId && XMPP_CONFIGURED) { + status = await xmppConnectionRequest( + connReqJabberId, + authExp, + CONNECTION_REQUEST_TIMEOUT, + debug, + deviceId, + ); + } else { + status = await httpConnectionRequest( + connectionRequestUrl, + authExp, + CONNECTION_REQUEST_ALLOW_BASIC_AUTH, + CONNECTION_REQUEST_TIMEOUT, + debug, + deviceId, + ); + } if (await udpProm) return ""; diff --git a/lib/config.ts b/lib/config.ts index 568b90f..595b2c4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -82,6 +82,9 @@ const options = { // Should probably never be changed DEVICE_ONLINE_THRESHOLD: { type: "int", default: 4000 }, + + XMPP_JID: { type: "string", default: "" }, + XMPP_PASSWORD: { type: "string", default: "" }, }; const allConfig: { [name: string]: string | number } = {}; diff --git a/lib/connection-request.ts b/lib/connection-request.ts index 5262a2c..ff90387 100644 --- a/lib/connection-request.ts +++ b/lib/connection-request.ts @@ -6,6 +6,10 @@ import { Expression } from "./types.ts"; import * as auth from "./auth.ts"; import * as extensions from "./extensions.ts"; import * as debug from "./debug.ts"; +import XmppClient from "./xmpp-client.ts"; +import * as config from "../lib/config.ts"; +import { encodeEntities, parseAttrs, Element } from "./xml-parser.ts"; +import * as logger from "../lib/logger.ts"; async function extractAuth( exp: Expression, @@ -217,3 +221,84 @@ export async function udpConnectionRequest( } client.close(); } + +const XMPP_JID = config.get("XMPP_JID") as string; +const XMPP_PASSWORD = config.get("XMPP_PASSWORD") as string; +const XMPP_RESOURCE = crypto.randomBytes(8).toString("hex"); + +let xmppClient: XmppClient; + +function xmppClientOnError(err: Error): void { + xmppClient = null; + logger.error({ + message: "XMPP exception", + exception: err, + pid: process.pid, + }); +} + +function xmppClientOnClose(): void { + xmppClient = null; +} + +export async function xmppConnectionRequest( + jid: string, + authExp: Expression, + timeout: number, + _debug: boolean, + deviceId: string, +): Promise { + if (!xmppClient) { + const [host, username] = XMPP_JID.split("@").reverse(); + xmppClient = await XmppClient.connect({ + host, + username, + resource: XMPP_RESOURCE, + password: XMPP_PASSWORD, + timeout: 120000, + }); + xmppClient.on("error", xmppClientOnError); + xmppClient.on("close", xmppClientOnClose); + xmppClient.unref(); + } + + let username: string; + let password: string; + + [username, password, authExp] = await extractAuth(authExp, null); + while (username != null && password != null) { + const msg = `${encodeEntities( + username, + )}${encodeEntities( + password, + )}`; + let res: Element, rawRes: string, rawReq: string; + try { + ({ res, rawRes, rawReq } = await xmppClient.sendIqStanza( + XMPP_JID, + jid, + "get", + msg, + timeout, + )); + } catch (err) { + return err.message; + } + if (_debug) { + debug.outgoingXmppStanza(deviceId, rawReq); + debug.incomingXmppStanza(deviceId, rawRes); + } + const attrs = parseAttrs(res.attrs); + const type = attrs.find((a) => a.name === "type"); + if (type && type.value === "result") return ""; + const error = res.children.find((c) => c.name === "error"); + if (!error || !error.children[0]) + return "Unexpected XMPP connection request response"; + if (error.children[0].name === "service-unavailable") + return "Device is offline"; + if (error.children[0].name !== "not-authorized") + return "Unexpected XMPP connection request response"; + [username, password, authExp] = await extractAuth(authExp, null); + } + return "Incorrect connection request credentials"; +} diff --git a/lib/debug.ts b/lib/debug.ts index 52c85a6..92a403b 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -200,3 +200,37 @@ export function clientError(remoteAddress: string, err: Error): void { appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n"); else throw new Error(`Unrecognized DEBUG_FORMAT option`); } + +export function outgoingXmppStanza(deviceId: string, body: string): void { + if (!DEBUG_FILE) return; + const now = new Date(); + const msg = { + event: "outgoing XMPP stanza", + timestamp: now, + deviceId: deviceId, + body: body, + }; + + if (DEBUG_FORMAT === "yaml") + appendFileSync(DEBUG_FILE, "---\n" + stringify(msg)); + else if (DEBUG_FORMAT === "json") + appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n"); + else throw new Error(`Unrecognized DEBUG_FORMAT option`); +} + +export function incomingXmppStanza(deviceId: string, body: string): void { + if (!DEBUG_FILE) return; + const now = new Date(); + const msg = { + event: "incoming XMPP stanza", + timestamp: now, + deviceId: deviceId, + body: body, + }; + + if (DEBUG_FORMAT === "yaml") + appendFileSync(DEBUG_FILE, "---\n" + stringify(msg)); + else if (DEBUG_FORMAT === "json") + appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n"); + else throw new Error(`Unrecognized DEBUG_FORMAT option`); +} diff --git a/lib/xmpp-client.ts b/lib/xmpp-client.ts new file mode 100644 index 0000000..9953ef6 --- /dev/null +++ b/lib/xmpp-client.ts @@ -0,0 +1,466 @@ +import * as net from "node:net"; +import * as tls from "node:tls"; +import { EventEmitter } from "node:events"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { parseXml, Element, parseAttrs } from "./xml-parser.ts"; + +function encodeBase64(str: string): string { + return Buffer.from(str).toString("base64"); +} + +function decodeBase64(str: string): string { + return Buffer.from(str, "base64").toString(); +} + +function detectStreamTag(data: string): number { + const i1 = data.indexOf("", i1); + if (i2 < 0) throw new Error("Cannot detect opening stream tag"); + return i2 + 1; +} + +function xmppStream( + socket: net.Socket, + callback: Generator, +): Promise { + return new Promise((resolve, reject) => { + const onError = (err: Error): void => { + socket.removeListener("error", onError); + reject(err); + }; + socket.on("error", onError); + + const onData = (chunk: Buffer): void => { + try { + const str = chunk.toString("utf8"); + const xml = parseXml(str); + const { value, done } = callback.next(xml.children[0]); + if (done) { + socket.removeListener("error", onError); + socket.removeListener("data", onData); + resolve(value as T); + } + } catch (err) { + socket.removeListener("error", onError); + socket.removeListener("data", onData); + reject(err); + } + }; + + socket.once("data", (chunk: Buffer) => { + try { + const str = chunk.toString("utf8"); + const i = detectStreamTag(str); + const streamTagStr = str.slice(0, i); + const xml = parseXml(streamTagStr + ""); + callback.next(xml.children[0]); + chunk = chunk.slice(Buffer.byteLength(streamTagStr)); + if (chunk.length) onData(chunk); + socket.on("data", onData); + } catch (err) { + socket.removeListener("error", onError); + socket.removeListener("data", onData); + reject(err); + } + }); + + try { + callback.next(); + } catch (err) { + socket.removeListener("error", onError); + socket.removeListener("data", onData); + reject(err); + } + }); +} + +const INT_1 = Buffer.from([0, 0, 0, 1]); +const saltedPasswordCache = { + password: "", + iterationCount: 0, + saltBase64: "", + salted: Buffer.allocUnsafe(0), +}; + +function saltPassword( + password: string, + saltBase64: string, + iteractionCount: number, +): Buffer { + if ( + password === saltedPasswordCache.password && + saltBase64 === saltedPasswordCache.saltBase64 && + iteractionCount === saltedPasswordCache.iterationCount + ) + return saltedPasswordCache.salted; + + const hi = createHmac("sha1", password) + .update(Buffer.concat([Buffer.from(saltBase64, "base64"), INT_1])) + .digest(); + let hi2: Buffer = hi; + for (let i = 1; i < iteractionCount; ++i) { + hi2 = createHmac("sha1", password).update(hi2).digest(); + for (const [j, b] of hi2.entries()) hi[j] ^= b; + } + + saltedPasswordCache.saltBase64 = saltBase64; + saltedPasswordCache.password = password; + saltedPasswordCache.iterationCount = iteractionCount; + saltedPasswordCache.salted = hi; + + return hi; +} + +function* loginPlain( + socket: net.Socket, + username: string, + password: string, +): Generator { + socket.write( + `${encodeBase64( + `\x00${username}\x00${password}`, + )}`, + ); + const res1 = yield; + + if ( + res1.name === "failure" && + res1.children.some((c) => c.name === "not-authorized") + ) + throw new Error("Not authorized"); + + if (res1.name !== "success") + throw new Error(`Unexpected response ${res1.name}`); +} + +function* loginScram( + socket: net.Socket, + username: string, + password: string, +): Generator { + const cnonce = randomBytes(8).toString("base64"); + const gs2Header = "n,,"; + const clientFirstMessageBare = `n=${username},r=${cnonce}`; + const clientFirstMessage = gs2Header + clientFirstMessageBare; + socket.write( + `${encodeBase64( + clientFirstMessage, + )}`, + ); + const res1 = yield; + if (res1.name !== "challenge") + throw new Error(`Unexpected element ${res1.name}`); + const serverFirstMessage = decodeBase64(res1.text); + + let iterationCount: number; + let saltBase64: string; + let nonce: string; + for (const s of serverFirstMessage.split(",")) { + if (s.startsWith("i=")) iterationCount = parseInt(s.slice(2)); + else if (s.startsWith("s=")) saltBase64 = s.slice(2); + else if (s.startsWith("r=")) nonce = s.slice(2); + } + if (iterationCount == null || isNaN(iterationCount)) + throw new Error("Invalid iteration count"); + if (saltBase64 == null) throw new Error("Missing salt"); + if (nonce == null) throw new Error("Missing nonce"); + + const saltedPassword = saltPassword(password, saltBase64, iterationCount); + const clientKey = createHmac("sha1", saltedPassword) + .update("Client Key") + .digest(); + const storedKey = createHash("sha1").update(clientKey).digest(); + + const clientFinalMessageWithoutProof = `c=${encodeBase64( + gs2Header, + )},r=${nonce}`; + const authMessage = `${clientFirstMessageBare},${serverFirstMessage},${clientFinalMessageWithoutProof}`; + const clientSignature = createHmac("sha1", storedKey) + .update(authMessage) + .digest(); + + const clientProof = Buffer.from(clientKey); + for (const [i, b] of clientSignature.entries()) clientProof[i] ^= b; + + const clientFinalMessage = `${clientFinalMessageWithoutProof},p=${clientProof.toString( + "base64", + )}`; + socket.write( + `${encodeBase64( + clientFinalMessage, + )}`, + ); + const res2 = yield; + + if ( + res2.name === "failure" && + res2.children.some((c) => c.name === "not-authorized") + ) + throw new Error("Not authorized"); + + if (res2.name !== "success") + throw new Error(`Unexpected response ${res2.name}`); + + const serverKey = createHmac("sha1", saltedPassword) + .update("Server Key") + .digest(); + const serverSignature = createHmac("sha1", serverKey) + .update(authMessage) + .digest("base64"); + + if (!decodeBase64(res2.text).endsWith(serverSignature)) + throw new Error("Invalid server signature"); +} + +function* starttls(socket: net.Socket): Generator { + socket.write(""); + const res1 = yield; + if (res1.name !== "proceed") throw new Error("Failed to initiate STARTTLS"); +} + +function* bind( + socket: net.Socket, + resource: string, +): Generator { + const id = randomBytes(8).toString("base64"); + socket.write( + `${resource}`, + ); + const res1 = yield; + if (res1.name !== "iq") throw new Error(`Unexpected element ${res1.name}`); + const attrs1 = parseAttrs(res1.attrs); + const idAttr = attrs1.find((a) => a.name === "id"); + if (!idAttr || idAttr.value !== id) throw new Error("Invalid ID"); + const typeAttr = attrs1.find((a) => a.name === "type"); + if (!typeAttr) throw new Error("Missing type attribute"); + if (typeAttr.value !== "result") throw new Error("Cannot bind to resource"); +} + +const STATUS_RESTART_STREAM = 1; +const STATUS_STARTTLS = 2; + +function* init( + socket: net.Socket, + host: string, + username: string, + password: string, + resource: string, +): Generator { + socket.write( + ``, + ); + const open = yield; + if (open.name !== "stream:stream") + throw new Error(`Unexpected element ${open.name}`); + const features = yield; + if (features.name !== "stream:features") + throw new Error(`Unexpected element ${features.name}`); + for (const feature of features.children) { + if (feature.name === "starttls") { + if (feature.children.some((c) => c.name === "required")) { + yield* starttls(socket); + return STATUS_STARTTLS; + } + } else if (feature.name === "mechanisms") { + const mechanisms: Set = new Set( + feature.children.map((c) => c.text), + ); + if (mechanisms.has("PLAIN")) { + yield* loginPlain(socket, username, password); + return STATUS_RESTART_STREAM; + } else if (mechanisms.has("SCRAM-SHA-1")) { + yield* loginScram(socket, username, password); + return STATUS_RESTART_STREAM; + } else { + throw new Error("No supported SASL method"); + } + } else if (feature.name === "bind") { + yield* bind(socket, resource); + return 0; + } + } + return 0; +} + +function upgradeTls(socket: net.Socket, host: string): Promise { + return new Promise((resolve, reject) => { + socket.on("error", reject); + const newSocket = tls.connect({ socket, host }, () => { + socket.removeListener("error", reject); + resolve(newSocket); + }); + }); +} + +interface XmppClientOptions { + host: string; + port?: number; + username?: string; + password?: string; + resource?: string; + timeout?: number; +} + +export default class XmppClient extends EventEmitter { + private _socket: net.Socket; + private _host: string; + private _username: string; + private _resource: string; + private _iqStanzaCallbacks: Map< + string, + (err: Error, r?: { rawRes: string; res: Element }) => void + >; + + private constructor() { + super(); + this._socket = null; + this._host = null; + this._username = null; + this._resource = null; + this._iqStanzaCallbacks = new Map(); + } + + static connect(opts: XmppClientOptions): Promise { + return new Promise((resolve, reject) => { + let socket = new net.Socket(); + socket.on("error", reject); + + socket.connect( + opts.port || 5222, + opts.host, + void async function () { + socket.removeListener("error", reject); + try { + let status = 1; + while (status) { + if (status === STATUS_STARTTLS) + socket = await upgradeTls(socket, opts.host); + status = await xmppStream( + socket, + init( + socket, + opts.host, + opts.username, + opts.password, + opts.resource, + ), + ); + } + + const client = new XmppClient(); + client._socket = socket; + client._host = opts.host; + client._username = opts.username; + client._resource = opts.resource; + socket.on("data", client._onData.bind(client)); + socket.on("error", client._onError.bind(client)); + if (opts.timeout) + socket.setTimeout(opts.timeout, client.close.bind(client)); + resolve(client); + } catch (err) { + socket.destroy(); + reject(err); + } + }, + ); + }); + } + + close(): void { + this._socket.end(""); + this._socket.removeAllListeners("data"); + this._socket.removeAllListeners("error"); + this.emit("close"); + } + + ref(): void { + this._socket.ref(); + } + + unref(): void { + this._socket.unref(); + } + + get host(): string { + return this._host; + } + + get username(): string { + return this._username; + } + + get resource(): string { + return this._resource; + } + + private _onData(chunk: Buffer): void { + try { + let close = false; + let str = chunk.toString("utf8"); + if (str.endsWith("")) { + str = str.slice(0, -16); + close = true; + } + const xml = parseXml(str); + const idx = xml.children.map((c) => c.bodyIndex); + for (const [i, c] of xml.children.entries()) { + const s = str.slice(idx[i], idx[i + 1]); + if (c.name === "iq") { + const attrs = parseAttrs(c.attrs); + const id = attrs.find((a) => a.name === "id"); + if (id) { + const cb = this._iqStanzaCallbacks.get(id.value); + if (cb) cb(null, { rawRes: s, res: c }); + } + } + this.emit("stanza", c, s); + } + if (close) { + this._socket.removeAllListeners("data"); + this._socket.removeAllListeners("error"); + this.emit("close"); + } + } catch (err) { + this._socket.removeAllListeners("data"); + this._socket.removeAllListeners("error"); + this.emit("error", err); + } + } + + private _onError(err: Error): void { + this._socket.end(); + this.emit("error", err); + for (const cb of this._iqStanzaCallbacks.values()) cb(err); + } + + send(msg: string): void { + this._socket.write(msg); + } + + sendIqStanza( + from: string, + to: string, + type: string, + body: string, + timeout = 3000, + ): Promise<{ rawReq: string; rawRes: string; res: Element }> { + return new Promise((resolve, reject) => { + const id = randomBytes(8).toString("base64"); + const rawReq = `${body}`; + this.send(rawReq); + const t = setTimeout(() => { + this._iqStanzaCallbacks.delete(id); + reject( + new Error("Did not receive IQ stanza response in a timely manner"), + ); + }, timeout); + this._iqStanzaCallbacks.set(id, (err, r) => { + this._iqStanzaCallbacks.delete(id); + clearTimeout(t); + if (err) reject(err); + else resolve(Object.assign(r, { rawReq })); + }); + }); + } +}