Friday, February 14, 2014

Gotcha of the Day: Calculating an SHA1 hash of a binary file on iOS in PhoneGap

Calculating an SHA1 hash of a binary file is usually pretty trivial. In PHP, there's a single function call that does all the work. Doing this in JavaScript, within PhoneGap (or Cordova) while on iOS, is non-trivial. At least it was non-trivial to me. Turns out, all the pieces are there, but be careful, there's a nasty bug lurking behind the scenes.

First off, you need to get access to the data within the file. While its tempting to use File.readAsBinaryString, don't bother. Using this method caused the data be encoded when I sent to my server using jQuery's ajax method. You want to use File.readAsArrayBuffer. You can pass this array buffer directly to ajax's data property and it will be passed to the server untouched.

Next, you'll want to use the very impressive CryptoJS library. I was originally thinking that I'd need to write some native plugin to access an Objective-C implementation of SHA1. But, CryptoJS does this and a whole lot more in pure JavaScript. It's truly a thing of beauty.

So now you're thinking you can just do:

function win(file) {
    var reader = new FileReader();
    reader.onloadend = function (evt) {
      var bytes = evt.target.result;
      var hash = CryptoJS.SHA1(bytes); // [1]
    };
    reader.readAsArrayBuffer(file);
};

var fail = function (error) {
    console.log(error.code);
};

entry.file(win, fail);

But you'd be wrong. With a bit more research you'd figure out that you need to convert the ArrayBuffer to a typed array. And CryptoJS's WordArray can be instantiated with just such a type:

 var hash = CryptoJS.SHA1(CryptoJS.lib.WordArray.create(new Uint8Array(data))); // [2]

OK, now we're getting close. There's no JavaScript error, but the checksum of the file will be wrong.

Further analysis will tell you that WordArray doesn't actually work out of the box with typed array buffers. Apparently it treats all typed array as 32 bit ints, instead of the 8 bits we are passing to it. Luckily, you'll find this discussion on the topic and realize that CryptoJS has a special, not particularly well documented, add-on that allows for use with typed Arrays:

To make CryptoJS aware of Uint8Array's, you'll add the following to the top of the file:

<script src="http://crypto-js.googlecode.com/svn/tags/3.1/build/components/core.js"></script>
<script src="http://crypto-js.googlecode.com/svn/tags/3.1/build/components/lib-typedarrays.js"></script>

And here's where things get really, really wicked. In Firefox, the above code now works (using [2] instead of [1]). But in your PhoneGap application, it won't work. In fact, in any version of Safari it won't work. Why? Because of this bug:

TypedArray support is broken in Safari due to the initial check in lib-typedarrays.js. In Safari, typeof ArrayBuffer is an 'object'. Here is a patch that works for all browsers.

You'll go ahead and edit lib-typedarrays.js and change:

 typeof ArrayBuffer != 'function'

to:

 typeof ArrayBuffer === 'undefined'

And now your code will work. Here it is all together:

<!-- ... other imports, including the SHA1 implementation ... -->
<script src="http://crypto-js.googlecode.com/svn/tags/3.1/build/components/core.js"></script>
<script src="http://crypto-js.googlecode.com/svn/tags/3.1/build/components/lib-typedarrays.js"></script>

function win(file) {
    var reader = new FileReader();
    reader.onloadend = function (evt) {
      var bytes = evt.target.result;
      var hash =  CryptoJS.SHA1(CryptoJS.lib.WordArray.create(new Uint8Array(data)));
    };
    reader.readAsArrayBuffer(file);
};

var fail = function (error) {
    console.log(error.code);
};

entry.file(win, fail);

See, nothing to it, right?

3 comments:

  1. Anonymous4:38 AM

    This post saved me hours of figuring out crypto's hidden lib-typedarrays dep.

    Only problem is, even after patching lib-typedarrays, safari is still producing the wrong digest. Firefox, Chrome, Opera even... all working. And I've verified that the conditional I patched is being skipped--the patch works, but it's not enough. Did you ever deal with this?

    Regardless, thanks for the tips on lib-typedarrays. Saved my butt.

    ReplyDelete
  2. For the final function did you mean: var hash = CryptoJS.SHA1(CryptoJS.lib.WordArray.create(new Uint8Array(bytes)));? notice data was replaced by bytes. I dont see where data was defined.

    ReplyDelete
  3. I have to say man, you saved my life it took me 3 hrs today to figure out why the hell encryption doesn't return same values as it did before (i forgot to add the lib-typedarrays.js to repository) and only your blog helped me to remember...

    ReplyDelete