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