diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a6a16a82..47bab9f5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -27,7 +27,7 @@ jobs: echo deb http://packages.prosody.im/debian $(lsb_release -sc) main | sudo tee /etc/apt/sources.list.d/prosody.list sudo wget https://prosody.im/files/prosody-debian-packages.key -O/etc/apt/trusted.gpg.d/prosody.gpg sudo apt-get update - sudo apt-get -y install prosody lua-bitop lua-sec + sudo apt-get -y install lua5.3 liblua5.3-dev prosody-trunk lua-bitop lua-sec luarocks sudo service prosody stop # - run: npm install -g npm diff --git a/.gitignore b/.gitignore index f10012a2..2e1fdeb4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ server/certs/ server/prosody.err server/prosody.log server/prosody.pid +server/modules +server/.cache !.gitkeep !.editorconfig diff --git a/Makefile b/Makefile index 487ab4ea..d1f0b5e3 100644 --- a/Makefile +++ b/Makefile @@ -28,10 +28,15 @@ ci: make bundlesize unit: - npx jest + npm run test e2e: - NODE_TLS_REJECT_UNAUTHORIZED=0 npx jest --runInBand --config e2e.config.cjs + $(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_fast > /dev/null + cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null + npm run e2e clean: make stop diff --git a/package-lock.json b/package-lock.json index ceef3302..73fc2e01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4569,6 +4569,10 @@ "resolved": "packages/sasl-anonymous", "link": true }, + "node_modules/@xmpp/sasl-ht-sha-256-none": { + "resolved": "packages/sasl-ht-sha-256-none", + "link": true + }, "node_modules/@xmpp/sasl-plain": { "resolved": "packages/sasl-plain", "link": true @@ -4577,6 +4581,10 @@ "resolved": "packages/sasl-scram-sha-1", "link": true }, + "node_modules/@xmpp/sasl2": { + "resolved": "packages/sasl2", + "link": true + }, "node_modules/@xmpp/session-establishment": { "resolved": "packages/session-establishment", "link": true @@ -13095,6 +13103,8 @@ }, "node_modules/saslmechanisms": { "version": "0.1.1", + "resolved": "https://registry.npmjs.org/saslmechanisms/-/saslmechanisms-0.1.1.tgz", + "integrity": "sha512-pVlvK5ysevz8MzybRnDIa2YMxn0OJ7b9lDiWhMoaKPoJ7YkAg/7YtNjUgaYzElkwHxsw8dBMhaEn7UP6zxEwPg==", "engines": { "node": ">= 0.4.0" } @@ -14557,15 +14567,18 @@ "@xmpp/resource-binding": "^0.13.2", "@xmpp/sasl": "^0.13.2", "@xmpp/sasl-anonymous": "^0.13.2", + "@xmpp/sasl-ht-sha-256-none": "^0.13.0", "@xmpp/sasl-plain": "^0.13.2", "@xmpp/sasl-scram-sha-1": "^0.13.2", + "@xmpp/sasl2": "^0.13.0", "@xmpp/session-establishment": "^0.13.2", "@xmpp/starttls": "^0.13.2", "@xmpp/stream-features": "^0.13.2", "@xmpp/stream-management": "^0.13.2", "@xmpp/tcp": "^0.13.2", "@xmpp/tls": "^0.13.2", - "@xmpp/websocket": "^0.13.2" + "@xmpp/websocket": "^0.13.2", + "saslmechanisms": "^0.1.1" }, "engines": { "node": ">= 20" @@ -14753,8 +14766,7 @@ "dependencies": { "@xmpp/base64": "^0.13.2", "@xmpp/error": "^0.13.2", - "@xmpp/xml": "^0.13.2", - "saslmechanisms": "^0.1.1" + "@xmpp/xml": "^0.13.2" }, "engines": { "node": ">= 20" @@ -14771,6 +14783,17 @@ "node": ">= 20" } }, + "packages/sasl-ht-sha-256-none": { + "name": "@xmpp/sasl-ht-sha-256-none", + "version": "0.13.0", + "license": "ISC", + "dependencies": { + "create-hmac": "^1.1.7" + }, + "engines": { + "node": ">= 14" + } + }, "packages/sasl-plain": { "name": "@xmpp/sasl-plain", "version": "0.13.2", @@ -14793,6 +14816,21 @@ "node": ">= 20" } }, + "packages/sasl2": { + "name": "@xmpp/sasl2", + "version": "0.13.0", + "license": "ISC", + "dependencies": { + "@xmpp/base64": "^0.13.0", + "@xmpp/error": "^0.13.0", + "@xmpp/jid": "^0.13.0", + "@xmpp/sasl": "^0.13.2", + "@xmpp/xml": "^0.13.0" + }, + "engines": { + "node": ">= 14" + } + }, "packages/session-establishment": { "name": "@xmpp/session-establishment", "version": "0.13.2", @@ -14945,8 +14983,10 @@ "@xmpp/resource-binding": "^0.13.2", "@xmpp/sasl": "^0.13.2", "@xmpp/sasl-anonymous": "^0.13.2", + "@xmpp/sasl-ht-sha-256-none": "^0.13.0", "@xmpp/sasl-plain": "^0.13.2", "@xmpp/sasl-scram-sha-1": "^0.13.2", + "@xmpp/sasl2": "^0.13.0", "@xmpp/session-establishment": "^0.13.2", "@xmpp/starttls": "^0.13.2", "@xmpp/stream-features": "^0.13.2", diff --git a/package.json b/package.json index 8629245d..fdd16165 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "selfsigned": "^2.4.1" }, "scripts": { + "test": "npx jest", + "e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 npx jest --runInBand --config e2e.config.cjs", "preversion": "make bundle" }, "engines": { diff --git a/packages/client/browser.js b/packages/client/browser.js index 3812fb45..baecf921 100644 --- a/packages/client/browser.js +++ b/packages/client/browser.js @@ -1,5 +1,6 @@ import { xml, jid, Client } from "@xmpp/client-core"; import getDomain from "./lib/getDomain.js"; +import SASLFactory from "saslmechanisms"; import _reconnect from "@xmpp/reconnect"; import _websocket from "@xmpp/websocket"; @@ -8,7 +9,7 @@ import _streamFeatures from "@xmpp/stream-features"; import _iqCaller from "@xmpp/iq/caller.js"; import _iqCallee from "@xmpp/iq/callee.js"; import _resolve from "@xmpp/resolve"; - +import _sasl2 from "@xmpp/sasl2"; import _sasl from "@xmpp/sasl"; import _resourceBinding from "@xmpp/resource-binding"; import _sessionEstablishment from "@xmpp/session-establishment"; @@ -19,6 +20,7 @@ import anonymous from "@xmpp/sasl-anonymous"; function client(options = {}) { const { resource, credentials, username, password, ...params } = options; + const { clientId, software, device } = params; const { domain, service } = params; if (!domain && service) { @@ -36,11 +38,30 @@ function client(options = {}) { const iqCallee = _iqCallee({ middleware, entity }); const resolve = _resolve({ entity }); // Stream features - order matters and define priority - const sasl = _sasl({ streamFeatures }, credentials || { username, password }); + + const saslFactory = new SASLFactory(); + // SASL mechanisms - order matters and define priority + const mechanisms = Object.entries({ + plain, + anonymous, + }).map(([k, v]) => ({ [k]: v(saslFactory) })); + + const sasl2 = _sasl2( + { streamFeatures, saslFactory }, + credentials || { username, password }, + { clientId, software, device }, + ); + + const sasl = _sasl( + { streamFeatures, saslFactory }, + credentials || { username, password }, + ); + const streamManagement = _streamManagement({ streamFeatures, entity, middleware, + sasl2, }); const resourceBinding = _resourceBinding( { iqCaller, streamFeatures }, @@ -50,10 +71,6 @@ function client(options = {}) { iqCaller, streamFeatures, }); - // SASL mechanisms - order matters and define priority - const mechanisms = Object.entries({ plain, anonymous }).map(([k, v]) => ({ - [k]: v(sasl), - })); return Object.assign(entity, { entity, @@ -64,6 +81,8 @@ function client(options = {}) { iqCaller, iqCallee, resolve, + saslFactory, + sasl2, sasl, resourceBinding, sessionEstablishment, diff --git a/packages/client/index.js b/packages/client/index.js index e944db40..2828ccc8 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -1,5 +1,6 @@ import { xml, jid, Client } from "@xmpp/client-core"; import getDomain from "./lib/getDomain.js"; +import SASLFactory from "saslmechanisms"; import _reconnect from "@xmpp/reconnect"; import _websocket from "@xmpp/websocket"; @@ -10,19 +11,21 @@ import _streamFeatures from "@xmpp/stream-features"; import _iqCaller from "@xmpp/iq/caller.js"; import _iqCallee from "@xmpp/iq/callee.js"; import _resolve from "@xmpp/resolve"; - import _starttls from "@xmpp/starttls/client.js"; +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 scramsha1 from "@xmpp/sasl-scram-sha-1"; +import htsha256 from "@xmpp/sasl-ht-sha-256-none"; import plain from "@xmpp/sasl-plain"; import anonymous from "@xmpp/sasl-anonymous"; function client(options = {}) { const { resource, credentials, username, password, ...params } = options; + const { clientId, software, device } = params; const { domain, service } = params; if (!domain && service) { @@ -43,11 +46,32 @@ function client(options = {}) { const resolve = _resolve({ entity }); // Stream features - order matters and define priority const starttls = _starttls({ streamFeatures }); - const sasl = _sasl({ streamFeatures }, credentials || { username, password }); + + const saslFactory = new SASLFactory(); + // SASL mechanisms - order matters and define priority + const mechanisms = Object.entries({ + scramsha1, + htsha256, + plain, + anonymous, + }).map(([k, v]) => ({ [k]: v(saslFactory) })); + + const sasl2 = _sasl2( + { streamFeatures, saslFactory }, + credentials || { username, password }, + { clientId, software, device }, + ); + + const sasl = _sasl( + { streamFeatures, saslFactory }, + credentials || { username, password }, + ); + const streamManagement = _streamManagement({ streamFeatures, entity, middleware, + sasl2, }); const resourceBinding = _resourceBinding( { iqCaller, streamFeatures }, @@ -57,12 +81,6 @@ function client(options = {}) { iqCaller, streamFeatures, }); - // SASL mechanisms - order matters and define priority - const mechanisms = Object.entries({ - scramsha1, - plain, - anonymous, - }).map(([k, v]) => ({ [k]: v(sasl) })); return Object.assign(entity, { entity, @@ -76,6 +94,8 @@ function client(options = {}) { iqCallee, resolve, starttls, + saslFactory, + sasl2, sasl, resourceBinding, sessionEstablishment, diff --git a/packages/client/package.json b/packages/client/package.json index 59b6cdfb..b71d73da 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -16,15 +16,18 @@ "@xmpp/resource-binding": "^0.13.2", "@xmpp/sasl": "^0.13.2", "@xmpp/sasl-anonymous": "^0.13.2", + "@xmpp/sasl-ht-sha-256-none": "^0.13.0", "@xmpp/sasl-plain": "^0.13.2", "@xmpp/sasl-scram-sha-1": "^0.13.2", + "@xmpp/sasl2": "^0.13.0", "@xmpp/session-establishment": "^0.13.2", "@xmpp/starttls": "^0.13.2", "@xmpp/stream-features": "^0.13.2", "@xmpp/stream-management": "^0.13.2", "@xmpp/tcp": "^0.13.2", "@xmpp/tls": "^0.13.2", - "@xmpp/websocket": "^0.13.2" + "@xmpp/websocket": "^0.13.2", + "saslmechanisms": "^0.1.1" }, "browser": "./browser.js", "react-native": "./react-native.js", diff --git a/packages/client/react-native.js b/packages/client/react-native.js index 3812fb45..baecf921 100644 --- a/packages/client/react-native.js +++ b/packages/client/react-native.js @@ -1,5 +1,6 @@ import { xml, jid, Client } from "@xmpp/client-core"; import getDomain from "./lib/getDomain.js"; +import SASLFactory from "saslmechanisms"; import _reconnect from "@xmpp/reconnect"; import _websocket from "@xmpp/websocket"; @@ -8,7 +9,7 @@ import _streamFeatures from "@xmpp/stream-features"; import _iqCaller from "@xmpp/iq/caller.js"; import _iqCallee from "@xmpp/iq/callee.js"; import _resolve from "@xmpp/resolve"; - +import _sasl2 from "@xmpp/sasl2"; import _sasl from "@xmpp/sasl"; import _resourceBinding from "@xmpp/resource-binding"; import _sessionEstablishment from "@xmpp/session-establishment"; @@ -19,6 +20,7 @@ import anonymous from "@xmpp/sasl-anonymous"; function client(options = {}) { const { resource, credentials, username, password, ...params } = options; + const { clientId, software, device } = params; const { domain, service } = params; if (!domain && service) { @@ -36,11 +38,30 @@ function client(options = {}) { const iqCallee = _iqCallee({ middleware, entity }); const resolve = _resolve({ entity }); // Stream features - order matters and define priority - const sasl = _sasl({ streamFeatures }, credentials || { username, password }); + + const saslFactory = new SASLFactory(); + // SASL mechanisms - order matters and define priority + const mechanisms = Object.entries({ + plain, + anonymous, + }).map(([k, v]) => ({ [k]: v(saslFactory) })); + + const sasl2 = _sasl2( + { streamFeatures, saslFactory }, + credentials || { username, password }, + { clientId, software, device }, + ); + + const sasl = _sasl( + { streamFeatures, saslFactory }, + credentials || { username, password }, + ); + const streamManagement = _streamManagement({ streamFeatures, entity, middleware, + sasl2, }); const resourceBinding = _resourceBinding( { iqCaller, streamFeatures }, @@ -50,10 +71,6 @@ function client(options = {}) { iqCaller, streamFeatures, }); - // SASL mechanisms - order matters and define priority - const mechanisms = Object.entries({ plain, anonymous }).map(([k, v]) => ({ - [k]: v(sasl), - })); return Object.assign(entity, { entity, @@ -64,6 +81,8 @@ function client(options = {}) { iqCaller, iqCallee, resolve, + saslFactory, + sasl2, sasl, resourceBinding, sessionEstablishment, diff --git a/packages/connection/index.js b/packages/connection/index.js index 5ab1a714..ca6cb249 100644 --- a/packages/connection/index.js +++ b/packages/connection/index.js @@ -256,6 +256,15 @@ class Connection extends EventEmitter { const headerElement = this.headerElement(); headerElement.attrs.to = domain; + if ( + this.socket.secure && + this.socket.secure() && + (this.streamFrom || this.jid) + ) { + // When the stream is secure there is no leak to setting the stream from + // This is recommended in general and required for FAST implementations + headerElement.attrs.from = (this.streamFrom || this.jid).toString(); + } headerElement.attrs["xml:lang"] = lang; this.root = headerElement; diff --git a/packages/error/test.js b/packages/error/test.js index da3b3867..5905200d 100644 --- a/packages/error/test.js +++ b/packages/error/test.js @@ -20,6 +20,7 @@ test("fromElement", () => { const error = XMPPError.fromElement(nonza); expect(error instanceof Error).toBe(true); + expect(error instanceof XMPPError).toBe(true); expect(error.name).toBe("XMPPError"); expect(error.condition).toBe("some-condition"); expect(error.text).toBe("foo"); @@ -42,6 +43,7 @@ test("fromElement - whitespaces", () => { const error = XMPPError.fromElement(nonza); expect(error instanceof Error).toBe(true); + expect(error instanceof XMPPError).toBe(true); expect(error.name).toBe("XMPPError"); expect(error.condition).toBe("some-condition"); expect(error.text).toBe("\n foo\n "); diff --git a/packages/sasl-ht-sha-256-none/index.js b/packages/sasl-ht-sha-256-none/index.js new file mode 100644 index 00000000..5a38015a --- /dev/null +++ b/packages/sasl-ht-sha-256-none/index.js @@ -0,0 +1,27 @@ +// https://datatracker.ietf.org/doc/draft-schmaus-kitten-sasl-ht/ +import createHmac from "create-hmac"; + +function Mechanism() {} + +Mechanism.prototype.Mechanism = Mechanism; +Mechanism.prototype.name = "HT-SHA-256-NONE"; +Mechanism.prototype.clientFirst = true; + +Mechanism.prototype.response = function response(cred) { + this.password = cred.password; + const hmac = createHmac("sha256", this.password); + hmac.update("Initiator"); + return cred.username + "\0" + hmac.digest("latin1"); +}; + +Mechanism.prototype.final = function final(data) { + const hmac = createHmac("sha256", this.password); + hmac.update("Responder"); + if (hmac.digest("latin1") !== data) { + throw new Error("Responder message from server was wrong"); + } +}; + +export default function saslHashedToken(sasl) { + sasl.use(Mechanism); +} diff --git a/packages/sasl-ht-sha-256-none/package.json b/packages/sasl-ht-sha-256-none/package.json new file mode 100644 index 00000000..3951a476 --- /dev/null +++ b/packages/sasl-ht-sha-256-none/package.json @@ -0,0 +1,24 @@ +{ + "name": "@xmpp/sasl-ht-sha-256-none", + "description": "XMPP SASL HT-SHA-256-NONE for JavaScript", + "repository": "github:xmppjs/xmpp.js", + "homepage": "https://github.com/xmppjs/xmpp.js/tree/main/packages/sasl-ht-sha-256-none", + "bugs": "http://github.com/xmppjs/xmpp.js/issues", + "version": "0.13.0", + "license": "ISC", + "type": "module", + "keywords": [ + "XMPP", + "sasl", + "plain" + ], + "dependencies": { + "create-hmac": "^1.1.7" + }, + "engines": { + "node": ">= 14" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sasl/README.md b/packages/sasl/README.md index c29bccd2..8efc7145 100644 --- a/packages/sasl/README.md +++ b/packages/sasl/README.md @@ -36,7 +36,7 @@ import { xmpp } from "@xmpp/client"; const client = xmpp({ credentials: authenticate }); -async function authenticate(auth, mechanism) { +async function authenticate(saslFactory, mechanism) { console.debug("authenticate", mechanism); const credentials = { username: await prompt("enter username"), diff --git a/packages/sasl/index.js b/packages/sasl/index.js index 42f6297d..fdc293e1 100644 --- a/packages/sasl/index.js +++ b/packages/sasl/index.js @@ -1,7 +1,6 @@ import { encode, decode } from "@xmpp/base64"; import SASLError from "./lib/SASLError.js"; import xml from "@xmpp/xml"; -import SASLFactory from "saslmechanisms"; // https://xmpp.org/rfcs/rfc6120.html#sasl @@ -14,8 +13,8 @@ function getMechanismNames(features) { .map((el) => el.text()); } -async function authenticate(SASL, entity, mechname, credentials) { - const mech = SASL.create([mechname]); +async function authenticate(saslFactory, entity, mechname, credentials) { + const mech = saslFactory.create([mechname]); if (!mech) { throw new Error("No compatible mechanism"); } @@ -74,12 +73,10 @@ async function authenticate(SASL, entity, mechname, credentials) { }); } -export default function sasl({ streamFeatures }, credentials) { - const SASL = new SASLFactory(); - +export default function sasl({ streamFeatures, saslFactory }, credentials) { streamFeatures.use("mechanisms", NS, async ({ stanza, entity }) => { const offered = getMechanismNames(stanza); - const supported = SASL._mechs.map(({ name }) => name); + const supported = saslFactory._mechs.map(({ name }) => name); // eslint-disable-next-line unicorn/prefer-array-find const intersection = supported.filter((mech) => { return offered.includes(mech); @@ -88,7 +85,7 @@ export default function sasl({ streamFeatures }, credentials) { if (typeof credentials === "function") { await credentials( - (creds) => authenticate(SASL, entity, mech, creds, stanza), + (creds) => authenticate(saslFactory, entity, mech, creds, stanza), mech, ); } else { @@ -96,15 +93,11 @@ export default function sasl({ streamFeatures }, credentials) { mech = "ANONYMOUS"; } - await authenticate(SASL, entity, mech, credentials, stanza); + await authenticate(saslFactory, entity, mech, credentials, stanza); } await entity.restart(); }); - return { - use(...args) { - return SASL.use(...args); - }, - }; + return {}; } diff --git a/packages/sasl/lib/SASLError.test.js b/packages/sasl/lib/SASLError.test.js new file mode 100644 index 00000000..78b7b325 --- /dev/null +++ b/packages/sasl/lib/SASLError.test.js @@ -0,0 +1,71 @@ +import XMPPError from "@xmpp/error"; +import SASLError from "./SASLError.js"; + +// https://xmpp.org/rfcs/rfc6120.html#rfc.section.6.4.5 +// https://xmpp.org/rfcs/rfc6120.html#rfc.section.A.4 + +test("SASL", () => { + const nonza = ( + + + + ); + + const error = SASLError.fromElement(nonza); + + expect(error instanceof Error).toBe(true); + expect(error instanceof XMPPError).toBe(true); + expect(error instanceof SASLError).toBe(true); + expect(error.name).toBe("SASLError"); + expect(error.condition).toBe("not-authorized"); + expect(error.text).toBe(""); +}); + +test("SASL with text", () => { + const nonza = ( + + + foo + + ); + expect(SASLError.fromElement(nonza).text).toBe("foo"); +}); + +// https://xmpp.org/extensions/xep-0388.html#failure +// https://github.com/xsf/xeps/pull/1411 +// https://xmpp.org/extensions/xep-0388.html#sect-idm46365286031040 + +test("SASL2", () => { + const nonza = ( + + + + ); + + const error = SASLError.fromElement(nonza); + + expect(error instanceof Error).toBe(true); + expect(error instanceof XMPPError).toBe(true); + expect(error instanceof SASLError).toBe(true); + expect(error.name).toBe("SASLError"); + expect(error.condition).toBe("aborted"); +}); + +test("SASL2 with text and application", () => { + const application = ( + + ); + + const nonza = ( + + + This is a terrible example. + {application} + + ); + + const error = SASLError.fromElement(nonza); + + expect(error.text).toBe("This is a terrible example."); + expect(error.application).toBe(application); +}); diff --git a/packages/sasl/package.json b/packages/sasl/package.json index 8bb5ef1b..ec63bb0b 100644 --- a/packages/sasl/package.json +++ b/packages/sasl/package.json @@ -15,8 +15,7 @@ "dependencies": { "@xmpp/base64": "^0.13.2", "@xmpp/error": "^0.13.2", - "@xmpp/xml": "^0.13.2", - "saslmechanisms": "^0.1.1" + "@xmpp/xml": "^0.13.2" }, "engines": { "node": ">= 20" diff --git a/packages/sasl2/README.md b/packages/sasl2/README.md new file mode 100644 index 00000000..a240a81e --- /dev/null +++ b/packages/sasl2/README.md @@ -0,0 +1,81 @@ +# SASL2 + +SASL2 Negotiation for `@xmpp/client` (including optional BIND2 and FAST). + +Note that if you set clientId then BIND2 will be used so you will not get offline messages (and are expected to do a MAM sync instead if you want that). + +Included and enabled in `@xmpp/client`. + +## Usage + +### object + +```js +import { xmpp } from "@xmpp/client"; +const client = xmpp({ + credentials: { + username: "foo", + password: "bar", + clientId: "Some UUID for this client/server pair (optional)", + software: "Name of this software (optional)", + device: "Description of this device (optional)", + }, +}); +``` + +### function + +Instead, you can provide a function that will be called every time authentication occurs (every (re)connect). + +Uses cases: + +- Have the user enter the password every time +- Do not ask for password before connection is made +- Debug authentication +- Using a SASL mechanism with specific requirements (such as FAST) +- Perform an asynchronous operation to get credentials + +```js +import { xmpp } from "@xmpp/client"; +const client = xmpp({ + credentials: authenticate, + clientId: "Some UUID for this client/server pair (optional)", + software: "Name of this software (optional)", + device: "Description of this device (optional)", +}); + +async function authenticate(callback, mechanisms) { + const fast = mechanisms.find((mech) => mech.canFast)?.name; + const mech = mechanisms.find((mech) => mech.canOther)?.name; + + if (fast) { + const [token, count] = await db.lookupFast(clientId); + if (token) { + await db.incrementFastCount(clientId); + return callback( + { + username: await prompt("enter username"), + password: token, + fastCount: count, + }, + fast, + ); + } + } + + return callback( + { + username: await prompt("enter username"), + password: await prompt("enter password"), + requestToken: fast, + }, + mech, + ); +} +``` + +## References + +- [SASL2](https://xmpp.org/extensions/xep-0388.html) +- [BIND2](https://xmpp.org/extensions/xep-0386.html) +- [FAST](https://xmpp.org/extensions/xep-0484.html) diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js new file mode 100644 index 00000000..bc2ae17a --- /dev/null +++ b/packages/sasl2/index.js @@ -0,0 +1,239 @@ +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 +// https://xmpp.org/extensions/xep-0386.html +// https://xmpp.org/extensions/xep-0484.html + +const NS = "urn:xmpp:sasl:2"; +const BIND2_NS = "urn:xmpp:bind:0"; +const FAST_NS = "urn:xmpp:fast:0"; + +async function authenticate( + saslFactory, + inlineHandlers, + bindInlineHandlers, + entity, + mechname, + credentials, + userAgent, + features, +) { + const mech = saslFactory.create([mechname]); + if (!mech) { + throw new Error("No compatible mechanism"); + } + + const { domain } = entity.options; + const creds = { + username: null, + password: null, + server: domain, + host: domain, + realm: domain, + serviceType: "xmpp", + serviceName: domain, + ...credentials, + }; + + const promise = new Promise((resolve, reject) => { + const handler = (element) => { + if (element.attrs.xmlns !== NS) { + return; + } + + if (element.name === "challenge") { + mech.challenge(decode(element.text())); + const resp = mech.response(creds); + entity.send( + xml( + "response", + { xmlns: NS, mechanism: mech.name }, + typeof resp === "string" ? encode(resp) : "", + ), + ); + return; + } + + switch (element.name) { + case "failure": { + reject(SASLError.fromElement(element)); + break; + } + case "continue": { + // No tasks supported yet + reject(); + break; + } + case "success": { + const additionalData = element.getChild("additional-data")?.text(); + 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); + } + } + const token = element.getChild("token", FAST_NS); + if (token) { + entity.emit("fast-token", token); + } + resolve(element); + break; + } + } + + entity.removeListener("nonza", handler); + }; + + entity.on("nonza", handler); + }); + + const sendInline = []; + const hPromises = []; + const inline = features.getChild("authentication", NS).getChild("inline"); + for (const el of inline?.children || []) { + const h = inlineHandlers["{" + el.attrs.xmlns + "}" + el.name]; + if (h) { + hPromises.push( + h(el, (addEl) => { + sendInline.push(addEl); + return promise; + }), + ); + } + } + + const bindInline = []; + const bind2 = inline?.getChild("bind", BIND2_NS); + for (const el of bind2?.getChild("inline")?.getChildren("feature") || []) { + const h = bindInlineHandlers[el.attrs.var]; + if (h) { + hPromises.push( + h((addEl) => { + bindInline.push(addEl); + return promise; + }), + ); + } + } + + entity.send( + xml("authenticate", { xmlns: NS, mechanism: mech.name }, [ + mech.clientFirst && + xml("initial-response", {}, encode(mech.response(creds))), + (userAgent?.clientId || userAgent?.software || userAgent?.device) && + xml( + "user-agent", + userAgent.clientId ? { id: userAgent.clientId } : {}, + [ + userAgent.software && xml("software", {}, userAgent.software), + userAgent.device && xml("device", {}, userAgent.device), + ], + ), + bind2 != null && + userAgent?.clientId && + xml("bind", { xmlns: BIND2_NS }, [ + userAgent?.software && xml("tag", {}, userAgent.software), + ...bindInline, + ]), + credentials.requestToken && + xml( + "request-token", + { xmlns: FAST_NS, mechanism: credentials.requestToken }, + [], + ), + (credentials.fastCount || credentials.fastCount === 0) && + xml("fast", { xmlns: FAST_NS, count: credentials.fastCount }, []), + ...sendInline, + ]), + ); + + await Promise.all([promise, ...hPromises]); +} + +export default function sasl2( + { streamFeatures, saslFactory }, + credentials, + userAgent, +) { + const handlers = {}; + const bindHandlers = {}; + + streamFeatures.use("authentication", NS, async ({ stanza, entity }) => { + const offered = new Set( + stanza + .getChild("authentication", NS) + .getChildren("mechanism", NS) + .map((m) => m.text()), + ); + const fast = new Set( + stanza + .getChild("authentication", NS) + .getChild("inline") + ?.getChild("fast", FAST_NS) + ?.getChildren("mechanism", FAST_NS) + ?.map((m) => m.text()) || [], + ); + const supported = saslFactory._mechs.map(({ name }) => name); + + const intersection = supported + .map((mech) => ({ + name: mech, + canFast: fast.has(mech), + canOther: offered.has(mech), + })) + .filter((mech) => mech.canFast || mech.canOther); + + if (typeof credentials === "function") { + await credentials((creds, mech) => { + authenticate( + saslFactory, + handlers, + bindHandlers, + entity, + mech, + creds, + userAgent, + stanza, + ); + }, intersection); + } else { + let mech = intersection[0]?.name; + if (!credentials.username && !credentials.password) { + mech = "ANONYMOUS"; + } + + await authenticate( + saslFactory, + handlers, + bindHandlers, + entity, + mech, + credentials, + userAgent, + stanza, + ); + } + + return true; // Not online yet, wait for next features + }); + + return { + inline(name, xmlns, handler) { + handlers["{" + xmlns + "}" + name] = handler; + }, + bindInline(feature, handler) { + bindHandlers[feature] = handler; + }, + }; +} diff --git a/packages/sasl2/package.json b/packages/sasl2/package.json new file mode 100644 index 00000000..15705e48 --- /dev/null +++ b/packages/sasl2/package.json @@ -0,0 +1,27 @@ +{ + "name": "@xmpp/sasl2", + "description": "XMPP SASL2 for JavaScript", + "repository": "github:xmppjs/xmpp.js", + "homepage": "https://github.com/xmppjs/xmpp.js/tree/main/packages/sasl2", + "bugs": "http://github.com/xmppjs/xmpp.js/issues", + "version": "0.13.0", + "license": "ISC", + "type": "module", + "keywords": [ + "XMPP", + "sasl" + ], + "dependencies": { + "@xmpp/base64": "^0.13.0", + "@xmpp/error": "^0.13.0", + "@xmpp/sasl": "^0.13.2", + "@xmpp/jid": "^0.13.0", + "@xmpp/xml": "^0.13.0" + }, + "engines": { + "node": ">= 14" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js new file mode 100644 index 00000000..844430ea --- /dev/null +++ b/packages/sasl2/test.js @@ -0,0 +1,286 @@ +import { mockClient, promise } from "@xmpp/test"; +import parse from "@xmpp/xml/lib/parse.js"; + +const username = "foo"; +const password = "bar"; +const credentials = { username, password }; + +test("no compatibles mechanisms", async () => { + const { entity } = mockClient({ username, password }); + + entity.mockInput( + + + FOO + + , + ); + + const error = await promise(entity, "error"); + expect(error instanceof Error).toBe(true); + expect(error.message).toBe("No compatible mechanism"); +}); + +test("with object credentials", async () => { + const { entity } = mockClient({ credentials }); + + entity.mockInput( + + + PLAIN + + , + ); + + expect(await promise(entity, "send")).toEqual( + + AGZvbwBiYXI= + , + ); + + entity.mockInput(); + entity.mockInput(); + + await promise(entity, "online"); +}); + +test("with function credentials", async () => { + const mech = "PLAIN"; + + function authenticate(auth, mechanisms) { + expect(mechanisms).toEqual([ + { name: mech, canFast: false, canOther: true }, + ]); + return auth(credentials, mech); + } + + const { entity } = mockClient({ credentials: authenticate }); + + entity.mockInput( + + + {mech} + + , + ); + + expect(await promise(entity, "send")).toEqual( + + AGZvbwBiYXI= + , + ); + + entity.mockInput(); + entity.mockInput(); + + await promise(entity, "online"); +}); + +test("failure", async () => { + const { entity } = mockClient({ credentials }); + + entity.mockInput( + + + PLAIN + + , + ); + + expect(await promise(entity, "send")).toEqual( + + AGZvbwBiYXI= + , + ); + + const failure = ( + + + + ); + + entity.mockInput(failure); + + const error = await promise(entity, "error"); + expect(error instanceof Error).toBe(true); + expect(error.name).toBe("SASLError"); + expect(error.condition).toBe("some-condition"); + expect(error.element).toBe(failure); +}); + +test("prefers SCRAM-SHA-1", async () => { + const { entity } = mockClient({ credentials }); + + entity.mockInput( + + + ANONYMOUS + PLAIN + SCRAM-SHA-1 + + , + ); + + const result = await promise(entity, "send"); + expect(result.attrs.mechanism).toEqual("SCRAM-SHA-1"); +}); + +test("use ANONYMOUS if username and password are not provided", async () => { + const { entity } = mockClient(); + + entity.mockInput( + + + ANONYMOUS + PLAIN + SCRAM-SHA-1 + + , + ); + + const result = await promise(entity, "send"); + expect(result.attrs.mechanism).toEqual("ANONYMOUS"); +}); + +test("with whitespaces", async () => { + const { entity } = mockClient(); + + entity.mockInput( + parse( + ` + + + ANONYMOUS + PLAIN + SCRAM-SHA-1 + + + `.trim(), + ), + ); + + const result = await promise(entity, "send"); + expect(result.attrs.mechanism).toEqual("ANONYMOUS"); +}); + +test("with bind2", async () => { + const { entity } = mockClient({ + credentials, + clientId: "uniqueid", + software: "xmpp.js", + device: "script", + }); + + entity.mockInput( + + + PLAIN + + + + + , + ); + + expect(await promise(entity, "send")).toEqual( + + AGZvbwBiYXI= + + xmpp.js + script + + + xmpp.js + + , + ); + + entity.mockInput( + + {entity.jid} + + , + ); + entity.mockInput(); + + await promise(entity, "online"); +}); + +test("with FAST", async () => { + const { entity } = mockClient({ + credentials: (callback, mechanisms) => { + expect(mechanisms).toEqual([ + { canFast: true, canOther: false, name: "HT-SHA-256-NONE" }, + { canFast: false, canOther: true, name: "PLAIN" }, + ]); + callback( + { ...credentials, requestToken: mechanisms[0].name }, + mechanisms[1].name, + ); + }, + }); + + entity.mockInput( + + + PLAIN + + + HT-SHA-256-NONE + + + + , + ); + + expect(await promise(entity, "send")).toEqual( + + AGZvbwBiYXI= + + , + ); + + entity.mockInput(); + entity.mockInput(); + + await promise(entity, "online"); +}); + +test("with FAST token", async () => { + const { entity } = mockClient({ + credentials: (callback, mechanisms) => { + expect(mechanisms).toEqual([ + { canFast: true, canOther: false, name: "HT-SHA-256-NONE" }, + { canFast: false, canOther: true, name: "PLAIN" }, + ]); + callback({ password: "TOKEN", fastCount: 2 }, mechanisms[0].name); + }, + }); + + entity.mockInput( + + + PLAIN + + + HT-SHA-256-NONE + + + + , + ); + + expect(await promise(entity, "send")).toEqual( + + + bnVsbAAAXAywUfR/w4Mr9SUDUtNAgPDajNI073fqfiZLMYcmfA== + + + , + ); + + entity.mockInput(); + entity.mockInput(); + + await promise(entity, "online"); +}); diff --git a/packages/stream-features/route.js b/packages/stream-features/route.js index 03e6ceb8..ee81628b 100644 --- a/packages/stream-features/route.js +++ b/packages/stream-features/route.js @@ -4,6 +4,9 @@ export default function route() { return next(); const prevent = await next(); - if (!prevent && entity.jid) entity._status("online", entity.jid); + // BIND2 inline handler may have already set to online, eg inline SM resume + if (!prevent && entity.jid && entity.status !== "online") { + entity._status("online", entity.jid); + } }; } diff --git a/packages/stream-management/index.js b/packages/stream-management/index.js index 4ec2bff7..5a148344 100644 --- a/packages/stream-management/index.js +++ b/packages/stream-management/index.js @@ -42,6 +42,7 @@ export default function streamManagement({ streamFeatures, entity, middleware, + sasl2, }) { let address = null; @@ -53,6 +54,7 @@ export default function streamManagement({ outbound: 0, inbound: 0, max: null, + outbound_q: [], }; entity.on("online", (jid) => { @@ -86,23 +88,29 @@ export default function streamManagement({ // 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 + function resumeSuccess() { + sm.enabled = true; + if (address) entity.jid = address; + entity.status = "online"; + } + + function resumeFailed() { + sm.id = ""; + sm.enabled = false; + sm.outbound = 0; + } + 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"; + resumeSuccess(await resume(entity, sm.inbound, sm.id)); return true; // If resumption fails, continue with session establishment } catch { - sm.id = ""; - sm.enabled = false; - sm.outbound = 0; + resumeFailed(); } } - // Enabling // Resource binding first @@ -125,5 +133,48 @@ export default function streamManagement({ sm.inbound = 0; }); + sasl2?.inline("sm", NS, async (_, addInline) => { + if (sm.id) { + const success = await addInline( + xml("resume", { xmlns: NS, h: sm.inbound, previd: sm.id }), + ); + const resumed = success.getChild("resumed", NS); + if (resumed) { + resumeSuccess(resumed); + } else { + resumeFailed(); + } + } + }); + + sasl2?.bindInline(NS, async (addInline) => { + const success = await addInline( + xml("enable", { + xmlns: NS, + max: sm.preferredMaximum, + resume: sm.allowResume ? "true" : undefined, + }), + ); + const bound = success.getChild("bound", "urn:xmpp:bind:0"); + if (!bound) return; // Did a resume or something, don't need this + + const enabled = bound?.getChild("enabled", NS); + if (enabled) { + if (sm.outbound_q.length > 0) { + throw new Error( + "Stream Management assertion failure, queue should be empty after enable", + ); + } + sm.outbound = 0; + sm.enabled = true; + sm.id = enabled.attrs.id; + sm.max = enabled.attrs.max; + } else { + sm.enabled = false; + } + + sm.inbound = 0; + }); + return sm; } diff --git a/packages/tls/lib/Socket.js b/packages/tls/lib/Socket.js index 0f0c5ad4..43550cde 100644 --- a/packages/tls/lib/Socket.js +++ b/packages/tls/lib/Socket.js @@ -8,6 +8,10 @@ class Socket extends EventEmitter { this.timeout = null; } + secure() { + 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..dd9cd48c 100644 --- a/packages/websocket/lib/Socket.js +++ b/packages/websocket/lib/Socket.js @@ -12,6 +12,10 @@ export default class Socket extends EventEmitter { this.listeners = Object.create(null); } + secure() { + return this.url.startsWith("wss") || this.url.startsWith("ws://localhost"); + } + connect(url) { this.url = url; this._attachSocket(new WebSocket(url, ["xmpp"])); diff --git a/packages/xmpp.js/package.json b/packages/xmpp.js/package.json index a5655768..9645a894 100644 --- a/packages/xmpp.js/package.json +++ b/packages/xmpp.js/package.json @@ -7,7 +7,6 @@ "version": "0.13.2", "license": "ISC", "type": "module", - "main": "index.js", "keywords": [ "XMPP", "jabber", @@ -37,8 +36,10 @@ "@xmpp/resource-binding": "^0.13.2", "@xmpp/sasl": "^0.13.2", "@xmpp/sasl-anonymous": "^0.13.2", + "@xmpp/sasl-ht-sha-256-none": "^0.13.0", "@xmpp/sasl-plain": "^0.13.2", "@xmpp/sasl-scram-sha-1": "^0.13.2", + "@xmpp/sasl2": "^0.13.0", "@xmpp/session-establishment": "^0.13.2", "@xmpp/starttls": "^0.13.2", "@xmpp/stream-features": "^0.13.2", diff --git a/server/index.js b/server/index.js index e9b405f9..2f9c4c22 100644 --- a/server/index.js +++ b/server/index.js @@ -87,7 +87,7 @@ async function _start() { makeCertificate(); - await exec("prosody", { + await exec("prosody -D", { cwd: DATA_PATH, env: { ...process.env, diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua index 480c0021..cb3eb0f8 100644 --- a/server/prosody.cfg.lua +++ b/server/prosody.cfg.lua @@ -4,6 +4,10 @@ local lfs = require "lfs"; +plugin_paths = { "modules" } +plugin_server = "https://modules.prosody.im/rocks/" +installer_plugin_path = lfs.currentdir() .. "/modules"; + modules_enabled = { "roster"; "saslauth"; @@ -18,13 +22,16 @@ modules_enabled = { "time"; "version"; "smacks"; + "sasl2"; + "sasl2_bind2"; + "sasl2_fast"; + "sasl2_sm"; }; modules_disabled = { "s2s"; } -daemonize = true; pidfile = lfs.currentdir() .. "/prosody.pid"; allow_registration = true; diff --git a/test/client.js b/test/client.js index 167eb1f4..98cd9cda 100644 --- a/test/client.js +++ b/test/client.js @@ -42,6 +42,73 @@ test("client", async () => { expect(address.bare().toString()).toBe(JID); }); +test("bind2", async () => { + expect.assertions(6); + + xmpp = client({ + credentials, + service: domain, + clientId: "75b2d490-3e3b-4d96-bca1-624b30ea6f82", + software: "xmpp.js tests", + device: "Test Device", + }); + debug(xmpp); + + xmpp.on("connect", () => { + expect().pass(); + }); + + xmpp.once("open", (el) => { + expect(el).toBeInstanceOf(xml.Element); + }); + + xmpp.on("online", (address) => { + expect(address).toBeInstanceOf(jid.JID); + expect(address.bare().toString()).toBe(JID); + }); + + const address = await xmpp.start(); + expect(address).toBeInstanceOf(jid.JID); + expect(address.bare().toString()).toBe(JID); +}); + +test("FAST", async () => { + expect.assertions(7); + + xmpp = client({ + service: domain, + credentials: { + ...credentials, + requestToken: true, + }, + clientId: "75b2d490-3e3b-4d96-bca1-624b30ea6f82", + software: "xmpp.js tests", + device: "Test Device", + }); + debug(xmpp); + + xmpp.on("connect", () => { + expect().pass(); + }); + + xmpp.once("open", (el) => { + expect(el).toBeInstanceOf(xml.Element); + }); + + xmpp.once("fast-token", (el) => { + expect(typeof el.attrs.token === "string").toBe(true); + }); + + xmpp.on("online", (address) => { + expect(address).toBeInstanceOf(jid.JID); + expect(address.bare().toString()).toBe(JID); + }); + + const address = await xmpp.start(); + expect(address).toBeInstanceOf(jid.JID); + expect(address.bare().toString()).toBe(JID); +}); + test("bad credentials", async () => { expect.assertions(6);