diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts index 9e38960..fd1c597 100644 --- a/frontend/src/crypto/index.ts +++ b/frontend/src/crypto/index.ts @@ -5,6 +5,11 @@ const ENC = new TextEncoder(); const DEC = new TextDecoder(); +// Wrap .slice() result so TypeScript sees Uint8Array, not Uint8Array +function u8(arr: Uint8Array, start?: number, end?: number): Uint8Array { + return new Uint8Array(start !== undefined ? arr.slice(start, end) : arr); +} + // ── Key derivation ────────────────────────────────────────────────────────── export async function generateKdfSalt(): Promise { @@ -47,21 +52,15 @@ export async function decryptMasterKey(encB64: string, derived: CryptoKey): Prom // ── X25519 ECDH keypair ────────────────────────────────────────────────────── -/** - * Generate an X25519 keypair. - * Returns the public key as base64 and the private key as a non-extractable CryptoKey. - * Also returns pkcs8Bytes so the caller can encrypt and store the private key once. - */ export async function generateX25519Keypair(): Promise<{ publicKeyB64: string; privateKey: CryptoKey; - pkcs8Bytes: Uint8Array; + pkcs8Bytes: Uint8Array; }> { const kp = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, ['deriveBits']); const publicRaw = await crypto.subtle.exportKey('raw', (kp as CryptoKeyPair).publicKey); const pkcs8 = await crypto.subtle.exportKey('pkcs8', (kp as CryptoKeyPair).privateKey); - // Re-import as non-extractable for runtime use const privateKey = await crypto.subtle.importKey( 'pkcs8', pkcs8, @@ -77,12 +76,10 @@ export async function generateX25519Keypair(): Promise<{ }; } -/** Encrypt the pkcs8-encoded private key with the master key for server storage */ -export async function encryptPrivateKey(pkcs8: Uint8Array, master: CryptoKey): Promise { +export async function encryptPrivateKey(pkcs8: Uint8Array, master: CryptoKey): Promise { return aesEncryptRaw(pkcs8, master); } -/** Decrypt and import the private key, returning a non-extractable CryptoKey */ export async function decryptPrivateKeyToCryptoKey( encB64: string, master: CryptoKey @@ -93,20 +90,14 @@ export async function decryptPrivateKeyToCryptoKey( // ── Conversation key exchange (ephemeral X25519 ECDH sealed box) ───────────── // -// Wire format for sealConvKey output: ephemeral_pub_key(32) | nonce(12) | ciphertext -// The ephemeral public key is used by the recipient to reconstruct the shared secret. +// Wire format: ephemeral_pub_key(32) | nonce(12) | ciphertext -export function generateConvKeyRaw(): Uint8Array { +export function generateConvKeyRaw(): Uint8Array { return crypto.getRandomValues(new Uint8Array(32)); } -/** - * Seal a conversation key for a recipient using their X25519 public key. - * Uses an ephemeral ECDH keypair so the sender stays anonymous. - * Only the holder of the matching private key can open it. - */ export async function sealConvKey( - keyRaw: Uint8Array, + keyRaw: Uint8Array, recipientPublicKeyB64: string ): Promise { const recipientPubKey = await crypto.subtle.importKey( @@ -117,7 +108,6 @@ export async function sealConvKey( [] ); - // Ephemeral keypair for this seal operation const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, [ 'deriveBits', ]); @@ -125,20 +115,17 @@ export async function sealConvKey( await crypto.subtle.exportKey('raw', (ephemeral as CryptoKeyPair).publicKey) ); - // ECDH: derive shared secret between ephemeral private key and recipient public key const sharedBits = await crypto.subtle.deriveBits( { name: 'X25519', public: recipientPubKey } as any, (ephemeral as CryptoKeyPair).privateKey, 256 ); - // HKDF to derive AES key — use ephemeral public key as salt for domain separation const aesKey = await hkdfToAes(sharedBits, ephemeralPubRaw, ['encrypt']); const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, keyRaw); - // Output: ephemeralPubKey(32) | nonce(12) | ciphertext const out = new Uint8Array(32 + 12 + ct.byteLength); out.set(ephemeralPubRaw, 0); out.set(nonce, 32); @@ -146,18 +133,14 @@ export async function sealConvKey( return bufToB64(out); } -/** - * Open a sealed conversation key using own X25519 private key. - * Returns the raw 32-byte AES key. - */ export async function openSealedConvKey( sealedB64: string, myPrivateKey: CryptoKey -): Promise { +): Promise> { const data = b64ToBuf(sealedB64); - const ephemeralPubRaw = data.slice(0, 32); - const nonce = data.slice(32, 44); - const ct = data.slice(44); + const ephemeralPubRaw = u8(data, 0, 32); + const nonce = u8(data, 32, 44); + const ct = u8(data, 44); const ephemeralPubKey = await crypto.subtle.importKey( 'raw', @@ -178,7 +161,7 @@ export async function openSealedConvKey( return new Uint8Array(pt); } -export async function importConvKey(raw: Uint8Array): Promise { +export async function importConvKey(raw: Uint8Array): Promise { return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ 'encrypt', 'decrypt', @@ -211,9 +194,9 @@ export async function encryptFile(file: File, key: CryptoKey): Promise { return new Blob([combined], { type: 'application/octet-stream' }); } -export async function decryptFile(blob: Blob, key: CryptoKey): Promise { +export async function decryptFile(blob: Blob, key: CryptoKey): Promise> { const data = new Uint8Array(await blob.arrayBuffer()); - const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12)); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: u8(data, 0, 12) }, key, u8(data, 12)); return new Uint8Array(pt); } @@ -229,7 +212,7 @@ export async function decryptFileKey(encB64: string, masterKey: CryptoKey): Prom async function hkdfToAes( sharedBits: ArrayBuffer, - salt: Uint8Array, + salt: Uint8Array, usages: KeyUsage[] ): Promise { const keyMaterial = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [ @@ -244,7 +227,7 @@ async function hkdfToAes( ); } -async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise { +async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise { const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data); const combined = new Uint8Array(12 + ct.byteLength); @@ -253,17 +236,17 @@ async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise return bufToB64(combined); } -async function aesDecryptRaw(b64: string, key: CryptoKey): Promise { +async function aesDecryptRaw(b64: string, key: CryptoKey): Promise> { const data = b64ToBuf(b64); - const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12)); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: u8(data, 0, 12) }, key, u8(data, 12)); return new Uint8Array(pt); } export function bufToB64(buf: ArrayBuffer | Uint8Array): string { - const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); - return btoa(String.fromCharCode(...u8)); + const u8arr = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return btoa(String.fromCharCode(...u8arr)); } -export function b64ToBuf(b64: string): Uint8Array { +export function b64ToBuf(b64: string): Uint8Array { return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); } diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index a5b9102..230866e 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -37,7 +37,7 @@ class CloudLinkSocket { on(handler: MessageHandler) { this.handlers.add(handler); - return () => this.handlers.delete(handler); + return () => { this.handlers.delete(handler); }; } } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 851f6bc..af15cc3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx",