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>
This commit is contained in:
2025-12-19 12:30:10 +01:00
parent ddb74c61b2
commit ebe2b695cc
47 changed files with 2541 additions and 128 deletions

View File

@@ -0,0 +1,150 @@
/**
* 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);
}

View File

@@ -0,0 +1,127 @@
/**
* NIP-05 Verification Helper
*
* Directly validates NIP-05 identifiers by fetching the .well-known/nostr.json
* file and comparing the pubkey.
*/
export interface Nip05ValidationResult {
valid: boolean;
pubkey?: string;
relays?: string[];
error?: string;
}
/**
* Parse a NIP-05 identifier into its components
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev" or "_@mleku.dev")
* @returns Object with name and domain, or null if invalid
*/
export function parseNip05(nip05: string): { name: string; domain: string } | null {
if (!nip05 || typeof nip05 !== 'string') {
return null;
}
const parts = nip05.toLowerCase().trim().split('@');
if (parts.length !== 2) {
return null;
}
const [name, domain] = parts;
if (!name || !domain) {
return null;
}
// Basic domain validation
if (!domain.includes('.') || domain.includes('/')) {
return null;
}
return { name, domain };
}
/**
* Validate a NIP-05 identifier against a pubkey
*
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev")
* @param expectedPubkey - The expected pubkey in hex format
* @param timeoutMs - Fetch timeout in milliseconds
* @returns Validation result with status and any discovered relays
*/
export async function validateNip05(
nip05: string,
expectedPubkey: string,
timeoutMs = 10000
): Promise<Nip05ValidationResult> {
const parsed = parseNip05(nip05);
if (!parsed) {
return { valid: false, error: 'Invalid NIP-05 format' };
}
const { name, domain } = parsed;
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
},
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
valid: false,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
const data = await response.json();
// Check if the names object exists and contains the requested name
if (!data.names || typeof data.names !== 'object') {
return { valid: false, error: 'Invalid nostr.json structure: missing names' };
}
// NIP-05 names are case-insensitive
const pubkeyFromJson = data.names[name] || data.names[name.toLowerCase()];
if (!pubkeyFromJson) {
return { valid: false, error: `Name "${name}" not found in nostr.json` };
}
// Compare pubkeys (case-insensitive hex comparison)
const normalizedExpected = expectedPubkey.toLowerCase();
const normalizedFound = pubkeyFromJson.toLowerCase();
const valid = normalizedExpected === normalizedFound;
// Extract relays if present
let relays: string[] | undefined;
if (data.relays && typeof data.relays === 'object') {
const relayList = data.relays[pubkeyFromJson] || data.relays[normalizedFound];
if (Array.isArray(relayList)) {
relays = relayList;
}
}
return {
valid,
pubkey: pubkeyFromJson,
relays,
error: valid ? undefined : 'Pubkey mismatch',
};
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
return { valid: false, error: 'Request timeout' };
}
return { valid: false, error: error.message };
}
return { valid: false, error: 'Unknown error' };
}
}

View File

@@ -0,0 +1,324 @@
/**
* NIP-42 Relay Authentication
*
* Handles WebSocket connections to relays that require authentication.
* When a relay sends an AUTH challenge, this module signs the challenge
* and authenticates before proceeding with event publishing.
*/
import { finalizeEvent, getPublicKey } from 'nostr-tools';
export interface AuthenticatedRelayConnection {
ws: WebSocket;
url: string;
authenticated: boolean;
pubkey: string;
}
export interface PublishResult {
relay: string;
success: boolean;
message: string;
}
/**
* Create a NIP-42 authentication event (kind 22242)
*/
function createAuthEvent(
relayUrl: string,
challenge: string,
privateKeyHex: string
): ReturnType<typeof finalizeEvent> {
const unsignedEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayUrl],
['challenge', challenge],
],
content: '',
};
// Convert hex private key to Uint8Array
const privkeyBytes = hexToBytes(privateKeyHex);
return finalizeEvent(unsignedEvent, privkeyBytes);
}
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
/**
* Connect to a relay with NIP-42 authentication support
*
* @param relayUrl - The relay WebSocket URL (e.g., wss://relay.example.com)
* @param privateKeyHex - The private key in hex format for signing
* @param timeoutMs - Connection and authentication timeout in milliseconds
* @returns Promise resolving to authenticated connection or null if failed
*/
export async function connectWithAuth(
relayUrl: string,
privateKeyHex: string,
timeoutMs = 10000
): Promise<AuthenticatedRelayConnection | null> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ws.close();
resolve(null);
}, timeoutMs);
const ws = new WebSocket(relayUrl);
const pubkey = getPublicKey(hexToBytes(privateKeyHex));
ws.onopen = () => {
// Connection open, wait for AUTH challenge or proceed directly
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
const messageType = message[0];
if (messageType === 'AUTH') {
// Relay sent an auth challenge
const challenge = message[1];
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
// Send AUTH response
ws.send(JSON.stringify(['AUTH', authEvent]));
} else if (messageType === 'OK') {
// Check if this is the AUTH response
const success = message[2];
const msg = message[3] || '';
if (success) {
clearTimeout(timeout);
resolve({
ws,
url: relayUrl,
authenticated: true,
pubkey,
});
} else {
console.error(`Auth failed for ${relayUrl}: ${msg}`);
clearTimeout(timeout);
ws.close();
resolve(null);
}
} else if (messageType === 'NOTICE') {
// Some relays don't require auth - connection is ready
clearTimeout(timeout);
resolve({
ws,
url: relayUrl,
authenticated: false,
pubkey,
});
}
} catch {
// Ignore parse errors
}
};
ws.onerror = () => {
clearTimeout(timeout);
resolve(null);
};
ws.onclose = () => {
clearTimeout(timeout);
};
// For relays that don't send AUTH challenge, resolve after short delay
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
clearTimeout(timeout);
resolve({
ws,
url: relayUrl,
authenticated: false, // No auth was required
pubkey,
});
}
}, 2000); // Wait 2 seconds for potential AUTH challenge
});
}
/**
* Publish an event to a relay with NIP-42 authentication support
*
* This function handles the complete flow:
* 1. Connect to relay
* 2. Handle AUTH challenge if sent
* 3. Publish the event
* 4. Wait for OK response
* 5. Close connection
*
* @param relayUrl - The relay WebSocket URL
* @param signedEvent - The already-signed Nostr event to publish
* @param privateKeyHex - Private key for AUTH (if required)
* @param timeoutMs - Timeout for the entire operation
* @returns Promise resolving to publish result
*/
export async function publishEventWithAuth(
relayUrl: string,
signedEvent: ReturnType<typeof finalizeEvent>,
privateKeyHex: string,
timeoutMs = 15000
): Promise<PublishResult> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
resolve({
relay: relayUrl,
success: false,
message: 'Timeout',
});
}, timeoutMs);
let ws: WebSocket;
let authenticated = false;
let eventSent = false;
try {
ws = new WebSocket(relayUrl);
} catch (e) {
clearTimeout(timeout);
resolve({
relay: relayUrl,
success: false,
message: `Connection failed: ${e}`,
});
return;
}
const sendEvent = () => {
if (!eventSent && ws.readyState === WebSocket.OPEN) {
eventSent = true;
ws.send(JSON.stringify(['EVENT', signedEvent]));
}
};
ws.onopen = () => {
// Wait a moment for potential AUTH challenge before sending event
setTimeout(() => {
if (!authenticated) {
// No auth challenge received, try sending event directly
sendEvent();
}
}, 500);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
const messageType = message[0];
if (messageType === 'AUTH') {
// Relay requires authentication
const challenge = message[1];
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
ws.send(JSON.stringify(['AUTH', authEvent]));
authenticated = true;
} else if (messageType === 'OK') {
const eventId = message[1];
const success = message[2];
const msg = message[3] || '';
// Check if this is our event or AUTH response
if (eventId === signedEvent.id) {
// This is the response to our published event
clearTimeout(timeout);
ws.close();
if (success) {
resolve({
relay: relayUrl,
success: true,
message: 'Published successfully',
});
} else {
// Check if we need to retry after auth
if (msg.includes('auth-required') && !authenticated) {
// Relay requires auth but didn't send challenge
// This shouldn't normally happen
resolve({
relay: relayUrl,
success: false,
message: 'Auth required but no challenge received',
});
} else {
resolve({
relay: relayUrl,
success: false,
message: msg || 'Publish rejected',
});
}
}
} else if (authenticated && !eventSent) {
// This is the OK response to our AUTH
if (success) {
// Auth succeeded, now send the event
sendEvent();
} else {
clearTimeout(timeout);
ws.close();
resolve({
relay: relayUrl,
success: false,
message: `Authentication failed: ${msg}`,
});
}
}
} else if (messageType === 'NOTICE') {
// Log notices but don't fail
console.log(`Relay ${relayUrl} notice: ${message[1]}`);
}
} catch {
// Ignore parse errors
}
};
ws.onerror = () => {
clearTimeout(timeout);
resolve({
relay: relayUrl,
success: false,
message: 'Connection error',
});
};
ws.onclose = () => {
// If we haven't resolved yet, treat as failure
clearTimeout(timeout);
};
});
}
/**
* Publish an event to multiple relays with NIP-42 support
*
* @param relayUrls - Array of relay WebSocket URLs
* @param signedEvent - The already-signed Nostr event to publish
* @param privateKeyHex - Private key for AUTH (if required)
* @returns Promise resolving to array of publish results
*/
export async function publishToRelaysWithAuth(
relayUrls: string[],
signedEvent: ReturnType<typeof finalizeEvent>,
privateKeyHex: string
): Promise<PublishResult[]> {
const results = await Promise.all(
relayUrls.map((url) => publishEventWithAuth(url, signedEvent, privateKeyHex))
);
return results;
}