Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f689b014 | ||
|
|
ccfbcddeda | ||
|
|
081201cd2f | ||
|
|
0e0380917f | ||
|
|
79ede00ef2 | ||
|
|
82ed742a67 | ||
|
|
53de5d01e7 |
No files matched your search
@@ -51,6 +51,8 @@ const externals = [
|
|||||||
"tls",
|
"tls",
|
||||||
"http",
|
"http",
|
||||||
"https",
|
"https",
|
||||||
|
"net",
|
||||||
|
"events",
|
||||||
"zlib",
|
"zlib",
|
||||||
"crypto",
|
"crypto",
|
||||||
"util",
|
"util",
|
||||||
|
|||||||
+44
-13
@@ -29,10 +29,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
httpConnectionRequest,
|
httpConnectionRequest,
|
||||||
udpConnectionRequest,
|
udpConnectionRequest,
|
||||||
|
xmppConnectionRequest,
|
||||||
} from "./connection-request";
|
} from "./connection-request";
|
||||||
import { Expression, Task } from "./types";
|
import { Expression, Task } from "./types";
|
||||||
import { flattenDevice } from "./mongodb-functions";
|
import { flattenDevice } from "./mongodb-functions";
|
||||||
import { evaluate } from "./common/expression";
|
import { evaluate } from "./common/expression";
|
||||||
|
import * as config from "../lib/config";
|
||||||
|
|
||||||
|
const XMPP_CONFIGURED = !!config.get("XMPP_JID");
|
||||||
|
|
||||||
export async function connectionRequest(
|
export async function connectionRequest(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
@@ -44,7 +48,12 @@ export async function connectionRequest(
|
|||||||
device = flattenDevice(res);
|
device = flattenDevice(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
let connectionRequestUrl, udpConnectionRequestAddress, username, password;
|
let connectionRequestUrl,
|
||||||
|
udpConnectionRequestAddress,
|
||||||
|
stunEnable,
|
||||||
|
connReqJabberId,
|
||||||
|
username,
|
||||||
|
password;
|
||||||
|
|
||||||
if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) {
|
if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) {
|
||||||
connectionRequestUrl = (device[
|
connectionRequestUrl = (device[
|
||||||
@@ -55,6 +64,12 @@ export async function connectionRequest(
|
|||||||
"InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress"
|
"InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress"
|
||||||
] || {}
|
] || {}
|
||||||
).value || [""])[0];
|
).value || [""])[0];
|
||||||
|
stunEnable = ((
|
||||||
|
device["InternetGatewayDevice.ManagementServer.STUNEnable"] || {}
|
||||||
|
).value || [""])[0];
|
||||||
|
connReqJabberId = ((
|
||||||
|
device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || {}
|
||||||
|
).value || [""])[0];
|
||||||
username = ((
|
username = ((
|
||||||
device[
|
device[
|
||||||
"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername"
|
"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername"
|
||||||
@@ -72,6 +87,10 @@ export async function connectionRequest(
|
|||||||
udpConnectionRequestAddress = ((
|
udpConnectionRequestAddress = ((
|
||||||
device["Device.ManagementServer.UDPConnectionRequestAddress"] || {}
|
device["Device.ManagementServer.UDPConnectionRequestAddress"] || {}
|
||||||
).value || [""])[0];
|
).value || [""])[0];
|
||||||
|
stunEnable = ((device["Device.ManagementServer.STUNEnable"] || {})
|
||||||
|
.value || [""])[0];
|
||||||
|
connReqJabberId = ((device["Device.ManagementServer.ConnReqJabberID"] || {})
|
||||||
|
.value || [""])[0];
|
||||||
username = ((
|
username = ((
|
||||||
device["Device.ManagementServer.ConnectionRequestUsername"] || {}
|
device["Device.ManagementServer.ConnectionRequestUsername"] || {}
|
||||||
).value || [""])[0];
|
).value || [""])[0];
|
||||||
@@ -146,7 +165,7 @@ export async function connectionRequest(
|
|||||||
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback);
|
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback);
|
||||||
|
|
||||||
let udpProm;
|
let udpProm;
|
||||||
if (udpConnectionRequestAddress) {
|
if (udpConnectionRequestAddress && +stunEnable) {
|
||||||
udpProm = udpConnectionRequest(
|
udpProm = udpConnectionRequest(
|
||||||
udpConnectionRequestAddress,
|
udpConnectionRequestAddress,
|
||||||
authExp,
|
authExp,
|
||||||
@@ -156,18 +175,30 @@ export async function connectionRequest(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await httpConnectionRequest(
|
let status;
|
||||||
connectionRequestUrl,
|
|
||||||
authExp,
|
|
||||||
CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
|
|
||||||
CONNECTION_REQUEST_TIMEOUT,
|
|
||||||
debug,
|
|
||||||
deviceId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (udpProm) {
|
if (connReqJabberId && XMPP_CONFIGURED) {
|
||||||
await udpProm;
|
status = await xmppConnectionRequest(
|
||||||
return "";
|
connReqJabberId,
|
||||||
|
authExp,
|
||||||
|
CONNECTION_REQUEST_TIMEOUT,
|
||||||
|
debug,
|
||||||
|
deviceId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
status = await httpConnectionRequest(
|
||||||
|
connectionRequestUrl,
|
||||||
|
authExp,
|
||||||
|
CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
|
||||||
|
CONNECTION_REQUEST_TIMEOUT,
|
||||||
|
debug,
|
||||||
|
deviceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (udpProm) {
|
||||||
|
await udpProm;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ const options = {
|
|||||||
|
|
||||||
// Should probably never be changed
|
// Should probably never be changed
|
||||||
DEVICE_ONLINE_THRESHOLD: { type: "int", default: 4000 },
|
DEVICE_ONLINE_THRESHOLD: { type: "int", default: 4000 },
|
||||||
|
|
||||||
|
XMPP_JID: { type: "string", default: "" },
|
||||||
|
XMPP_PASSWORD: { type: "string", default: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const allConfig: { [name: string]: string | number } = {};
|
const allConfig: { [name: string]: string | number } = {};
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { Expression } from "./types";
|
|||||||
import * as auth from "./auth";
|
import * as auth from "./auth";
|
||||||
import * as extensions from "./extensions";
|
import * as extensions from "./extensions";
|
||||||
import * as debug from "./debug";
|
import * as debug from "./debug";
|
||||||
|
import XmppClient from "./xmpp-client";
|
||||||
|
import * as config from "../lib/config";
|
||||||
|
import { encodeEntities, parseAttrs, Element } from "./xml-parser";
|
||||||
|
import * as logger from "../lib/logger";
|
||||||
|
|
||||||
async function extractAuth(
|
async function extractAuth(
|
||||||
exp: Expression,
|
exp: Expression,
|
||||||
@@ -234,3 +238,84 @@ export async function udpConnectionRequest(
|
|||||||
}
|
}
|
||||||
client.close();
|
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<string> {
|
||||||
|
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 = `<connectionRequest xmlns="urn:broadband-forum-org:cwmp:xmppConnReq-1-0"><username>${encodeEntities(
|
||||||
|
username
|
||||||
|
)}</username><password>${encodeEntities(
|
||||||
|
password
|
||||||
|
)}</password></connectionRequest>`;
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -202,3 +202,37 @@ export function outgoingUdpMessage(
|
|||||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
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" + yaml.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" + yaml.stringify(msg));
|
||||||
|
else if (DEBUG_FORMAT === "json")
|
||||||
|
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||||
|
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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("<stream:stream");
|
||||||
|
if (i1 < 0) throw new Error("Cannot detect opening stream tag");
|
||||||
|
const i2 = data.indexOf(">", i1);
|
||||||
|
if (i2 < 0) throw new Error("Cannot detect opening stream tag");
|
||||||
|
return i2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmppStream<T>(
|
||||||
|
socket: net.Socket,
|
||||||
|
callback: Generator<void, T, Element>
|
||||||
|
): Promise<T> {
|
||||||
|
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 + "</stream:stream>");
|
||||||
|
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<void, void, Element> {
|
||||||
|
socket.write(
|
||||||
|
`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="PLAIN">${encodeBase64(
|
||||||
|
`\x00${username}\x00${password}`
|
||||||
|
)}</auth>`
|
||||||
|
);
|
||||||
|
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<void, void, Element> {
|
||||||
|
const cnonce = randomBytes(8).toString("base64");
|
||||||
|
const gs2Header = "n,,";
|
||||||
|
const clientFirstMessageBare = `n=${username},r=${cnonce}`;
|
||||||
|
const clientFirstMessage = gs2Header + clientFirstMessageBare;
|
||||||
|
socket.write(
|
||||||
|
`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="SCRAM-SHA-1">${encodeBase64(
|
||||||
|
clientFirstMessage
|
||||||
|
)}</auth>`
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
`<response xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${encodeBase64(
|
||||||
|
clientFinalMessage
|
||||||
|
)}</response>`
|
||||||
|
);
|
||||||
|
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<void, void, Element> {
|
||||||
|
socket.write("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
|
||||||
|
const res1 = yield;
|
||||||
|
if (res1.name !== "proceed") throw new Error("Failed to initiate STARTTLS");
|
||||||
|
}
|
||||||
|
|
||||||
|
function* bind(
|
||||||
|
socket: net.Socket,
|
||||||
|
resource: string
|
||||||
|
): Generator<void, void, Element> {
|
||||||
|
const id = randomBytes(8).toString("base64");
|
||||||
|
socket.write(
|
||||||
|
`<iq id='${id}' type='set'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>${resource}</resource></bind></iq>`
|
||||||
|
);
|
||||||
|
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<void, number, Element> {
|
||||||
|
socket.write(
|
||||||
|
`<?xml version='1.0'?><stream:stream from='${username}@${host}' to='${host}' version='1.0' xml:lang='en' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>`
|
||||||
|
);
|
||||||
|
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<string> = 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<tls.TLSSocket> {
|
||||||
|
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<XmppClient> {
|
||||||
|
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.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("</stream:stream>");
|
||||||
|
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("</stream:stream>")) {
|
||||||
|
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 = `<iq from="${from}" to="${to}" id="${id}" type="${type}">${body}</iq>`;
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user