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

@@ -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);
}
/**