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:
@@ -0,0 +1,10 @@
|
||||
@if (visible) {
|
||||
<div class="deriving-overlay">
|
||||
<div class="deriving-modal">
|
||||
<div class="deriving-spinner"></div>
|
||||
<h3>{{ message }}</h3>
|
||||
<div class="deriving-timer">{{ elapsed.toFixed(1) }}s</div>
|
||||
<p class="deriving-note">This may take 3-6 seconds for security</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Modal always uses dark theme for visibility over any content
|
||||
.deriving-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.deriving-modal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #3d3d3d;
|
||||
|
||||
h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: #fafafa;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.deriving-timer {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #ff3eb5;
|
||||
font-family: monospace;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.deriving-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #a1a1a1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.deriving-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #3d3d3d;
|
||||
border-top-color: #ff3eb5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-deriving-modal',
|
||||
templateUrl: './deriving-modal.component.html',
|
||||
styleUrl: './deriving-modal.component.scss',
|
||||
})
|
||||
export class DerivingModalComponent implements OnDestroy {
|
||||
visible = false;
|
||||
elapsed = 0;
|
||||
message = 'Deriving encryption key';
|
||||
|
||||
#startTime: number | null = null;
|
||||
#animationFrame: number | null = null;
|
||||
|
||||
/**
|
||||
* Show the deriving modal and start the timer
|
||||
* @param message Optional custom message
|
||||
*/
|
||||
show(message?: string): void {
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
this.visible = true;
|
||||
this.elapsed = 0;
|
||||
this.#startTime = performance.now();
|
||||
this.#updateTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal and stop the timer
|
||||
*/
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
this.#stopTimer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.#stopTimer();
|
||||
}
|
||||
|
||||
#updateTimer(): void {
|
||||
if (this.#startTime !== null) {
|
||||
this.elapsed = (performance.now() - this.#startTime) / 1000;
|
||||
this.#animationFrame = requestAnimationFrame(() => this.#updateTimer());
|
||||
}
|
||||
}
|
||||
|
||||
#stopTimer(): void {
|
||||
this.#startTime = null;
|
||||
if (this.#animationFrame !== null) {
|
||||
cancelAnimationFrame(this.#animationFrame);
|
||||
this.#animationFrame = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal file
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal 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);
|
||||
}
|
||||
|
||||
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal file
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal file
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal 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;
|
||||
}
|
||||
@@ -166,10 +166,19 @@ export const encryptIdentity = async function (
|
||||
return encryptedIdentity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locked vault context for decryption during unlock
|
||||
* - v1 vaults use password (PBKDF2)
|
||||
* - v2 vaults use keyBase64 (pre-derived Argon2id key)
|
||||
*/
|
||||
export type LockedVaultContext =
|
||||
| { iv: string; password: string; keyBase64?: undefined }
|
||||
| { iv: string; keyBase64: string; password?: undefined };
|
||||
|
||||
export const decryptIdentities = async function (
|
||||
this: StorageService,
|
||||
identities: Identity_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED[]> {
|
||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||
|
||||
@@ -188,7 +197,7 @@ export const decryptIdentities = async function (
|
||||
export const decryptIdentity = async function (
|
||||
this: StorageService,
|
||||
identity: Identity_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
@@ -201,30 +210,62 @@ export const decryptIdentity = async function (
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
nick: await this.decryptWithLockedVaultV2(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVaultV2(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
privkey: await this.decryptWithLockedVaultV2(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
nick: await this.decryptWithLockedVault(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVault(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
privkey: await this.decryptWithLockedVault(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Permission_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const deletePermission = async function (
|
||||
this: StorageService,
|
||||
@@ -32,7 +33,7 @@ export const deletePermission = async function (
|
||||
export const decryptPermission = async function (
|
||||
this: StorageService,
|
||||
permission: Permission_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
@@ -48,36 +49,82 @@ export const decryptPermission = async function (
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
method: await this.decryptWithLockedVaultV2(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVaultV2(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
host: await this.decryptWithLockedVaultV2(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
decryptedPermission.kind = await this.decryptWithLockedVaultV2(
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
method: await this.decryptWithLockedVault(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVault(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
host: await this.decryptWithLockedVault(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
@@ -85,7 +132,7 @@ export const decryptPermission = async function (
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
@@ -94,7 +141,7 @@ export const decryptPermission = async function (
|
||||
export const decryptPermissions = async function (
|
||||
this: StorageService,
|
||||
permissions: Permission_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED[]> {
|
||||
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Relay_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const addRelay = async function (
|
||||
this: StorageService,
|
||||
@@ -126,7 +127,7 @@ export const updateRelay = async function (
|
||||
export const decryptRelay = async function (
|
||||
this: StorageService,
|
||||
relay: Relay_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
@@ -139,36 +140,74 @@ export const decryptRelay = async function (
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
url: await this.decryptWithLockedVaultV2(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
read: await this.decryptWithLockedVaultV2(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
write: await this.decryptWithLockedVaultV2(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
url: await this.decryptWithLockedVault(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
read: await this.decryptWithLockedVault(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
write: await this.decryptWithLockedVault(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
@@ -177,7 +216,7 @@ export const decryptRelay = async function (
|
||||
export const decryptRelays = async function (
|
||||
this: StorageService,
|
||||
relays: Relay_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED[]> {
|
||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
BrowserSyncData,
|
||||
CryptoHelper,
|
||||
StorageService,
|
||||
generateSalt,
|
||||
generateIV,
|
||||
deriveKeyArgon2,
|
||||
} from '@common';
|
||||
import { decryptIdentities } from './identity';
|
||||
import { Buffer } from 'buffer';
|
||||
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
|
||||
import { decryptPermissions } from './permission';
|
||||
import { decryptRelays } from './relay';
|
||||
import { decryptRelays, encryptRelay } from './relay';
|
||||
|
||||
export const createNewVault = async function (
|
||||
this: StorageService,
|
||||
@@ -16,9 +20,17 @@ export const createNewVault = async function (
|
||||
|
||||
const vaultHash = await CryptoHelper.hash(password);
|
||||
|
||||
// v2: Generate random salt and derive key with Argon2id
|
||||
const salt = generateSalt();
|
||||
const iv = generateIV();
|
||||
const saltBytes = Buffer.from(salt, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
|
||||
const sessionData: BrowserSessionData = {
|
||||
iv: CryptoHelper.generateIV(),
|
||||
vaultPassword: password,
|
||||
iv,
|
||||
salt,
|
||||
vaultKey, // v2: Store pre-derived key instead of password
|
||||
identities: [],
|
||||
permissions: [],
|
||||
relays: [],
|
||||
@@ -29,7 +41,8 @@ export const createNewVault = async function (
|
||||
|
||||
const syncData: BrowserSyncData = {
|
||||
version: this.latestVersion,
|
||||
iv: sessionData.iv,
|
||||
salt, // v2: Random salt for Argon2id
|
||||
iv,
|
||||
vaultHash,
|
||||
identities: [],
|
||||
permissions: [],
|
||||
@@ -44,6 +57,7 @@ export const unlockVault = async function (
|
||||
password: string
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
console.log('[vault] Starting unlock...');
|
||||
|
||||
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (browserSessionData) {
|
||||
@@ -59,55 +73,190 @@ export const unlockVault = async function (
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[vault] Checking password hash...');
|
||||
const passwordHash = await CryptoHelper.hash(password);
|
||||
if (passwordHash !== browserSyncData.vaultHash) {
|
||||
throw new Error('Invalid password.');
|
||||
}
|
||||
console.log('[vault] Password hash verified');
|
||||
|
||||
// Ok. Everything is fine. We can unlock the vault now.
|
||||
// Detect vault version
|
||||
const isV2 = !!browserSyncData.salt;
|
||||
console.log('[vault] Vault version:', isV2 ? 'v2' : 'v1');
|
||||
|
||||
// Decrypt the identities.
|
||||
const withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
password,
|
||||
};
|
||||
let withLockedVault: LockedVaultContext;
|
||||
let vaultKey: string | undefined;
|
||||
let vaultPassword: string | undefined;
|
||||
|
||||
if (isV2) {
|
||||
// v2: Derive key with Argon2id (~3 seconds)
|
||||
console.log('[vault] Deriving key with Argon2id...');
|
||||
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
console.log('[vault] Key derived, length:', keyBytes.length);
|
||||
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
keyBase64: vaultKey,
|
||||
};
|
||||
} else {
|
||||
// v1: Use password with PBKDF2
|
||||
vaultPassword = password;
|
||||
withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
console.log('[vault] Decrypting identities...');
|
||||
const decryptedIdentities = await decryptIdentities.call(
|
||||
this,
|
||||
browserSyncData.identities,
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedIdentities.length, 'identities');
|
||||
|
||||
console.log('[vault] Decrypting permissions...');
|
||||
const decryptedPermissions = await decryptPermissions.call(
|
||||
this,
|
||||
browserSyncData.permissions,
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedPermissions.length, 'permissions');
|
||||
|
||||
console.log('[vault] Decrypting relays...');
|
||||
const decryptedRelays = await decryptRelays.call(
|
||||
this,
|
||||
browserSyncData.relays,
|
||||
withLockedVault
|
||||
);
|
||||
const decryptedSelectedIdentityId =
|
||||
browserSyncData.selectedIdentityId === null
|
||||
? null
|
||||
: await this.decryptWithLockedVault(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
password
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
|
||||
|
||||
console.log('[vault] Decrypting selectedIdentityId...');
|
||||
let decryptedSelectedIdentityId: string | null = null;
|
||||
if (browserSyncData.selectedIdentityId !== null) {
|
||||
if (isV2) {
|
||||
decryptedSelectedIdentityId = await this.decryptWithLockedVaultV2(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
vaultKey!
|
||||
);
|
||||
} else {
|
||||
decryptedSelectedIdentityId = await this.decryptWithLockedVault(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
password
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('[vault] selectedIdentityId:', decryptedSelectedIdentityId);
|
||||
|
||||
browserSessionData = {
|
||||
vaultPassword: password,
|
||||
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||
vaultKey: isV2 ? vaultKey : undefined,
|
||||
iv: browserSyncData.iv,
|
||||
salt: browserSyncData.salt,
|
||||
permissions: decryptedPermissions,
|
||||
identities: decryptedIdentities,
|
||||
selectedIdentityId: decryptedSelectedIdentityId,
|
||||
relays: decryptedRelays,
|
||||
};
|
||||
|
||||
console.log('[vault] Saving session data...');
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
this.getBrowserSessionHandler().setFullData(browserSessionData);
|
||||
console.log('[vault] Session data saved');
|
||||
|
||||
// Auto-migrate v1 to v2 after successful unlock
|
||||
if (!isV2) {
|
||||
console.log('[vault] Migrating v1 to v2...');
|
||||
await migrateVaultV1ToV2.call(this, password);
|
||||
console.log('[vault] Migration complete');
|
||||
}
|
||||
|
||||
console.log('[vault] Unlock complete!');
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a v1 vault (PBKDF2) to v2 (Argon2id)
|
||||
* Called automatically after successful v1 unlock
|
||||
*/
|
||||
async function migrateVaultV1ToV2(
|
||||
this: StorageService,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSyncData || !browserSessionData) {
|
||||
throw new Error('Cannot migrate: data not available');
|
||||
}
|
||||
|
||||
// Generate new salt and derive Argon2id key
|
||||
const newSalt = generateSalt();
|
||||
const newIv = generateIV();
|
||||
const saltBytes = Buffer.from(newSalt, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
|
||||
// Update session data with new v2 credentials
|
||||
browserSessionData.salt = newSalt;
|
||||
browserSessionData.iv = newIv;
|
||||
browserSessionData.vaultKey = vaultKey;
|
||||
browserSessionData.vaultPassword = undefined; // Remove v1 password
|
||||
|
||||
// Re-encrypt all data with new v2 key
|
||||
const encryptedIdentities = [];
|
||||
for (const identity of browserSessionData.identities) {
|
||||
const encrypted = await encryptIdentity.call(this, identity);
|
||||
encryptedIdentities.push(encrypted);
|
||||
}
|
||||
|
||||
const encryptedRelays = [];
|
||||
for (const relay of browserSessionData.relays) {
|
||||
const encrypted = await encryptRelay.call(this, relay);
|
||||
encryptedRelays.push(encrypted);
|
||||
}
|
||||
|
||||
// For permissions, we need to re-encrypt them too
|
||||
const encryptedPermissions = [];
|
||||
for (const permission of browserSessionData.permissions) {
|
||||
const encryptedPermission = {
|
||||
id: await this.encrypt(permission.id),
|
||||
identityId: await this.encrypt(permission.identityId),
|
||||
host: await this.encrypt(permission.host),
|
||||
method: await this.encrypt(permission.method),
|
||||
methodPolicy: await this.encrypt(permission.methodPolicy),
|
||||
kind: permission.kind !== undefined ? await this.encrypt(permission.kind.toString()) : undefined,
|
||||
};
|
||||
encryptedPermissions.push(encryptedPermission);
|
||||
}
|
||||
|
||||
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
|
||||
? await this.encrypt(browserSessionData.selectedIdentityId)
|
||||
: null;
|
||||
|
||||
// Update sync data with v2 format
|
||||
const migratedSyncData: BrowserSyncData = {
|
||||
version: this.latestVersion,
|
||||
salt: newSalt,
|
||||
iv: newIv,
|
||||
vaultHash: browserSyncData.vaultHash, // Keep same password hash
|
||||
identities: encryptedIdentities,
|
||||
permissions: encryptedPermissions,
|
||||
relays: encryptedRelays,
|
||||
selectedIdentityId: encryptedSelectedIdentityId,
|
||||
};
|
||||
|
||||
// Save migrated data
|
||||
await this.getBrowserSyncHandler().saveAndSetFullData(migratedSyncData);
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
console.log('Vault migrated from v1 (PBKDF2) to v2 (Argon2id)');
|
||||
}
|
||||
|
||||
export const deleteVault = async function (
|
||||
this: StorageService,
|
||||
doNotSetIsInitializedToFalse: boolean
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './types';
|
||||
import { SignerMetaHandler } from './signer-meta-handler';
|
||||
import { CryptoHelper } from '@common';
|
||||
import { Buffer } from 'buffer';
|
||||
import {
|
||||
addIdentity,
|
||||
deleteIdentity,
|
||||
@@ -31,7 +32,7 @@ export interface StorageServiceConfig {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StorageService {
|
||||
readonly latestVersion = 1;
|
||||
readonly latestVersion = 2;
|
||||
isInitialized = false;
|
||||
|
||||
#browserSessionHandler!: BrowserSessionHandler;
|
||||
@@ -231,10 +232,19 @@ export class StorageService {
|
||||
async encrypt(value: string): Promise<string> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return CryptoHelper.encrypt(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
@@ -242,16 +252,54 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 encryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||
*/
|
||||
async encryptV2(text: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const cipherText = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(text)
|
||||
);
|
||||
|
||||
return Buffer.from(cipherText).toString('base64');
|
||||
}
|
||||
|
||||
async decrypt(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): Promise<any> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
const decryptedValue = await this.decryptV2(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultKey
|
||||
);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return this.decryptWithLockedVault(
|
||||
value,
|
||||
returnType,
|
||||
@@ -260,6 +308,52 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 decryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||
*/
|
||||
async decryptV2(encryptedBase64: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipherText
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decrypted string value into the desired type
|
||||
*/
|
||||
private parseDecryptedValue(
|
||||
decryptedValue: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): any {
|
||||
switch (returnType) {
|
||||
case 'number':
|
||||
return parseInt(decryptedValue);
|
||||
case 'boolean':
|
||||
return decryptedValue === 'true';
|
||||
case 'string':
|
||||
default:
|
||||
return decryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1: Decrypt with locked vault using password (PBKDF2)
|
||||
*/
|
||||
async decryptWithLockedVault(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean',
|
||||
@@ -267,18 +361,20 @@ export class StorageService {
|
||||
password: string
|
||||
): Promise<any> {
|
||||
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
switch (returnType) {
|
||||
case 'number':
|
||||
return parseInt(decryptedValue);
|
||||
|
||||
case 'boolean':
|
||||
return decryptedValue === 'true';
|
||||
|
||||
case 'string':
|
||||
default:
|
||||
return decryptedValue;
|
||||
}
|
||||
/**
|
||||
* v2: Decrypt with locked vault using pre-derived key (Argon2id)
|
||||
*/
|
||||
async decryptWithLockedVaultV2(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean',
|
||||
iv: string,
|
||||
keyBase64: string
|
||||
): Promise<any> {
|
||||
const decryptedValue = await this.decryptV2(value, iv, keyBase64);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface BrowserSyncData_PART_Unencrypted {
|
||||
version: number;
|
||||
iv: string;
|
||||
vaultHash: string;
|
||||
// Version 2+: Random 32-byte salt for Argon2id key derivation (base64)
|
||||
// Version 1: Not present (uses PBKDF2 with hardcoded salt)
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Encrypted {
|
||||
@@ -69,10 +72,13 @@ export enum BrowserSyncFlow {
|
||||
export interface BrowserSessionData {
|
||||
// The following properties purely come from the browser session storage
|
||||
// and will never be going into the browser sync storage.
|
||||
vaultPassword?: string;
|
||||
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||
vaultKey?: string; // v2+: pre-derived key bytes (base64) from Argon2id
|
||||
|
||||
// The following properties initially come from the browser sync storage.
|
||||
iv: string;
|
||||
// Version 2+: Random salt for Argon2id (base64)
|
||||
salt?: string;
|
||||
permissions: Permission_DECRYPTED[];
|
||||
identities: Identity_DECRYPTED[];
|
||||
selectedIdentityId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user