- 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>
151 lines
4.0 KiB
TypeScript
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);
|
|
}
|
|
|