diff --git a/src/bosh.js b/src/bosh.js index 42f02da..f23c9a4 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..9bb35b1 100644 --- a/src/connection.js +++ b/src/connection.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, @@ -27,6 +28,9 @@ import Bosh from './bosh.js'; import WorkerWebsocket from './worker-websocket.js'; import Websocket from './websocket.js'; +/** @type {import('./scram.js').SCRAMKey} */ +/** @type {import('./sasl2_fast.js').FastCredential} */ + /** * @typedef {import("./sasl.js").default} SASLMechanism * @typedef {import("./request.js").default} Request @@ -464,15 +468,6 @@ class Connection { this.protocolErrorHandlers[protocol][status_code] = callback; } - /** - * @typedef {Object} Password - * @property {string} Password.name - * @property {string} Password.ck - * @property {string} Password.sk - * @property {number} Password.iter - * @property {string} Password.salt - */ - /** * Starts the connection process. * @@ -501,7 +496,7 @@ class Connection { * ck: String, the base64 encoding of the SCRAM client key * sk: String, the base64 encoding of the SCRAM server key * } - * @param {string|Password} pass - The user password + * @param {string|SCRAMKey|FastCredential} pass - The user password * @param {Function} callback - The connect callback function. * @param {number} [wait] - The optional HTTPBIND wait value. This is the * time the server will wait before returning an empty result for @@ -1070,6 +1065,7 @@ class Connection { SASLSHA256, SASLSHA384, SASLSHA512, + SASLHTSHA256NONE, ] ).forEach((m) => this.registerSASLMechanism(m)); } @@ -1305,6 +1301,8 @@ class Connection { } } ); + + return elem; } /** @@ -1328,39 +1326,23 @@ class Connection { * want to do something special). * @param {string} [raw] - The stanza as raw string. */ - _connect_cb(req, _callback, raw) { + _connect_cb(req, _no_auth_callback, raw) { log.debug('_connect_cb was called'); this.connected = true; 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; - } - 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); + bodyWrap = this._dataRecv(req, raw) + if (!bodyWrap) { + throw new Error(`connection: failed to parse opening stanza ${req}`); } - } - 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 +1350,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, _no_auth_callback) } } @@ -1429,15 +1385,96 @@ 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 - the initial opening stanza from the server + * @param {function} _no_auth_callback */ - authenticate(matched) { - if (!this._attemptSASLAuth(matched)) { - this._attemptLegacyAuth(); - } + authenticate(bodyWrap, _no_auth_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]; + + // Start sending authentication + this._changeConnectStatus(Status.AUTHENTICATING, null); + + /*** These handlers listen for the triggered by a login, + * and in turn trigger the progression of the login process. */ + /** @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 + ) + ); + + /* SASL2: https://xmpp.org/extensions/xep-0388.html */ + this.sasl2.authenticate().then((authenticated) => { + + if (authenticated != this.authenticated) { + throw new Error(`SASL2 authentication attempt returned ${authenticated} but connection.authenticated=${this.authenticated}`); + } + if (authenticated) { return } + + /* SASL */ + const sasl_header = features.getElementsByTagNameNS(NS.SASL, 'mechanisms')[0] + const server_mechanisms = + [...sasl_header.children ?? []] + .filter((e) => e.tagName == 'mechanism') + .map((m) => m.textContent); + console.debug("Server advertised these SASL auth methods:", server_mechanisms); + + let mechanisms = server_mechanisms + .map((m) => this.mechanisms[m]) + .filter((m) => m); + console.info("Of those, these are available:", mechanisms); + + if (this._attemptSASLAuth(mechanisms)) { + return; + } + + /* Legacy auth */ + if (bodyWrap.getElementsByTagName('auth').length > 0) { + this._attemptLegacyAuth(); + return; + } + + this._proto._no_auth_received(_no_auth_callback); + }) + } /** @@ -1458,21 +1495,21 @@ class Connection { } this._sasl_success_handler = this._addSysHandler( this._sasl_success_cb.bind(this), - null, + NS.SASL, 'success', null, null ); this._sasl_failure_handler = this._addSysHandler( this._sasl_failure_cb.bind(this), - null, + NS.SASL, 'failure', null, null ); this._sasl_challenge_handler = this._addSysHandler( this._sasl_challenge_cb.bind(this), - null, + NS.SASL, 'challenge', null, null @@ -1504,7 +1541,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 +1559,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,28 +1616,48 @@ 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)); - const attribMatch = /([a-z]+)=([^,]+)(,|$)/; - const matches = success.match(attribMatch); - if (matches[1] === 'v') { - serverSignature = matches[2]; + + let success; + if (elem.namespaceURI == NS.SASL2) { + success = elem.querySelector('additional-data') + } else if (elem.namespaceURI == NS.SASL) { + success = elem; + } else { + return this._sasl_failure_cb(elem); } - if (serverSignature !== this._sasl_data['server-signature']) { - // remove old handlers - this.deleteHandler(this._sasl_failure_handler); - this._sasl_failure_handler = null; - if (this._sasl_challenge_handler) { - this.deleteHandler(this._sasl_challenge_handler); - this._sasl_challenge_handler = null; + + if (success) { // XXX dangerous! this disables signature verification if only the server refuses to send + // but I've enabled fall-through to multiple SASL methods so I need to revamp when 'server-signature' gets set + + success = atob(getText(success)) + + const attribMatch = /([a-z]+)=([^,]+)(,|$)/; + const matches = success.match(attribMatch); + 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; + if (this._sasl_challenge_handler) { + this.deleteHandler(this._sasl_challenge_handler); + this._sasl_challenge_handler = null; + } + this._sasl_data = {}; + return this._sasl_failure_cb(elem); } - this._sasl_data = {}; - return this._sasl_failure_cb(null); } } log.info('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 +1672,11 @@ 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(); + if (elem.namespaceURI == NS.SASL) { + // under SASL1 we must send an xmpp:restart now + this._sendRestart(); + } return false; } @@ -1823,7 +1845,21 @@ class Connection { } if (this._sasl_mechanism) this._sasl_mechanism.onFailure(); - this._changeConnectStatus(Status.AUTHFAIL, null, elem); + + let error_condition + if (elem) { + let error_type = elem.children[0]?.tagName + let error_msg = getText(elem.querySelector("text")) + let error_condition = `${error_type}` + if (error_msg) { + error_msg = decodeURIComponent(error_msg) + error_condition += `: ${error_msg}` + } + console.error(`SASL: ${error_condition}`) + } + + this._changeConnectStatus(Status.AUTHFAIL, error_condition, elem); + return false; } @@ -1882,6 +1918,37 @@ class Connection { return hand; } + // create a handler that efficiently searches for *nested* elements + // this won't _always_ be appropriate, but XMPP is heavily + // designed around namespaces allowing for quick filtering + // of relevant data + _addSysNSHandler(handler, ns, name) { + const hand = this._addSysHandler((elem) => { + // recursively search for all <{ns}name> tags + const matches = [...elem.getElementsByTagNameNS(ns, name)] + + // add the parent itself because getElementsByTagNameNS() doesn't include + if ((!ns || hand.getNamespace(elem) == ns) && (!name || elem.tagName == name)) { + matches.unshift(elem) + } + + // call handler on all matches + const signals = matches.map(handler) + + // tricky: decide how to combine multiple listen/forget signals + if (signals.length == 0 || signals.some((listen) => listen)) { + // If any says remember **or** in the + // common case that there are no matches + // at all, keep listening. + return true + } else { + // But forget if all the matches say forget. + return true + } + }, null, null, null, null) + return hand + } + /** * _Private_ timeout handler for handling non-graceful disconnection. * diff --git a/src/index.js b/src/index.js index 2a5704c..9242a44 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,8 @@ 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 SASL2 from './sasl2.js'; +import FAST from './sasl2_fast.js'; import TimedHandler from './timed-handler.js'; import Websocket from './websocket.js'; import WorkerWebsocket from './worker-websocket.js'; @@ -190,4 +192,14 @@ 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('sasl2', SASL2); +Strophe.addNamespace('SASL2', SASL2.NS); + +Strophe.addConnectionPlugin('fast', FAST); +Strophe.addNamespace('FAST', FAST.NS); + + 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..1600a65 --- /dev/null +++ b/src/sasl-ht-sha256-none.js @@ -0,0 +1,61 @@ +/** + * @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) { + return (connection.fast?.credential?.mechanism == this.mechname) + && (Date.now() < connection.fast?.credential?.expiry - 30); // -30 for some wiggle room in clock skew etc + } + + /** + * @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?.credential?.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/sasl-sha1.js b/src/sasl-sha1.js index 8c78f77..9a1fdc1 100644 --- a/src/sasl-sha1.js +++ b/src/sasl-sha1.js @@ -17,7 +17,7 @@ class SASLSHA1 extends SASLMechanism { */ // eslint-disable-next-line class-methods-use-this test(connection) { - return connection.authcid !== null; + return scram.test(connection, 'SHA-1', 160); } /** diff --git a/src/sasl-sha256.js b/src/sasl-sha256.js index 01fcc94..8b7a4aa 100644 --- a/src/sasl-sha256.js +++ b/src/sasl-sha256.js @@ -17,7 +17,7 @@ class SASLSHA256 extends SASLMechanism { */ // eslint-disable-next-line class-methods-use-this test(connection) { - return connection.authcid !== null; + return scram.test(connection, 'SHA-256', 256); } /** diff --git a/src/sasl-sha384.js b/src/sasl-sha384.js index 5f1f427..97bbbf3 100644 --- a/src/sasl-sha384.js +++ b/src/sasl-sha384.js @@ -17,7 +17,7 @@ class SASLSHA384 extends SASLMechanism { */ // eslint-disable-next-line class-methods-use-this test(connection) { - return connection.authcid !== null; + return scram.test(connection, 'SHA-384', 384); } /** diff --git a/src/sasl-sha512.js b/src/sasl-sha512.js index a08939d..1ef24b3 100644 --- a/src/sasl-sha512.js +++ b/src/sasl-sha512.js @@ -17,7 +17,7 @@ class SASLSHA512 extends SASLMechanism { */ // eslint-disable-next-line class-methods-use-this test(connection) { - return connection.authcid !== null; + return scram.test(connection, 'SHA-512', 512); } /** diff --git a/src/sasl2.js b/src/sasl2.js new file mode 100644 index 0000000..a643232 --- /dev/null +++ b/src/sasl2.js @@ -0,0 +1,233 @@ +/** + * @typedef {import("./connection.js").default} Connection + */ +import { Status } from './constants.js'; + +const SASL2 = { + // TODO: turn this into a full module + 'NS': 'urn:xmpp:sasl:2', + + /** @type {Connection} */ + conn: null, + + /** + * Create and initialize a new Handler. + * + * @param {Connection} connection + */ + init: function (connection) { + this.conn = connection; + + // generate a new ephemeral client ID for stanza + // XXX note that if using FAST this is *non* ephemeral: + // it is the username and the FAST token is the password + this.clientID = this.conn.getUniqueId(); + }, + + /** + * + * @param {Number} status + */ + statusChanged: function (status) { + + console.warn("statusChanged:", status) + + if (status == Status.CONNECTING) { + + // Register listeners *before* we read any data (CONNECTING) + // so we can catch the crucial first stanzas + + // This listener doesn't work with the normal Strophe handlers because + // 1. is special-cased in connection.js and not passed to handlers + // 2. addSysHandler() can only hook top-level tags, but is nested in + // TODO: rewrite more verbosely using addSysHandler. If possible. + this.conn._addSysNSHandler( + this.onAuth.bind(this), + this.NS, + 'authentication' + ) + } + }, + + /** + * @param {Element} elem + */ + onAuth: async function (elem) { + const server_mechanisms = [...elem.querySelectorAll('mechanism')] + .map((m) => m.textContent); + + /** @type {String[]} } */ + this.mechanisms = [... + new Set(server_mechanisms) + .intersection(new Set(Object.keys(this.conn.mechanisms))) + ]; + + console.info( + "SASL2: server advertised ", server_mechanisms, + " of which we support", this.mechanisms); + if (this.mechanisms.length == 0) { + console.warn("FAST offered but with no known mechanisms."); + } + + return false; // stop listening + }, + + async authenticateStanza(/** @type String */ mechname) { + const authenticate = $build('authenticate', { + 'xmlns': this.NS, + 'mechanism': mechname, + }); + + authenticate + .c('user-agent', { 'id': this.clientID }) + .c("software", "Strophe.js").up() + .c("device", navigator?.userAgent ?? "").up() + .up(); + + return authenticate + }, + + authenticate: async function () { + if (this.conn.authenticated) { + console.warn("SASL2: Already authenticated; not authenticating again."); + return + } + + // sort by priority + this.mechanisms.sort((a, b) => { + return (this.conn.mechanisms[b].priority - this.conn.mechanisms[a].priority) + }) + + for (let mechname of this.mechanisms) { + + let mechanism = this.conn.mechanisms[mechname] + if (!mechanism) { + console.warn(`SASL2: Unknown mechanism ${mechname}`) + } + if (!mechanism.test(this.conn)) { + console.debug("SASL2: skipping mechanism", mechname) + continue + } + console.debug("SASL2: trying mechanism", mechname) + + if (await this._authenticate(mechname)) { + this.conn.authenticated = true; + console.debug("SASL 2 mechanism", mechname, "succeded") + return true; + } else { + console.debug("SASL 2 mechanism", mechname, "failed") + // return false ; // *stop* fallback + } + } + + return false; + }, + + _authenticate: async function (mechname) { + let mechanism = this.conn.mechanisms[mechname] + if (!mechanism) { + console.warn(`SASL2: Unknown mechanism ${mechname}`) + } + if (!mechanism.test(this.conn)) { + return false + } + this.conn._sasl_mechanism = mechanism; // backwards compat + + // wrap Strophe's callback-based API into a deferred Promise + let resolve_sasl_response + /** @type Promise */ + let _response = new Promise((resolve, _) => { + + resolve_sasl_response = resolve + }) + + let success_handler = this.conn._addSysHandler( + (elem) => resolve_sasl_response(elem), + this.NS, + 'success', + null, + null + ); + let failure_handler = this.conn._addSysHandler( + (elem) => resolve_sasl_response(elem), + this.NS, + 'failure', + null, + null + ); + let challenge_handler = this.conn._addSysHandler( + (elem) => resolve_sasl_response(elem), + this.NS, + 'challenge', + null, + null + ); + + const clear_handlers = () => { + if (success_handler) { + this.conn.deleteHandler(success_handler); + success_handler = null + } + if (failure_handler) { + this.conn.deleteHandler(failure_handler); + failure_handler = null + } + if (challenge_handler) { + this.conn.deleteHandler(challenge_handler); + challenge_handler = null + } + } + + // delay to allow the idle loop to reliably install the new handlers + await Promise.resolve(); + + // fast needs to modify non-fast s + // and needs to change the format slightly for FAST s + + let authenticate = await this.authenticateStanza(mechname) + + mechanism.onStart(this.conn); + if (mechanism.isClientFirst) { + const response = await mechanism.clientChallenge(this.conn); + authenticate + .c('initial-response', + null, + btoa(/** @type {string} */(response))) + .up(); + } + console.debug("SASL2: sending ", authenticate.tree()) + this.conn.send(authenticate.tree()); + try { + while (true) { // SASL loops, sending [ ... ] until + + const response = await _response; + console.debug(`SASL2: <${response.tagName}>:`, response) + + if (response.tagName == 'challenge') { + this.conn._sasl_challenge_cb.bind(this.conn)(response); // this callback replies to the server with a + } else if (response.tagName == 'success') { + this.conn._sasl_success_cb.bind(this.conn)(response); // this callback verifies the server's final + return true + } else if (response.tagName == 'failure') { + this.conn._sasl_failure_cb.bind(this.conn)(response); // this sends an error event up the stack + return false + } else { + throw new Exception("Unknown SASL2 response:", response) + } + + // reset the deferred promise + _response = new Promise(resolve => { + resolve_sasl_response = resolve + }) + } + } catch (e) { + console.error(e); + throw e; + } finally { + clear_handlers() + } + + }, +}; + +export default SASL2; diff --git a/src/sasl2_fast.js b/src/sasl2_fast.js new file mode 100644 index 0000000..f2635d5 --- /dev/null +++ b/src/sasl2_fast.js @@ -0,0 +1,239 @@ +/** + * @typedef {import("./connection.js").default} Connection +*/ +import { Status } from './constants.js'; + +/** + * @typedef {Object} FastCredential + * @property {string} [clientID] + * @property {string} [mechanism] + * @property {string} [token] + * @property {Number} [expiry] + * @property {Number} [counter] + */ + +/** + * @this {{ conn: Connection }} + */ +const FAST = { + NS: 'urn:xmpp:fast:0', + + /** @type {Connection} */ + conn: null, + + // Mechanisms supported by both us and the server + // note that there are SASL2 mechanisms and then SASL2-FAST mechanisms + // it's nested in a ... + // we merge the two, but the server sends them separately, + // and those in *this* subset are ones we respond with our own tag. + /** @type {String[]} } */ + mechanisms: null, + + /** @type {FastCredential} */ + credential: null, + + /** + * Create and initialize a new Handler. + * + * @param {Connection} connection + */ + init: function (connection) { + this.conn = connection + + this.credential = { + 'clientID': this.conn.sasl2.clientID, + } + + // **monkey-patch** SASL2 to add + // - FAST to outgoing s using a FAST mechanism + // - FAST other outgoing s + // + // (XXX: a better solution would be an _outgoing_ handler system) + // + // TODO: for completeness, test that it's possible to use a FAST + // with one mechanism to for a different + // e.g. it should be legal and possible to send + // + + const authenticateStanza = this.conn.sasl2.authenticateStanza.bind(this.conn.sasl2) // XXX is the .bind() necessary? + this.conn.sasl2.authenticateStanza = async (mechname) => { + let authenticate = await authenticateStanza(mechname); + + // Try to authenticate with FAST + // The protocol here is subtly different. The *implicit assumption* is that + // that the and the / mechanisms do not overlap. + if (/* mechname is in fact accepted by the server as a FAST mechanism */ + this.mechanisms.indexOf(mechname) != -1 && + /* AND we have a token for it */ + this.credential?.mechanism == mechname) { + + if (!this.credential.token) { + // mechanism.test() shouldn't have let us get here + throw new Exception(`Tried to authenticate with FAST mechanism ${mechname} without fast.credential being defined`); + } + + // > To indicate that it is providing a token, the client MUST + // > include a element qualified by the 'urn:xmpp:fast:0' namespace, + // > within its SASL2 authentication request. + // - https://xmpp.org/extensions/xep-0484.html#fast-auth + authenticate + .c("fast", { + 'xmlns': this.NS, + // 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.credential.counter.toString() + }).up() + // > The value of this attribute MUST be a positive integer, which is + // > incremented by the client on every authentication attempt + // - https://xmpp.org/extensions/xep-0484.html#fast-auth + this.credential.counter++; + } else { + // When authenticating with a different-than-current FAST mechanism + // initiate FAST by sending + let other_mechanisms = [...this.mechanisms ?? []].filter((m) => m != mechname) + if (other_mechanisms.length > 0) { + + // just pick the first mechanism + let mechname = other_mechanisms[0] + + // remember which one we picked because the server won't tell us + this.credential = { ...this.credential, 'mechanism': mechname } + + // send it + authenticate + .c('request-token', { + 'xmlns': this.NS, + 'mechanism': mechname, + }).up() + } + } + + return authenticate; + } + + // **MONKEY-PATCH** connection to catch the logout event + let reset = this.conn.reset.bind(this.conn) + this.conn.reset = () => { + this.logout.bind(this)().then(() => { + reset() + }) + } + }, + + /** + * + * @param {Number} status + */ + statusChanged: function (status) { + if (status === Status.CONNECTING) { + // Register listeners before we get data (CONNECTING) so + // we can catch the crucial first stanzas + this.conn._addSysNSHandler(this.onAuth.bind(this), this.NS, 'fast'); + this.conn._addSysNSHandler(this.onToken.bind(this), this.NS, 'token'); + + // Load token from passed in credentials, if given, + // Make sure to synchronize token's clientID with SASL2; + // clientID is optional for SASL2 but unfortunately mandatory for FAST + // > 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 + // Simply: clientID plays the role of username and token the role of password. + const { pass } = this.conn; + if (pass.token) { + this.credential = /** @type {FastCredential} */ pass; + this.conn.sasl2.clientID = this.credential.clientID + } else { + // + this.credential = { + 'clientID': this.conn.sasl2.clientID, + } + } + } + }, + + /** + * @param {Element} elem + */ + onAuth: function (elem) { + + // Note: this looks *a lot* like sasl2.onAuth(), + // but it runs on a different tag, a sub-tag of the main stanza + + const server_mechanisms = new Set([...elem.querySelectorAll('mechanism')] + .map((m) => m.textContent)); + + this.mechanisms = [...server_mechanisms + .intersection( + new Set(Object.keys(this.conn.mechanisms)) + )]; + // sort by priority + this.mechanisms.sort((a, b) => { + (this.conn.mechanisms[b].priority - this.conn.mechanisms[a].priority) + }) + + console.info( + "FAST: server advertised ", server_mechanisms, + " of which we support", this.mechanisms); + if (this.mechanisms.length == 0) { + console.warn("FAST offered but with no known mechanisms."); + return + } + + // Append to the list of SASL2 mechanisms + // XXX what happens if this runs before sasl2.onAuth()? + this.conn.sasl2.mechanisms = [... + new Set(this.conn.sasl2.mechanism) + .union(new Set(this.mechanisms)) + ]; + // sort by priority XXX the FAST mechanisms should have higher priority! + this.conn.sasl2.mechanisms.sort((a, b) => { + return (this.conn.mechanisms[b].priority - this.conn.mechanisms[a].priority) + }) + + return false; // stop listening + }, + + /** + * @param {Element} elem + */ + onToken: function (elem) { + + this.credential = { + ...this.credential, + 'token': elem.getAttribute('token'), + 'expiry': Date.parse(elem.getAttribute('expiry')), + 'counter': 0, + }; + console.log("fast plugin onToken", elem, this.credential) + + return true; // keep listening: the server is allowed to rotate our token anytime it wishes + }, + + logout: async function () { + // Invalidate the FAST token on log out + // XXX this does not seem to actually get sent, + // and Converse does not forget the token from its IndexedDB + // if you edit Local Storage using the web debugger to re-add conversejs-session-jid: 'user@xmpp.example.org' + // and reload then FAST will happily log you back in + + if (this.credential.token) { + let authenticate = await this.conn.sasl2.authenticateStanza(this.credential.mechanism) + + // XXX copy-pasta + const response = await this.conn.mechanisms[this.credential.mechanism].clientChallenge(this.conn); + authenticate + .c('initial-response', + null, + btoa(/** @type {string} */(response))) + .up(); + + authenticate.nodeTree.querySelector("fast")?.setAttribute("invalidate", "true") + + this.conn.send(authenticate.tree()) + } + } +}; + +export default FAST; diff --git a/src/scram.js b/src/scram.js index a5edd30..5c1bd7c 100644 --- a/src/scram.js +++ b/src/scram.js @@ -127,15 +127,23 @@ function generate_cnonce() { } /** - * @typedef {Object} Password - * @property {string} Password.name - * @property {string} Password.ck - * @property {string} Password.sk - * @property {number} Password.iter - * @property {string} salt + * @typedef {Object} SCRAMKey + * @property {string} SCRAMKey.name + * @property {string} SCRAMKey.ck + * @property {string} SCRAMKey.sk + * @property {number} SCRAMKeyiter + * @property {string} SCRAMKey.salt */ const scram = { + test: function (connection, hashName, hashBits) { + return connection.authcid !== null + && ( + (typeof connection.pass === 'string' || connection.pass instanceof String) + || (connection.pass?.name === hashName) + ); + }, + /** * On success, sets * connection_sasl_data["server-signature"] @@ -178,9 +186,9 @@ const scram = { serverKey = keys.sk; } else if ( // Either restore the client key and server key passed in, or derive new ones - /** @type {Password} */ (pass)?.name === hashName && - /** @type {Password} */ (pass)?.salt === utils.arrayBufToBase64(challengeData.salt) && - /** @type {Password} */ (pass)?.iter === challengeData.iter + /** @type {SCRAMKey} */ (pass)?.name === hashName && + /** @type {SCRAMKey} */ (pass)?.salt === utils.arrayBufToBase64(challengeData.salt) && + /** @type {SCRAMKey} */ (pass)?.iter === challengeData.iter ) { const { ck, sk } = /** @type {Password} */ (pass); clientKey = utils.base64ToArrayBuf(ck); 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', }); }