@@ -497,3 +497,107 @@ export function hashToEmoji(hash: string, count: number = DEFAULT_EMOJI_GRID_SIZ
497497 }
498498 return emojis . join ( '' ) ;
499499}
500+
501+ // ─── Passphrase-based encryption (PBKDF2 + AES-256-GCM) ───────────────────
502+
503+ /** Default PBKDF2 iteration count. High to compensate for short PINs (~1-2s on modern hardware). */
504+ const DEFAULT_PBKDF2_ITERATIONS = 2_000_000 ;
505+ const PBKDF2_SALT_BYTES = 16 ;
506+ const PBKDF2_IV_BYTES = 12 ;
507+
508+ /**
509+ * Derives an AES-256-GCM key from a passphrase using PBKDF2-SHA256.
510+ *
511+ * @param passphrase - The user-provided passphrase or PIN
512+ * @param salt - Random salt bytes
513+ * @param iterations - PBKDF2 iteration count (default: 2,000,000)
514+ * @returns An AES-256-GCM CryptoKey
515+ */
516+ export async function deriveKeyFromPassphrase (
517+ passphrase : string ,
518+ salt : Uint8Array ,
519+ iterations : number = DEFAULT_PBKDF2_ITERATIONS ,
520+ ) : Promise < CryptoKey > {
521+ const keyMaterial = await crypto . subtle . importKey ( 'raw' , new TextEncoder ( ) . encode ( passphrase ) , 'PBKDF2' , false , [
522+ 'deriveKey' ,
523+ ] ) ;
524+ return crypto . subtle . deriveKey (
525+ { name : 'PBKDF2' , salt : salt as BufferSource , iterations, hash : 'SHA-256' } ,
526+ keyMaterial ,
527+ { name : 'AES-GCM' , length : 256 } ,
528+ false ,
529+ [ 'encrypt' , 'decrypt' ] ,
530+ ) ;
531+ }
532+
533+ /**
534+ * Encrypts arbitrary bytes with a passphrase using PBKDF2 + AES-256-GCM.
535+ *
536+ * Output layout: `[salt (16)] [iv (12)] [ciphertext (...)]`
537+ *
538+ * @param plaintext - Data to encrypt
539+ * @param passphrase - User passphrase or PIN
540+ * @param iterations - PBKDF2 iteration count (default: 2,000,000)
541+ * @returns A Uint8Array containing salt + iv + ciphertext
542+ */
543+ export async function encryptWithPassphrase (
544+ plaintext : Uint8Array ,
545+ passphrase : string ,
546+ iterations : number = DEFAULT_PBKDF2_ITERATIONS ,
547+ ) : Promise < Uint8Array > {
548+ const salt = crypto . getRandomValues ( new Uint8Array ( PBKDF2_SALT_BYTES ) ) ;
549+ const iv = crypto . getRandomValues ( new Uint8Array ( PBKDF2_IV_BYTES ) ) ;
550+ const key = await deriveKeyFromPassphrase ( passphrase , salt , iterations ) ;
551+ const ciphertext = new Uint8Array (
552+ await crypto . subtle . encrypt ( { name : 'AES-GCM' , iv } , key , plaintext as BufferSource ) ,
553+ ) ;
554+ const result = new Uint8Array ( PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES + ciphertext . length ) ;
555+ result . set ( salt , 0 ) ;
556+ result . set ( iv , PBKDF2_SALT_BYTES ) ;
557+ result . set ( ciphertext , PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES ) ;
558+ return result ;
559+ }
560+
561+ /**
562+ * Decrypts data produced by {@link encryptWithPassphrase}.
563+ *
564+ * @param data - The encrypted blob (salt + iv + ciphertext)
565+ * @param passphrase - The passphrase used during encryption
566+ * @param iterations - PBKDF2 iteration count (must match encryption)
567+ * @returns The decrypted plaintext bytes
568+ * @throws On wrong passphrase (AES-GCM auth tag mismatch)
569+ */
570+ export async function decryptWithPassphrase (
571+ data : Uint8Array ,
572+ passphrase : string ,
573+ iterations : number = DEFAULT_PBKDF2_ITERATIONS ,
574+ ) : Promise < Uint8Array > {
575+ const salt = data . slice ( 0 , PBKDF2_SALT_BYTES ) ;
576+ const iv = data . slice ( PBKDF2_SALT_BYTES , PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES ) ;
577+ const ciphertext = data . slice ( PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES ) ;
578+ const key = await deriveKeyFromPassphrase ( passphrase , salt , iterations ) ;
579+ return new Uint8Array ( await crypto . subtle . decrypt ( { name : 'AES-GCM' , iv } , key , ciphertext as BufferSource ) ) ;
580+ }
581+
582+ /**
583+ * Converts a Uint8Array to a base64 string.
584+ */
585+ export function uint8ToBase64 ( bytes : Uint8Array ) : string {
586+ let binary = '' ;
587+ for ( const b of bytes ) {
588+ binary += String . fromCharCode ( b ) ;
589+ }
590+ return btoa ( binary ) ;
591+ }
592+
593+ /**
594+ * Converts a base64 string to a Uint8Array.
595+ */
596+ export function base64ToUint8 ( b64 : string ) : Uint8Array {
597+ const binary = atob ( b64 ) ;
598+ const bytes = new Uint8Array ( binary . length ) ;
599+ for ( let i = 0 ; i < binary . length ; i ++ ) {
600+ bytes [ i ] = binary . charCodeAt ( i ) ;
601+ }
602+ return bytes ;
603+ }
0 commit comments