nsec-crypto.js raw
1 /**
2 * Secure nsec encryption/decryption using Argon2id + AES-GCM
3 *
4 * - Argon2id key derivation with ~3 second computation time (runs in Web Worker)
5 * - AES-256-GCM authenticated encryption
6 * - Validates bech32 nsec format and checksum on decryption
7 */
8
9 import { argon2id } from "hash-wasm";
10 import { decode as nip19Decode } from "nostr-tools/nip19";
11
12 // Argon2id parameters tuned for ~3 second derivation on typical hardware
13 const ARGON2_CONFIG = {
14 parallelism: 4, // 4 threads
15 iterations: 8, // Time cost
16 memorySize: 262144, // 256 MB memory
17 hashLength: 32, // 256-bit key for AES-256
18 outputType: "binary"
19 };
20
21 // Worker singleton and message ID counter
22 let worker = null;
23 let messageId = 0;
24 const pendingRequests = new Map();
25
26 /**
27 * Get or create the Argon2 worker
28 */
29 function getWorker() {
30 if (worker) return worker;
31
32 // Inline worker code - includes hash-wasm import via importScripts alternative
33 // Since we can't easily import ES modules in workers, we'll use a different approach
34 // We'll run argon2id in chunks with yielding to allow UI updates
35
36 const workerCode = `
37 importScripts('https://cdn.jsdelivr.net/npm/hash-wasm@4.11.0/dist/argon2.umd.min.js');
38
39 const ARGON2_CONFIG = {
40 parallelism: 4,
41 iterations: 8,
42 memorySize: 262144,
43 hashLength: 32,
44 outputType: "binary"
45 };
46
47 self.onmessage = async function(e) {
48 const { password, salt, id } = e.data;
49
50 try {
51 const result = await hashwasm.argon2id({
52 password: password,
53 salt: new Uint8Array(salt),
54 ...ARGON2_CONFIG
55 });
56
57 self.postMessage({
58 id,
59 success: true,
60 result: Array.from(result)
61 });
62 } catch (error) {
63 self.postMessage({
64 id,
65 success: false,
66 error: error.message
67 });
68 }
69 };
70 `;
71
72 const blob = new Blob([workerCode], { type: 'application/javascript' });
73 worker = new Worker(URL.createObjectURL(blob));
74
75 worker.onmessage = function(e) {
76 const { id, success, result, error } = e.data;
77 const pending = pendingRequests.get(id);
78 if (pending) {
79 pendingRequests.delete(id);
80 if (success) {
81 pending.resolve(new Uint8Array(result));
82 } else {
83 pending.reject(new Error(error));
84 }
85 }
86 };
87
88 worker.onerror = function(e) {
89 console.error('Argon2 worker error:', e);
90 };
91
92 return worker;
93 }
94
95 /**
96 * Derive an encryption key from password using Argon2id (in Web Worker)
97 * @param {string} password - User's password
98 * @param {Uint8Array} salt - Random 32-byte salt
99 * @returns {Promise<Uint8Array>} - 32-byte derived key
100 */
101 export async function deriveKey(password, salt) {
102 // Try to use worker, fall back to main thread if it fails
103 try {
104 const w = getWorker();
105 const id = ++messageId;
106
107 return new Promise((resolve, reject) => {
108 pendingRequests.set(id, { resolve, reject });
109 w.postMessage({
110 id,
111 password,
112 salt: Array.from(salt)
113 });
114 });
115 } catch (e) {
116 // Fallback to main thread (will block UI but at least works)
117 console.warn('Worker failed, falling back to main thread:', e);
118 const result = await argon2id({
119 password: password,
120 salt: salt,
121 ...ARGON2_CONFIG
122 });
123 return result;
124 }
125 }
126
127 /**
128 * Encrypt an nsec with a password
129 * @param {string} nsec - The nsec in bech32 format (nsec1...)
130 * @param {string} password - User's password
131 * @returns {Promise<string>} - Base64 encoded encrypted data (salt + iv + ciphertext)
132 */
133 export async function encryptNsec(nsec, password) {
134 // Validate nsec format first
135 if (!nsec.startsWith("nsec1")) {
136 throw new Error("Invalid nsec format - must start with nsec1");
137 }
138
139 // Validate bech32 checksum
140 try {
141 const decoded = nip19Decode(nsec);
142 if (decoded.type !== "nsec") {
143 throw new Error("Invalid nsec - wrong type");
144 }
145 } catch (e) {
146 throw new Error("Invalid nsec - bech32 checksum failed");
147 }
148
149 // Generate random salt and IV
150 const salt = crypto.getRandomValues(new Uint8Array(32));
151 const iv = crypto.getRandomValues(new Uint8Array(12));
152
153 // Derive key using Argon2id (~3 seconds, in worker)
154 const keyBytes = await deriveKey(password, salt);
155
156 // Import key for AES-GCM
157 const key = await crypto.subtle.importKey(
158 "raw",
159 keyBytes,
160 { name: "AES-GCM" },
161 false,
162 ["encrypt"]
163 );
164
165 // Encrypt the nsec
166 const encoder = new TextEncoder();
167 const encrypted = await crypto.subtle.encrypt(
168 { name: "AES-GCM", iv: iv },
169 key,
170 encoder.encode(nsec)
171 );
172
173 // Combine salt + iv + ciphertext and encode as base64
174 const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
175 combined.set(salt, 0);
176 combined.set(iv, salt.length);
177 combined.set(new Uint8Array(encrypted), salt.length + iv.length);
178
179 return btoa(String.fromCharCode(...combined));
180 }
181
182 /**
183 * Decrypt an nsec with a password
184 * @param {string} encryptedData - Base64 encoded encrypted data
185 * @param {string} password - User's password
186 * @returns {Promise<string>} - The decrypted nsec in bech32 format
187 * @throws {Error} - If password is wrong or data is corrupted
188 */
189 export async function decryptNsec(encryptedData, password) {
190 // Decode base64
191 const combined = new Uint8Array(
192 atob(encryptedData).split("").map(c => c.charCodeAt(0))
193 );
194
195 // Validate minimum length (32 salt + 12 iv + 16 auth tag + some ciphertext)
196 if (combined.length < 60) {
197 throw new Error("Invalid encrypted data - too short");
198 }
199
200 // Extract salt, iv, and ciphertext
201 const salt = combined.slice(0, 32);
202 const iv = combined.slice(32, 44);
203 const ciphertext = combined.slice(44);
204
205 // Derive key using Argon2id (~3 seconds, in worker)
206 const keyBytes = await deriveKey(password, salt);
207
208 // Import key for AES-GCM
209 const key = await crypto.subtle.importKey(
210 "raw",
211 keyBytes,
212 { name: "AES-GCM" },
213 false,
214 ["decrypt"]
215 );
216
217 // Decrypt
218 let decrypted;
219 try {
220 decrypted = await crypto.subtle.decrypt(
221 { name: "AES-GCM", iv: iv },
222 key,
223 ciphertext
224 );
225 } catch (e) {
226 throw new Error("Decryption failed - invalid password or corrupted data");
227 }
228
229 const decoder = new TextDecoder();
230 const nsec = decoder.decode(decrypted);
231
232 // Validate the decrypted nsec has correct bech32 format and checksum
233 if (!nsec.startsWith("nsec1")) {
234 throw new Error("Decryption produced invalid data - not an nsec");
235 }
236
237 try {
238 const decoded = nip19Decode(nsec);
239 if (decoded.type !== "nsec") {
240 throw new Error("Decryption produced invalid nsec type");
241 }
242 } catch (e) {
243 throw new Error("Decryption produced invalid nsec - bech32 checksum failed");
244 }
245
246 return nsec;
247 }
248
249 /**
250 * Check if a string is a valid nsec (validates bech32 format and checksum)
251 * @param {string} nsec - The string to validate
252 * @returns {boolean} - True if valid nsec
253 */
254 export function isValidNsec(nsec) {
255 if (!nsec || !nsec.startsWith("nsec1")) {
256 return false;
257 }
258 try {
259 const decoded = nip19Decode(nsec);
260 return decoded.type === "nsec";
261 } catch {
262 return false;
263 }
264 }
265
266 /**
267 * Terminate the worker (call when done to free resources)
268 */
269 export function terminateWorker() {
270 if (worker) {
271 worker.terminate();
272 worker = null;
273 }
274 }
275