| author | Phillip Davis
<phdavis1027@gmail.com> 2026-08-21 02:29:53 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-08-21 14:11:07 UTC |
| parent | 89559d2fb705b1270b02f25e42381d8e66224994 |
| Makefile | +1 | -1 |
| borogove/streams/XmppJsStream.hx | +18 | -1 |
| npm/test/no-dns.mjs | +4 | -0 |
| test/fast-auth.spec.ts | +157 | -0 |
diff --git a/Makefile b/Makefile index 15ee8b3..a85b64f 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogov cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d persistence-browser.ts playwright/.cache/borogove.js: npm - cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@ + cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=./test/no-dns.mjs" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@ playwright/.cache/sqlite-wasm.js: npm cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_Channel as Channel }" --outfile=../$@ diff --git a/borogove/streams/XmppJsStream.hx b/borogove/streams/XmppJsStream.hx index b4c6319..aaceaf5 100644 --- a/borogove/streams/XmppJsStream.hx +++ b/borogove/streams/XmppJsStream.hx @@ -9,6 +9,7 @@ using StringTools; import borogove.FSM; import borogove.GenericStream; +import borogove.ID; import borogove.Stanza; import borogove.Util; @@ -46,7 +47,8 @@ extern class XmppJsClient { }; var saslFactory: Dynamic; var fast: { - saveToken: ({ token: String, expiry: String, mechanism: String })->Promise<Any> + saveToken: ({ token: String, expiry: String, mechanism: String })->Promise<Any>, + deleteToken: ()->Promise<Any> }; } @@ -243,6 +245,7 @@ class XmppJsStream extends GenericStream { }); final clientId = jid.resource; + var credentials: Dynamic = null; final xmpp = new XmppJsClient({ service: jid.domain, resource: jid.resource, @@ -267,12 +270,26 @@ class XmppJsStream extends GenericStream { creds = { password: null, fastCount: null, username: jid.local, token: { token: "fail", mechanism: creds.mechanism }, mechanism: null }; } } + credentials = creds; return callback(creds, creds.mechanism ?? mech, new XmppJsXml("user-agent", { id: clientId })); }); } }); new XmppJsScramSha1(xmpp.saslFactory); xmpp.jid = this.jid; + // As of writing, xmpp.js will fall back to SASL + // even if password is absent or empty, which crashes irrecoverably + // when that empty-or-null password is passed to HMAC. + // So we set it to some random nonsense, since recovery from a bad + // password works. + xmpp.fast.deleteToken = () -> { + if (credentials.password == null || credentials.password == "") { + credentials.password = ID.unique(); + } + credentials.token = null; + this.trigger("fast-token", { token: null }); + return Promise.resolve(null); + }; xmpp.streamFeatures.use("csi", "urn:xmpp:csi:0", (ctx, next, feature) -> { csi = true; diff --git a/npm/test/no-dns.mjs b/npm/test/no-dns.mjs new file mode 100644 index 0000000..6d2cc79 --- /dev/null +++ b/npm/test/no-dns.mjs @@ -0,0 +1,4 @@ +export default { + lookup: (x, y, cb) => cb(null, []), + resolveSrv: (x, cb) => cb(null, []), +}; diff --git a/test/fast-auth.spec.ts b/test/fast-auth.spec.ts new file mode 100644 index 0000000..e14e6df --- /dev/null +++ b/test/fast-auth.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; +import fs from "fs"; +import xml from "@xmpp/xml"; +import { parse } from "ltx"; + +const BIND2 = "urn:xmpp:bind:0"; +const SASL2 = "urn:xmpp:sasl:2"; +const FRAMING = "urn:ietf:params:xml:ns:xmpp-framing"; +const SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; +const STREAM = "http://etherx.jabber.org/streams"; +const BOROGOVE_CODE = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + +const HOST_META = xml( + "XRD", + { xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0" }, + xml("Link", { + rel: "urn:xmpp:alt-connections:websocket", + href: "ws://127.0.0.1/xmpp", + }), +).toString(); +const OPEN = xml("open", { + from: "127.0.0.1", + id: "stream", + version: "1.0", + xmlns: FRAMING, +}).toString(); +const FEATURES = xml( + "stream:features", + { xmlns: "jabber:client", "xmlns:stream": STREAM }, + xml( + "authentication", + { xmlns: SASL2 }, + xml("mechanism", {}, "PLAIN"), + xml("mechanism", {}, "SCRAM-SHA-1"), + xml( + "inline", + {}, + xml( + "fast", + { xmlns: "urn:xmpp:fast:0" }, + xml("mechanism", {}, "HT-SHA-256-NONE"), + ), + xml("bind", { xmlns: BIND2 }), + ), + ), +).toString(); +const NOT_AUTHORIZED = xml( + "failure", + { xmlns: SASL2 }, + xml("not-authorized", { xmlns: SASL }), +).toString(); +const CLOSE = xml("close", { xmlns: FRAMING }).toString(); + +test.beforeEach(async ({ page }) => { + await page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + await page.route("https://127.0.0.1/.well-known/host-meta", (route) => + route.fulfill({ + body: HOST_META, + contentType: "application/xrd+xml", + }), + ); +}); + +test("a rejected unexpired FAST token uses a non-empty password before reconnecting", async ({ + page, +}) => { + let connectionCount = 0; + let resolveSocketClosed: () => void; + const socketClosed = new Promise<void>((resolve) => { + resolveSocketClosed = resolve; + }); + await page.routeWebSocket("ws://127.0.0.1/xmpp", (socket) => { + connectionCount++; + socket.onMessage((message: string) => { + const stanza = parse(message); + expect(stanza.is("open", FRAMING)).toBe(true); + if (connectionCount === 2) { + socket.onMessage((message: string) => { + const stanza = parse(message); + expect(stanza.is("close", FRAMING)).toBe(true); + socket.send(CLOSE); + }); + socket.send(OPEN); + socket.send(FEATURES); + return; + } + socket.onMessage((message: string) => { + const stanza = parse(message); + expect(stanza.is("authenticate", SASL2)).toBe(true); + expect(stanza.attrs.mechanism).toBe("HT-SHA-256-NONE"); + expect(stanza.getChildText("initial-response")).toBe( + "dGVzdGVyANlEKT65Z6grrZHbeSjhb05VNRnDs6X1T3Wh0pHQg7cv", + ); + socket.onMessage((message: string) => { + const stanza = parse(message); + expect(stanza.is("authenticate", SASL2)).toBe(true); + expect(stanza.attrs.mechanism).toBe("PLAIN"); + const [, username, password] = atob( + stanza.getChildText("initial-response"), + ).split("\0"); + expect(username).toBe("tester"); + expect(password).not.toBe(""); + expect(password).not.toBe("persisted secret"); + socket.send(NOT_AUTHORIZED); + }); + socket.send(NOT_AUTHORIZED); + }); + socket.send(OPEN); + socket.send(FEATURES); + }); + socket.onClose(() => resolveSocketClosed()); + }); + await page.goto("https://localhost/"); + + await page.evaluate( + async ({ code }) => { + const moduleUrl = URL.createObjectURL( + new Blob([code], { type: "text/javascript" }), + ); + const borogove = await import(moduleUrl); + const persistence = new borogove.persistence.Dummy(); + persistence.getLogin = async () => ({ + clientId: "test-client", + displayName: "Test", + fastCount: 0, + token: JSON.stringify({ + token: "persisted secret", + expiry: "2099-01-01T00:00:00Z", + mechanism: "HT-SHA-256-NONE", + }), + }); + persistence.getStreamManagement = async () => ({ + sortId: "a ", + sm: null, + }); + persistence.getChats = async () => []; + persistence.getChatsUnreadDetails = async () => []; + const client = new borogove.Client( + "tester@127.0.0.1", + persistence, + ); + client.stream.debug = false; + const passwordRequested = Promise.withResolvers<void>(); + client.addPasswordNeededListener(() => { + passwordRequested.resolve(); + }); + client.start(); + await passwordRequested.promise; + client.stream.disconnect(); + }, + { code: BOROGOVE_CODE }, + ); + await socketClosed; + expect(connectionCount).toBe(2); +});