diff --git a/src/bosh.js b/src/bosh.js index 42f02da..51b30c7 100644 --- a/src/bosh.js +++ b/src/bosh.js @@ -146,6 +146,7 @@ class Bosh { const body = this._buildBody().attrs({ 'to': this._conn.domain, + ...(this._conn.service.startsWith("https://") ? {'from': this._conn.jid}} : {}), 'xml:lang': 'en', 'wait': this.wait, 'hold': this.hold, @@ -451,6 +452,7 @@ class Bosh { if (data[i] === 'restart') { body.attrs({ 'to': this._conn.domain, + ...(this._conn.service.startsWith("https://") ? {'from': this._conn.jid}} : {}), 'xml:lang': 'en', 'xmpp:restart': 'true', 'xmlns:xmpp': NS.BOSH, diff --git a/src/connection.js b/src/connection.js index 4e58c12..becd425 100644 --- a/src/connection.js +++ b/src/connection.js @@ -1,4 +1,4 @@ -import Handler from './handler.js'; +import { Handler, NSHandler } from './handler.js'; import TimedHandler from './timed-handler.js'; import Builder, { $build, $iq, $pres } from './builder.js'; import log from './log.js'; @@ -11,6 +11,7 @@ import SASLSHA1 from './sasl-sha1.js'; import SASLSHA256 from './sasl-sha256.js'; import SASLSHA384 from './sasl-sha384.js'; import SASLSHA512 from './sasl-sha512.js'; +import SASLHTSHA256NONE from './sasl-ht-sha256-none.js'; import SASLXOAuth2 from './sasl-xoauth2.js'; import { addCookies, @@ -1070,6 +1071,7 @@ class Connection { SASLSHA256, SASLSHA384, SASLSHA512, + SASLHTSHA256NONE, ] ).forEach((m) => this.registerSASLMechanism(m)); } @@ -1274,6 +1276,47 @@ class Connection { } // send each incoming stanza through the handler chain + + // nested-depth handlers, but they only support searching by ns and tagname + this.handlers = this.handlers.reduce((handlers, handler) => { + + if (handler instanceof NSHandler) { + //console.log("Checking NSHandler", handler) + + let keep = true; + + try { + if (handler.ns && handler.name) { + for (const _elem of elem.getElementsByTagNameNS(handler.ns, handler.name)) { + if (handler.run(_elem)) { + keep = false + break + } + } + } else if (handler.name) { + for (const _elem of elem.getElementsByTagNameNS(handler.ns, handler.name)) { + if (!handler.run(_elem)) { + keep = false; + break + } + } + } + } catch (e) { + // if the handler throws an exception, we consider it as false + keep = false; + log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); + } + + if (keep) { handlers.push(handler); } + } else { + handlers.push(handler) + } + + return handlers; + }, []); + + // single-depth handlers, but they support searching by others + forEachChild( elem, null, @@ -1281,18 +1324,23 @@ class Connection { (child) => { const matches = []; this.handlers = this.handlers.reduce((handlers, handler) => { - try { - if (handler.isMatch(child) && (this.authenticated || !handler.user)) { - if (handler.run(child)) { + if (handler instanceof Handler) { + try { + //console.log("Checking", child, "against", handler) + if (handler.isMatch(child) && (this.authenticated || !handler.user)) { + if (handler.run(child)) { + handlers.push(handler); + } + matches.push(handler); + } else { handlers.push(handler); } - matches.push(handler); - } else { - handlers.push(handler); + } catch (e) { + // if the handler throws an exception, we consider it as false + log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); } - } catch (e) { - // if the handler throws an exception, we consider it as false - log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message); + } else { + handlers.push(handler); } return handlers; @@ -1305,6 +1353,8 @@ class Connection { } } ); + + return elem; } /** @@ -1334,33 +1384,17 @@ class Connection { let bodyWrap; try { - bodyWrap = /** @type {Element} */ ( - '_reqToData' in this._proto ? this._proto._reqToData(/** @type {Request} */ (req)) : req - ); - } catch (e) { - if (e.name !== ErrorCondition.BAD_FORMAT) { - throw e; + bodyWrap = this._dataRecv(req, raw) + if (!bodyWrap) { + throw new Error("Failed to parse opening stanza?"); } - this._changeConnectStatus(Status.CONNFAIL, ErrorCondition.BAD_FORMAT); - this._doDisconnect(ErrorCondition.BAD_FORMAT); - } - if (!bodyWrap) { - return; - } - - if (this.xmlInput !== Connection.prototype.xmlInput) { - if (bodyWrap.nodeName === this._proto.strip && bodyWrap.childNodes.length) { - this.xmlInput(bodyWrap.childNodes[0]); - } else { - this.xmlInput(bodyWrap); - } - } - if (this.rawInput !== Connection.prototype.rawInput) { - if (raw) { - this.rawInput(raw); - } else { - this.rawInput(Builder.serialize(bodyWrap)); + } catch (e) { + if (e.name === ErrorCondition.BAD_FORMAT) { + this._changeConnectStatus(Status.CONNFAIL, ErrorCondition.BAD_FORMAT); + this._doDisconnect(ErrorCondition.BAD_FORMAT); + return } + throw e; } const conncheck = this._proto._connect_cb(bodyWrap); @@ -1368,34 +1402,8 @@ class Connection { return; } - // Check for the stream:features tag - let hasFeatures; - if (bodyWrap.getElementsByTagNameNS) { - hasFeatures = bodyWrap.getElementsByTagNameNS(NS.STREAM, 'features').length > 0; - } else { - hasFeatures = - bodyWrap.getElementsByTagName('stream:features').length > 0 || - bodyWrap.getElementsByTagName('features').length > 0; - } - if (!hasFeatures) { - this._proto._no_auth_received(_callback); - return; - } - - const matched = Array.from(bodyWrap.getElementsByTagName('mechanism')) - .map((m) => this.mechanisms[m.textContent]) - .filter((m) => m); - - if (matched.length === 0) { - if (bodyWrap.getElementsByTagName('auth').length === 0) { - // There are no matching SASL mechanisms and also no legacy - // auth available. - this._proto._no_auth_received(_callback); - return; - } - } if (this.do_authentication !== false) { - this.authenticate(matched); + this.authenticate(bodyWrap, _callback) } } @@ -1429,15 +1437,123 @@ class Connection { * Continues the initial connection request by setting up authentication * handlers and starting the authentication process. * - * SASL authentication will be attempted if available, otherwise + * SASL2 and SASL authentication will be attempted if available, otherwise * the code will fall back to legacy authentication. * - * @param {SASLMechanism[]} matched - Array of SASL mechanisms supported. + * @param {Element} bodyWrap + * @param {function} _callback */ - authenticate(matched) { - if (!this._attemptSASLAuth(matched)) { + authenticate(bodyWrap, _callback) { + + // server-advertised features, including, especially, auth methods + let features = + bodyWrap.getElementsByTagNameNS(NS.STREAM, 'features')[0] ?? + bodyWrap.getElementsByTagName('stream:features')[0] ?? + bodyWrap.getElementsByTagName('features')[0]; + + /* SASL2 */ + // xmpp.js does something similar: https://github.com/xmppjs/xmpp.js/pull/1030/files#diff-9c5bec6cda48a980004e01658b0215b0987650c8112b813dec10639f49e0a475R10 + const sasl2_header = features.getElementsByTagNameNS(NS.SASL2, 'authentication')[0]; + + const sasl2_offers = + [...sasl2_header?.children ?? []] + .filter((e) => e.tagName == 'mechanism') + .map((m) => m.textContent); + + console.debug("Server advertised these SASL2 auth methods:", sasl2_offers); + let sasl2_matched = sasl2_offers + .map((m) => this.mechanisms[m]) + .filter((m) => m); + console.debug("Of those, these are available:", sasl2_matched); + + /* SASL */ + const sasl_header = features.getElementsByTagNameNS(NS.SASL, 'mechanisms')[0] + const sasl_offers = + [...sasl_header.children ?? []] + .filter((e) => e.tagName == 'mechanism') + .map((m) => m.textContent); + console.debug("Server advertised these SASL auth methods:", sasl_offers); + + let sasl_matched = sasl_offers + .map((m) => this.mechanisms[m]) + .filter((m) => m); + console.info("Of those, these are available:", sasl_matched); + + + // Start sending authentication + + this._changeConnectStatus(Status.AUTHENTICATING, null); + + // different solution: all we do it try fast + // and if it fails, we forget the token + + if (this.fast?.test()) { + + + + /*** these guys are to handle the triggered */ + /** @type {Handler[]} */ + const streamfeature_handlers = []; + + /** + * @param {Handler[]} handlers + * @param {Element} elem + */ + const wrapper = (handlers, elem) => { + while (handlers.length) { + this.deleteHandler(handlers.pop()); + } + this._onStreamFeaturesAfterSASL(elem); + return false; + }; + + streamfeature_handlers.push( + this._addSysHandler( + /** @param {Element} elem */ + (elem) => wrapper(streamfeature_handlers, elem), + null, + 'stream:features', + null, + null + ) + ); + + streamfeature_handlers.push( + this._addSysHandler( + /** @param {Element} elem */ + (elem) => wrapper(streamfeature_handlers, elem), + NS.STREAM, + 'features', + null, + null + ) + ); + + this.fast._auth().then((elem) => { + console.log(":tada: FAST logged us in") + this._sasl_success_cb.bind(this)(elem) + }).catch((elem) => { + console.warn("FAST failed") + this._sasl_failure_cb.bind(this)(elem) + // invalidate creds? + }) + return true; // true because we found a method + } + + if (this._attemptSASL2Auth(sasl2_matched)) { + return true; + } + + if (this._attemptSASLAuth(sasl_matched)) { + return true; + } + + if (bodyWrap.getElementsByTagName('auth').length > 0) { this._attemptLegacyAuth(); + return true; } + + this._proto._no_auth_received(_callback); } /** @@ -1449,13 +1565,15 @@ class Connection { * @return {Boolean} mechanism_found - true or false, depending on whether a * valid SASL mechanism was found with which authentication could be started. */ - _attemptSASLAuth(mechanisms) { + _attemptSASL2Auth(mechanisms) { mechanisms = this.sortMechanismsByPriority(mechanisms || []); - let mechanism_found = false; + for (let i = 0; i < mechanisms.length; ++i) { + console.debug("Trying SASL 2 mechanism", mechanisms[i]) if (!mechanisms[i].test(this)) { continue; } + this._sasl_success_handler = this._addSysHandler( this._sasl_success_cb.bind(this), null, @@ -1465,14 +1583,138 @@ class Connection { ); this._sasl_failure_handler = this._addSysHandler( this._sasl_failure_cb.bind(this), - null, + NS.SASL2, 'failure', null, null ); this._sasl_challenge_handler = this._addSysHandler( this._sasl_challenge_cb.bind(this), + NS.SASL2, + 'challenge', + null, + null + ); + + + /*** these guys are to handle the triggered */ + /** @type {Handler[]} */ + const streamfeature_handlers = []; + + /** + * @param {Handler[]} handlers + * @param {Element} elem + */ + const wrapper = (handlers, elem) => { + while (handlers.length) { + this.deleteHandler(handlers.pop()); + } + this._onStreamFeaturesAfterSASL(elem); + return false; + }; + + streamfeature_handlers.push( + this._addSysHandler( + /** @param {Element} elem */ + (elem) => wrapper(streamfeature_handlers, elem), + null, + 'stream:features', + null, + null + ) + ); + + streamfeature_handlers.push( + this._addSysHandler( + /** @param {Element} elem */ + (elem) => wrapper(streamfeature_handlers, elem), + NS.STREAM, + 'features', + null, + null + ) + ); + + this._sasl_mechanism = mechanisms[i]; + this._sasl_mechanism.onStart(this); + + const authenticate = $build('authenticate', { + 'xmlns': NS.SASL2, + 'mechanism': this._sasl_mechanism.mechname, + }); + if (this._sasl_mechanism.isClientFirst) { + const response = this._sasl_mechanism.clientChallenge(this); + authenticate + .c('initial-response', + null, + btoa(/** @type {string} */(response))) + .up(); + } + + // FAST + // XXX can this code live in fast.js, somehow? + // I don't see how without an *outgoing* stanza hook, + // which Strophe doesn't seem to have + if (this.fast?.mechname) { + authenticate + .c('request-token', { + 'xmlns': NS.FAST, + 'mechanism': this.fast.mechname + }).up() + // > MUST also provide the a SASL2 element with an 'id' attribute + // > (both of these values are discussed in more detail in XEP-0388). + // - https://xmpp.org/extensions/xep-0484.html#rules-clients + .c('user-agent', { + // TODO: *this should be cached* in browser storage; else it will appear like hundreds of devices are connected to one's account + 'id': "111222333444" //this.getUniqueId("useragent") + }) + .c("software", "Strophe.js").up() + .c("device", "MacBook").up(); + } + + this.send(authenticate.tree()); + return true; + } + return false; + } + + /** + * Iterate through an array of SASL mechanisms and attempt authentication + * with the highest priority (enabled) mechanism. + * + * @private + * @param {SASLMechanism[]} mechanisms - Array of SASL mechanisms. + * @return {Boolean} mechanism_found - true or false, depending on whether a + * valid SASL mechanism was found with which authentication could be started. + */ + _attemptSASLAuth(mechanisms) { + + // okay somewhere in there + + mechanisms = this.sortMechanismsByPriority(mechanisms || []); + let mechanism_found = false; + for (let i = 0; i < mechanisms.length; ++i) { + console.debug("Trying SASL 1 mechanism", mechanisms[i]) + if (!mechanisms[i].test(this)) { + continue; + } + this._sasl_success_handler = this._addSysHandler( + this._sasl_success_cb.bind(this), + NS.SASL, + 'success', null, + null + ); + this._sasl_failure_handler = this._addSysHandler( + this._sasl_failure_cb.bind(this), + NS.SASL, + 'failure', + null, + null + ); + this._sasl_challenge_handler = this._addSysHandler( + this._sasl_challenge_cb.bind(this), + NS.SASL, 'challenge', null, null @@ -1504,7 +1746,7 @@ class Connection { async _sasl_challenge_cb(elem) { const challenge = atob(getText(elem)); const response = await this._sasl_mechanism.onChallenge(this, challenge); - const stanza = $build('response', { 'xmlns': NS.SASL }); + const stanza = $build('response', { 'xmlns': elem.namespaceURI }); if (response) stanza.t(btoa(response)); this.send(stanza.tree()); return true; @@ -1522,7 +1764,6 @@ class Connection { this.disconnect(ErrorCondition.MISSING_JID_NODE); } else { // Fall back to legacy authentication - this._changeConnectStatus(Status.AUTHENTICATING, null); this._addSysHandler(this._onLegacyAuthIQResult.bind(this), null, null, null, '_auth_1'); this.send( $iq({ @@ -1580,15 +1821,28 @@ class Connection { * @return {false} `false` to remove the handler. */ _sasl_success_cb(elem) { + if (this._sasl_data['server-signature']) { - let serverSignature; - const success = atob(getText(elem)); + + console.debug("sasl success: checking final signature") + let success; + if (elem.namespaceURI == NS.SASL2) { + success = elem.querySelector('additional-data') + } else if (elem.namespaceURI == NS.SASL) { + success = elem; + } else { + console.error("Unsupported namespace ${elem.namespaceURI}, cannot authenticate server.") + return this._sasl_failure_cb(elem); + } + success = atob(getText(success)) + console.debug("sasl success: final signature is", success) + const attribMatch = /([a-z]+)=([^,]+)(,|$)/; const matches = success.match(attribMatch); - if (matches[1] === 'v') { - serverSignature = matches[2]; - } - if (serverSignature !== this._sasl_data['server-signature']) { + if (!(matches + && matches.length > 2 + && matches[1] === 'v' + && matches[2] === this._sasl_data['server-signature'])) { // remove old handlers this.deleteHandler(this._sasl_failure_handler); this._sasl_failure_handler = null; @@ -1597,10 +1851,15 @@ class Connection { this._sasl_challenge_handler = null; } this._sasl_data = {}; - return this._sasl_failure_cb(null); + return this._sasl_failure_cb(elem); } } - log.info('SASL authentication succeeded.'); + console.log('SASL authentication succeeded.'); + + if (elem.namespaceURI == NS.SASL2) { + // TODO: do something useful with this? + console.log("SASL2 authorized us as ", getText(elem.querySelector('authorization-identifier'))) + } if (this._sasl_data.keys) { this.scram_keys = this._sasl_data.keys; @@ -1616,45 +1875,7 @@ class Connection { this.deleteHandler(this._sasl_challenge_handler); this._sasl_challenge_handler = null; } - /** @type {Handler[]} */ - const streamfeature_handlers = []; - /** - * @param {Handler[]} handlers - * @param {Element} elem - */ - const wrapper = (handlers, elem) => { - while (handlers.length) { - this.deleteHandler(handlers.pop()); - } - this._onStreamFeaturesAfterSASL(elem); - return false; - }; - - streamfeature_handlers.push( - this._addSysHandler( - /** @param {Element} elem */ - (elem) => wrapper(streamfeature_handlers, elem), - null, - 'stream:features', - null, - null - ) - ); - - streamfeature_handlers.push( - this._addSysHandler( - /** @param {Element} elem */ - (elem) => wrapper(streamfeature_handlers, elem), - NS.STREAM, - 'features', - null, - null - ) - ); - - // we must send an xmpp:restart now - this._sendRestart(); return false; } @@ -1882,6 +2103,13 @@ class Connection { return hand; } + _addSysNSHandler(handler, ns, name) { + const hand = new NSHandler(handler, ns, name); + hand.user = false; + this.addHandlers.push(hand); + return hand; + } + /** * _Private_ timeout handler for handling non-graceful disconnection. * diff --git a/src/constants.js b/src/constants.js index 2320f57..7794cee 100644 --- a/src/constants.js +++ b/src/constants.js @@ -32,6 +32,7 @@ export const NS = { DISCO_ITEMS: 'http://jabber.org/protocol/disco#items', MUC: 'http://jabber.org/protocol/muc', SASL: 'urn:ietf:params:xml:ns:xmpp-sasl', + SASL2: 'urn:xmpp:sasl:2', STREAM: 'http://etherx.jabber.org/streams', FRAMING: 'urn:ietf:params:xml:ns:xmpp-framing', BIND: 'urn:ietf:params:xml:ns:xmpp-bind', diff --git a/src/fast.js b/src/fast.js new file mode 100644 index 0000000..4e9b565 --- /dev/null +++ b/src/fast.js @@ -0,0 +1,280 @@ +/** + * @typedef {import("./connection.js").default} Connection + */ +import SASLMechanism from './sasl.js'; +import { ElementType, Status } from './constants.js'; +import { + getBareJidFromJid, + getText +} from './utils.js'; + +const SASL2 = { + // TODO: turn this into a full module + 'NS': 'urn:xmpp:sasl:2' +} + +/** + * @this {{ conn: Connection }} + */ +const FAST = { + NS: 'urn:xmpp:fast:0', + + /** @type {Connection} */ + conn: null, + + /** + * @typedef {Object} FastToken + * @property {string} [mechanism] + * @property {string} [token] + * @property {Number} [expiry] + * @property {Number} [counter] + */ + + /** @type {FastToken} */ + token: null, + + // Mechanisms supported by both us and the server + /** @type {String[]} } */ + mechanisms: null, + + // The **active** mechanism + /** @type {String} */ + mechname: null, + + /** + * Create and initialize a new Handler. + * + * @param {Connection} connection + */ + init: function (connection) { + this.conn = connection + this.mechanisms = [] + + // XXX messy; should be up in Converse.JS + let jid = getBareJidFromJid(localStorage.getItem("strophe-jid")); + if (!this.jid || (jid === getBareJidFromJid(this.jid)) && !this.token) { + this.conn.jid = jid; + let _token = localStorage.getItem(`strophe-fast-token:${jid}`) + if (_token) { + this.token = JSON.parse(_token) + console.log("Loaded", this.token, "from localStorage") + } + } + + this._auth = this._auth.bind(this) + this.test = this.test.bind(this) + }, + + /** + * + * @param {Number} status + */ + statusChanged: function (status) { + // init doesn't actually init: handlers get cleared at connection time, + // or at least, ConverseJS clears them then + // the safe place to do init is here, which is special-cased in connection.js + + + // Register listeners before we get data (CONNECTING) so + // we can catch the crucial first stanzas but actually do + // auth only once we get to AUTHENTICATING because that + // needs the basic infrastructure of the stream/session to be set up + + if (status === Status.CONNECTING) { + + //Strophe.addNamespace('FAST', 'urn:xmpp:fast:0') // TODO: load this plugin in a more sensible way + + console.warn("FAST: .onConnecting") + // TODO: make this re-entrant, i.e. either always delete and recreate or track if our handler has been assigned + this.conn._addSysNSHandler(this.onSuccess.bind(this), SASL2.NS, 'success'); + this.conn._addSysNSHandler(this.onChallenge.bind(this), SASL2.NS, 'challenge'); + this.conn._addSysNSHandler(this.onFailure.bind(this), SASL2.NS, 'failure'); + + this.conn._addSysNSHandler(this.onAuth.bind(this), this.NS, 'fast'); + this.conn._addSysNSHandler(this.onToken.bind(this), this.NS, 'token'); + + console.warn("/FAST: .onConnecting") + // the less disruptive way to design this, instead of hacking the core match logic: + // - set up a sasl2 module that hooks the top level , , , , stanzas + // - provide an events API *in there* (using that .on() library?) + // - fast hooks into the events API + // + // or, even less invasive: + // + // - hook the top level SASL2 stanzas we know we need to look for here (namely: and ) + // and just parse them, as connection.js does for SASLv1 + // + // but always: + // - still hack the core logic to allow hooking the first stanza, that's important for auth + // + } else if (status === Status.AUTHENTICATING) { + + // console.warn("FAST: .onAuthenticating") + // this._auth().then(() => { + + // console.warn("/FAST: .onAuthenticating") + // }).catch((err) => console.error(err)); + } + }, + + + // TODO: pull these to a SASL2 module + /** + * @param {Element} elem + */ + onSuccess: function (elem) { + const username = getText(elem.getElementsByTagName('authorization-identifier')[0]) + console.info(`SASL2: authenticated as ${username}`, elem) + this.conn.authenticated = true; + + // XXX messy; should be up in Converse.JS + let jid = getBareJidFromJid(this.conn.jid) + localStorage.setItem("strophe-jid", jid) + localStorage.setItem(`strophe-fast-token:${jid}`, JSON.stringify(this.token)); // disabled for debugging + + console.log("saved", this.token, "to localStorage") + + return true; // keep listening + }, + /** + * @param {Element} elem + */ + onChallenge: function (elem) { + console.debug("SASL2: challenge received:", elem) + return true; // keep listening + }, + /** + * @param {Element} elem + */ + onFailure: function (elem) { + console.info("SASL2: authentication failed", elem) + this.conn.authenticated = false; + return true; // keep listening + }, + + /** + * @param {Element} elem + */ + onAuth: function (elem) { + const sasl2_fast_offers = [...elem.querySelectorAll('mechanism')] + .map((m) => m.textContent); + + //* @var {Set} sasl2_fast_matched */ + let sasl2_fast_matched = (new Set(sasl2_fast_offers)).intersection(new Set(Object.keys(this.conn.mechanisms))); + + this.mechanisms = [...sasl2_fast_matched] + console.info( + "FAST: server advertised ", sasl2_fast_offers, + " of which we support", this.mechanisms); + if (this.mechanisms.length == 0) { + console.warn("FAST offered but with no known mechanisms."); + return + } + + if (this.token && sasl2_fast_matched.has(this.token.mechanism)) { + // prefer the method of our current token, if we have it + this.mechname = this.token.mechanism; + } else { + // pick the first one + this.mechname = this.mechanisms[0] + } + + console.info(`FAST: chose ${this.mechname}`); + + // TODO: we need a way to edit outgoing stanzas too to tack the 'request-token' bit onto the backs of other auths.. + // but how? Is there an outgoing listeners system? + + return true; + }, + + test: function () { + // this is janky + return (this.token?.mechanism && this.mechname + && new Set(this.mechanisms).has(this.token.mechanism) + && this.conn.mechanisms[this.mechname].test(this.conn)) + }, + _auth: async function () { + console.warn("fast._auth()") + // and attempt to authenticate with it + console.info("FAST: attempting login") + let initial_response = await this.conn.mechanisms[this.mechname].clientChallenge(this.conn, null); + if (initial_response == false) { + console.warn(`FAST ${this.mechname} refused to provide an `) + } + initial_response = btoa(initial_response) + console.info("initial response", initial_response) + const authenticate = $build('authenticate', { + 'xmlns': SASL2.NS, + 'mechanism': this.mechname, + }).c('initial-response', null, + initial_response) + .up() + + // > MUST also provide the a SASL2 element with an 'id' attribute + // > (both of these values are discussed in more detail in XEP-0388). + // - https://xmpp.org/extensions/xep-0484.html#rules-clients + .c('user-agent', { + // TODO: *this should be cached* in browser storage; else it will appear like hundreds of devices are connected to one's account + 'id': "111222333444" //this.conn.getUniqueId("useragent") + }) + .c("software", "Strophe.js").up() + .c("device", "MacBook").up() + .up().c("fast", { + // replay protection + // > Servers MUST reject any authentication requests received via + // > TLS 0-RTT payloads that do not include a 'count' attribute + // - https://xmpp.org/extensions/xep-0484.html#fast-auth + 'count': this.token.counter.toString(), + 'xmlns': this.NS, + }) + + let t = authenticate.tree() + console.error("Senidng FAST AUTH", t) + this.conn.send(t); + console.error("/Senidng FAST AUTH", t) + + this.token.counter++; + + return new Promise((resolve, reject) => { + this.conn._addSysHandler((elem /** {Element} */) => { + console.log("FAST: succeeded", elem) + // check that this.conn.jid == elem.jid.text? + resolve(elem) + false; // stop listening + }, SASL2.NS, 'success', null, null) + this.conn._addSysHandler((elem /** {Element} */) => { + console.warn("FAST: token login failed; invalidating current token", elem) + reject(elem) + + // AND invalidate creds + // That means the user will drop back to the login page + // and then have to try again with a password + let jid = getBareJidFromJid(this.conn.jid) + //localStorage.removeItem(`strophe-fast-token:${jid}`) + this.token = null; + + console.log("cleared", this.token, "from localStorage") + }, SASL2.NS, 'failure', null, null) + }); + + }, + + /** + * @param {Element} elem + */ + onToken: function (elem) { + console.log("fast plugin onToken", elem) + console.log(this) + + this.token = { + 'mechanism': this.mechname, + 'token': elem.getAttribute('token'), + 'expiry': Date.parse(elem.getAttribute('expiry')), + 'counter': 0 + }; + let v = 1; + return true; + } +}; + +export default FAST; \ No newline at end of file diff --git a/src/handler.js b/src/handler.js index 18983bc..46a44a6 100644 --- a/src/handler.js +++ b/src/handler.js @@ -13,7 +13,43 @@ import { getBareJidFromJid, handleError, isTagEqual } from './utils.js'; * will use {@link Connection.addHandler} and * {@link Connection.deleteHandler}. */ -class Handler { + +export class NSHandler { + + /** + * Create and initialize a new NSHandler. + * + * @param {Function} handler - A function to be executed when the handler is run. + * @param {string} ns - The namespace to match. + * @param {string} name - The element name to match. + */ + constructor(handler, ns, name) { + this.handler = handler; + this.ns = ns; + this.name = name; + // whether the handler is a user handler or a system handler + this.user = true; + } + + + /** + * Run the callback on a matching stanza. + * @param {Element} elem - The DOM element that triggered the Handler. + * @return {boolean} - A boolean indicating if the handler should remain active. + */ + run(elem) { + let result = null; + try { + result = this.handler(elem); + } catch (e) { + handleError(e); + throw e; + } + return result; + } +} + +export class Handler { /** * @typedef {Object} HandlerOptions * @property {boolean} [HandlerOptions.matchBareFromJid] @@ -131,4 +167,4 @@ class Handler { } } -export default Handler; +export default { Handler, NSHandler }; diff --git a/src/index.js b/src/index.js index 2a5704c..96137c6 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ import SASLSHA256 from './sasl-sha256.js'; import SASLSHA384 from './sasl-sha384.js'; import SASLSHA512 from './sasl-sha512.js'; import SASLXOAuth2 from './sasl-xoauth2.js'; +import FAST from './fast.js'; import TimedHandler from './timed-handler.js'; import Websocket from './websocket.js'; import WorkerWebsocket from './worker-websocket.js'; @@ -190,4 +191,9 @@ globalThis.stx = stx; const toStanza = Stanza.toElement; globalThis.toStanza = Stanza.toElement; // Deprecated +// XXX hack to break the circular dependency +// the encouraged way to do plugins is to import them all in your app along with Strophe as a peer +Strophe.addConnectionPlugin('fast', FAST); +Strophe.addNamespace('FAST', 'urn:xmpp:fast:0') + export { Builder, $build, $iq, $msg, $pres, Strophe, Stanza, stx, toStanza, Request }; diff --git a/src/sasl-ht-sha256-none.js b/src/sasl-ht-sha256-none.js new file mode 100644 index 0000000..b915c6d --- /dev/null +++ b/src/sasl-ht-sha256-none.js @@ -0,0 +1,63 @@ +/** + * @typedef {import("./types/connection.js").default} Connection +*/ +import SASLMechanism from './sasl.js'; +import { + getNodeFromJid, +} from './utils.js'; +// TODO: factor this and do the other methods defined in https://datatracker.ietf.org/doc/draft-schmaus-kitten-sasl-ht/09/ +// import ht from './ht.js'; + +class SASLHTSHA256NONE extends SASLMechanism { + /** + * SASL HT SHA 256 authentication. + */ + constructor(mechname = 'HT-SHA-256-NONE', isClientFirst = true, priority = 75) { + super(mechname, isClientFirst, priority); + } + + /** + * @param {Connection} connection + */ + // eslint-disable-next-line class-methods-use-this + test(connection) { + let T = (connection.fast?.token?.mechanism == this.mechname) + && (Date.now() < connection.fast?.token?.expiry - 30); // -30 for some wiggle room in clock skew etc + + return true; + } + + /** + * @param {Connection} connection + * @param {string} [challenge] + */ + // eslint-disable-next-line class-methods-use-this + onChallenge(connection, challenge) { + throw new Error("Hashed-Token methods do not respond to challenges"); + } + + /** + * @param {Connection} connection + * @param {string} [test_cnonce] + */ + // eslint-disable-next-line class-methods-use-this + async clientChallenge(connection, test_cnonce) { + // from https://github.com/xmppjs/xmpp.js/blob/d01b2f1dcb81c7d2880d1021ca352256675873a4/packages/sasl-ht-sha-256-none/index.js#L12 + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(connection.fast?.token?.token), + // https://developer.mozilla.org/en-US/docs/Web/API/HmacImportParams + { name: "HMAC", hash: "SHA-256" }, + false, // extractable + ["sign", "verify"], + ) + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode("Initiator"), + ) + return `${getNodeFromJid(connection.jid)}\0${String.fromCodePoint(...new Uint8Array(signature))}`; + } +} + +export default SASLHTSHA256NONE; diff --git a/src/websocket.js b/src/websocket.js index 25e7024..7206558 100644 --- a/src/websocket.js +++ b/src/websocket.js @@ -67,6 +67,7 @@ class Websocket { return $build('open', { 'xmlns': NS.FRAMING, 'to': this._conn.domain, + ...((this._conn.service.startsWith("wss") || this._conn.service.startsWith("ws://localhost")) ? { 'from': this._conn.jid } : {}), 'version': '1.0', }); }