From 53de5d01e7dabb67c0915170b2153a7202b4e114 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Wed, 4 Nov 2020 22:39:08 -0800 Subject: [PATCH] Initial XmppClient implementation --- lib/xmpp-client.ts | 388 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 lib/xmpp-client.ts diff --git a/lib/xmpp-client.ts b/lib/xmpp-client.ts new file mode 100644 index 0000000..6481a7c --- /dev/null +++ b/lib/xmpp-client.ts @@ -0,0 +1,388 @@ +/** + * Copyright 2013-2020 GenieACS Inc. + * + * This file is part of GenieACS. + * + * GenieACS is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * GenieACS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with GenieACS. If not, see . + */ + +import * as net from "net"; +import * as tls from "tls"; +import { EventEmitter } from "events"; +import { createHash, createHmac, randomBytes } from "crypto"; +import { parseXml, Element, parseAttrs } from "./xml-parser"; + +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* login( + 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("SCRAM-SHA-1")) { + yield* login(socket, username, password); + return STATUS_RESTART_STREAM; + } + } else if (feature.name === "bind") { + if (feature.children.some((c) => c.name === "required")) { + 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; +} + +export class XmppClient extends EventEmitter { + private _socket: net.Socket; + private _host: string; + private _username: string; + private _resource: string; + + private constructor() { + super(); + this._socket = null; + this._host = null; + this._username = null; + this._resource = null; + } + + 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, async () => { + 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); + socket.on("error", client._onError); + resolve(client); + } catch (err) { + socket.destroy(); + reject(err); + } + }); + }); + } + + disconnect(): void { + this._socket.removeListener("data", this._onData); + this._socket.removeListener("error", this._onError); + this._socket.end(); + } + + get host(): string { + return this._host; + } + + get username(): string { + return this._username; + } + + get resource(): string { + return this._resource; + } + + private _onData(chunk: Buffer): void { + try { + const str = chunk.toString("utf8"); + const xml = parseXml(str); + for (const c of xml.children) this.emit("stanza", c); + } catch (err) { + this.emit("error", err); + } + } + + private _onError(err: Error): void { + this._socket.end(); + this.emit("error", err); + } + + send(msg: string): void { + this._socket.write(msg); + } +}