Bug 1527480 - Fix the SCRAM-SHA-1 authentication mechanism for XMPP. r=florian a=jorgk
authorPatrick Cloke <clokep@gmail.com>
Wed, 20 Feb 2019 09:01:29 -0500
changeset 34368 2960e4a6cec05f9fcd3061719f64772a112167ec
parent 34367 8eed720334644dd96ce6dd0dd788a0b2176b08c4
child 34369 ea9948ac39b0c4a5e26e6c5370e24adf0a9cbd32
push id389
push userclokep@gmail.com
push dateMon, 18 Mar 2019 19:01:53 +0000
reviewersflorian, jorgk
bugs1527480
Bug 1527480 - Fix the SCRAM-SHA-1 authentication mechanism for XMPP. r=florian a=jorgk
chat/protocols/xmpp/test/test_authmechs.js
chat/protocols/xmpp/test/xpcshell.ini
chat/protocols/xmpp/xmpp-authmechs.jsm
chat/protocols/xmpp/xmpp-session.jsm
new file mode 100644
--- /dev/null
+++ b/chat/protocols/xmpp/test/test_authmechs.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var {XMPPAuthMechanisms} = ChromeUtils.import("resource:///modules/xmpp-authmechs.jsm");
+var {Stanza} = ChromeUtils.import("resource:///modules/xmpp-xml.jsm");
+
+/*
+ * Test PLAIN using the examples given in section 6 of RFC 6120.
+ */
+add_task(async function testPlain() {
+  const username = "juliet";
+  const password = "r0m30myr0m30";
+
+  let mech = XMPPAuthMechanisms["PLAIN"](username, password, undefined);
+
+  // Send the initiation message.
+  let result = mech.next();
+  ok(!result.done);
+  let value = await Promise.resolve(result.value);
+
+  // Check the PLAIN content.
+  equal(value.send.children[0].text, "AGp1bGlldAByMG0zMG15cjBtMzA=");
+
+  // Receive the success.
+  let response = Stanza.node("success", Stanza.NS.sasl);
+  result = mech.next(response);
+  ok(result.done);
+  // There is no final value.
+  equal(result.value, undefined);
+});
+
+/*
+ * Test SCRAM-SHA-1 using the examples given in section 5 of RFC 5802.
+ *
+ * Full test vectors of intermediate values are available at:
+ * https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1
+ */
+add_task(async function testScram() {
+  const username = "user";
+  const password = "pencil";
+
+  // Use a constant value for the nonce.
+  const nonce = "fyko+d2lbbFgONRv9qkxdawL";
+
+  let mech = XMPPAuthMechanisms["SCRAM-SHA-1"](username, password, undefined, nonce);
+
+  // Send the client-first-message.
+  let result = mech.next();
+  ok(!result.done);
+  let value = await Promise.resolve(result.value);
+
+  // Check the SCRAM content.
+  equal(atob(value.send.children[0].text), "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL");
+
+  // Receive the server-first-message and send the client-final-message.
+  let response = Stanza.node("challenge", Stanza.NS.sasl, null, btoa("r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"));
+  result = mech.next(response);
+  ok(!result.done);
+  value = await Promise.resolve(result.value);
+
+  // Check the SCRAM content.
+  equal(atob(value.send.children[0].text), "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=");
+
+  // Receive the server-final-message.
+  response = Stanza.node("success", Stanza.NS.sasl, null, btoa("v=rmF9pqV8S7suAoZWja4dJRkFsKQ="));
+  result = mech.next(response);
+  ok(result.done);
+  // There is no final value.
+  equal(result.value, undefined);
+});
--- a/chat/protocols/xmpp/test/xpcshell.ini
+++ b/chat/protocols/xmpp/test/xpcshell.ini
@@ -1,9 +1,10 @@
 [DEFAULT]
 head =
 tail =
 
+[test_authmechs.js]
 [test_dnsSrv.js]
 [test_parseJidAndNormalization.js]
 [test_saslPrep.js]
 [test_xmppParser.js]
-[test_xmppXml.js]
\ No newline at end of file
+[test_xmppXml.js]
--- a/chat/protocols/xmpp/xmpp-authmechs.jsm
+++ b/chat/protocols/xmpp/xmpp-authmechs.jsm
@@ -1,24 +1,27 @@
 /* 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/. */
 
 // This module exports XMPPAuthMechanisms, an object containing all
 // the supported SASL authentication mechanisms.
-// By default we currently support the PLAIN and the DIGEST-MD5 mechanisms.
+// By default we currently support the PLAIN and the SCRAM-SHA-1 mechanisms.
 // As this is only used by XMPPSession, it may seem like an internal
 // detail of the XMPP implementation, but exporting it is valuable so that
 // add-ons can add support for more auth mechanisms easily by adding them
 // in XMPPAuthMechanisms without having to modify XMPPSession.
 
 this.EXPORTED_SYMBOLS = ["XMPPAuthMechanisms"];
 
+ChromeUtils.import("resource://services-common/utils.js");
 ChromeUtils.import("resource://services-crypto/utils.js");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource:///modules/xmpp-xml.jsm");
+XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]);
 
 // Handle PLAIN authorization mechanism.
 function* PlainAuth(aUsername, aPassword, aDomain) {
   let data = "\0"+ aUsername + "\0" + aPassword;
 
   // btoa for Unicode, see https://developer.mozilla.org/en-US/docs/DOM/window.btoa
   let base64Data = btoa(unescape(encodeURIComponent(data)));
 
@@ -302,20 +305,45 @@ function saslName(aName) {
 function bytesAndSHA1(aMessage) {
   let hasher =
     Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
   hasher.init(hasher.SHA1);
 
   return CryptoUtils.digestBytes(aMessage, hasher);
 }
 
-function* scramSHA1Auth(aUsername, aPassword, aDomain) {
+/**
+ * PBKDF2 password stretching with SHA-1 hmac.
+ *
+ * This is a copy of CryptoUtils.pbkdf2Generate, but using SHA-1 instead of
+ * SHA-256.
+ *
+ * @param {string} passphrase Passphrase as an octet string.
+ * @param {string} salt Salt as an octet string.
+ * @param {string} iterations Number of iterations, a positive integer.
+ * @param {string} len Desired output length in bytes.
+ */
+ async function pbkdf2Generate(passphrase, salt, iterations, len) {
+  passphrase = CommonUtils.byteStringToArrayBuffer(passphrase);
+  salt = CommonUtils.byteStringToArrayBuffer(salt);
+  const key = await crypto.subtle.importKey("raw", passphrase, {name: "PBKDF2"}, false, ["deriveBits"]);
+  const output = await crypto.subtle.deriveBits({
+      name: "PBKDF2",
+      hash: "SHA-1",
+      salt,
+      iterations,
+  }, key, len * 8);
+  return CommonUtils.arrayBufferToByteString(new Uint8Array(output));
+}
+
+function* scramSHA1Auth(aUsername, aPassword, aDomain, aNonce) {
   // RFC 5802 (5): SCRAM Authentication Exchange.
   const gs2Header = "n,,";
-  let cNonce = createNonce(32);
+  // If a hard-coded nonce was given (e.g. for testing), use it.
+  let cNonce = aNonce ? aNonce : createNonce(32);
 
   let clientFirstMessageBare =
     "n=" + saslName(aUsername) + ",r=" + cNonce;
   let clientFirstMessage = gs2Header + clientFirstMessageBare;
 
   let receivedStanza = yield {
     send: Stanza.node("auth", Stanza.NS.sasl, {mechanism: "SCRAM-SHA-1"},
                       btoa(clientFirstMessage))
@@ -344,60 +372,65 @@ function* scramSHA1Auth(aUsername, aPass
   let clientFinalMessageWithoutProof =
     "c=" + btoa(gs2Header) + ",r=" + attributes.r;
 
   // Calculate ClientProof.
 
   // SaltedPassword := Hi(Normalize(password), salt, i)
   // Normalize using saslPrep.
   // dkLen MUST be equal to the SHA-1 digest size.
-  let saltedPassword =
-    CryptoUtils.pbkdf2Generate(saslPrep(aPassword), atob(attributes.s),
-                               parseInt(attributes.i), 20);
+  let passwordPromise = pbkdf2Generate(saslPrep(aPassword), atob(attributes.s),
+                                       parseInt(attributes.i), 20);
+
+  // The server signature is calculated below, but needs to escape back to the main scope.
+  let serverSignature;
 
-  // ClientKey := HMAC(SaltedPassword, "Client Key")
-  let saltedPasswordHasher =
-    CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
-                               CryptoUtils.makeHMACKey(saltedPassword));
-  let clientKey = CryptoUtils.digestBytes("Client Key", saltedPasswordHasher);
+  // Once the promise resolves, continue with the handshake..
+  receivedStanza = yield passwordPromise.then((saltedPassword) => {
+    // ClientKey := HMAC(SaltedPassword, "Client Key")
+    let saltedPasswordHasher =
+      CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
+                                 CryptoUtils.makeHMACKey(saltedPassword));
+    let clientKey = CryptoUtils.digestBytes("Client Key", saltedPasswordHasher);
 
-  // StoredKey := H(ClientKey)
-  let storedKey = bytesAndSHA1(clientKey);
+    // StoredKey := H(ClientKey)
+    let storedKey = bytesAndSHA1(clientKey);
 
-  let authMessage =
-    clientFirstMessageBare + "," + decodedChallenge + "," +
-    clientFinalMessageWithoutProof;
+    let authMessage =
+      clientFirstMessageBare + "," + decodedChallenge + "," +
+      clientFinalMessageWithoutProof;
 
-  // ClientSignature := HMAC(StoredKey, AuthMessage)
-  let storedKeyHasher =
-    CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
-                               CryptoUtils.makeHMACKey(storedKey));
-  let clientSignature = CryptoUtils.digestBytes(authMessage, storedKeyHasher);
+    // ClientSignature := HMAC(StoredKey, AuthMessage)
+    let storedKeyHasher =
+      CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
+                                 CryptoUtils.makeHMACKey(storedKey));
+    let clientSignature = CryptoUtils.digestBytes(authMessage, storedKeyHasher);
 
-  // ClientProof := ClientKey XOR ClientSignature
-  let clientProof = CryptoUtils.xor(clientKey, clientSignature);
+    // ClientProof := ClientKey XOR ClientSignature
+    let clientProof = CryptoUtils.xor(clientKey, clientSignature);
 
-  // Calculate ServerSignature.
+    // Calculate ServerSignature.
 
-  // ServerKey := HMAC(SaltedPassword, "Server Key")
-  let serverKey = CryptoUtils.digestBytes("Server Key", saltedPasswordHasher);
+    // ServerKey := HMAC(SaltedPassword, "Server Key")
+    let serverKey = CryptoUtils.digestBytes("Server Key", saltedPasswordHasher);
 
-  // ServerSignature := HMAC(ServerKey, AuthMessage)
-  let serverKeyHasher =
-    CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
-                               CryptoUtils.makeHMACKey(serverKey));
-  let serverSignature = CryptoUtils.digestBytes(authMessage, serverKeyHasher);
+    // ServerSignature := HMAC(ServerKey, AuthMessage)
+    let serverKeyHasher =
+      CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
+                                 CryptoUtils.makeHMACKey(serverKey));
+    serverSignature = CryptoUtils.digestBytes(authMessage, serverKeyHasher);
 
-  let clientFinalMessage =
-    clientFinalMessageWithoutProof + ",p=" + btoa(clientProof);
+    let clientFinalMessage =
+      clientFinalMessageWithoutProof + ",p=" + btoa(clientProof);
 
-  receivedStanza = yield {
-    send: Stanza.node("response", Stanza.NS.sasl, null, btoa(clientFinalMessage)),
-    log: '<response/> (base64 encoded SCRAM response containing password not logged)'
-  };
+    return {
+      send: Stanza.node("response", Stanza.NS.sasl, null, btoa(clientFinalMessage)),
+      log: '<response/> (base64 encoded SCRAM response containing password not logged)'
+    };
+  });
 
   // Only check server signature if we succeed to authenticate.
   if (receivedStanza.localName != "success")
     throw "Didn't receive the expected auth success stanza.";
 
   let decodedResponse = atob(receivedStanza.innerText);
 
   // Expected to contain a base64-encoded ServerSignature (v).
--- a/chat/protocols/xmpp/xmpp-session.jsm
+++ b/chat/protocols/xmpp/xmpp-session.jsm
@@ -464,18 +464,24 @@ XMPPSession.prototype = {
         result = aAuthMec.next(aStanza);
       } catch(e) {
         this.ERROR(e);
         this.onError(Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
                      _("connection.error.authenticationFailure"));
         return;
       }
 
-      if (result.value && result.value.send)
-        this.send(result.value.send.getXML(), result.value.log);
+      // The authentication mechanism can yield a promise which must resolve
+      // before sending data.
+      if (result.value) {
+        Promise.resolve(result.value).then((value) => {
+          if (value.send)
+            this.send(value.send.getXML(), value.log);
+        });
+      }
       if (result.done) {
         this.startStream();
         this.onXmppStanza = this.stanzaListeners.startBind;
       }
     },
     startBind: function(aStanza) {
       if (!aStanza.getElement(["bind"])) {
         this.ERROR("Unexpected lack of the bind feature");