diff --git a/Makefile b/Makefile index 11e52185..9e717c36 100644 --- a/Makefile +++ b/Makefile @@ -34,10 +34,9 @@ e2e: $(warning e2e tests require prosody-trunk and luarocks) cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2 > /dev/null cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_bind2 > /dev/null - + cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null # https://github.com/xmppjs/xmpp.js/pull/1006 # cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_fast > /dev/null -# cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null npm run e2e clean: diff --git a/packages/client-core/src/bind2/bind2.js b/packages/client-core/src/bind2/bind2.js index 5862e115..4fd186bd 100644 --- a/packages/client-core/src/bind2/bind2.js +++ b/packages/client-core/src/bind2/bind2.js @@ -3,15 +3,51 @@ import xml from "@xmpp/xml"; const NS_BIND = "urn:xmpp:bind:0"; export default function bind2({ sasl2 }, tag) { - sasl2.use(NS_BIND, async (element) => { - if (!element.is("bind", NS_BIND)) return; + const features = new Map(); - tag = typeof tag === "function" ? await tag() : tag; + sasl2.use( + NS_BIND, + async (element) => { + if (!element.is("bind", NS_BIND)) return; - return xml( - "bind", - { xmlns: "urn:xmpp:bind:0" }, - tag && xml("tag", null, tag), - ); - }); + tag = typeof tag === "function" ? await tag() : tag; + + const sessionFeatures = await getSessionFeatures({ element, features }); + + return xml( + "bind", + { xmlns: "urn:xmpp:bind:0" }, + tag && xml("tag", null, tag), + ...sessionFeatures, + ); + }, + (element) => { + for (const child of element.getChildElements()) { + const feature = features.get(child.getNS()); + feature?.[1]?.(child); + } + }, + ); + + return { + use(ns, req, res) { + features.set(ns, [req, res]); + }, + }; +} + +function getSessionFeatures({ element, features }) { + const promises = []; + + const inline = element.getChild("inline"); + if (!inline) return promises; + + for (const element of inline.getChildElements()) { + const xmlns = element.attrs.var; + const feature = features.get(xmlns); + if (!feature) continue; + promises.push(feature[0](element)); + } + + return Promise.all(promises); } diff --git a/packages/client/README.md b/packages/client/README.md index aeab2f58..18804533 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -55,7 +55,7 @@ xmpp.on("offline", () => { console.log("offline"); }); -xmpp.on("stanza", async (stanza) => { +xmpp.once("stanza", async (stanza) => { if (stanza.is("message")) { await xmpp.send(xml("presence", { type: "unavailable" })); await xmpp.stop(); diff --git a/packages/client/example.js b/packages/client/example.js index 6bab1abc..9bd5f21d 100644 --- a/packages/client/example.js +++ b/packages/client/example.js @@ -24,7 +24,7 @@ xmpp.on("offline", () => { console.log("offline"); }); -xmpp.on("stanza", async (stanza) => { +xmpp.once("stanza", async (stanza) => { if (stanza.is("message")) { await xmpp.send(xml("presence", { type: "unavailable" })); await xmpp.stop(); diff --git a/packages/client/index.js b/packages/client/index.js index 86bbc2fb..14bcb46c 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -60,6 +60,11 @@ function client(options = {}) { { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), ); + + // SASL2 inline features + const bind2 = _bind2({ sasl2 }, resource); + + // Stream features - order matters and define priority const sasl = _sasl( { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), @@ -68,6 +73,8 @@ function client(options = {}) { streamFeatures, entity, middleware, + bind2, + sasl2, }); const resourceBinding = _resourceBinding( { iqCaller, streamFeatures }, @@ -78,9 +85,6 @@ function client(options = {}) { streamFeatures, }); - // SASL2 inline features - const bind2 = _bind2({ sasl2 }, resource); - return Object.assign(entity, { entity, reconnect, diff --git a/packages/component/README.md b/packages/component/README.md index 0a9c41e4..4a1fbb93 100644 --- a/packages/component/README.md +++ b/packages/component/README.md @@ -34,7 +34,7 @@ xmpp.on("offline", () => { console.log("offline"); }); -xmpp.on("stanza", async (stanza) => { +xmpp.once("stanza", async (stanza) => { if (stanza.is("message")) { await xmpp.stop(); } diff --git a/packages/component/example.js b/packages/component/example.js index ce5847b5..2c8bec4a 100644 --- a/packages/component/example.js +++ b/packages/component/example.js @@ -18,7 +18,7 @@ xmpp.on("offline", () => { console.log("offline"); }); -xmpp.on("stanza", async (stanza) => { +xmpp.once("stanza", async (stanza) => { if (stanza.is("message")) { await xmpp.stop(); } diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js index b8ad5e46..29c237be 100644 --- a/packages/sasl2/index.js +++ b/packages/sasl2/index.js @@ -1,6 +1,5 @@ import { encode, decode } from "@xmpp/base64"; import SASLError from "@xmpp/sasl/lib/SASLError.js"; -import jid from "@xmpp/jid"; import xml from "@xmpp/xml"; // https://xmpp.org/extensions/xep-0388.html @@ -17,7 +16,8 @@ async function authenticate({ mechanism, credentials, userAgent, - sessionFeatures, + streamFeatures, + features, }) { const mech = saslFactory.create([mechanism]); if (!mech) { @@ -38,7 +38,7 @@ async function authenticate({ return new Promise((resolve, reject) => { const handler = (element) => { - if (element.attrs.xmlns !== NS) { + if (element.getNS() !== NS) { return; } @@ -61,7 +61,6 @@ async function authenticate({ } if (element.name === "continue") { - // No tasks supported yet reject(new Error("continue is not supported yet")); return; } @@ -71,19 +70,18 @@ async function authenticate({ if (additionalData && mech.final) { mech.final(decode(additionalData)); } - // This jid will be bare unless we do inline bind2 then it will be the bound full jid - const aid = element.getChild("authorization-identifier")?.text(); - if (aid) { - if (!entity.jid?.resource) { - // No jid or bare jid, so update it - entity._jid(aid); - } else if (jid(aid).resource) { - // We have a full jid so use it - entity._jid(aid); - } + + // 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. + const aid = element.getChildText("authorization-identifier"); + if (aid) entity._jid(aid); + + for (const child of element.getChildElements()) { + const feature = features.get(child.getNS()); + feature?.[1]?.(child); } + resolve(element); - return; } entity.removeListener("nonza", handler); @@ -97,7 +95,7 @@ async function authenticate({ mech.clientFirst && xml("initial-response", {}, encode(mech.response(creds))), userAgent, - ...sessionFeatures, + ...streamFeatures, ]), ) .catch(reject); @@ -105,7 +103,6 @@ async function authenticate({ } export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { - // inline const features = new Map(); streamFeatures.use( @@ -120,7 +117,7 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { throw new SASLError("SASL: No compatible mechanism available."); } - const sessionFeatures = await getSessionFeatures({ element, features }); + const streamFeatures = await getStreamFeatures({ element, features }); async function done(credentials, mechanism, userAgent) { await authenticate({ @@ -129,24 +126,25 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { mechanism, credentials, userAgent, - sessionFeatures, + streamFeatures, + features, }); } await onAuthenticate(done, intersection); - - return true; // Not online yet, wait for next features + // Not online yet, wait for next features + return true; }, ); return { use(ns, req, res) { - features.set(ns, req, res); + features.set(ns, [req, res]); }, }; } -function getSessionFeatures({ element, features }) { +function getStreamFeatures({ element, features }) { const promises = []; const inline = element.getChild("inline"); @@ -156,7 +154,7 @@ function getSessionFeatures({ element, features }) { const xmlns = element.getNS(); const feature = features.get(xmlns); if (!feature) continue; - promises.push(feature(element)); + promises.push(feature[0](element)); } return Promise.all(promises); diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js index 0be1f91a..0a7e696c 100644 --- a/packages/sasl2/test.js +++ b/packages/sasl2/test.js @@ -129,7 +129,7 @@ test("prefers SCRAM-SHA-1", async () => { expect(result.attrs.mechanism).toEqual("SCRAM-SHA-1"); }); -test.skip("use ANONYMOUS if username and password are not provided", async () => { +test("use ANONYMOUS if username and password are not provided", async () => { const { entity } = mockClient(); entity.mockInput( diff --git a/packages/stream-features/route.js b/packages/stream-features/route.js index 03e6ceb8..0aed4f5c 100644 --- a/packages/stream-features/route.js +++ b/packages/stream-features/route.js @@ -3,6 +3,9 @@ export default function route() { 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/README.md b/packages/stream-management/README.md index 7377c3dd..296b8ad4 100644 --- a/packages/stream-management/README.md +++ b/packages/stream-management/README.md @@ -11,3 +11,7 @@ However `entity.status` is set to `online`. If the session fails to resume, entity will fallback to regular session establishment in which case `online` event will be emitted. Automatically responds to acks but does not support requesting acks yet. + +## References + +[XEP-0198: Stream Management](https://xmpp.org/extensions/xep-0198.html#inline-enable) diff --git a/packages/stream-management/bind2.test.js b/packages/stream-management/bind2.test.js new file mode 100644 index 00000000..4bdfdbbf --- /dev/null +++ b/packages/stream-management/bind2.test.js @@ -0,0 +1,96 @@ +import { mockClient } from "@xmpp/test"; + +test("enable", async () => { + const { entity, streamManagement: sm } = mockClient(); + + entity.mockInput( + + + PLAIN + + + + + + + + + + , + ); + + const stanza_out = await entity.catchOutgoing(); + expect(stanza_out).toEqual( + + {stanza_out.getChild("initial-response")} + + + + , + ); + + expect(sm.enabled).toBe(false); + expect(sm.id).toBe(""); + expect(sm.max).toBe(null); + + entity.mockInput( + + + + + , + ); + + expect(sm.enabled).toBe(true); + expect(sm.id).toBe("2j44j2"); + expect(sm.max).toBe("600"); +}); + +// https://xmpp.org/extensions/xep-0198.html#example-29 +test("Client failed to enable stream management", async () => { + const { entity, streamManagement: sm } = mockClient(); + + entity.mockInput( + + + PLAIN + + + + + + + + + + , + ); + + const stanza_out = await entity.catchOutgoing(); + expect(stanza_out).toEqual( + + {stanza_out.getChild("initial-response")} + + + + , + ); + + expect(sm.enabled).toBe(false); + expect(sm.id).toBe(""); + expect(sm.max).toBe(null); + + entity.mockInput( + + + + + + + , + ); + + expect(sm.enabled).toBe(false); + expect(sm.id).toBe(""); + expect(sm.max).toBe(null); +}); diff --git a/packages/stream-management/index.js b/packages/stream-management/index.js index 4ec2bff7..1cabd870 100644 --- a/packages/stream-management/index.js +++ b/packages/stream-management/index.js @@ -4,10 +4,20 @@ import xml from "@xmpp/xml"; const NS = "urn:xmpp:sm:3"; -async function enable(entity, resume, max) { - await entity.send( - xml("enable", { xmlns: NS, max, resume: resume ? "true" : undefined }), - ); +function makeEnableElement({ sm }) { + return xml("enable", { + xmlns: NS, + max: sm.preferredMaximum, + resume: sm.allowResume ? "true" : undefined, + }); +} + +function makeResumeElement({ sm }) { + return xml("resume", { xmlns: NS, h: sm.inbound, previd: sm.id }); +} + +async function enable(entity, sm) { + await entity.send(makeEnableElement({ sm })); return new Promise((resolve, reject) => { function listener(nonza) { @@ -26,10 +36,8 @@ async function enable(entity, resume, max) { }); } -async function resume(entity, h, previd) { - const response = await entity.sendReceive( - xml("resume", { xmlns: NS, h, previd }), - ); +async function resume(entity, sm) { + const response = await entity.sendReceive(makeResumeElement({ sm })); if (!response.is("resumed", NS)) { throw response; @@ -42,9 +50,9 @@ export default function streamManagement({ streamFeatures, entity, middleware, + bind2, + sasl2, }) { - let address = null; - const sm = { allowResume: true, preferredMaximum: null, @@ -55,6 +63,26 @@ export default function streamManagement({ max: null, }; + let address = null; + + function resumed() { + sm.enabled = true; + if (address) entity.jid = address; + entity.status = "online"; + } + + function failed() { + sm.enabled = false; + sm.id = ""; + sm.outbound = 0; + } + + function enabled({ id, max }) { + sm.enabled = true; + sm.id = id; + sm.max = max; + } + entity.on("online", (jid) => { address = jid; sm.outbound = 0; @@ -83,23 +111,46 @@ export default function streamManagement({ return next(); }); + if (bind2) { + setupBind2({ bind2, sm, failed, enabled }); + } + if (sasl2) { + setupSasl2({ sasl2, sm, failed, resumed }); + } + if (streamFeatures) { + setupStreamFeature({ + streamFeatures, + sm, + entity, + resumed, + failed, + enabled, + }); + } + + return sm; +} + +function setupStreamFeature({ + streamFeatures, + sm, + entity, + resumed, + failed, + enabled, +}) { // https://xmpp.org/extensions/xep-0198.html#enable // For client-to-server connections, the client MUST NOT attempt to enable stream management until after it has completed Resource Binding unless it is resuming a previous session - streamFeatures.use("sm", NS, async (context, next) => { // Resuming if (sm.id) { try { - await resume(entity, sm.inbound, sm.id); - sm.enabled = true; - entity.jid = address; - entity.status = "online"; + await resume(entity, sm); + resumed(); return true; // If resumption fails, continue with session establishment } catch { - sm.id = ""; - sm.enabled = false; - sm.outbound = 0; + failed(); } } @@ -108,22 +159,54 @@ export default function streamManagement({ // Resource binding first await next(); - const promiseEnable = enable(entity, sm.allowResume, sm.preferredMaximum); + const promiseEnable = enable(entity, sm); // > The counter for an entity's own sent stanzas is set to zero and started after sending either or . sm.outbound = 0; try { const response = await promiseEnable; - sm.enabled = true; - sm.id = response.attrs.id; - sm.max = response.attrs.max; + enabled(response.attrs); } catch { sm.enabled = false; } sm.inbound = 0; }); +} - return sm; +function setupSasl2({ sasl2, sm, failed, resumed }) { + sasl2.use( + "urn:xmpp:sm:3", + (element) => { + if (!element.is("sm")) return; + if (sm.id) return makeResumeElement({ sm }); + }, + (element) => { + if (element.is("resumed")) { + resumed(); + } else if (element.is(failed)) { + // const error = StreamError.fromElement(element) + failed(); + } + }, + ); +} + +function setupBind2({ bind2, sm, failed, enabled }) { + bind2.use( + "urn:xmpp:sm:3", + // https://xmpp.org/extensions/xep-0198.html#inline-examples + (_element) => { + return makeEnableElement({ sm }); + }, + (element) => { + if (element.is("enabled")) { + enabled(element.attrs); + } else if (element.is("failed")) { + // const error = StreamError.fromElement(element) + failed(); + } + }, + ); } diff --git a/packages/stream-management/sasl2.test.js b/packages/stream-management/sasl2.test.js new file mode 100644 index 00000000..697fc2d2 --- /dev/null +++ b/packages/stream-management/sasl2.test.js @@ -0,0 +1,78 @@ +import { mockClient } from "@xmpp/test"; + +test("resume", async () => { + const { entity, streamManagement: sm } = mockClient(); + + sm.id = Math.random().toString().slice(2); + + entity.mockInput( + + + PLAIN + + + + + , + ); + + sm.outbound = 45; + sm.inbound = 54; + + // eslint-disable-next-line unicorn/no-await-expression-member + const element_resume = (await entity.catchOutgoing()).getChild("resume"); + element_resume.parent = null; + expect(element_resume).toEqual( + , + ); + + entity.mockInput( + + + , + ); + + expect(entity.streamManagement.outbound).toBe(45); + expect(entity.streamManagement.inbound).toBe(54); + expect(entity.streamManagement.enabled).toBe(true); +}); + +// https://xmpp.org/extensions/xep-0198.html#example-30 +test("Client failed to resume stream", async () => { + const { entity, streamManagement: sm } = mockClient(); + + sm.id = Math.random().toString().slice(2); + + entity.mockInput( + + + PLAIN + + + + + , + ); + + sm.outbound = 45; + sm.inbound = 54; + + // eslint-disable-next-line unicorn/no-await-expression-member + const element_resume = (await entity.catchOutgoing()).getChild("resume"); + element_resume.parent = null; + expect(element_resume).toEqual( + , + ); + + entity.mockInput( + + + + + , + ); + + expect(entity.streamManagement.outbound).toBe(45); + expect(entity.streamManagement.inbound).toBe(54); + expect(entity.streamManagement.enabled).toBe(false); +}); diff --git a/packages/stream-management/test.js b/packages/stream-management/stream-features.test.js similarity index 100% rename from packages/stream-management/test.js rename to packages/stream-management/stream-features.test.js diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua index 56f05b3a..c917b4a2 100644 --- a/server/prosody.cfg.lua +++ b/server/prosody.cfg.lua @@ -24,9 +24,9 @@ modules_enabled = { "smacks"; "sasl2"; "sasl2_bind2"; + "sasl2_sm"; -- https://github.com/xmppjs/xmpp.js/pull/1006 -- "sasl2_fast"; - -- "sasl2_sm"; }; modules_disabled = {