Bug 1577688 - Support SCRAM-SHA-256 for XMPP. r=florian
authorPatrick Cloke <clokep@gmail.com>
Tue, 03 Sep 2019 12:32:31 -0400
changeset 36932 475ba5f24a14c73649746ecabc140baa841ca18b
parent 36931 06921c904e77816b62449d0d1c43e8bbea137851
child 36933 8cf6fa3cb76d760e72dc1f59ab38b3ac538e98b9
push id395
push userclokep@gmail.com
push dateMon, 02 Dec 2019 19:38:57 +0000
reviewersflorian
bugs1577688
Bug 1577688 - Support SCRAM-SHA-256 for XMPP. r=florian
chat/protocols/xmpp/test/test_authmechs.js
chat/protocols/xmpp/xmpp-authmechs.jsm
chat/protocols/xmpp/xmpp-session.jsm
--- a/chat/protocols/xmpp/test/test_authmechs.js
+++ b/chat/protocols/xmpp/test/test_authmechs.js
@@ -15,16 +15,18 @@ add_task(async function testPlain() {
 
   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 algorithm.
+  equal(value.send.attributes.mechanism, "PLAIN");
   // 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.
@@ -32,17 +34,17 @@ add_task(async function testPlain() {
 });
 
 /*
  * 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() {
+add_task(async function testScramSha1() {
   const username = "user";
   const password = "pencil";
 
   // Use a constant value for the nonce.
   const nonce = "fyko+d2lbbFgONRv9qkxdawL";
 
   let mech = XMPPAuthMechanisms["SCRAM-SHA-1"](
     username,
@@ -51,16 +53,18 @@ add_task(async function testScram() {
     nonce
   );
 
   // Send the client-first-message.
   let result = mech.next();
   ok(!result.done);
   let value = await Promise.resolve(result.value);
 
+  // Check the algorithm.
+  equal(value.send.attributes.mechanism, "SCRAM-SHA-1");
   // 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(
@@ -88,8 +92,67 @@ add_task(async function testScram() {
     null,
     btoa("v=rmF9pqV8S7suAoZWja4dJRkFsKQ=")
   );
   result = mech.next(response);
   ok(result.done);
   // There is no final value.
   equal(result.value, undefined);
 });
+
+/*
+ * Test SCRAM-SHA-256 using the examples given in section 3 of RFC 7677.
+ */
+add_task(async function testScramSha256() {
+  const username = "user";
+  const password = "pencil";
+
+  // Use a constant value for the nonce.
+  const nonce = "rOprNGfwEbeRWgbNEkqO";
+
+  let mech = XMPPAuthMechanisms["SCRAM-SHA-256"](
+    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 algorithm.
+  equal(value.send.attributes.mechanism, "SCRAM-SHA-256");
+  // Check the SCRAM content.
+  equal(atob(value.send.children[0].text), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
+
+  // Receive the server-first-message and send the client-final-message.
+  let response = Stanza.node(
+    "challenge",
+    Stanza.NS.sasl,
+    null,
+    btoa(
+      "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,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=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
+  );
+
+  // Receive the server-final-message.
+  response = Stanza.node(
+    "success",
+    Stanza.NS.sasl,
+    null,
+    btoa("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=")
+  );
+  result = mech.next(response);
+  ok(result.done);
+  // There is no final value.
+  equal(result.value, undefined);
+});
--- a/chat/protocols/xmpp/xmpp-authmechs.jsm
+++ b/chat/protocols/xmpp/xmpp-authmechs.jsm
@@ -342,190 +342,218 @@ function saslName(aName) {
     .replace(/,/g, "=2C");
   if (!saslName) {
     throw new Error("Name is not valid");
   }
 
   return saslName;
 }
 
-// Converts aMessage to array of bytes then apply SHA-1 hashing.
-function bytesAndSHA1(aMessage) {
+// Converts aMessage to array of bytes then apply hashing.
+function bytesAndHash(aMessage, aHash) {
   let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
     Ci.nsICryptoHash
   );
-  hasher.init(hasher.SHA1);
+  hasher.init(hasher[aHash]);
 
   return CryptoUtils.digestBytes(aMessage, hasher);
 }
 
 /**
- * PBKDF2 password stretching with SHA-1 hmac.
+ * PBKDF2 password stretching with hmac.
  *
- * This is a copy of CryptoUtils.pbkdf2Generate, but using SHA-1 instead of
- * SHA-256.
+ * This is a copy of CryptoUtils.pbkdf2Generate, but with an additional argument to take the hash type.
  *
  * @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.
+ * @param {string} hash The desired hash algorithm (e.g. SHA-1 or SHA-256).
  */
-async function pbkdf2Generate(passphrase, salt, iterations, len) {
+async function pbkdf2Generate(passphrase, salt, iterations, len, hash) {
   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",
+      hash,
       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,,";
-  // If a hard-coded nonce was given (e.g. for testing), use it.
-  let cNonce = aNonce ? aNonce : createNonce(32);
+/*
+ * Given hash functions return a generator to be used as an XMPP authentication
+ * mechanism.
+ *
+ * @param {string} aHashFunctionName The name of a hash, e.g. SHA-1 or SHA-256.
+ * @param {string} aDigestLength The length of a hash digest, e.g. 20 for SHA-1 or 32 for SHA-256.
+ */
+function generateScramAuth(aHashFunctionName, aDigestLength) {
+  function* scramAuth(aUsername, aPassword, aDomain, aNonce) {
+    // The hash function name, without the '-' in it (e.g. convert SHA-1 to SHA1).
+    const hashFunctionProp = aHashFunctionName.replace("-", "");
+
+    // RFC 5802 (5): SCRAM Authentication Exchange.
+    const gs2Header = "n,,";
+    // 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 clientFirstMessageBare = "n=" + saslName(aUsername) + ",r=" + cNonce;
+    let clientFirstMessage = gs2Header + clientFirstMessageBare;
+
+    let receivedStanza = yield {
+      send: Stanza.node(
+        "auth",
+        Stanza.NS.sasl,
+        { mechanism: "SCRAM-" + aHashFunctionName },
+        btoa(clientFirstMessage)
+      ),
+    };
+
+    if (receivedStanza.localName != "challenge") {
+      throw new Error("Not authorized");
+    }
+
+    // RFC 5802 (3): SCRAM Algorithm Overview.
+    let decodedChallenge = atob(receivedStanza.innerText);
 
-  let receivedStanza = yield {
-    send: Stanza.node(
-      "auth",
-      Stanza.NS.sasl,
-      { mechanism: "SCRAM-SHA-1" },
-      btoa(clientFirstMessage)
-    ),
-  };
+    // Expected to contain the user’s iteration count (i) and the user’s
+    // salt (s), and the server appends its own nonce to the client-specified
+    // one (r).
+    let attributes = parseChallenge(decodedChallenge);
+    if (attributes.hasOwnProperty("e")) {
+      throw new Error("Authentication failed: " + attributes.e);
+    } else if (
+      !attributes.hasOwnProperty("i") ||
+      !attributes.hasOwnProperty("s") ||
+      !attributes.hasOwnProperty("r")
+    ) {
+      throw new Error("Unexpected response: " + decodedChallenge);
+    }
+    if (!attributes.r.startsWith(cNonce)) {
+      throw new Error("Nonce is not correct");
+    }
+
+    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 digest size.
+    let passwordPromise = pbkdf2Generate(
+      saslPrep(aPassword),
+      atob(attributes.s),
+      parseInt(attributes.i),
+      aDigestLength,
+      aHashFunctionName
+    );
+
+    // The server signature is calculated below, but needs to escape back to the main scope.
+    let serverSignature;
 
-  if (receivedStanza.localName != "challenge") {
-    throw new Error("Not authorized");
-  }
+    // Once the promise resolves, continue with the handshake.
+    receivedStanza = yield passwordPromise.then(saltedPassword => {
+      // ClientKey := HMAC(SaltedPassword, "Client Key")
+      let saltedPasswordHasher = CryptoUtils.makeHMACHasher(
+        Ci.nsICryptoHMAC[hashFunctionProp],
+        CryptoUtils.makeHMACKey(saltedPassword)
+      );
+      let clientKey = CryptoUtils.digestBytes(
+        "Client Key",
+        saltedPasswordHasher
+      );
+
+      // StoredKey := H(ClientKey)
+      let storedKey = bytesAndHash(clientKey, hashFunctionProp);
 
-  // RFC 5802 (3): SCRAM Algorithm Overview.
-  let decodedChallenge = atob(receivedStanza.innerText);
+      let authMessage =
+        clientFirstMessageBare +
+        "," +
+        decodedChallenge +
+        "," +
+        clientFinalMessageWithoutProof;
+
+      // ClientSignature := HMAC(StoredKey, AuthMessage)
+      let storedKeyHasher = CryptoUtils.makeHMACHasher(
+        Ci.nsICryptoHMAC[hashFunctionProp],
+        CryptoUtils.makeHMACKey(storedKey)
+      );
+      let clientSignature = CryptoUtils.digestBytes(
+        authMessage,
+        storedKeyHasher
+      );
+
+      // ClientProof := ClientKey XOR ClientSignature
+      let clientProof = CryptoUtils.xor(clientKey, clientSignature);
+
+      // Calculate ServerSignature.
 
-  // Expected to contain the user’s iteration count (i) and the user’s
-  // salt (s), and the server appends its own nonce to the client-specified
-  // one (r).
-  let attributes = parseChallenge(decodedChallenge);
-  if (attributes.hasOwnProperty("e")) {
-    throw new Error("Authentication failed: " + attributes.e);
-  } else if (
-    !attributes.hasOwnProperty("i") ||
-    !attributes.hasOwnProperty("s") ||
-    !attributes.hasOwnProperty("r")
-  ) {
-    throw new Error("Unexpected response: " + decodedChallenge);
-  }
-  if (!attributes.r.startsWith(cNonce)) {
-    throw new Error("Nonce is not correct");
+      // ServerKey := HMAC(SaltedPassword, "Server Key")
+      let serverKey = CryptoUtils.digestBytes(
+        "Server Key",
+        saltedPasswordHasher
+      );
+
+      // ServerSignature := HMAC(ServerKey, AuthMessage)
+      let serverKeyHasher = CryptoUtils.makeHMACHasher(
+        Ci.nsICryptoHMAC[hashFunctionProp],
+        CryptoUtils.makeHMACKey(serverKey)
+      );
+      serverSignature = CryptoUtils.digestBytes(authMessage, serverKeyHasher);
+
+      let clientFinalMessage =
+        clientFinalMessageWithoutProof + ",p=" + btoa(clientProof);
+
+      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 new Error("Didn't receive the expected auth success stanza.");
+    }
+
+    let decodedResponse = atob(receivedStanza.innerText);
+
+    // Expected to contain a base64-encoded ServerSignature (v).
+    attributes = parseChallenge(decodedResponse);
+    if (!attributes.hasOwnProperty("v")) {
+      throw new Error("Unexpected response: " + decodedResponse);
+    }
+
+    // Compare ServerSignature with our ServerSignature which we calculated in
+    // _generateResponse.
+    let serverSignatureResponse = atob(attributes.v);
+    if (serverSignature != serverSignatureResponse) {
+      throw new Error("Server signature does not match");
+    }
   }
 
-  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 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;
-
-  // 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);
-
-    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);
-
-    // ClientProof := ClientKey XOR ClientSignature
-    let clientProof = CryptoUtils.xor(clientKey, clientSignature);
-
-    // Calculate ServerSignature.
-
-    // 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)
-    );
-    serverSignature = CryptoUtils.digestBytes(authMessage, serverKeyHasher);
-
-    let clientFinalMessage =
-      clientFinalMessageWithoutProof + ",p=" + btoa(clientProof);
-
-    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 new Error("Didn't receive the expected auth success stanza.");
-  }
-
-  let decodedResponse = atob(receivedStanza.innerText);
-
-  // Expected to contain a base64-encoded ServerSignature (v).
-  attributes = parseChallenge(decodedResponse);
-  if (!attributes.hasOwnProperty("v")) {
-    throw new Error("Unexpected response: " + decodedResponse);
-  }
-
-  // Compare ServerSignature with our ServerSignature which we calculated in
-  // _generateResponse.
-  let serverSignatureResponse = atob(attributes.v);
-  if (serverSignature != serverSignatureResponse) {
-    throw new Error("Server signature does not match");
-  }
+  return scramAuth;
 }
 
-var XMPPAuthMechanisms = { PLAIN: PlainAuth, "SCRAM-SHA-1": scramSHA1Auth };
+var XMPPAuthMechanisms = {
+  PLAIN: PlainAuth,
+  "SCRAM-SHA-1": generateScramAuth("SHA-1", 20),
+  "SCRAM-SHA-256": generateScramAuth("SHA-256", 32),
+};
--- a/chat/protocols/xmpp/xmpp-session.jsm
+++ b/chat/protocols/xmpp/xmpp-session.jsm
@@ -470,17 +470,17 @@ XMPPSession.prototype = {
         } else {
           this._networkError(_("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 explicly allowed that
+      // 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) {