Skip to content

Commit

Permalink
Add an implementation of HMAC-SHA-256
Browse files Browse the repository at this point in the history
We’re going to use this to replace our current usage of CryptoJS, which
we intend to remove in #1239.

We considered using the implementation of HMAC from the Web Crypto API,
but after weighing the implications of doing so (token signing becoming
unavailable in non-secure contexts) against the mildly-increased library
size (1.61 kB increase) from adding an HMAC implementation, decided to
go with the latter [1].

I picked this implementation because it's a single file that contains
precisely the functionality that we need and it claims to be “designed
for efficient minification”. It doesn’t come with any tests, but since
our SDK has quite a few tests that will fail if the result of token
signing is incorrect, I believe we’re OK.

The code added here is taken verbatim from the linked gist, and then
I’ve added an attribution comment and run Prettier.

[1] https://ably-real-time.slack.com/archives/C030C5YLY/p1686152214032779?thread_ts=1686096207.512069&cid=C030C5YLY
  • Loading branch information
lawrence-forooghian committed Jun 7, 2023
1 parent b110420 commit fc94b80
Showing 1 changed file with 234 additions and 0 deletions.
234 changes: 234 additions & 0 deletions src/platform/web/lib/util/hmac-sha256.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Copied from https://gist.github.com/stevendesu/2d52f7b5e1f1184af3b667c0b5e054b8
*
* "A simple, open-source, HMAC-SHA256 implementation in pure JavaScript. Designed for efficient minification."
*
* I asked about licensing, and the author said:
*
* > Feel free to use it however you'd like 😄 As the gist title indicates,
* > this is "a simple open source implementation". Feel free to choose whatever
* > license you find most permissible, but I offer no warranty for the code.
* > It's 100% free to do with as you please.
*/

// To ensure cross-browser support even without a proper SubtleCrypto
// impelmentation (or without access to the impelmentation, as is the case with
// Chrome loaded over HTTP instead of HTTPS), this library can create SHA-256
// HMAC signatures using nothing but raw JavaScript

/* eslint-disable no-magic-numbers, id-length, no-param-reassign, new-cap */

// By giving internal functions names that we can mangle, future calls to
// them are reduced to a single byte (minor space savings in minified file)
var uint8Array = Uint8Array;
var uint32Array = Uint32Array;
var pow = Math.pow;

// Will be initialized below
// Using a Uint32Array instead of a simple array makes the minified code
// a bit bigger (we lose our `unshift()` hack), but comes with huge
// performance gains
var DEFAULT_STATE = new uint32Array(8);
var ROUND_CONSTANTS = [];

// Reusable object for expanded message
// Using a Uint32Array instead of a simple array makes the minified code
// 7 bytes larger, but comes with huge performance gains
var M = new uint32Array(64);

// After minification the code to compute the default state and round
// constants is smaller than the output. More importantly, this serves as a
// good educational aide for anyone wondering where the magic numbers come
// from. No magic numbers FTW!
function getFractionalBits(n) {
return ((n - (n | 0)) * pow(2, 32)) | 0;
}

var n = 2,
nPrime = 0;
while (nPrime < 64) {
// isPrime() was in-lined from its original function form to save
// a few bytes
var isPrime = true;
// Math.sqrt() was replaced with pow(n, 1/2) to save a few bytes
// var sqrtN = pow(n, 1 / 2);
// So technically to determine if a number is prime you only need to
// check numbers up to the square root. However this function only runs
// once and we're only computing the first 64 primes (up to 311), so on
// any modern CPU this whole function runs in a couple milliseconds.
// By going to n / 2 instead of sqrt(n) we net 8 byte savings and no
// scaling performance cost
for (var factor = 2; factor <= n / 2; factor++) {
if (n % factor === 0) {
isPrime = false;
}
}
if (isPrime) {
if (nPrime < 8) {
DEFAULT_STATE[nPrime] = getFractionalBits(pow(n, 1 / 2));
}
ROUND_CONSTANTS[nPrime] = getFractionalBits(pow(n, 1 / 3));

nPrime++;
}

n++;
}

// For cross-platform support we need to ensure that all 32-bit words are
// in the same endianness. A UTF-8 TextEncoder will return BigEndian data,
// so upon reading or writing to our ArrayBuffer we'll only swap the bytes
// if our system is LittleEndian (which is about 99% of CPUs)
var LittleEndian = !!new uint8Array(new uint32Array([1]).buffer)[0];

function convertEndian(word) {
if (LittleEndian) {
return (
// byte 1 -> byte 4
(word >>> 24) |
// byte 2 -> byte 3
(((word >>> 16) & 0xff) << 8) |
// byte 3 -> byte 2
((word & 0xff00) << 8) |
// byte 4 -> byte 1
(word << 24)
);
} else {
return word;
}
}

function rightRotate(word, bits) {
return (word >>> bits) | (word << (32 - bits));
}

function sha256(data) {
// Copy default state
var STATE = DEFAULT_STATE.slice();

// Caching this reduces occurrences of ".length" in minified JavaScript
// 3 more byte savings! :D
var legth = data.length;

// Pad data
var bitLength = legth * 8;
var newBitLength = 512 - ((bitLength + 64) % 512) - 1 + bitLength + 65;

// "bytes" and "words" are stored BigEndian
var bytes = new uint8Array(newBitLength / 8);
var words = new uint32Array(bytes.buffer);

bytes.set(data, 0);
// Append a 1
bytes[legth] = 0b10000000;
// Store length in BigEndian
words[words.length - 1] = convertEndian(bitLength);

// Loop iterator (avoid two instances of "var") -- saves 2 bytes
var round;

// Process blocks (512 bits / 64 bytes / 16 words at a time)
for (var block = 0; block < newBitLength / 32; block += 16) {
var workingState = STATE.slice();

// Rounds
for (round = 0; round < 64; round++) {
var MRound;
// Expand message
if (round < 16) {
// Convert to platform Endianness for later math
MRound = convertEndian(words[block + round]);
} else {
var gamma0x = M[round - 15];
var gamma1x = M[round - 2];
MRound =
M[round - 7] +
M[round - 16] +
(rightRotate(gamma0x, 7) ^ rightRotate(gamma0x, 18) ^ (gamma0x >>> 3)) +
(rightRotate(gamma1x, 17) ^ rightRotate(gamma1x, 19) ^ (gamma1x >>> 10));
}

// M array matches platform endianness
M[round] = MRound |= 0;

// Computation
var t1 =
(rightRotate(workingState[4], 6) ^ rightRotate(workingState[4], 11) ^ rightRotate(workingState[4], 25)) +
((workingState[4] & workingState[5]) ^ (~workingState[4] & workingState[6])) +
workingState[7] +
MRound +
ROUND_CONSTANTS[round];
var t2 =
(rightRotate(workingState[0], 2) ^ rightRotate(workingState[0], 13) ^ rightRotate(workingState[0], 22)) +
((workingState[0] & workingState[1]) ^ (workingState[2] & (workingState[0] ^ workingState[1])));
for (var i = 7; i > 0; i--) {
workingState[i] = workingState[i - 1];
}
workingState[0] = (t1 + t2) | 0;
workingState[4] = (workingState[4] + t1) | 0;
}

// Update state
for (round = 0; round < 8; round++) {
STATE[round] = (STATE[round] + workingState[round]) | 0;
}
}

// Finally the state needs to be converted to BigEndian for output
// And we want to return a Uint8Array, not a Uint32Array
return new uint8Array(
new uint32Array(
STATE.map(function (val) {
return convertEndian(val);
})
).buffer
);
}

function hmac(key, data) {
if (key.length > 64) key = sha256(key);

if (key.length < 64) {
const tmp = new Uint8Array(64);
tmp.set(key, 0);
key = tmp;
}

// Generate inner and outer keys
var innerKey = new Uint8Array(64);
var outerKey = new Uint8Array(64);
for (var i = 0; i < 64; i++) {
innerKey[i] = 0x36 ^ key[i];
outerKey[i] = 0x5c ^ key[i];
}

// Append the innerKey
var msg = new Uint8Array(data.length + 64);
msg.set(innerKey, 0);
msg.set(data, 64);

// Has the previous message and append the outerKey
var result = new Uint8Array(64 + 32);
result.set(outerKey, 0);
result.set(sha256(msg), 64);

// Hash the previous message
return sha256(result);
}

// Convert a string to a Uint8Array, SHA-256 it, and convert back to string
const encoder = new TextEncoder('utf-8');

export function sign(inputKey, inputData) {
const key = typeof inputKey === 'string' ? encoder.encode(inputKey) : inputKey;
const data = typeof inputData === 'string' ? encoder.encode(inputData) : inputData;
return hmac(key, data);
}

export function hash(str) {
return hex(sha256(encoder.encode(str)));
}

export function hex(bin) {
return bin.reduce((acc, val) => acc + ('00' + val.toString(16)).substr(-2), '');
}

0 comments on commit fc94b80

Please sign in to comment.