diff options
Diffstat (limited to 'comm/chat/protocols/xmpp/xmpp-session.sys.mjs')
-rw-r--r-- | comm/chat/protocols/xmpp/xmpp-session.sys.mjs | 764 |
1 files changed, 764 insertions, 0 deletions
diff --git a/comm/chat/protocols/xmpp/xmpp-session.sys.mjs b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs new file mode 100644 index 0000000000..ca2fd4eebb --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs @@ -0,0 +1,764 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { Socket } from "resource:///modules/socket.sys.mjs"; +import { Stanza, XMPPParser } from "resource:///modules/xmpp-xml.sys.mjs"; +import { XMPPAuthMechanisms } from "resource:///modules/xmpp-authmechs.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); + +export function XMPPSession( + aHost, + aPort, + aSecurity, + aJID, + aPassword, + aAccount +) { + this._host = aHost; + this._port = aPort; + + this._connectionSecurity = aSecurity; + if (this._connectionSecurity == "old_ssl") { + this._security = ["ssl"]; + } else if (this._connectionSecurity != "none") { + this._security = [aPort == 5223 || aPort == 443 ? "ssl" : "starttls"]; + } + + if (!aJID.node) { + aAccount.reportDisconnecting( + Ci.prplIAccount.ERROR_INVALID_USERNAME, + lazy._("connection.error.invalidUsername") + ); + aAccount.reportDisconnected(); + return; + } + this._jid = aJID; + this._domain = aJID.domain; + this._password = aPassword; + this._account = aAccount; + this._resource = aJID.resource; + this._handlers = new Map(); + this._account.reportConnecting(); + + // The User has specified a certain server or port, so we should not do + // DNS SRV lookup or the preference of disabling DNS SRV part and use + // normal connect is set. + // RFC 6120 (Section 3.2.3): When Not to Use SRV. + if ( + Services.prefs.getBoolPref("chat.dns.srv.disable") || + this._account.prefs.prefHasUserValue("server") || + this._account.prefs.prefHasUserValue("port") + ) { + this.connect(this._host, this._port, this._security); + return; + } + + // RFC 6120 (Section 3.2.1): SRV lookup. + this._account.reportConnecting(lazy._("connection.srvLookup")); + DNS.srv("_xmpp-client._tcp." + this._host) + .then(aResult => this._handleSrvQuery(aResult)) + .catch(aError => { + if (aError === this.SRV_ERROR_XMPP_NOT_SUPPORTED) { + this.LOG("SRV: XMPP is not supported on this domain."); + + // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): Abort as the + // service is decidedly not available at this domain. + this._account.reportDisconnecting( + Ci.prplIAccount.ERROR_OTHER_ERROR, + lazy._("connection.error.XMPPNotSupported") + ); + this._account.reportDisconnected(); + return; + } + + this.ERROR("Error during SRV lookup:", aError); + + // Since we don't receive a response to SRV query, we SHOULD attempt the + // fallback process (use normal connect without SRV lookup). + this.connect(this._host, this._port, this._security); + }); +} + +XMPPSession.prototype = { + /* for the socket.jsm helper */ + __proto__: Socket, + connectTimeout: 60, + readWriteTimeout: 300, + + // Contains the remaining SRV records if we failed to connect the current one. + _srvRecords: [], + + sendPing() { + this.sendStanza( + Stanza.iq("get", null, null, Stanza.node("ping", Stanza.NS.ping)), + this.cancelDisconnectTimer, + this + ); + }, + _lastReceiveTime: 0, + _lastSendTime: 0, + checkPingTimer(aJustSentSomething = false) { + // Don't start a ping timer if we're not fully connected yet. + if (this.onXmppStanza != this.stanzaListeners.accountListening) { + return; + } + let now = Date.now(); + if (aJustSentSomething) { + this._lastSendTime = now; + } else { + this._lastReceiveTime = now; + } + // We only cancel the ping timer if we've both received and sent + // something in the last two minutes. This is because Openfire + // servers will disconnect us if we don't send anything for a + // couple of minutes. + if ( + Math.min(this._lastSendTime, this._lastReceiveTime) > + now - this.kTimeBeforePing + ) { + this.resetPingTimer(); + } + }, + + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _security: null, + _encrypted: false, + + // DNS SRV errors in XMPP. + SRV_ERROR_XMPP_NOT_SUPPORTED: -2, + + // Handles result of DNS SRV query and saves sorted results if it's OK in _srvRecords, + // otherwise throws error. + _handleSrvQuery(aResult) { + this.LOG("SRV lookup: " + JSON.stringify(aResult)); + if (aResult.length == 0) { + // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): No SRV records, + // try to login with the given domain name. + this.connect(this._host, this._port, this._security); + return; + } else if (aResult.length == 1 && aResult[0].host == ".") { + throw this.SRV_ERROR_XMPP_NOT_SUPPORTED; + } + + // Sort results: Lower priority is more preferred and higher weight is + // more preferred in equal priorities. + aResult.sort(function (a, b) { + return a.prio - b.prio || b.weight - a.weight; + }); + + this._srvRecords = aResult; + this._connectNextRecord(); + }, + + _connectNextRecord() { + if (!this._srvRecords.length) { + this.ERROR( + "_connectNextRecord is called and there are no more records " + + "to connect." + ); + return; + } + + let record = this._srvRecords.shift(); + + // RFC 3920 (Section 5.1): Certificates MUST be checked against the + // hostname as provided by the initiating entity (e.g. user). + this.connect( + this._domain, + this._port, + this._security, + null, + record.host, + record.port + ); + }, + + /* Disconnect from the server */ + disconnect() { + if (this.onXmppStanza == this.stanzaListeners.accountListening) { + this.send("</stream:stream>"); + } + delete this.onXmppStanza; + Socket.disconnect.call(this); + if (this._parser) { + this._parser.destroy(); + delete this._parser; + } + this.cancelDisconnectTimer(); + }, + + /* Report errors to the account */ + onError(aError, aException) { + // If we're trying to connect to SRV entries, then keep trying until a + // successful connection occurs or we run out of SRV entries to try. + if (this._srvRecords.length) { + this._connectNextRecord(); + return; + } + + this._account.onError(aError, aException); + }, + + /* Send a text message to the server */ + send(aMsg, aLogString) { + this.sendString(aMsg, "UTF-8", aLogString); + }, + + /* Send a stanza to the server. + * Can set a callback if required, which will be called when the server + * responds to the stanza with a stanza of the same id. The callback should + * return true if the stanza was handled, false if not. Note that an + * undefined return value is treated as true. + */ + sendStanza(aStanza, aCallback, aThis, aLogString) { + if (!aStanza.attributes.hasOwnProperty("id")) { + aStanza.attributes.id = this._account.generateId(); + } + if (aCallback) { + this._handlers.set(aStanza.attributes.id, aCallback.bind(aThis)); + } + this.send(aStanza.getXML(), aLogString); + this.checkPingTimer(true); + return aStanza.attributes.id; + }, + + /* This method handles callbacks for specific ids. */ + execHandler(aId, aStanza) { + let handler = this._handlers.get(aId); + if (!handler) { + return false; + } + let isHandled = handler(aStanza); + // Treat undefined return values as handled. + if (isHandled === undefined) { + isHandled = true; + } + this._handlers.delete(aId); + return isHandled; + }, + + /* Start the XMPP stream */ + startStream() { + if (this._parser) { + this._parser.destroy(); + } + this._parser = new XMPPParser(this); + this.send( + '<?xml version="1.0"?><stream:stream to="' + + this._domain + + '" xmlns="jabber:client" xmlns:stream="http://etherx.jabber.org/streams" version="1.0">' + ); + }, + + startSession() { + this.sendStanza( + Stanza.iq("set", null, null, Stanza.node("session", Stanza.NS.session)), + aStanza => aStanza.attributes.type == "result" + ); + this.onXmppStanza = this.stanzaListeners.sessionStarted; + }, + + /* XEP-0078: Non-SASL Authentication */ + startLegacyAuth() { + if (!this._encrypted && this._connectionSecurity == "require_tls") { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSNotSupported") + ); + return; + } + + this.onXmppStanza = this.stanzaListeners.legacyAuth; + let s = Stanza.iq( + "get", + null, + this._domain, + Stanza.node( + "query", + Stanza.NS.auth, + null, + Stanza.node("username", null, null, this._jid.node) + ) + ); + this.sendStanza(s); + }, + + // If aResource is null, it will request to bind a server-generated + // resourcepart, otherwise request to bind a client-submitted resourcepart. + _requestBind(aResource) { + let resourceNode = aResource + ? Stanza.node("resource", null, null, aResource) + : null; + this.sendStanza( + Stanza.iq( + "set", + null, + null, + Stanza.node("bind", Stanza.NS.bind, null, resourceNode) + ) + ); + }, + + /* Socket events */ + /* The connection is established */ + onConnection() { + if (this._security.includes("ssl")) { + this.onXmppStanza = this.stanzaListeners.startAuth; + this._encrypted = true; + } else { + this.onXmppStanza = this.stanzaListeners.initStream; + } + + // Clear SRV results since we have connected. + this._srvRecords = []; + + this._account.reportConnecting(lazy._("connection.initializingStream")); + this.startStream(); + }, + + /* When incoming data is available to be parsed */ + onDataReceived(aData) { + this.checkPingTimer(); + this._lastReceivedData = aData; + try { + this._parser.onDataAvailable(aData); + } catch (e) { + console.error(e); + this.onXMLError("parser-exception", e); + } + delete this._lastReceivedData; + }, + + /* The connection got disconnected without us closing it. */ + onConnectionClosed() { + this._networkError(lazy._("connection.error.serverClosedConnection")); + }, + onConnectionSecurityError(aTLSError, aNSSErrorMessage) { + let error = this._account.handleConnectionSecurityError(this); + this.onError(error, aNSSErrorMessage); + }, + onConnectionReset() { + this._networkError(lazy._("connection.error.resetByPeer")); + }, + onConnectionTimedOut() { + this._networkError(lazy._("connection.error.timedOut")); + }, + _networkError(aMessage) { + this.onError(Ci.prplIAccount.ERROR_NETWORK_ERROR, aMessage); + }, + + /* Methods called by the XMPPParser instance */ + onXMLError(aError, aException) { + if (aError == "parsing-characters") { + this.WARN(aError + ": " + aException + "\n" + this._lastReceivedData); + } else { + this.ERROR(aError + ": " + aException + "\n" + this._lastReceivedData); + } + if (aError != "parse-warning" && aError != "parsing-characters") { + this._networkError(lazy._("connection.error.receivedUnexpectedData")); + } + }, + + // All the functions in stanzaListeners are used as onXmppStanza + // implementations at various steps of establishing the session. + stanzaListeners: { + initStream(aStanza) { + if (aStanza.localName != "features") { + this.ERROR( + "Unexpected stanza " + aStanza.localName + ", expected 'features'" + ); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let starttls = aStanza.getElement(["starttls"]); + if (starttls && this._security.includes("starttls")) { + this._account.reportConnecting( + lazy._("connection.initializingEncryption") + ); + this.sendStanza(Stanza.node("starttls", Stanza.NS.tls)); + this.onXmppStanza = this.stanzaListeners.startTLS; + return; + } + if (starttls && starttls.children.some(c => c.localName == "required")) { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSRequired") + ); + return; + } + if (!starttls && this._connectionSecurity == "require_tls") { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSNotSupported") + ); + return; + } + + // If we aren't starting TLS, jump to the auth step. + this.onXmppStanza = this.stanzaListeners.startAuth; + this.onXmppStanza(aStanza); + }, + startTLS(aStanza) { + if (aStanza.localName != "proceed") { + this._networkError(lazy._("connection.error.failedToStartTLS")); + return; + } + + this.startTLS(); + this._encrypted = true; + this.startStream(); + this.onXmppStanza = this.stanzaListeners.startAuth; + }, + startAuth(aStanza) { + if (aStanza.localName != "features") { + this.ERROR( + "Unexpected stanza " + aStanza.localName + ", expected 'features'" + ); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let mechs = aStanza.getElement(["mechanisms"]); + if (!mechs) { + let auth = aStanza.getElement(["auth"]); + if (auth && auth.uri == Stanza.NS.auth_feature) { + this.startLegacyAuth(); + } else { + this._networkError(lazy._("connection.error.noAuthMec")); + } + return; + } + + // Select the auth mechanism we will use. PLAIN will be treated + // a bit differently as we want to avoid it over an unencrypted + // connection, except if the user has explicitly allowed that + // behavior. + let authMechanisms = this._account.authMechanisms || XMPPAuthMechanisms; + let selectedMech = ""; + let canUsePlain = false; + mechs = mechs.getChildren("mechanism"); + for (let m of mechs) { + let mech = m.innerText; + if (mech == "PLAIN" && !this._encrypted) { + // If PLAIN is proposed over an unencrypted connection, + // remember that it's a possibility but don't bother + // checking if the user allowed it until we have verified + // that nothing more secure is available. + canUsePlain = true; + } else if (authMechanisms.hasOwnProperty(mech)) { + selectedMech = mech; + break; + } + } + if (!selectedMech && canUsePlain) { + if (this._connectionSecurity == "allow_unencrypted_plain_auth") { + selectedMech = "PLAIN"; + } else { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.notSendingPasswordInClear") + ); + return; + } + } + if (!selectedMech) { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.noCompatibleAuthMec") + ); + return; + } + let authMec = authMechanisms[selectedMech]( + this._jid.node, + this._password, + this._domain + ); + this._password = null; + + this._account.reportConnecting(lazy._("connection.authenticating")); + this.onXmppStanza = this.stanzaListeners.authDialog.bind(this, authMec); + this.onXmppStanza(null); // the first auth step doesn't read anything + }, + authDialog(aAuthMec, aStanza) { + if (aStanza && aStanza.localName == "failure") { + let errorMsg = "authenticationFailure"; + if ( + aStanza.getElement(["not-authorized"]) || + aStanza.getElement(["bad-auth"]) + ) { + errorMsg = "notAuthorized"; + } + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error." + errorMsg) + ); + return; + } + + let result; + try { + result = aAuthMec.next(aStanza); + } catch (e) { + this.ERROR("Error in auth mechanism: " + e); + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + return; + } + + // The authentication mechanism can yield a promise which must resolve + // before sending data. If it rejects, abort. + if (result.value) { + Promise.resolve(result.value).then( + value => { + // Send the XML stanza that is returned. + if (value.send) { + this.send(value.send.getXML(), value.log); + } + }, + e => { + this.ERROR("Error resolving auth mechanism result: " + e); + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + } + ); + } + if (result.done) { + this.startStream(); + this.onXmppStanza = this.stanzaListeners.startBind; + } + }, + startBind(aStanza) { + if (!aStanza.getElement(["bind"])) { + this.ERROR("Unexpected lack of the bind feature"); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + this._account.reportConnecting(lazy._("connection.gettingResource")); + this._requestBind(this._resource); + this.onXmppStanza = this.stanzaListeners.bindResult; + }, + bindResult(aStanza) { + if (aStanza.attributes.type == "error") { + let error = this._account.parseError(aStanza); + let message; + switch (error.condition) { + case "resource-constraint": + // RFC 6120 (7.6.2.1): Resource Constraint. + // The account has reached a limit on the number of simultaneous + // connected resources allowed. + message = "connection.error.failedMaxResourceLimit"; + break; + case "bad-request": + // RFC 6120 (7.7.2.1): Bad Request. + // The provided resourcepart cannot be processed by the server. + message = "connection.error.failedResourceNotValid"; + break; + case "conflict": + // RFC 6120 (7.7.2.2): Conflict. + // The provided resourcepart is already in use and the server + // disallowed the resource binding attempt. + this._requestBind(); + return; + default: + this.WARN(`Unhandled bind result error ${error.condition}.`); + message = "connection.error.failedToGetAResource"; + } + this._networkError(lazy._(message)); + return; + } + + let jid = aStanza.getElement(["bind", "jid"]); + if (!jid) { + this._networkError(lazy._("connection.error.failedToGetAResource")); + return; + } + jid = jid.innerText; + this.DEBUG("jid = " + jid); + this._jid = this._account._parseJID(jid); + this._resource = this._jid.resource; + this.startSession(); + }, + legacyAuth(aStanza) { + if (aStanza.attributes.type == "error") { + let error = aStanza.getElement(["error"]); + if (!error) { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let code = parseInt(error.attributes.code, 10); + if (code == 401) { + // Failed Authentication (Incorrect Credentials) + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.notAuthorized") + ); + return; + } else if (code == 406) { + // Failed Authentication (Required Information Not Provided) + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + return; + } + // else if (code == 409) { + // Failed Authentication (Resource Conflict) + // XXX Flo The spec in XEP-0078 defines this error code, but + // I've yet to find a server sending it. The server I tested + // with just closed the first connection when a second + // connection was attempted with the same resource. + // libpurple's jabber prpl doesn't support this code either. + // } + } + + if (aStanza.attributes.type != "result") { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + if (aStanza.children.length == 0) { + // Success! + this._password = null; + this.startSession(); + return; + } + + let query = aStanza.getElement(["query"]); + let values = {}; + for (let c of query.children) { + values[c.qName] = c.innerText; + } + + if (!("username" in values) || !("resource" in values)) { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + // If the resource is empty, we will fallback to brandShortName as + // resource is REQUIRED. + if (!this._resource) { + this._resource = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + this._jid = this._setJID( + this._jid.domain, + this._jid.node, + this._resource + ); + } + + let children = [ + Stanza.node("username", null, null, this._jid.node), + Stanza.node("resource", null, null, this._resource), + ]; + + let logString; + if ("digest" in values && this._streamId) { + let hashBase = this._streamId + this._password; + + let ch = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + ch.init(ch.SHA1); + // Non-US-ASCII characters MUST be encoded as UTF-8 since the + // SHA-1 hashing algorithm operates on byte arrays. + let data = [...new TextEncoder().encode(hashBase)]; + ch.update(data, data.length); + let hash = ch.finish(false); + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + let digest = Object.keys(hash) + .map(i => toHexString(hash.charCodeAt(i))) + .join(""); + + children.push(Stanza.node("digest", null, null, digest)); + logString = + "legacyAuth stanza containing SHA-1 hash of the password not logged"; + } else if ("password" in values) { + if ( + !this._encrypted && + this._connectionSecurity != "allow_unencrypted_plain_auth" + ) { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.notSendingPasswordInClear") + ); + return; + } + children.push(Stanza.node("password", null, null, this._password)); + logString = "legacyAuth stanza containing password not logged"; + } else { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.noCompatibleAuthMec") + ); + return; + } + + let s = Stanza.iq( + "set", + null, + this._domain, + Stanza.node("query", Stanza.NS.auth, null, children) + ); + this.sendStanza( + s, + undefined, + undefined, + `<iq type="set".../> (${logString})` + ); + }, + sessionStarted(aStanza) { + this.resetPingTimer(); + this._account.onConnection(); + this.LOG("Account successfully connected."); + this.onXmppStanza = this.stanzaListeners.accountListening; + }, + accountListening(aStanza) { + let id = aStanza.attributes.id; + if (id && this.execHandler(id, aStanza)) { + return; + } + + this._account.onXmppStanza(aStanza); + let name = aStanza.qName; + if (name == "presence") { + this._account.onPresenceStanza(aStanza); + } else if (name == "message") { + this._account.onMessageStanza(aStanza); + } else if (name == "iq") { + this._account.onIQStanza(aStanza); + } + }, + }, + onXmppStanza(aStanza) { + this.ERROR("should not be reached\n"); + }, +}; |