Files
plebeian-signer/projects/common/src/lib/helpers/argon2-crypto.ts
mleku ebe2b695cc Release v1.0.0 - Major security upgrade with Argon2id encryption
- Upgrade vault encryption from PBKDF2 (1000 iterations) to Argon2id
  (256MB memory, 8 iterations, 4 threads, ~3 second derivation)
- Add automatic migration from v1 to v2 vault format on unlock
- Add WebAssembly CSP support for hash-wasm Argon2id implementation
- Add NIP-42 relay authentication support for auth-required relays
- Add profile edit feature with pencil icon on identity page
- Add direct NIP-05 validation (removes NDK dependency for validation)
- Add deriving modal with progress timer during key derivation
- Add client tag "plebeian-signer" to profile events
- Fix modal colors (dark theme for visibility)
- Fix NIP-05 badge styling to include check/error indicator
- Add release zip packages for Chrome and Firefox

New files:
- projects/common/src/lib/helpers/argon2-crypto.ts
- projects/common/src/lib/helpers/websocket-auth.ts
- projects/common/src/lib/helpers/nip05-validator.ts
- projects/common/src/lib/components/deriving-modal/
- projects/{chrome,firefox}/src/app/components/profile-edit/
- releases/plebeian-signer-{chrome,firefox}-v1.0.0.zip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:30:10 +01:00

151 lines
4.0 KiB
TypeScript

/**
* Secure vault encryption/decryption using Argon2id + AES-GCM
*
* - Argon2id key derivation with ~3 second computation time
* - AES-256-GCM authenticated encryption
* - Random 32-byte salt per vault
* - Random 12-byte IV per encryption
*
* Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
* in browser extensions cannot load external scripts due to CSP restrictions.
* The deriving modal provides user feedback during the ~3 second derivation.
*/
import { argon2id } from 'hash-wasm';
import { Buffer } from 'buffer';
// 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' as const,
};
/**
* Derive an encryption key from password using Argon2id
* @param password - User's password
* @param salt - Random 32-byte salt
* @returns 32-byte derived key
*/
export async function deriveKeyArgon2(
password: string,
salt: Uint8Array
): Promise<Uint8Array> {
// Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
// This blocks the UI for ~3 seconds, which is why we show a modal
const result = await argon2id({
password: password,
salt: salt,
...ARGON2_CONFIG,
});
return result;
}
/**
* Generate a random salt for Argon2id
* @returns Base64 encoded 32-byte salt
*/
export function generateSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(32));
return Buffer.from(salt).toString('base64');
}
/**
* Generate a random IV for AES-GCM
* @returns Base64 encoded 12-byte IV
*/
export function generateIV(): string {
const iv = crypto.getRandomValues(new Uint8Array(12));
return Buffer.from(iv).toString('base64');
}
/**
* Encrypt data using Argon2id-derived key + AES-256-GCM
* @param plaintext - Data to encrypt
* @param password - User's password
* @param saltBase64 - Base64 encoded 32-byte salt
* @param ivBase64 - Base64 encoded 12-byte IV
* @returns Base64 encoded ciphertext
*/
export async function encryptWithArgon2(
plaintext: string,
password: string,
saltBase64: string,
ivBase64: string
): Promise<string> {
const salt = Buffer.from(saltBase64, 'base64');
const iv = Buffer.from(ivBase64, 'base64');
// Derive key using Argon2id (~3 seconds, in worker)
const keyBytes = await deriveKeyArgon2(password, salt);
// Import key for AES-GCM
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// Encrypt the data
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encoder.encode(plaintext)
);
return Buffer.from(encrypted).toString('base64');
}
/**
* Decrypt data using Argon2id-derived key + AES-256-GCM
* @param ciphertextBase64 - Base64 encoded ciphertext
* @param password - User's password
* @param saltBase64 - Base64 encoded 32-byte salt
* @param ivBase64 - Base64 encoded 12-byte IV
* @returns Decrypted plaintext
* @throws Error if password is wrong or data is corrupted
*/
export async function decryptWithArgon2(
ciphertextBase64: string,
password: string,
saltBase64: string,
ivBase64: string
): Promise<string> {
const salt = Buffer.from(saltBase64, 'base64');
const iv = Buffer.from(ivBase64, 'base64');
const ciphertext = Buffer.from(ciphertextBase64, 'base64');
// Derive key using Argon2id (~3 seconds, in worker)
const keyBytes = await deriveKeyArgon2(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 {
throw new Error('Decryption failed - invalid password or corrupted data');
}
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}