11import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
22import { sha256 } from '@noble/hashes/sha2.js'
3- import { bytesToHex , hexToBytes } from '@noble/hashes/utils.js'
3+ import { hexToBytes } from '@noble/hashes/utils.js'
4+ import { encrypt , decrypt , detectEncryptionMethod } from '$lib/encryptionService'
45
56const LOCAL_STORAGE_KEY_PREFIX = 'spark_wallet_'
67
7- /**
8- * Derives a 32-byte (256-bit) encryption key from the user's Nostr public key.
9- * The mnemonic is tied to the user's Nostr identity - only they can decrypt it.
10- * @param pubkey The user's Nostr public key (hex string).
11- * @returns A Uint8Array representing the 32-byte encryption key.
12- */
13- function deriveKey ( pubkey : string ) : Uint8Array {
14- // Convert hex pubkey to bytes, then hash with SHA-256
15- // This ties the encryption to the user's Nostr identity
8+ // ── V2 storage format ────────────────────────────────────────
9+
10+ interface StoredMnemonicV2 {
11+ version : 2
12+ ciphertext : string
13+ }
14+
15+ // ── V1 legacy support (migration only) ──────────────────────
16+
17+ /** @deprecated V1 key derivation — used only for migrating old stored mnemonics */
18+ function deriveKeyV1 ( pubkey : string ) : Uint8Array {
1619 const pubkeyBytes = hexToBytes ( pubkey )
1720 return sha256 ( pubkeyBytes )
1821}
1922
23+ /** Decrypt a V1 (XChaCha20-Poly1305 with pubkey-derived key) stored mnemonic */
24+ function decryptV1 ( pubkey : string , storedDataHex : string ) : string | null {
25+ try {
26+ const storedData = hexToBytes ( storedDataHex )
27+ const nonce = storedData . slice ( 0 , 24 )
28+ const ciphertext = storedData . slice ( 24 )
29+ const key = deriveKeyV1 ( pubkey )
30+ const cipher = xchacha20poly1305 ( key , nonce )
31+ const decrypted = cipher . decrypt ( ciphertext )
32+ return new TextDecoder ( ) . decode ( decrypted )
33+ } catch {
34+ return null
35+ }
36+ }
37+
38+ // ── V2: NIP-44 encrypt-to-self ──────────────────────────────
39+
2040/**
21- * Saves an encrypted mnemonic to local storage.
22- * The mnemonic is encrypted using XChaCha20-Poly1305 with a key derived from the Nostr pubkey .
41+ * Saves an encrypted mnemonic to local storage using NIP-44 encrypt-to-self .
42+ * The mnemonic can only be decrypted with the user's Nostr private key .
2343 * @param pubkey The user's Nostr public key (hex string).
2444 * @param mnemonic The mnemonic string to encrypt and save.
2545 */
2646export async function saveMnemonic ( pubkey : string , mnemonic : string ) : Promise < void > {
27- const key = deriveKey ( pubkey )
28- const nonce = crypto . getRandomValues ( new Uint8Array ( 24 ) ) // 24-byte nonce for XChaCha20-Poly1305
29- const plaintext = new TextEncoder ( ) . encode ( mnemonic )
30-
31- const cipher = xchacha20poly1305 ( key , nonce )
32- const ciphertext = cipher . encrypt ( plaintext )
33-
34- // Store nonce + ciphertext as a single hex string
35- const storedData = bytesToHex ( new Uint8Array ( [ ...nonce , ...ciphertext ] ) )
36- localStorage . setItem ( `${ LOCAL_STORAGE_KEY_PREFIX } ${ pubkey } ` , storedData )
47+ const { ciphertext } = await encrypt ( pubkey , mnemonic )
48+ const stored : StoredMnemonicV2 = { version : 2 , ciphertext }
49+ localStorage . setItem ( `${ LOCAL_STORAGE_KEY_PREFIX } ${ pubkey } ` , JSON . stringify ( stored ) )
3750}
3851
3952/**
4053 * Loads and decrypts a mnemonic from local storage.
54+ * Handles both V2 (NIP-44) and V1 (legacy XChaCha20) formats.
55+ * V1 data is silently migrated to V2 on successful load when a signer is available.
4156 * @param pubkey The user's Nostr public key (hex string).
4257 * @returns The decrypted mnemonic string, or null if not found or decryption fails.
4358 */
4459export async function loadMnemonic ( pubkey : string ) : Promise < string | null > {
45- const storedDataHex = localStorage . getItem ( `${ LOCAL_STORAGE_KEY_PREFIX } ${ pubkey } ` )
46- if ( ! storedDataHex ) {
47- return null
60+ const raw = localStorage . getItem ( `${ LOCAL_STORAGE_KEY_PREFIX } ${ pubkey } ` )
61+ if ( ! raw ) return null
62+
63+ // Try V2 JSON format first
64+ try {
65+ const parsed = JSON . parse ( raw ) as StoredMnemonicV2
66+ if ( parsed . version === 2 && parsed . ciphertext ) {
67+ const method = detectEncryptionMethod ( parsed . ciphertext )
68+ return await decrypt ( pubkey , parsed . ciphertext , method )
69+ }
70+ } catch {
71+ // JSON parse failed — this is a V1 legacy hex string
4872 }
4973
74+ // V1 legacy: decrypt with old method, then migrate to V2
5075 try {
51- const storedData = hexToBytes ( storedDataHex )
52- const nonce = storedData . slice ( 0 , 24 )
53- const ciphertext = storedData . slice ( 24 )
76+ const mnemonic = decryptV1 ( pubkey , raw )
77+ if ( ! mnemonic ) {
78+ console . error ( '[Wallet Storage] Failed to decrypt V1 mnemonic' )
79+ return null
80+ }
5481
55- const key = deriveKey ( pubkey )
56- const cipher = xchacha20poly1305 ( key , nonce )
57- const decrypted = cipher . decrypt ( ciphertext )
82+ // Silent migration to V2 (best-effort; if signer unavailable, skip)
83+ try {
84+ await saveMnemonic ( pubkey , mnemonic )
85+ } catch {
86+ // Migration failed (signer unavailable) — mnemonic stays in V1 until next load
87+ }
5888
59- return new TextDecoder ( ) . decode ( decrypted )
89+ return mnemonic
6090 } catch ( error ) {
61- console . error ( 'Failed to decrypt mnemonic:' , error )
91+ console . error ( '[Wallet Storage] Failed to decrypt mnemonic:' , error )
6292 return null
6393 }
6494}
@@ -84,10 +114,12 @@ export function deleteMnemonic(pubkey: string): void {
84114 * Clears all Spark wallet mnemonics from local storage.
85115 */
86116export function clearAllSparkWallets ( ) : void {
117+ const keysToRemove : string [ ] = [ ]
87118 for ( let i = 0 ; i < localStorage . length ; i ++ ) {
88119 const key = localStorage . key ( i )
89120 if ( key && key . startsWith ( LOCAL_STORAGE_KEY_PREFIX ) ) {
90- localStorage . removeItem ( key )
121+ keysToRemove . push ( key )
91122 }
92123 }
124+ keysToRemove . forEach ( ( key ) => localStorage . removeItem ( key ) )
93125}
0 commit comments