Have you ever tried to encrypt and decrypt data on the browser? Maybe you used one of the multiple pure-js solutions out there, or maybe one of the Web Assembly ports of dedicated libraries. In this post series we’re going to explore the possibilities that Web Crypto offers us.
Preliminaries
Before starting with Web Crypto, let’s take a look at the different typed arrays and utilities we will need.
Typed Arrays
An ArrayBuffer is a generic byte array, is just an array of bytes. The issue is that you can’t manipulate those bytes, basically because an ArrayBuffer doesn’t know how you want to access those bytes, do you want to get 32 bit integers? or the individual bytes? You need to specify how you want to access those bytes. There are basically two ways to do this, the one we’re going to use the most is with typed arrays, there are multiple classes we can use, one for each kind of “view” we want over this byte array. In our case, we’re going to use Uint8Array, because it let us view the individual bytes as an 8 bit unsigned integer (0-255), which is something that we’re used to when we think about byte arrays.
Another option is to use a DataView, it’s a much more flexible class than the typed arrays because it allows us to see those bytes in different ways using only one class, and it also allows us to control the endiannes of the bytes when we write and read them. But we’re not going to use this in our examples.
So, how do I create an UInt8Array?
// Copy the bytes from the passed iterable
let array1 = Uint8Array.from( [ 1, 2, 3, 4 ] );
// A view on a buffer
let array2 = new Uint8Array( anArrayBuffer );
// 5 elements, initialized to zero
let array3 = new Uint8Array( 5 );
And there’re multiple options, I recommend you read the documentation for the class.
After we have our typed array, we can just manipulate it like any other array:
let array = new Uint8Array( 32 ); // A 32 byte array
let index = 23;
let newByte = 0x12;
array[ index ] = newByte;
array[ index ] ^= 0x56;
console.log( array[ index ] );
Text Encoding
We’re going to work a lot with text, so we need a way to convert text to and from an array buffer or typed array. This is the work of the TextEncoder and TextDecoder classes.
They’re very easy to use, and you can use different encodings. We’re going to use the default (UTF-8)
let message = 'Hello, World!';
let encoded = new TextEncoder().encode( message );
let decoded = new TextDecoder().decode( encoded );
console.log( 'Original message:', message );
console.log( 'Encoded message:', encoded );
console.log( 'Decoded message:', decoded );
Random Values
Now, to Web Crypto! We’ll start with 2 very easy to use functions, those that generate random values.
A common need we’ll have is to get cryptographically-secure random bytes. This is where the getRandomValues
from the crypto
object comes in hand. It receives an integer typed array and it fills it with random bytes.
let randomData = new Uint8Array( 32 );
crypto.getRandomValues( randomData );
console.log( 'Random data:', randomData );
But be careful, the following are application notes taken directly from the MDN documentation:
To guarantee enough performance, implementations are not using a truly random number generator, but they are using a pseudo-random number generator seeded with a value with enough entropy. The pseudo-random number generator algorithm (PRNG) may vary across user agents, but is suitable for cryptographic purposes.
getRandomValues()
is the only member of theCrypto
interface which can be used from an insecure context.Don’t use
getRandomValues()
to generate encryption keys. Instead, use thegenerateKey()
method. There are a few reasons for this; for example,getRandomValues()
is not guaranteed to be running in a secure context.There is no minimum degree of entropy mandated by the Web Cryptography specification. User agents are instead urged to provide the best entropy they can when generating random numbers, using a well-defined, efficient pseudorandom number generator built into the user agent itself, but seeded with values taken from an external source of pseudorandom numbers, such as a platform-specific random number function, the Unix
MDN Documentation/dev/urandom
device, or other source of random or pseudorandom data.
The other function, we won’t use it, but for reference here it is:
let UUIDv4 = crypto.randomUUID();
console.log( 'Generated UUIDv4', UUIDv4 );
It generates a UUID v4 and returns its textual representation.
Digests
A digest (cryptographic hash) is a one-way function with some interesting properties, but to sum it up, it’s a way to calculate a finite-length byte array from an arbitrary-length input, with the property that the function is impossible to reverse (that is, you can get the original value back), and it’s very difficult to crack (to try to get the original value) or produce collisions (to get another value that produces the same output).
We can use the digest
function of the SubtleCrypto
interface to produce these digests. We only have the “SHA” family of digets: SHA-1 (deprecated, because it’s insecure), SHA-256, SHA-384 and SHA-512. The number indicated the amount of bits of the output (except for SHA-1, which produces 160 bits). It’s very easy to use, let’s see an example:
let message = 'This is a message';
let encodedMessage = new TextEncoder().encode( message );
let digest = await crypto.subtle.digest( 'SHA-256', encodedMessage );
console.log( "The digest is:", digest );
This is one common method to store passwords (although, these hashes are NOT recommended for password storage), using a random salt to avoid attacks like rainbow table
Let’s make a simple example with this, but first, let’s throw some utility functions to the mix:
Uint8Array.prototype.toUint8Array = function toUint8Array() {
return this;
};
String.prototype.toUint8Array = function toUint8Array() {
return new TextEncoder().encode( this );
};
Object.prototype.toUint8Array = function toUint8Array() {
return new Uint8Array( this );
};
Uint8Array.prototype.concat = function concat( ...arrayConvertibles ) {
return this.constructor.concat( this, ...arrayConvertibles );
};
Uint8Array.concat = function concat( ...arrayConvertibles ) {
let arrays = arrayConvertibles.map( it => it.toUint8Array() );
let length = arrays.reduce( ( total, array ) => total + array.length, 0 );
let newArray = new this( length );
let destinationIndex = 0;
arrays.forEach( array => {
for ( let originIndex = 0; originIndex < array.length; originIndex++ )
newArray[ destinationIndex++ ] = array[ originIndex ];
} );
return newArray;
};
Uint8Array.random = function random( length ) {
let array = new this( length );
crypto.getRandomValues( array );
return array;
};
Uint8Array.prototype.equals = function equals( anArrayConvertible ) {
let array = anArrayConvertible.toUint8Array();
if ( this.length !== array.length )
return false;
for ( let i = 0; i < this.length; i++ ) {
if ( this[ i ] !== array[ i ] )
return false;
}
return true;
};
ArrayBuffer.prototype.equals = function equals( anArrayConvertible ) {
return this.toUint8Array().equals( anArrayConvertible );
};
Now we are ready to calculate some hashes:
async function hashPassword( password ) {
let randomSalt = Uint8Array.random( 16 );
let encodedPassword = password.toUint8Array();
let dataToDigest = randomSalt.concat( encodedPassword );
let digest = await crypto.subtle.digest( 'SHA-256', dataToDigest );
return randomSalt.concat( digest );
}
async function verifyPassword( password, hash ) {
let randomSalt = hash.slice( 0, 16 );
let expectedDigest = hash.slice( 16 );
let encodedPassword = password.toUint8Array();
let dataToDigest = randomSalt.concat( encodedPassword );
let digest = await crypto.subtle.digest( 'SHA-256', dataToDigest );
return expectedDigest.equals( digest );
}
let hash = await hashPassword( 'my super password' );
if ( await verifyPassword( 'my super password', hash ) )
console.log( 'Password matches' );
else
console.log( 'Wrong password' );
So we can store the hash (with its random salt) instead of the password, and we can use that to verify, at a later time, if the introduced password is correct or not. VERY IMPORTANT: This is just a simple example of using the digest
function, is not what you should be using, there are special hash functions for passwords, you should be using that. This is just a simple example. Also note that there’re multiple ways to combine the salt with the password, and some ways might be weaker than others, so don’t do this in a production system.
HMAC
A hash-based message authentication code is a function that uses a cryptographic hash/digest function to “sign” a piece of data. It works by hashing the data and a secret together, so, if you have the secret, you can verify that the message hasn’t been tampered with by calculating and comparing the hash.
We could implement our own HMAC following the specification on the RFC 2104 , but we better not! Let’s not roll our own crypto. So, let’s make some functions to check messages:
async function hmac( message, key ) {
let encodedMessage = message.toUint8Array();
let importedKey = await crypto.subtle.importKey( 'raw', key.toUint8Array(), { name: 'HMAC', hash: 'SHA-256' }, [ false ], [ 'sign' ] );
return crypto.subtle.sign( 'HMAC', importedKey, message.toUint8Array() );
}
async function verifyHmac( message, key, signature ) {
let actualSignature = await hmac( message, key );
return signature.equals( actualSignature );
}
let message = 'Hello, this is my message';
let key = 'this is my password';
let signature = await hmac( message, key );
if ( await verifyHmac( message, key, signature ) )
console.log( 'Signature is OK' );
else
console.log( 'Signature is INVALID' );
Si, here we used the sign
function that is a little bit more complicated than the digest
function because it can work with different signing algorithms. The main complication is that it requires a “key” in a way that the Web Crypto API can use it. For this we used the importKey
function, we’re basically telling this function that we want to import the RAW data of the key, and that we’re going to use this key for an HMAC using the SHA-256 Hash function, and we’re using it to “sign” data, not to “verify” data.
We’re perfoming the verification manually, but we could also use the verify
function like this:
async function verifyHmac( message, key, signature ) {
let encodedMessage = message.toUint8Array();
let importedKey = await crypto.subtle.importKey( 'raw', key.toUint8Array(), { name: 'HMAC', hash: 'SHA-256' }, [ false ], [ 'verify' ] );
return crypto.subtle.verify( 'HMAC', importedKey, signature, message.toUint8Array() );
}
It should work exactly the same (note the change from 'sign'
to 'verify'
in the uses of the key), because signature verification is done like that, the signature is calculated and compared with the expected signature.
Now we can verify that a message hasn’t been altered if the emissor and the receptor of the message share the same secret (and an attacker doesn’t know that secret).
We’ll talk more about keys, signatures and encryption in later posts.