From a527bf23cddd479e0b2db21bf02585224fb400b5 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 16:50:48 +0300 Subject: [PATCH] Add a crypto module --- frontend/src/crypto/asymmetric.ts | 23 +++++++++++ frontend/src/crypto/backup.ts | 68 +++++++++++++++++++++++++++++++ frontend/src/crypto/index.ts | 4 ++ frontend/src/crypto/kdf.ts | 28 +++++++++++++ frontend/src/crypto/symmetric.ts | 19 +++++++++ frontend/src/crypto/types.d.ts | 6 +++ package.json | 3 +- 7 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 frontend/src/crypto/asymmetric.ts create mode 100644 frontend/src/crypto/backup.ts create mode 100644 frontend/src/crypto/index.ts create mode 100644 frontend/src/crypto/kdf.ts create mode 100644 frontend/src/crypto/symmetric.ts create mode 100644 frontend/src/crypto/types.d.ts diff --git a/frontend/src/crypto/asymmetric.ts b/frontend/src/crypto/asymmetric.ts new file mode 100644 index 0000000..2aca8f5 --- /dev/null +++ b/frontend/src/crypto/asymmetric.ts @@ -0,0 +1,23 @@ +import nacl from "tweetnacl"; +import { hkdfExtractAndExpand } from "../crypto/kdf"; + +export interface X25519KeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export function generateX25519KeyPair(): X25519KeyPair { + const kp = nacl.box.keyPair(); + return { publicKey: kp.publicKey, privateKey: kp.secretKey }; +} + +export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { + // nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF. + return nacl.box.before(theirPublicKey, myPrivateKey); +} + +export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { + return hkdfExtractAndExpand(sharedSecret.buffer, salt, info, 32); +} + + diff --git a/frontend/src/crypto/backup.ts b/frontend/src/crypto/backup.ts new file mode 100644 index 0000000..da5d320 --- /dev/null +++ b/frontend/src/crypto/backup.ts @@ -0,0 +1,68 @@ +import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; +import { importPassword, deriveKEK, randomBytes } from "./kdf"; + +export interface PrivateKeyBundle { + version: 1; + privateKey: Uint8Array; // X25519 private key +} + +export interface EncryptedBackupBlob { + salt: Uint8Array; // for PBKDF2 derivation of KEK + iv: Uint8Array; // AES-GCM IV + ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle +} + +export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array { + const header = new Uint8Array([bundle.version]); + const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer); + const out = new Uint8Array(1 + 4 + bundle.privateKey.length); + out.set(header, 0); + out.set(len, 1); + out.set(bundle.privateKey, 5); + return out; +} + +export function deserializeBundle(data: Uint8Array): PrivateKeyBundle { + const version = data[0] as 1; + const len = new Uint32Array(data.slice(1, 5).buffer)[0]; + const pk = data.slice(5, 5 + len); + return { version, privateKey: pk }; +} + +export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise { + const salt = randomBytes(16); + const pw = await importPassword(password); + const kek = await deriveKEK(pw, salt); + const serialized = serializeBundle(bundle); + const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized); + return { salt, iv, ciphertext }; +} + +export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise { + const pw = await importPassword(password); + const kek = await deriveKEK(pw, blob.salt); + const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext); + return deserializeBundle(plaintext); +} + +export function encodeBlob(blob: EncryptedBackupBlob): string { + function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); } + return JSON.stringify({ + salt: b64(blob.salt), + iv: b64(blob.iv), + ciphertext: b64(blob.ciphertext) + }); +} + +export function decodeBlob(json: string): EncryptedBackupBlob { + function ub64(s: string) { + const bin = atob(s); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } + const obj = JSON.parse(json); + return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; +} + + diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts new file mode 100644 index 0000000..cfd5e8d --- /dev/null +++ b/frontend/src/crypto/index.ts @@ -0,0 +1,4 @@ +export * from "./kdf"; +export * from "./symmetric"; +export * from "./asymmetric"; +export * from "./backup"; \ No newline at end of file diff --git a/frontend/src/crypto/kdf.ts b/frontend/src/crypto/kdf.ts new file mode 100644 index 0000000..c3c6912 --- /dev/null +++ b/frontend/src/crypto/kdf.ts @@ -0,0 +1,28 @@ +export async function importPassword(password: string): Promise { + const enc = new TextEncoder(); + return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); +} + +export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterations = 210_000): Promise { + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + passwordKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function hkdfExtractAndExpand(inputKeyMaterial: ArrayBuffer, salt: Uint8Array, info: Uint8Array, length = 32): Promise { + const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, ikmKey, length * 8); + return new Uint8Array(bits); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} + + diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts new file mode 100644 index 0000000..fc75380 --- /dev/null +++ b/frontend/src/crypto/symmetric.ts @@ -0,0 +1,19 @@ +export interface AesGcmCiphertext { + iv: Uint8Array; + ciphertext: Uint8Array; +} + +export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); + return { iv, ciphertext: new Uint8Array(ct) }; +} + +export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ciphertext: Uint8Array): Promise { + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new Uint8Array(pt); +} + +export async function importAesGcmKey(rawKey: Uint8Array): Promise { + return crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); +} \ No newline at end of file diff --git a/frontend/src/crypto/types.d.ts b/frontend/src/crypto/types.d.ts new file mode 100644 index 0000000..5a531a0 --- /dev/null +++ b/frontend/src/crypto/types.d.ts @@ -0,0 +1,6 @@ +declare module "tweetnacl" { + const nacl: any; + export default nacl; +} + + diff --git a/package.json b/package.json index 3d590b4..f06de8f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "vite-plugin-html": "^3.2.2" }, "dependencies": { - "mdui": "^2.1.4" + "mdui": "^2.1.4", + "tweetnacl": "^1.0.3" } }