June 5th 2017: Apple announced the new Safari 11 that supports the latest WebCrypto specification
- "Includes new cryptographic algorithms such as AES-CFB, AES-GCM, ECDH, and PBKDF2."
- "SubtleCrypto is no longer prefixed and is now asynchronous."
This should solve all the problems mentioned in this article.
September 25th 2017: See my new post about Web Cryptography API
In this article, we are going to write one of these ubiquitous Todo applications with Ionic.
The app stores the data in localStorage
on the client, but not in clear text; instead, it encrypts the data before storing it.
Web Cryptography API ¶
First, I wrote the application with the Web Cryptography API. A web standard developed by the W3C and built into modern browsers. When you look at caniuse.com, the browser support looks very promising.
Unfortunately, at the time of writing, the app did not run on Safari. Safari hides the Web Cryptography API behind a prefix. That's not a problem and easy to solve. The following snippet maps the prefixed object (crypto.webkitSubtle
) to the standard (crypto.subtle
).
if (window.crypto && !window.crypto.subtle && window.crypto.webkitSubtle) {
window.crypto.subtle = window.crypto.webkitSubtle;
}
The problem is that Safari does not provide a password derivation algorithm implementation. The app depends on such an algorithm to derive the AES key from a password.
asmcrypto.js ¶
Because I wanted an application that runs on all platforms, I switched to asmcrypto.js, a cryptographic library that is written in JavaScript. But in my opinion, the Web Cryptography API is the way to go in the future when all browser supports it and implement a similar set of algorithms. In this blog post, you see the performance benefit of the Web Cryptography API, which is implemented natively in the browsers, compared to cryptography libraries written in JavaScript. If you are interested, here is the source code of the Todo app written with the Web Cryptography API. It works on Chrome (also Chrome on macOS) and Android.
App ¶
The app we develop in this post contains three pages. The first page the users sees is the Password (html / ts) page. On this page, the user enters his password, the app derives the AES key from it and uses that to encrypt and decrypt the todo entries.
The second page (Home: html / ts) lists all the todo entries.
And the third page (Edit: html / ts) contains a form where the user can create and update todo entries.
The application is based on the Ionic blank starter template, and I added the asmcrypto library to the project.
npm install asmcrypto.js
I won't go into the implementation details for these pages. They are written with straightforward Ionic / Angular code, and there are already many tutorials on how to write a todo app with Ionic (here, here, here).
Derive Key ¶
After the user enters his password and taps on the 'Show Todos' button, the app calls the function setPassword
from the todo service.
setPassword(password: string): void {
const value = localStorage.getItem('lastTodoId');
if (value) {
this.lastId = JSON.parse(value);
} else {
this.lastId = 0;
}
this.deriveAesKey(password);
return this.decryptTodos();
}
It first loads the last used id from localStorage
. Every time the user creates a new todo object, the application increases this id by one and assigns it to the id field of the todo object.
Next, the method calls the deriveAesKey
function that creates the AES key.
deriveAesKey(password: string): void {
this.aesKey = Pbkdf2HmacSha256(string_to_bytes(password), this.salt, this.iterations, 32);
}
PBKDF2 is an algorithm that can be used for deriving a key from a string. The application has to specify a salt, the number of iterations, and the number of bytes the function should return. Because we want to use AES with a key length of 256 bit, PBKDF2 has to generate a key with 32 bytes. In this app, the salt is just a constant string that should be okay for this single-user application. iterations
specifies how many times the PBKDF2 algorithm should run. Higher is better but slower. Recommended is a value of at least 1000; the app uses 4096.
Decryption ¶
As the last statement in the setPassword
function the applications calls the decryptTodos
function.
decryptTodos(): void {
const binaryString = localStorage.getItem('todos');
if (binaryString) {
const encryptedTodos = string_to_bytes(binaryString);
const encryptedBytes = this.decrypt(encryptedTodos);
const decryptedString = bytes_to_string(encryptedBytes);
this.todos = new Map(JSON.parse(decryptedString));
} else {
this.todos = new Map();
}
}
This method fetches the data from storage. When the app is started for the first time, the result is undefined, and the method assigns an empty new Map instance to the todos
instance variable. If there is data stored in localStorage
, the app decrypts it with a call to the decrypt
method.
decrypt(buffer: Uint8Array): Uint8Array {
if (this.aesKey === null) {
throw new Error('aes key not set');
}
const parts = this.separateNonceFromData(buffer);
return AES_GCM.decrypt(parts.data, this.aesKey, parts.nonce);
}
To decrypt the data, the algorithm needs the nonce
, that was created during the encryption process and prepended to the encrypted data.
The method separateNonceFromData splits the stored blob into the nonce and the encrypted data. The decrypt
method then calls the AES_GCM.decrypt
method from asmcrypto to decrypt the encrypted data. This method returns the decrypted data as an Uint8Array object. Back in the decryptTodos
method, the application needs to convert this buffer into a string (bytes_to_string
) and then parses it to a JavaScript object.
Encryption ¶
Each time the user creates or updates a todo entry, the method encryptAndSaveTodos
is called.
encryptAndSaveTodos(): void {
if (this.todos) {
const todosString = JSON.stringify([...this.todos]);
const encrypted = this.encrypt(string_to_bytes(todosString));
localStorage.setItem('todos', bytes_to_string(encrypted));
}
}
This method first converts the todos
Map into a JSON string and then encrypts it with a call to encrypt
.
encrypt(data: Uint8Array): Uint8Array {
if (this.aesKey === null) {
throw new Error('aes key not set');
}
const nonce = new Uint8Array(this.nonceLen);
this.getRandomValues(nonce);
const encrypted = AES_GCM.encrypt(data, this.aesKey, nonce);
return this.joinNonceAndData(nonce, new Uint8Array(encrypted));
}
The encrypt
function first creates the nonce, an arbitrary number that may only be used once. Then it calls the AES_GCM.encrypt
function with the plain text, key, and the nonce as parameters. After the encryption, the function merges the nonce and the encrypted data into one buffer (joinNonceAndData) because the application needs to provide the same arguments in the decrypting step. Back in the encryptAndSaveTodos
method, the application stores the encrypted blob into localStorage
.
Notice! Security is hard, and I'm not a cryptographic expert. If there is code or a description wrong, please tell me, so I can correct it. Thanks.
You find the entire source code for this app on GitHub.
If you want to learn more about the Web Cryptography API, take a look at this GitHub repository that hosts examples for all the algorithms and operations: https://github.com/diafygi/webcrypto-examples
The repository also contains a live view where you can see the features of the Web Cryptography API your browser supports: https://diafygi.github.io/webcrypto-examples/