Note from author: this question is unlike multiple similar questions as it is specific to the very limited browser sandbox and specifically asks about key persistency
I have a public key authentication system that is powered by a Key Encapsulation Mechanism (KEM), namely Kyber. When a user wishes to authenticate with the server, the following process happens.
TLDR START
Note, this process isn't precisely important to the question, rather is provided for completeness. You can safely skip to TLDR END.
- Client requests authentication, so server initiates challenge
- Server, in the challenge, sends the following data:
- the encapsulation
c
of the secret symmetric keyss
, which is obtained from the stored public keypk
- the shared (but not secret) initialization vector
iv
- the target, randomly generated message
m
- note: all of the above items are newly generated and have never been used before
- the encapsulation
- In response to the challenge, the user
- uses their password
p
to decrypt the encrypted private key intosk
- uses
sk
to decrypt the encapsulationc
into the secret symmetric keyss
- uses
ss
andiv
to encryptm
intoe
, generating anauthTag
on the way - sends back
e
andauthTag
- uses their password
- To verify the user's reponse, the server
- takes
e
and decrypts it withss
andiv
, usingauthTa
g to receive a decrypted messaged
- if
d
is equal tom
, the user has successfully authenticated and can be issued a token - if
d
is not equal tom
, the user failed authentication
- takes
And here is the code that facilitates this process (again, not directly relevant)
// generic shared functions
const kyber = require('crystals-kyber');
function buf(array) { return Buffer.from(array); }
function hex(buffer) { return '0x' + buffer.toString('hex'); }
// not shared, but exists before
let pk_sk = kyber.KeyGen1024();
let pk = pk_sk[0]; // stored on server
let sk = pk_sk[1]; // stored on client
/*** CURRENTLY IN SERVER ***/
// the user has requested an authentication challenge and wants to sign in as user 'u'
var u = "anyusernamehere";
// Generate a random symmetric key (ss) and its encapsulation (c)
let c_ss = kyber.Encrypt1024(pk);
let c = c_ss[0];
let ss1 = c_ss[1];
console.log("server symmetric", hex(ss1));
// decide on algorithm, initialization vector, and message
const crypto = require('crypto');
const algorithm = 'aes-256-ocb';
const iv = crypto.randomBytes(12);
const m = buf(crypto.getRandomValues(new Uint8Array(16)));
console.log("message", hex(m));
/*** SEND BACK algorithm, m, c, iv ***/
/*** CURRENTLY IN CLIENT ***/
// To decapsulate and obtain the same symmetric key
let ss2 = kyber.Decrypt1024(c, sk);
console.log("client symmetric", hex(ss2));
// Encrypting text using the symmetric key
const cipher = crypto.createCipheriv(algorithm, ss2, iv, {
authTagLength: 16
});
let encrypted = cipher.update(m);
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
console.log("encrypted", hex(encrypted));
console.log("authTag", hex(authTag));
/*** SEND BACK encrypted, authTag ***/
/*** CURRENTLY IN SERVER ***/
// Decrypting text using the symmetric key
const decipher = crypto.createDecipheriv(algorithm, ss1, iv, {
authTagLength: 16
});
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
console.log("decrypted", hex(decrypted));
console.log(hex(decrypted) === hex(m));
This is not intended to be run on the browser, as it is a NodeJS script.
Here's some example console output:
> node .\kyber-auth.js
server symmetric 0x2be4644b917f9fac46b128a4965896cb1ebb6e0a42951c23e73b15d973e105e3
message 0xe1c317bdf1fab107cf7ca81ee676419e
client symmetric 0x2be4644b917f9fac46b128a4965896cb1ebb6e0a42951c23e73b15d973e105e3
encrypted 0x8b55d71ae76c87d731d86f9168fae831
authTag 0x9a38ce25e0de749da2a9507f6c7544d0
decrypted 0xe1c317bdf1fab107cf7ca81ee676419e
true
TLDR END
Now, with everything in place, all we need is to store the data. The server has no problem storing the key, but the client is limited (by the browser sandbox).
Traditional methods like cookies and localStorage
easily fail when the user clears their browser data, flushing the encrypted private key with everything else. This is unacceptable, as the user just lost access to their account. It can't be this easy to lose your account.
Solutions I found through research are downloading the encrypted key file to the user's filesystem and asking for it when the quasi-permanent storage fails. A scannable-and-savable QR code that contains the encrypted private key was also suggested. However, both of these solutions seem very roundabout and are horrible for UX.
I'm asking for a way which I can store the encrypted private key safely on the client side. By "safely" I don't mean that hackers shouldn't be able to access it, I mean that the user shouldn't be able to accidentally delete their only account key. How would I achieve this level of persistency? Or am I doing something obviously wrong?
Edit: I've tried deriving keys from the password itself, but Kyber does not support doing that using the cryptosystem functions themselves.