You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

274 lines
7.9 KiB

/**
* Secure nsec encryption/decryption using Argon2id + AES-GCM
*
* - Argon2id key derivation with ~3 second computation time (runs in Web Worker)
* - AES-256-GCM authenticated encryption
* - Validates bech32 nsec format and checksum on decryption
*/
import { argon2id } from "hash-wasm";
import { decode as nip19Decode } from "nostr-tools/nip19";
// Argon2id parameters tuned for ~3 second derivation on typical hardware
const ARGON2_CONFIG = {
parallelism: 4, // 4 threads
iterations: 8, // Time cost
memorySize: 262144, // 256 MB memory
hashLength: 32, // 256-bit key for AES-256
outputType: "binary"
};
// Worker singleton and message ID counter
let worker = null;
let messageId = 0;
const pendingRequests = new Map();
/**
* Get or create the Argon2 worker
*/
function getWorker() {
if (worker) return worker;
// Inline worker code - includes hash-wasm import via importScripts alternative
// Since we can't easily import ES modules in workers, we'll use a different approach
// We'll run argon2id in chunks with yielding to allow UI updates
const workerCode = `
importScripts('https://cdn.jsdelivr.net/npm/hash-wasm@4.11.0/dist/argon2.umd.min.js');
const ARGON2_CONFIG = {
parallelism: 4,
iterations: 8,
memorySize: 262144,
hashLength: 32,
outputType: "binary"
};
self.onmessage = async function(e) {
const { password, salt, id } = e.data;
try {
const result = await hashwasm.argon2id({
password: password,
salt: new Uint8Array(salt),
...ARGON2_CONFIG
});
self.postMessage({
id,
success: true,
result: Array.from(result)
});
} catch (error) {
self.postMessage({
id,
success: false,
error: error.message
});
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = function(e) {
const { id, success, result, error } = e.data;
const pending = pendingRequests.get(id);
if (pending) {
pendingRequests.delete(id);
if (success) {
pending.resolve(new Uint8Array(result));
} else {
pending.reject(new Error(error));
}
}
};
worker.onerror = function(e) {
console.error('Argon2 worker error:', e);
};
return worker;
}
/**
* Derive an encryption key from password using Argon2id (in Web Worker)
* @param {string} password - User's password
* @param {Uint8Array} salt - Random 32-byte salt
* @returns {Promise<Uint8Array>} - 32-byte derived key
*/
export async function deriveKey(password, salt) {
// Try to use worker, fall back to main thread if it fails
try {
const w = getWorker();
const id = ++messageId;
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
w.postMessage({
id,
password,
salt: Array.from(salt)
});
});
} catch (e) {
// Fallback to main thread (will block UI but at least works)
console.warn('Worker failed, falling back to main thread:', e);
const result = await argon2id({
password: password,
salt: salt,
...ARGON2_CONFIG
});
return result;
}
}
/**
* Encrypt an nsec with a password
* @param {string} nsec - The nsec in bech32 format (nsec1...)
* @param {string} password - User's password
* @returns {Promise<string>} - Base64 encoded encrypted data (salt + iv + ciphertext)
*/
export async function encryptNsec(nsec, password) {
// Validate nsec format first
if (!nsec.startsWith("nsec1")) {
throw new Error("Invalid nsec format - must start with nsec1");
}
// Validate bech32 checksum
try {
const decoded = nip19Decode(nsec);
if (decoded.type !== "nsec") {
throw new Error("Invalid nsec - wrong type");
}
} catch (e) {
throw new Error("Invalid nsec - bech32 checksum failed");
}
// Generate random salt and IV
const salt = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
// Derive key using Argon2id (~3 seconds, in worker)
const keyBytes = await deriveKey(password, salt);
// Import key for AES-GCM
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["encrypt"]
);
// Encrypt the nsec
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
key,
encoder.encode(nsec)
);
// Combine salt + iv + ciphertext and encode as base64
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(encrypted), salt.length + iv.length);
return btoa(String.fromCharCode(...combined));
}
/**
* Decrypt an nsec with a password
* @param {string} encryptedData - Base64 encoded encrypted data
* @param {string} password - User's password
* @returns {Promise<string>} - The decrypted nsec in bech32 format
* @throws {Error} - If password is wrong or data is corrupted
*/
export async function decryptNsec(encryptedData, password) {
// Decode base64
const combined = new Uint8Array(
atob(encryptedData).split("").map(c => c.charCodeAt(0))
);
// Validate minimum length (32 salt + 12 iv + 16 auth tag + some ciphertext)
if (combined.length < 60) {
throw new Error("Invalid encrypted data - too short");
}
// Extract salt, iv, and ciphertext
const salt = combined.slice(0, 32);
const iv = combined.slice(32, 44);
const ciphertext = combined.slice(44);
// Derive key using Argon2id (~3 seconds, in worker)
const keyBytes = await deriveKey(password, salt);
// Import key for AES-GCM
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["decrypt"]
);
// Decrypt
let decrypted;
try {
decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv },
key,
ciphertext
);
} catch (e) {
throw new Error("Decryption failed - invalid password or corrupted data");
}
const decoder = new TextDecoder();
const nsec = decoder.decode(decrypted);
// Validate the decrypted nsec has correct bech32 format and checksum
if (!nsec.startsWith("nsec1")) {
throw new Error("Decryption produced invalid data - not an nsec");
}
try {
const decoded = nip19Decode(nsec);
if (decoded.type !== "nsec") {
throw new Error("Decryption produced invalid nsec type");
}
} catch (e) {
throw new Error("Decryption produced invalid nsec - bech32 checksum failed");
}
return nsec;
}
/**
* Check if a string is a valid nsec (validates bech32 format and checksum)
* @param {string} nsec - The string to validate
* @returns {boolean} - True if valid nsec
*/
export function isValidNsec(nsec) {
if (!nsec || !nsec.startsWith("nsec1")) {
return false;
}
try {
const decoded = nip19Decode(nsec);
return decoded.type === "nsec";
} catch {
return false;
}
}
/**
* Terminate the worker (call when done to free resources)
*/
export function terminateWorker() {
if (worker) {
worker.terminate();
worker = null;
}
}