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

@@ -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!
),
};

View File

@@ -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[] = [];

View File

@@ -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[] = [];

View File

@@ -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

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

View File

@@ -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;