Skip to content

Commit 258681a

Browse files
authored
harden wallet mnemonic storage with NIP-44 encrypt-to-self (#224)
1 parent 69bea59 commit 258681a

File tree

2 files changed

+67
-35
lines changed

2 files changed

+67
-35
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zap.cooking",
33
"license": "MIT",
4-
"version": "4.2.1",
4+
"version": "4.2.2",
55
"private": true,
66
"scripts": {
77
"dev": "vite dev",

src/lib/spark/storage.ts

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,94 @@
11
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
22
import { 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

56
const 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
*/
2646
export 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
*/
4459
export 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
*/
86116
export 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

Comments
 (0)