0d94b684bf
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
|
import sodium from 'libsodium-wrappers';
|
|
|
|
// Encrypts/decrypts the device private key with a user-provided passphrase
|
|
// so the backup string can be safely written down or stored in a password
|
|
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
|
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
|
//
|
|
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
|
// can version it):
|
|
//
|
|
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
|
|
|
const MAGIC = 'chatapp-backup-v1.';
|
|
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
|
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
|
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
|
|
|
async function ensureSodium(): Promise<typeof sodium> {
|
|
await sodium.ready;
|
|
return sodium;
|
|
}
|
|
|
|
function b64url(bytes: Uint8Array): string {
|
|
let s = '';
|
|
for (const b of bytes) s += String.fromCharCode(b);
|
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function unb64url(s: string): Uint8Array {
|
|
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
while (str.length % 4) str += '=';
|
|
const bin = atob(str);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
|
return sodiumLib.crypto_pwhash(
|
|
KEY_LEN,
|
|
passphrase,
|
|
salt,
|
|
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
|
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
|
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
|
);
|
|
}
|
|
|
|
export async function exportDeviceKey(
|
|
privateKey: Uint8Array,
|
|
passphrase: string,
|
|
): Promise<string> {
|
|
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
|
const s = await ensureSodium();
|
|
const salt = s.randombytes_buf(SALT_LEN);
|
|
const nonce = s.randombytes_buf(NONCE_LEN);
|
|
const key = await deriveKey(passphrase, salt, s);
|
|
const backend = getCryptoBackend();
|
|
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
|
s.memzero(key);
|
|
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
|
blob.set(salt, 0);
|
|
blob.set(nonce, SALT_LEN);
|
|
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
|
return MAGIC + b64url(blob);
|
|
}
|
|
|
|
export async function importDeviceKey(
|
|
backup: string,
|
|
passphrase: string,
|
|
): Promise<Uint8Array> {
|
|
if (!backup.startsWith(MAGIC)) {
|
|
throw new Error('Invalid backup format');
|
|
}
|
|
const blob = unb64url(backup.slice(MAGIC.length));
|
|
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
|
throw new Error('Backup too short');
|
|
}
|
|
const salt = blob.slice(0, SALT_LEN);
|
|
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
|
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
|
const s = await ensureSodium();
|
|
const key = await deriveKey(passphrase, salt, s);
|
|
const backend = getCryptoBackend();
|
|
try {
|
|
return backend.secretboxOpen(ciphertext, nonce, key);
|
|
} catch {
|
|
throw new Error('Wrong passphrase or corrupt backup');
|
|
} finally {
|
|
s.memzero(key);
|
|
}
|
|
}
|