7

I am evaluating how WebCrypto performance compares to third-party crypto libraries SJCL and Forge. I would expect WebCrypto to be much faster since it is a native browser implementation. This has also been benchmarked before and has shown such.

I have implemented the following tests using Benchmark.js to test key derivation (PBKDF2-SHA256), encrypt (AES-CBC), and decrypt (AES-CBC). These tests show web crypto to be significantly slower than both SJCL and Forge for encrypt/decrypt.

Benchmark Code

See fiddle here: https://jsfiddle.net/kspearrin/1Lzvpzkz/

var iterations = 5000;
var keySize = 256;

sjcl.beware['CBC mode is dangerous because it doesn\'t protect message integrity.']();

// =========================================================
// Precomputed enc values for decrypt benchmarks
// =========================================================

var encIv = 'FX7Y3pYmcLIQt6WrKc62jA==';
var encCt = 'EDlxtzpEOfGIAIa8PkCQmA==';

// =========================================================
// Precomputed keys for benchmarks
// =========================================================

function sjclMakeKey() {
  return sjcl.misc.pbkdf2('mypassword', 'a salt', iterations, keySize, null);
}

var sjclKey = sjclMakeKey();

function forgeMakeKey() {
  return forge.pbkdf2('mypassword', 'a salt', iterations, keySize / 8, 'sha256');
}

var forgeKey = forgeMakeKey();

var webcryptoKey = null;
window.crypto.subtle.importKey(
  'raw', fromUtf8('mypassword'), {
    name: 'PBKDF2'
  },
  false, ['deriveKey', 'deriveBits']
).then(function(importedKey) {
  window.crypto.subtle.deriveKey({
      'name': 'PBKDF2',
      salt: fromUtf8('a salt'),
      iterations: iterations,
      hash: {
        name: 'SHA-256'
      }
    },
    importedKey, {
      name: 'AES-CBC',
      length: keySize
    },
    true, ['encrypt', 'decrypt']
  ).then(function(derivedKey) {
    webcryptoKey = derivedKey;
  });
});

// =========================================================
// IV helpers for encrypt benchmarks so all are using same PRNG methods
// =========================================================

function getRandomSjclBytes() {
  var bytes = new Uint32Array(4);
  return window.crypto.getRandomValues(bytes);
}

function getRandomForgeBytes() {
  var bytes = new Uint8Array(16);
  window.crypto.getRandomValues(bytes);
  return String.fromCharCode.apply(null, bytes);
}

// =========================================================
// Serialization helpers for web crypto
// =========================================================

function fromUtf8(str) {
  var strUtf8 = unescape(encodeURIComponent(str));
  var ab = new Uint8Array(strUtf8.length);
  for (var i = 0; i < strUtf8.length; i++) {
    ab[i] = strUtf8.charCodeAt(i);
  }
  return ab;
}

function toUtf8(buf, inputType) {
  inputType = inputType || 'ab';

  var bytes = new Uint8Array(buf);
  var encodedString = String.fromCharCode.apply(null, bytes),
    decodedString = decodeURIComponent(escape(encodedString));
  return decodedString;
}

function fromB64(str) {
  var binary_string = window.atob(str);
  var len = binary_string.length;
  var bytes = new Uint8Array(len);
  for (var i = 0; i < len; i++) {
    bytes[i] = binary_string.charCodeAt(i);
  }
  return bytes.buffer;
}

function toB64(buf) {
  var binary = '';
  var bytes = new Uint8Array(buf);
  var len = bytes.byteLength;
  for (var i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return window.btoa(binary);
}

// =========================================================
// The benchmarks
// =========================================================

$("#makekey").click(function() {
  console.log('Starting test: Make Key (PBKDF2)');

  var suite = new Benchmark.Suite;

  suite
    .add('SJCL', function() {
      sjclMakeKey();
    })
    .add('Forge', function() {
      forgeMakeKey();
    })
    .add('WebCrypto', {
      defer: true,
      fn(deferred) {
        window.crypto.subtle.importKey(
          'raw', fromUtf8('mypassword'), {
            name: 'PBKDF2'
          },
          false, ['deriveKey', 'deriveBits']
        ).then(function(importedKey) {
          window.crypto.subtle.deriveKey({
              'name': 'PBKDF2',
              salt: fromUtf8('a salt'),
              iterations: iterations,
              hash: {
                name: 'SHA-256'
              }
            },
            importedKey, {
              name: 'AES-CBC',
              length: keySize
            },
            true, ['encrypt', 'decrypt']
          ).then(function(derivedKey) {
            window.crypto.subtle.exportKey('raw', derivedKey)
              .then(function(exportedKey) {
                deferred.resolve();
              });
          });
        });
      }
    })
    .on('cycle', function(event) {
      console.log(String(event.target));
    })
    .on('complete', function() {
      console.log('Fastest is ' + this.filter('fastest').map('name'));
    })
    .run({
      'async': true
    });
});

// =========================================================
// =========================================================

$("#encrypt").click(function() {
  console.log('Starting test: Encrypt');

  var suite = new Benchmark.Suite;

  suite
    .add('SJCL', function() {
      var response = {};
      var params = {
        mode: 'cbc',
        iv: getRandomSjclBytes()
      };
      var ctJson = sjcl.encrypt(sjclKey, 'some message', params, response);

      var result = {
        ct: ctJson.match(/"ct":"([^"]*)"/)[1],
        iv: sjcl.codec.base64.fromBits(response.iv)
      };
    })
    .add('Forge', function() {
      var buffer = forge.util.createBuffer('some message', 'utf8');
      var cipher = forge.cipher.createCipher('AES-CBC', forgeKey);
      var ivBytes = getRandomForgeBytes();
      cipher.start({
        iv: ivBytes
      });
      cipher.update(buffer);
      cipher.finish();
      var encryptedBytes = cipher.output.getBytes();

      var result = {
        iv: forge.util.encode64(ivBytes),
        ct: forge.util.encode64(encryptedBytes)
      };
    })
    .add('WebCrypto', {
      defer: true,
      fn(deferred) {
        var ivBytes = window.crypto.getRandomValues(new Uint8Array(16));
        window.crypto.subtle.encrypt({
          name: 'AES-CBC',
          iv: ivBytes
        }, webcryptoKey, fromUtf8('some message')).then(function(encrypted) {
          var ivResult = toB64(ivBytes);
          var ctResult = toB64(encrypted);
          deferred.resolve();
        });
      }
    })
    .on('cycle', function(event) {
      console.log(String(event.target));
    })
    .on('complete', function() {
      console.log('Fastest is ' + this.filter('fastest').map('name'));
    })
    .run({
      'async': true
    });
});

// =========================================================
// =========================================================

$("#decrypt").click(function() {
  console.log('Starting test: Decrypt');

  var suite = new Benchmark.Suite;

  suite
    .add('SJCL', function() {
      var ivBits = sjcl.codec.base64.toBits(encIv);
      var ctBits = sjcl.codec.base64.toBits(encCt);
      var aes = new sjcl.cipher.aes(sjclKey);

      var messageBits = sjcl.mode.cbc.decrypt(aes, ctBits, ivBits, null);
      var result = sjcl.codec.utf8String.fromBits(messageBits);
    })
    .add('Forge', function() {
      var decIvBytes = forge.util.decode64(encIv);
      var ctBytes = forge.util.decode64(encCt);
      var ctBuffer = forge.util.createBuffer(ctBytes);

      var decipher = forge.cipher.createDecipher('AES-CBC', forgeKey);
      decipher.start({
        iv: decIvBytes
      });
      decipher.update(ctBuffer);
      decipher.finish();

      var result = decipher.output.toString('utf8');
    })
    .add('WebCrypto', {
      defer: true,
      fn(deferred) {
        var ivBytes = fromB64(encIv);
        var ctBytes = fromB64(encCt);

        window.crypto.subtle.decrypt({
          name: 'AES-CBC',
          iv: ivBytes
        }, webcryptoKey, ctBytes).then(function(decrypted) {
          var result = toUtf8(decrypted);
          deferred.resolve();
        });
      }
    })
    .on('cycle', function(event) {
      console.log(String(event.target));
    })
    .on('complete', function() {
      console.log('Fastest is ' + this.filter('fastest').map('name'));
    })
    .run({
      'async': true
    });
});

Benchmark Results (Chrome)

Starting test: Make Key (PBKDF2)
SJCL x 26.31 ops/sec ±1.11% (37 runs sampled)
Forge x 13.55 ops/sec ±1.46% (26 runs sampled)
WebCrypto x 172 ops/sec ±2.71% (58 runs sampled)
Fastest is WebCrypto

Starting test: Encrypt
SJCL x 42,618 ops/sec ±1.43% (60 runs sampled)
Forge x 76,653 ops/sec ±1.76% (60 runs sampled)
WebCrypto x 18,011 ops/sec ±5.16% (47 runs sampled)
Fastest is Forge

Starting test: Decrypt
SJCL x 79,352 ops/sec ±2.51% (50 runs sampled)
Forge x 154,463 ops/sec ±2.12% (61 runs sampled)
WebCrypto x 22,368 ops/sec ±4.08% (53 runs sampled)
Fastest is Forge

Benchmark Results (Firefox)

Starting test: Make Key (PBKDF2)
SJCL x 20.21 ops/sec ±1.18% (34 runs sampled)
Forge x 11.63 ops/sec ±6.35% (30 runs sampled)
WebCrypto x 101 ops/sec ±9.68% (46 runs sampled)
Fastest is WebCrypto

Starting test: Encrypt
SJCL x 32,135 ops/sec ±4.37% (51 runs sampled)
Forge x 99,216 ops/sec ±7.50% (47 runs sampled)
WebCrypto x 11,458 ops/sec ±2.79% (52 runs sampled)
Fastest is Forge

Starting test: Decrypt
SJCL x 87,290 ops/sec ±4.35% (45 runs sampled)
Forge x 114,086 ops/sec ±6.76% (46 runs sampled)
WebCrypto x 10,170 ops/sec ±3.69% (42 runs sampled)
Fastest is Forge

What is going on here? Why is WebCrypto so much slower for encrypt/decrypt functions? Am I using Benchmark.js incorrectly or something?

kspearrin
  • 10,238
  • 9
  • 53
  • 82
  • 2
    1. PBKDF2 is not siupposed to be fast, a good reasonably secure value is 100ms. 2. if "some message" is the data being encrypted you are mainly measuring AES setup time. – zaph Mar 13 '17 at 20:29
  • 4
    @zaph I realize that PBKDF2 is not suppose to be fast, however, when benchmarking it against other PBKDF2 implementations you can hope for better performance. This will allow me to introduce *more iterations* without sacrificing wait time from the client. The benchmarks show PBKDF2 as being much faster with web crypto anyways. The question is mainly geared towards the encrypt/decrypt tests. – kspearrin Mar 13 '17 at 20:56
  • 1
    @zaph the PBKDF2 algorithm is not supposed to be fast, but a PBKDF2 implementation is supposed to be as fast (efficient) as is possible. An adversary looking to crack passwords will be using a highly optimized implementation in their attack. If you are using a slow implementation you are either making their job easier or your life harder. – Chris_F Mar 10 '21 at 02:22

1 Answers1

10

I have a hunch that, with such a short message length, you're mostly measuring invocation overhead. With its asynchronous promise-based interface, WebCrypto probably loses out a bit there.

I modified your encryption benchmark to use a 1.5 kib plaintext, and the results look very different:

Starting test: Encrypt
SJCL x 3,632 ops/sec ±2.20% (61 runs sampled)
Forge x 2,968 ops/sec ±3.02% (60 runs sampled)
WebCrypto x 5,522 ops/sec ±6.94% (42 runs sampled)
Fastest is WebCrypto

With a 96 kib plaintext, the difference is even greater:

Starting test: Encrypt
SJCL x 56.77 ops/sec ±5.43% (49 runs sampled)
Forge x 48.17 ops/sec ±1.12% (41 runs sampled)
WebCrypto x 162 ops/sec ±4.53% (45 runs sampled)
Fastest is WebCrypto
Ilmari Karonen
  • 49,047
  • 9
  • 93
  • 153
  • This is interesting since my implementation will be used for lots of small bits of data as the test shows. Do you see any ways to reduce the invocation overheard for such use-case? – kspearrin Mar 13 '17 at 20:53
  • WebCrypto using 96 KiB is about 16 MiB/s. Native on an iPhone 6S runs about 350 MiB/s so there is still quite a wasy to go in performance. – zaph Mar 13 '17 at 21:11
  • 1
    @kspearrin There are ways to handle small encryptions faster but they depend on your threat model. For example will they or can they all be encrypted with the same key? – zaph Mar 13 '17 at 21:12
  • @zaph The example benchmarks share the same derived `CryptoKey` (`webcryptoKey`) for all web crypto tests. – kspearrin Mar 13 '17 at 21:24
  • 1
    @kspearrin: Other than micro-optimizations like reusing the `ivBytes` array, the first parameter to `.encrypt()` and the `.then()` callback function context instead of reallocating them every time, not really. It would seem that ES6 promises are [just kind of unavoidably slow](http://softwareengineering.stackexchange.com/q/278778). That said, are you sure that even 0.1 ms per encryption really is a significant performance issue in your use case? – Ilmari Karonen Mar 14 '17 at 00:57