PWA Encryption and Auto Sign-in
Mute Swan is a progressive web app I’ve been coding for my own amusement. It’s a playground for me to mess around with experimental web standards. Also to remind myself to buy milk.
I’ve recently implemented hidden Dropbox backup and sync functionality. With that in place I decided that my grocery list was of the upmost secrecy. What if my Dropbox account was hacked?
Encryption
Mute Swan uses local storage to persist state data between browser sessions.
I wrote an asynchronous wrapper for the getItem
and setItem
methods; encrypting and decrypting respectively. My encrypted local storage interface plugs into Redux Persist† as a custom storage engine.
Encryption is handled by the Web Crypto API. I’ve used AES-GCM with a key generated from a SHA-256 hash of a text password.
I begin with the hashing function:
async function sha256(value) {
return await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(value)
);
}
From that the CryptoKey
is derived:
async function getKey(password) {
return await crypto.subtle.importKey(
'raw',
await sha256(password),
{name: 'AES-GCM'},
false,
['encrypt', 'decrypt']
);
}
const key = await getKey('supersecretpassword');
With this key, text can be encoded and encrypted:
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{
iv,
name: 'AES-GCM'
},
key,
new TextEncoder().encode('Buy milk?')
);
And later decrypted and decoded:
let decrypted = await crypto.subtle.decrypt(
{
iv,
name: 'AES-GCM'
},
key,
encrypted
);
decrypted = new TextDecoder().decode(decrypted);
// Output should be: "Buy milk?"
console.log(decrypted);
Sounds secure, but the default password is stored right in the source code.
To use a custom password I need to request that before the Redux store can be configured. I mocked up a sign-in form to accept the password. In React I’m mounting the form component at a higher level prior to the Redux provider and persist gate.
Auto sign-in
Requesting a password at the start of each session is an annoying experience. I briefly considered how safe it would be to store the password itself in local storage or a JavaScript cookie. Both are susceptible to cross-site scripting and lack any user management. Further research lead me to the Credential Management API.
Auto sign-in (the correct way)
Chrome provides the best auto sign-in experience. If enabled the password can be retrieved seamlessly without user interaction. A temporary notification pops up and passwords can be managed via the key button.
On Chrome Android the sign-in notification pops up below. The random sky blue is an interesting choice. I’d prefer if it would use the manifest theme colour.
Passwords can be stored programmatically:
const data = new window.PasswordCredential(form);
window.navigator.credentials.store(data);
And retrieved silently:
const data = await window.navigator.credentials.get({
password: true,
mediation: 'optional'
});
Works in Chrome-like browsers. Fails elsewhere. If the password cannot be retrieved via the method above, or is incorrect, I fall back to a sign-in form.
<input
required
autocomplete="current-password"
type="password"
name="password"
/>
Browsers will offer to save the password for future auto-completion.
Auto sign-in (the hack way)
If the browser doesn’t cough up the password immediately the sign-in form is presented. The auto-completed password can be detected with an event:
window.document.addEventListener(
'input',
(ev) => {
if (
ev.target.name === 'password' &&
ev.inputType === 'insertReplacementText'
) {
// Attempt auto sign-in by submitting the form...
}
},
{once: true}
);
If this event is triggered the form can be submitted to attempt an “auto” sign-in. Only try this once because an incorrect password results in an infinite loop!
This method has noticeable latency and the form will appear briefly. I considered making the form invisible to avoid the UI flash. However, a timeout would be necessary to show the form again if auto-complete didn’t occur, or was not detected. Such a delay seems like the greater evil.
This feels very hacky but it works in Firefox.
Fallback
Finally, if neither auto sign-in method works, or no password was saved, the form must be submitted manually by the user.
Safari is particularly hesitant to auto-fill for me. Hopefully browser support for credential management auto sign-in becomes standard.
And so…
And so, my grocery list is now encrypted! The browser secures my password between sessions. Keys never leave internal JavaScript memory. Naturally, there’s a more than zero percent chance I’ve done something wrong and my code is entirely exploitable. I’ll continue to learn, test, and iterate.
At some point I do plan to publish Mute Swan on GitHub. A lot of the functionality is still hidden behind secret flags. It turns out that building user-friendly UI is incredibly time consuming!
Related articles
- Bundle a PWA as an Android App
- Debugging a Todo App
- Bubblewrap Apps in Android Studio
- PWA Encryption and Auto Sign-in
Last updated: June 2020.
† Redux Persist updates local storage asynchronously. There is some throttling going on but it basically updates after every state change. I ran some very primitive benchmarks and found that my encryption interface is plenty fast enough; milliseconds, if that.