Skip to content

Commit

Permalink
Implement sasl2 stream management (#1037)
Browse files Browse the repository at this point in the history
---
Co-authored-by: Stephen Paul Weber [email protected]
  • Loading branch information
sonnyp authored Dec 29, 2024
1 parent c3c8672 commit 5d751dd
Show file tree
Hide file tree
Showing 16 changed files with 368 additions and 67 deletions.
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 45 additions & 9 deletions packages/client-core/src/bind2/bind2.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/client/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 7 additions & 3 deletions packages/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -68,6 +73,8 @@ function client(options = {}) {
streamFeatures,
entity,
middleware,
bind2,
sasl2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
Expand All @@ -78,9 +85,6 @@ function client(options = {}) {
streamFeatures,
});

// SASL2 inline features
const bind2 = _bind2({ sasl2 }, resource);

return Object.assign(entity, {
entity,
reconnect,
Expand Down
2 changes: 1 addition & 1 deletion packages/component/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/component/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
46 changes: 22 additions & 24 deletions packages/sasl2/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,7 +16,8 @@ async function authenticate({
mechanism,
credentials,
userAgent,
sessionFeatures,
streamFeatures,
features,
}) {
const mech = saslFactory.create([mechanism]);
if (!mech) {
Expand All @@ -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;
}

Expand All @@ -61,7 +61,6 @@ async function authenticate({
}

if (element.name === "continue") {
// No tasks supported yet
reject(new Error("continue is not supported yet"));
return;
}
Expand All @@ -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);
Expand All @@ -97,15 +95,14 @@ async function authenticate({
mech.clientFirst &&
xml("initial-response", {}, encode(mech.response(creds))),
userAgent,
...sessionFeatures,
...streamFeatures,
]),
)
.catch(reject);
});
}

export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) {
// inline
const features = new Map();

streamFeatures.use(
Expand All @@ -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({
Expand All @@ -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");
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/sasl2/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/stream-features/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
4 changes: 4 additions & 0 deletions packages/stream-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
96 changes: 96 additions & 0 deletions packages/stream-management/bind2.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mockClient } from "@xmpp/test";

test("enable", async () => {
const { entity, streamManagement: sm } = mockClient();

entity.mockInput(
<features xmlns="http://etherx.jabber.org/streams">
<authentication xmlns="urn:xmpp:sasl:2">
<mechanism>PLAIN</mechanism>
<inline>
<bind xmlns="urn:xmpp:bind:0">
<inline>
<feature var="urn:xmpp:sm:3" />
</inline>
</bind>
<sm xmlns="urn:xmpp:sm:3" />
</inline>
</authentication>
</features>,
);

const stanza_out = await entity.catchOutgoing();
expect(stanza_out).toEqual(
<authenticate xmlns="urn:xmpp:sasl:2" mechanism="PLAIN">
{stanza_out.getChild("initial-response")}
<bind xmlns="urn:xmpp:bind:0">
<enable xmlns="urn:xmpp:sm:3" resume="true" />
</bind>
</authenticate>,
);

expect(sm.enabled).toBe(false);
expect(sm.id).toBe("");
expect(sm.max).toBe(null);

entity.mockInput(
<success xmlns="urn:xmpp:sasl:2">
<bound xmlns="urn:xmpp:bind:0">
<enabled resume="1" xmlns="urn:xmpp:sm:3" id="2j44j2" max="600" />
</bound>
</success>,
);

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(
<features xmlns="http://etherx.jabber.org/streams">
<authentication xmlns="urn:xmpp:sasl:2">
<mechanism>PLAIN</mechanism>
<inline>
<bind xmlns="urn:xmpp:bind:0">
<inline>
<feature var="urn:xmpp:sm:3" />
</inline>
</bind>
<sm xmlns="urn:xmpp:sm:3" />
</inline>
</authentication>
</features>,
);

const stanza_out = await entity.catchOutgoing();
expect(stanza_out).toEqual(
<authenticate xmlns="urn:xmpp:sasl:2" mechanism="PLAIN">
{stanza_out.getChild("initial-response")}
<bind xmlns="urn:xmpp:bind:0">
<enable xmlns="urn:xmpp:sm:3" resume="true" />
</bind>
</authenticate>,
);

expect(sm.enabled).toBe(false);
expect(sm.id).toBe("");
expect(sm.max).toBe(null);

entity.mockInput(
<success xmlns="urn:xmpp:sasl:2">
<bound xmlns="urn:xmpp:bind:0">
<failed xmlns="urn:xmpp:sm:3">
<internal-server-error xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" />
</failed>
</bound>
</success>,
);

expect(sm.enabled).toBe(false);
expect(sm.id).toBe("");
expect(sm.max).toBe(null);
});
Loading

0 comments on commit 5d751dd

Please sign in to comment.