diff --git a/packages/client-core/lib/Client.js b/packages/client-core/lib/Client.js index 11c86c6d..bd9a18a7 100644 --- a/packages/client-core/lib/Client.js +++ b/packages/client-core/lib/Client.js @@ -42,8 +42,14 @@ class Client extends Connection { return this.Transport.prototype.socketParameters(...args); } - header(...args) { - return this.Transport.prototype.header(...args); + header(headerElement, ...args) { + // if the client knows the XMPP identity then it SHOULD include the 'from' attribute + // after the confidentiality and integrity of the stream are protected via TLS + // or an equivalent security layer. + // https://xmpp.org/rfcs/rfc6120.html#rfc.section.4.7.1 + const from = this.socket?.isSecure() && this.jid?.bare().toString(); + if (from) headerElement.attrs.from = from; + return this.Transport.prototype.header(headerElement, ...args); } headerElement(...args) { diff --git a/packages/client-core/src/bind2/bind2.js b/packages/client-core/src/bind2/bind2.js index 4fd186bd..fa1ae5c1 100644 --- a/packages/client-core/src/bind2/bind2.js +++ b/packages/client-core/src/bind2/bind2.js @@ -2,7 +2,7 @@ import xml from "@xmpp/xml"; const NS_BIND = "urn:xmpp:bind:0"; -export default function bind2({ sasl2 }, tag) { +export default function bind2({ sasl2, entity }, tag) { const features = new Map(); sasl2.use( @@ -22,6 +22,8 @@ export default function bind2({ sasl2 }, tag) { ); }, (element) => { + if (!element.is("bound")) return; + entity._ready(false); for (const child of element.getChildElements()) { const feature = features.get(child.getNS()); feature?.[1]?.(child); diff --git a/packages/client-core/test/Client.js b/packages/client-core/test/Client.js index 728da9e0..e510e9cb 100644 --- a/packages/client-core/test/Client.js +++ b/packages/client-core/test/Client.js @@ -1,4 +1,5 @@ import Client from "../lib/Client.js"; +import { JID } from "@xmpp/test"; test("_findTransport", () => { class Transport { @@ -21,3 +22,31 @@ test("_findTransport", () => { expect(entity._findTransport("b")).toBe(undefined); expect(entity._findTransport("c")).toBe(undefined); }); + +test("header", () => { + class Transport { + header(el) { + return el; + } + } + + const entity = new Client(); + entity.Transport = Transport; + entity.socket = {}; + + entity.jid = null; + entity.socket.isSecure = () => false; + expect(entity.header()).toEqual(); + + entity.jid = null; + entity.socket.isSecure = () => true; + expect(entity.header()).toEqual(); + + entity.jid = new JID("foo@bar/example"); + entity.socket.isSecure = () => false; + expect(entity.header()).toEqual(); + + entity.jid = new JID("foo@bar/example"); + entity.socket.isSecure = () => true; + expect(entity.header()).toEqual(); +}); diff --git a/packages/client/example.js b/packages/client/example.js index 9bd5f21d..f0b342bd 100644 --- a/packages/client/example.js +++ b/packages/client/example.js @@ -8,6 +8,7 @@ import debug from "@xmpp/debug"; const xmpp = client({ service: "ws://localhost:5280/xmpp-websocket", // service: "xmpps://localhost:5223", + // service: "xmpp://localhost:5222", domain: "localhost", resource: "example", username: "username", diff --git a/packages/client/index.js b/packages/client/index.js index 14bcb46c..0d5c1e5b 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -15,7 +15,6 @@ import _starttls from "@xmpp/starttls"; import _sasl2 from "@xmpp/sasl2"; import _sasl from "@xmpp/sasl"; import _resourceBinding from "@xmpp/resource-binding"; -import _sessionEstablishment from "@xmpp/session-establishment"; import _streamManagement from "@xmpp/stream-management"; import _bind2 from "@xmpp/client-core/src/bind2/bind2.js"; @@ -34,6 +33,9 @@ function client(options = {}) { } const entity = new Client(params); + if (username && params.domain) { + entity.jid = jid(username, params.domain); + } const reconnect = _reconnect({ entity }); const websocket = _websocket({ entity }); @@ -62,7 +64,7 @@ function client(options = {}) { ); // SASL2 inline features - const bind2 = _bind2({ sasl2 }, resource); + const bind2 = _bind2({ sasl2, entity }, resource); // Stream features - order matters and define priority const sasl = _sasl( @@ -80,10 +82,6 @@ function client(options = {}) { { iqCaller, streamFeatures }, resource, ); - const sessionEstablishment = _sessionEstablishment({ - iqCaller, - streamFeatures, - }); return Object.assign(entity, { entity, @@ -101,7 +99,6 @@ function client(options = {}) { sasl2, sasl, resourceBinding, - sessionEstablishment, streamManagement, mechanisms, bind2, diff --git a/packages/component-core/lib/Component.js b/packages/component-core/lib/Component.js index 6f3cd284..fded6354 100644 --- a/packages/component-core/lib/Component.js +++ b/packages/component-core/lib/Component.js @@ -37,7 +37,7 @@ class Component extends Connection { } this._jid(this.options.domain); - this._status("online", this.jid); + this._ready(false); } } diff --git a/packages/connection-tcp/Socket.js b/packages/connection-tcp/Socket.js new file mode 100644 index 00000000..7fd8d457 --- /dev/null +++ b/packages/connection-tcp/Socket.js @@ -0,0 +1,7 @@ +import { Socket as TCPSocket } from "net"; + +export default class Socket extends TCPSocket { + isSecure() { + return false; + } +} diff --git a/packages/connection-tcp/index.js b/packages/connection-tcp/index.js index 398a0188..9c4e4085 100644 --- a/packages/connection-tcp/index.js +++ b/packages/connection-tcp/index.js @@ -1,4 +1,4 @@ -import { Socket } from "net"; +import Socket from "./Socket.js"; import Connection from "@xmpp/connection"; import { Parser } from "@xmpp/xml"; import { parseURI } from "@xmpp/connection/lib/util.js"; diff --git a/packages/connection-tcp/test/Connection.js b/packages/connection-tcp/test/Connection.js index fc95f0fe..9ffcfe46 100644 --- a/packages/connection-tcp/test/Connection.js +++ b/packages/connection-tcp/test/Connection.js @@ -1,7 +1,7 @@ import _Connection from "@xmpp/connection"; import Connection from "../index.js"; +import Socket from "../Socket.js"; -import net from "net"; import xml from "@xmpp/xml"; const NS_STREAM = "http://etherx.jabber.org/streams"; @@ -14,7 +14,7 @@ test("new Connection()", () => { test("Socket", () => { const conn = new Connection(); - expect(conn.Socket).toBe(net.Socket); + expect(conn.Socket).toBe(Socket); }); test("NS", () => { diff --git a/packages/connection-tcp/test/Socket.js b/packages/connection-tcp/test/Socket.js new file mode 100644 index 00000000..18177858 --- /dev/null +++ b/packages/connection-tcp/test/Socket.js @@ -0,0 +1,12 @@ +import net from "node:net"; +import Socket from "../Socket.js"; + +test("isSecure()", () => { + const socket = new Socket(); + expect(socket.isSecure()).toBe(false); +}); + +test("instance of net.Socket", () => { + const socket = new Socket(); + expect(socket).toBeInstanceOf(net.Socket); +}); diff --git a/packages/connection/index.js b/packages/connection/index.js index 5ab1a714..881ac528 100644 --- a/packages/connection/index.js +++ b/packages/connection/index.js @@ -22,10 +22,10 @@ class Connection extends EventEmitter { } _reset() { - this.jid = null; this.status = "offline"; this._detachSocket(); this._detachParser(); + this.root = null; } async _streamError(condition, children) { @@ -184,6 +184,14 @@ class Connection extends EventEmitter { this.emit(status, ...args); } + _ready(resumed = false) { + if (resumed) { + this.status = "online"; + } else { + this._status("online", this.jid); + } + } + async _end() { let el; try { @@ -272,6 +280,7 @@ class Connection extends EventEmitter { */ async stop() { const el = await this._end(); + this.jid = null; if (this.status !== "offline") this._status("offline", el); return el; } diff --git a/packages/connection/test/close.js b/packages/connection/test/close.js index 34441a75..30c9b24d 100644 --- a/packages/connection/test/close.js +++ b/packages/connection/test/close.js @@ -1,15 +1,13 @@ import Connection from "../index.js"; import { EventEmitter, promise, timeout, TimeoutError } from "@xmpp/events"; -import xml from "@xmpp/xml"; +import { xml } from "@xmpp/test"; test("resets properties on socket close event", () => { const conn = new Connection(); conn._attachSocket(new EventEmitter()); - conn.jid = {}; conn.status = "online"; conn.socket.emit("connect"); conn.socket.emit("close"); - expect(conn.jid).toBe(null); expect(conn.status).toBe("disconnect"); }); diff --git a/packages/connection/test/stop.js b/packages/connection/test/stop.js index 5057068b..59eb456d 100644 --- a/packages/connection/test/stop.js +++ b/packages/connection/test/stop.js @@ -1,4 +1,5 @@ import Connection from "../index.js"; +import { JID } from "@xmpp/test"; test("resolves if socket property is undefined", async () => { const conn = new Connection(); @@ -38,3 +39,12 @@ test("does not throw if connection is not established", async () => { await conn.stop(); expect().pass(); }); + +test("resets jid", async () => { + const conn = new Connection(); + conn.jid = new JID("foo@bar"); + + expect(conn.jid).not.toEqual(null); + await conn.stop(); + expect(conn.jid).toEqual(null); +}); diff --git a/packages/resource-binding/index.js b/packages/resource-binding/index.js index 5324db22..5b9352ff 100644 --- a/packages/resource-binding/index.js +++ b/packages/resource-binding/index.js @@ -15,6 +15,7 @@ async function bind(entity, iqCaller, resource) { const result = await iqCaller.set(makeBindElement(resource)); const jid = result.getChildText("jid"); entity._jid(jid); + entity._ready(false); return jid; } diff --git a/packages/sasl-anonymous/package.json b/packages/sasl-anonymous/package.json index 87a5b10d..00fbc57c 100644 --- a/packages/sasl-anonymous/package.json +++ b/packages/sasl-anonymous/package.json @@ -10,8 +10,7 @@ "main": "index.js", "keywords": [ "XMPP", - "sasl", - "anonymous" + "sasl" ], "dependencies": { "sasl-anonymous": "^0.1.0" diff --git a/packages/sasl-plain/package.json b/packages/sasl-plain/package.json index 87c9c0cf..9b401b8f 100644 --- a/packages/sasl-plain/package.json +++ b/packages/sasl-plain/package.json @@ -10,8 +10,7 @@ "main": "index.js", "keywords": [ "XMPP", - "sasl", - "plain" + "sasl" ], "dependencies": { "sasl-plain": "^0.1.0" diff --git a/packages/sasl-scram-sha-1/package.json b/packages/sasl-scram-sha-1/package.json index a5990455..17ee55c0 100644 --- a/packages/sasl-scram-sha-1/package.json +++ b/packages/sasl-scram-sha-1/package.json @@ -10,8 +10,7 @@ "main": "index.js", "keywords": [ "XMPP", - "sasl", - "plain" + "sasl" ], "dependencies": { "sasl-scram-sha-1": "^1.3.0" diff --git a/packages/sasl/test.js b/packages/sasl/test.js index 5147a99d..86513b56 100644 --- a/packages/sasl/test.js +++ b/packages/sasl/test.js @@ -43,14 +43,13 @@ test("with object credentials", async () => { ); entity.mockInput(); - - await promise(entity, "online"); }); test("with function credentials", async () => { expect.assertions(2); const mech = "PLAIN"; + let promise_authenticate; async function onAuthenticate(authenticate, mechanisms) { expect(mechanisms).toEqual([mech]); @@ -79,7 +78,7 @@ test("with function credentials", async () => { entity.mockInput(); - await promise(entity, "online"); + await promise_authenticate; }); test("Mechanism not found", async () => { diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js index 29c237be..1b93b46f 100644 --- a/packages/sasl2/index.js +++ b/packages/sasl2/index.js @@ -72,9 +72,11 @@ async function authenticate({ } // https://xmpp.org/extensions/xep-0388.html#success - // this is a bare JID, unless resource binding has occurred, in which case it is a full JID. + // this is a bare JID, unless resource binding or stream resumption has occurred, in which case it is a full JID. const aid = element.getChildText("authorization-identifier"); - if (aid) entity._jid(aid); + if (aid) { + entity._jid(aid); + } for (const child of element.getChildElements()) { const feature = features.get(child.getNS()); @@ -132,8 +134,6 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { } await onAuthenticate(done, intersection); - // Not online yet, wait for next features - return true; }, ); diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js index 0a7e696c..b2042769 100644 --- a/packages/sasl2/test.js +++ b/packages/sasl2/test.js @@ -37,10 +37,17 @@ test("with object credentials", async () => { , ); - entity.mockInput(); - entity.mockInput(); + const jid = "username@localhost/example~Ln8YSSzsyK-b_3-vIFvOJNnE"; - await promise(entity, "online"); + expect(entity.jid?.toString()).not.toBe(jid); + + entity.mockInput( + + {jid} + , + ); + + expect(entity.jid.toString()).toBe(jid); }); test("with function credentials", async () => { @@ -74,10 +81,17 @@ test("with function credentials", async () => { , ); - entity.mockInput(); - entity.mockInput(); + const jid = "username@localhost/example~Ln8YSSzsyK-b_3-vIFvOJNnE"; + + expect(entity.jid?.toString()).not.toBe(jid); + + entity.mockInput( + + {jid} + , + ); - await promise(entity, "online"); + expect(entity.jid.toString()).toBe(jid); }); test("failure", async () => { diff --git a/packages/session-establishment/test.js b/packages/session-establishment/test.js index 5418ace1..5ca535f9 100644 --- a/packages/session-establishment/test.js +++ b/packages/session-establishment/test.js @@ -1,7 +1,9 @@ import { mockClient, promise, timeout } from "@xmpp/test"; +import sessionEstablishment from "./index.js"; test("mandatory", async () => { const { entity } = mockClient(); + sessionEstablishment(entity); entity.mockInput( @@ -15,12 +17,11 @@ test("mandatory", async () => { expect(child).toEqual( , ); - - await promise(entity, "online"); }); test("optional", async () => { const { entity } = mockClient(); + sessionEstablishment(entity); entity.mockInput( @@ -32,8 +33,6 @@ test("optional", async () => { const promiseSend = promise(entity, "send"); - await promise(entity, "online"); - await timeout(promiseSend, 0).catch((err) => { expect(err.name).toBe("TimeoutError"); }); diff --git a/packages/stream-features/index.js b/packages/stream-features/index.js index bf41a2db..0ffc79be 100644 --- a/packages/stream-features/index.js +++ b/packages/stream-features/index.js @@ -1,5 +1,3 @@ -import route from "./route.js"; - /** * References * https://xmpp.org/rfcs/rfc6120.html#streams-negotiation Stream Negotiation @@ -8,8 +6,6 @@ import route from "./route.js"; */ export default function streamFeatures({ middleware }) { - middleware.use(route()); - function use(name, xmlns, handler) { return middleware.use((ctx, next) => { const { stanza } = ctx; diff --git a/packages/stream-features/route.js b/packages/stream-features/route.js deleted file mode 100644 index 0aed4f5c..00000000 --- a/packages/stream-features/route.js +++ /dev/null @@ -1,12 +0,0 @@ -export default function route() { - return async ({ stanza, entity }, next) => { - if (!stanza.is("features", "http://etherx.jabber.org/streams")) - return next(); - - // FIXME: instead of this prevent mechanism - // emit online once all stream features have negotiated - // and if entity.jid is set - const prevent = await next(); - if (!prevent && entity.jid) entity._status("online", entity.jid); - }; -} diff --git a/packages/stream-management/index.js b/packages/stream-management/index.js index 1cabd870..74fb74eb 100644 --- a/packages/stream-management/index.js +++ b/packages/stream-management/index.js @@ -63,12 +63,9 @@ export default function streamManagement({ max: null, }; - let address = null; - function resumed() { sm.enabled = true; - if (address) entity.jid = address; - entity.status = "online"; + entity._ready(true); } function failed() { @@ -83,8 +80,7 @@ export default function streamManagement({ sm.max = max; } - entity.on("online", (jid) => { - address = jid; + entity.on("online", () => { sm.outbound = 0; sm.inbound = 0; }); @@ -147,7 +143,7 @@ function setupStreamFeature({ try { await resume(entity, sm); resumed(); - return true; + return; // If resumption fails, continue with session establishment } catch { failed(); diff --git a/packages/tls/lib/Socket.js b/packages/tls/lib/Socket.js index 0f0c5ad4..1e3f2a64 100644 --- a/packages/tls/lib/Socket.js +++ b/packages/tls/lib/Socket.js @@ -8,6 +8,10 @@ class Socket extends EventEmitter { this.timeout = null; } + isSecure() { + return true; + } + connect(...args) { this._attachSocket(tls.connect(...args)); } diff --git a/packages/websocket/lib/Socket.js b/packages/websocket/lib/Socket.js index 171e7b0a..018e09d6 100644 --- a/packages/websocket/lib/Socket.js +++ b/packages/websocket/lib/Socket.js @@ -1,5 +1,6 @@ import WS from "ws"; import { EventEmitter } from "@xmpp/events"; +import { parseURI } from "@xmpp/connection/lib/util.js"; // eslint-disable-next-line n/no-unsupported-features/node-builtins const WebSocket = globalThis.WebSocket || WS; @@ -12,6 +13,14 @@ export default class Socket extends EventEmitter { this.listeners = Object.create(null); } + isSecure() { + if (!this.url) return false; + const uri = parseURI(this.url); + if (uri.protocol === "wss:") return true; + if (["localhost", "127.0.0.1", "::1"].includes(uri.hostname)) return true; + return false; + } + connect(url) { this.url = url; this._attachSocket(new WebSocket(url, ["xmpp"])); diff --git a/packages/websocket/test/Socket.js b/packages/websocket/test/Socket.js new file mode 100644 index 00000000..9f8d4c5d --- /dev/null +++ b/packages/websocket/test/Socket.js @@ -0,0 +1,21 @@ +import Socket from "../lib/Socket.js"; + +test("isSecure", () => { + const socket = new Socket(); + expect(socket.isSecure()).toBe(false); + + socket.url = "ws://example.com/foo"; + expect(socket.isSecure()).toBe(false); + + socket.url = "ws://localhost/foo"; + expect(socket.isSecure()).toBe(true); + + socket.url = "ws://127.0.0.1/foo"; + expect(socket.isSecure()).toBe(true); + + socket.url = "ws://[::1]/foo"; + expect(socket.isSecure()).toBe(true); + + socket.url = "wss://example.com/foo"; + expect(socket.isSecure()).toBe(true); +});