Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement sasl2 stream management #1037

Merged
merged 5 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading