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

@@ -17,6 +17,7 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
export const routes: Routes = [
{
@@ -75,6 +76,10 @@ export const routes: Routes = [
path: 'whitelisted-apps',
component: WhitelistedAppsComponent,
},
{
path: 'profile-edit',
component: ProfileEditComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,

View File

@@ -1,6 +1,10 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<img src="edit.svg" alt="Edit" class="edit-icon" />
</button>
</div>
<div class="identity-container">
@@ -22,7 +26,6 @@
</div>
<!-- Display name (primary, large) -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
<div class="name-badge-container" (click)="onClickShowDetails()">
<span class="display-name">
{{ displayName || selectedIdentity?.nick || 'Unknown' }}

View File

@@ -3,6 +3,34 @@
display: flex;
flex-direction: column;
.sam-text-header {
position: relative;
.edit-btn {
position: absolute;
right: var(--size);
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.edit-icon {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
}
}
.identity-container {
flex: 1;
display: flex;
@@ -123,6 +151,7 @@
}
.nip05-row {
@extend %text-badge;
display: flex;
flex-direction: row;
align-items: center;
@@ -134,7 +163,6 @@
}
.nip05-badge {
@extend %text-badge;
font-size: 13px;
color: var(--primary);
}

View File

@@ -9,8 +9,8 @@ import {
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
} from '@common';
import NDK from '@nostr-dev-kit/ndk';
@Component({
selector: 'app-identity',
@@ -67,6 +67,13 @@ export class IdentityComponent implements OnInit {
);
}
onClickEditProfile() {
if (!this.selectedIdentity) {
return;
}
this.#router.navigateByUrl('/profile-edit');
}
async #loadData() {
try {
const selectedIdentityId =
@@ -125,28 +132,18 @@ export class IdentityComponent implements OnInit {
try {
this.validating = true;
// Get relays for validation
const relays =
this.#storage
.getBrowserSessionHandler()
.browserSessionData?.relays.filter(
(x) => x.identityId === this.selectedIdentity?.id
) ?? [];
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
if (relevantRelays.length > 0) {
const ndk = new NDK({
explicitRelayUrls: relevantRelays,
});
await ndk.connect();
const user = ndk.getUser({ pubkey });
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
this.nip05isValidated = false;
this.validating = false;
}
}

View File

@@ -0,0 +1,148 @@
<div class="sam-text-header">
<span>Edit Profile</span>
</div>
@if(loading) {
<div class="loading-container">
<span class="sam-text-muted">Loading profile...</span>
</div>
} @else {
<div class="content">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
type="text"
placeholder="Your name"
class="form-control"
[(ngModel)]="profile.name"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="display_name">Display Name</label>
<input
id="display_name"
type="text"
placeholder="Display name"
class="form-control"
[(ngModel)]="profile.display_name"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="picture">Avatar URL</label>
<input
id="picture"
type="url"
placeholder="https://example.com/avatar.jpg"
class="form-control"
[(ngModel)]="profile.picture"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="banner">Banner URL</label>
<input
id="banner"
type="url"
placeholder="https://example.com/banner.jpg"
class="form-control"
[(ngModel)]="profile.banner"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="website">Website</label>
<input
id="website"
type="url"
placeholder="https://yourwebsite.com"
class="form-control"
[(ngModel)]="profile.website"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="about">About</label>
<textarea
id="about"
placeholder="Tell us about yourself..."
class="form-control"
rows="4"
[(ngModel)]="profile.about"
></textarea>
</div>
<div class="form-group">
<label for="nip05">NIP-05 Identifier</label>
<input
id="nip05"
type="text"
placeholder="you@example.com"
class="form-control"
[(ngModel)]="profile.nip05"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="lud16">Lightning Address (LUD-16)</label>
<input
id="lud16"
type="text"
placeholder="you@getalby.com"
class="form-control"
[(ngModel)]="profile.lud16"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="lnurl">LNURL</label>
<input
id="lnurl"
type="text"
placeholder="lnurl1..."
class="form-control"
[(ngModel)]="profile.lnurl"
autocomplete="off"
/>
</div>
</div>
<div class="sam-footer-grid-2">
<button type="button" class="btn btn-secondary" (click)="onClickCancel()">
Cancel
</button>
<button
[disabled]="saving"
type="button"
class="btn btn-primary"
(click)="onClickSave()"
>
@if(saving) {
Saving...
} @else {
Save
}
</button>
</div>
@if(alertMessage) {
<div class="alert-container">
<div class="alert alert-danger sam-flex-row gap" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ alertMessage }}</span>
</div>
</div>
}
}
<lib-toast #toast></lib-toast>

View File

@@ -0,0 +1,69 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.content {
padding-left: var(--size);
padding-right: var(--size);
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
padding-bottom: var(--size);
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
font-weight: 500;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-control {
font-size: 14px;
background: var(--background-light);
border: 1px solid var(--border);
color: var(--foreground);
border-radius: var(--radius);
padding: 8px 12px;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
&::placeholder {
color: var(--muted-foreground);
opacity: 0.6;
}
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
}
.alert-container {
position: absolute;
bottom: 70px;
left: var(--size);
right: var(--size);
}
}

View File

@@ -0,0 +1,326 @@
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
FALLBACK_PROFILE_RELAYS,
NavComponent,
NostrHelper,
ProfileMetadataService,
RelayListService,
StorageService,
ToastComponent,
publishToRelaysWithAuth,
} from '@common';
import { SimplePool } from 'nostr-tools/pool';
import { finalizeEvent } from 'nostr-tools';
import { hexToBytes } from '@noble/hashes/utils';
interface ProfileFormData {
name: string;
display_name: string;
picture: string;
banner: string;
website: string;
about: string;
nip05: string;
lud16: string;
lnurl: string;
}
@Component({
selector: 'app-profile-edit',
templateUrl: './profile-edit.component.html',
styleUrl: './profile-edit.component.scss',
imports: [FormsModule, ToastComponent],
})
export class ProfileEditComponent extends NavComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #relayList = inject(RelayListService);
profile: ProfileFormData = {
name: '',
display_name: '',
picture: '',
banner: '',
website: '',
about: '',
nip05: '',
lud16: '',
lnurl: '',
};
// Store original event content to preserve extra fields
#originalContent: Record<string, unknown> = {};
#originalTags: string[][] = [];
loading = true;
saving = false;
alertMessage: string | undefined;
#privkey: string | undefined;
#pubkey: string | undefined;
async ngOnInit() {
await this.#loadProfile();
}
async #loadProfile() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId
);
if (!identity) {
this.loading = false;
return;
}
this.#privkey = identity.privkey;
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
// Initialize services
await this.#profileMetadata.initialize();
// Try to get cached profile first
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
if (cachedProfile) {
this.profile = {
name: cachedProfile.name || '',
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
picture: cachedProfile.picture || '',
banner: cachedProfile.banner || '',
website: cachedProfile.website || '',
about: cachedProfile.about || '',
nip05: cachedProfile.nip05 || '',
lud16: cachedProfile.lud16 || '',
lnurl: cachedProfile.lud06 || '',
};
}
// Fetch the actual kind 0 event to get original content and tags
await this.#fetchOriginalEvent();
this.loading = false;
} catch (error) {
console.error('Failed to load profile:', error);
this.loading = false;
}
}
async #fetchOriginalEvent() {
if (!this.#pubkey) return;
const pool = new SimplePool();
try {
const events = await this.#queryWithTimeout(
pool,
FALLBACK_PROFILE_RELAYS,
[{ kinds: [0], authors: [this.#pubkey] }],
10000
);
if (events.length > 0) {
// Get the most recent event
const latestEvent = events.reduce((latest, event) =>
event.created_at > latest.created_at ? event : latest
);
// Store original tags (excluding the ones we'll update)
this.#originalTags = latestEvent.tags.filter(
(tag: string[]) =>
tag[0] !== 'name' &&
tag[0] !== 'display_name' &&
tag[0] !== 'picture' &&
tag[0] !== 'banner' &&
tag[0] !== 'website' &&
tag[0] !== 'about' &&
tag[0] !== 'nip05' &&
tag[0] !== 'lud16' &&
tag[0] !== 'client'
);
// Parse and store original content
try {
this.#originalContent = JSON.parse(latestEvent.content);
// Update form with values from event content
this.profile = {
name: (this.#originalContent['name'] as string) || '',
display_name:
(this.#originalContent['display_name'] as string) ||
(this.#originalContent['displayName'] as string) ||
'',
picture: (this.#originalContent['picture'] as string) || '',
banner: (this.#originalContent['banner'] as string) || '',
website: (this.#originalContent['website'] as string) || '',
about: (this.#originalContent['about'] as string) || '',
nip05: (this.#originalContent['nip05'] as string) || '',
lud16: (this.#originalContent['lud16'] as string) || '',
lnurl: (this.#originalContent['lnurl'] as string) || '',
};
} catch {
console.error('Failed to parse profile content');
}
}
} finally {
pool.close(FALLBACK_PROFILE_RELAYS);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
return new Promise((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const events: any[] = [];
let settled = false;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
resolve(events);
}
}, timeoutMs);
const sub = pool.subscribeMany(relays, filters, {
onevent(event) {
events.push(event);
},
oneose() {
if (!settled) {
settled = true;
clearTimeout(timeout);
sub.close();
resolve(events);
}
},
});
});
}
async onClickSave() {
if (this.saving || !this.#privkey || !this.#pubkey) return;
this.saving = true;
this.alertMessage = undefined;
try {
// Build the content JSON, preserving extra fields
const content: Record<string, unknown> = { ...this.#originalContent };
// Update with form values
content['name'] = this.profile.name;
content['display_name'] = this.profile.display_name;
content['displayName'] = this.profile.display_name; // Some clients use this
content['picture'] = this.profile.picture;
content['banner'] = this.profile.banner;
content['website'] = this.profile.website;
content['about'] = this.profile.about;
content['nip05'] = this.profile.nip05;
content['lud16'] = this.profile.lud16;
if (this.profile.lnurl) {
content['lnurl'] = this.profile.lnurl;
}
content['pubkey'] = this.#pubkey;
// Build tags array, preserving extra tags
const tags: string[][] = [...this.#originalTags];
// Add standard tags
if (this.profile.name) tags.push(['name', this.profile.name]);
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
if (this.profile.website) tags.push(['website', this.profile.website]);
if (this.profile.about) tags.push(['about', this.profile.about]);
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
// Add alt tag if not present
if (!tags.some(t => t[0] === 'alt')) {
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
}
// Always add client tag
tags.push(['client', 'plebeian-signer']);
// Create the unsigned event
const unsignedEvent = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(content),
};
// Sign the event
const privkeyBytes = hexToBytes(this.#privkey);
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
// Get write relays from NIP-65 or use fallback
await this.#relayList.initialize();
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
let relayUrls: string[];
if (writeRelays.length > 0) {
// Filter to write relays only
relayUrls = writeRelays
.filter(r => r.write)
.map(r => r.url);
// If no write relays found, use all relays
if (relayUrls.length === 0) {
relayUrls = writeRelays.map(r => r.url);
}
} else {
// Use fallback relays
relayUrls = FALLBACK_PROFILE_RELAYS;
}
// Publish to relays with NIP-42 authentication support
const results = await publishToRelaysWithAuth(
relayUrls,
signedEvent,
this.#privkey
);
// Count successes
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
if (failures.length > 0) {
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
}
if (successes.length === 0) {
throw new Error('Failed to publish to any relay');
}
console.log(`Profile published to ${successes.length}/${results.length} relays`);
// Clear cached profile and refetch
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
await this.#profileMetadata.fetchProfile(this.#pubkey);
// Navigate back to identity page
this.#router.navigateByUrl('/home/identity');
} catch (error) {
console.error('Failed to save profile:', error);
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
setTimeout(() => {
this.alertMessage = undefined;
}, 4500);
} finally {
this.saving = false;
}
}
onClickCancel() {
this.#router.navigateByUrl('/home/identity');
}
}

View File

@@ -1,3 +1,5 @@
<app-deriving-modal #derivingModal></app-deriving-modal>
<div class="sam-text-header">
<span>Plebeian Signer</span>
</div>

View File

@@ -1,15 +1,17 @@
import { Component, inject } from '@angular/core';
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService } from '@common';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
imports: [FormsModule],
imports: [FormsModule, DerivingModalComponent],
templateUrl: './new.component.html',
styleUrl: './new.component.scss',
})
export class NewComponent extends NavComponent {
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
password = '';
readonly #router = inject(Router);
@@ -28,7 +30,15 @@ export class NewComponent extends NavComponent {
return;
}
await this.#storage.createNewVault(this.password);
this.#router.navigateByUrl('/home/identities');
// Show deriving modal during key derivation (~3-6 seconds)
this.derivingModal.show('Creating secure vault');
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();
console.error('Failed to create vault:', error);
}
}
}

View File

@@ -1,3 +1,5 @@
<app-deriving-modal #derivingModal></app-deriving-modal>
<div class="sam-text-header">
<span class="brand">Plebeian Signer</span>
</div>

View File

@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -14,10 +15,11 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
selector: 'app-vault-login',
templateUrl: './vault-login.component.html',
styleUrl: './vault-login.component.scss',
imports: [FormsModule, ConfirmComponent],
imports: [FormsModule, ConfirmComponent, DerivingModalComponent],
})
export class VaultLoginComponent implements AfterViewInit {
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
loginPassword = '';
showInvalidPasswordAlert = false;
@@ -40,24 +42,38 @@ export class VaultLoginComponent implements AfterViewInit {
}
async loginVault() {
console.log('[login] loginVault called');
if (!this.loginPassword) {
console.log('[login] No password, returning');
return;
}
console.log('[login] Showing deriving modal');
// Show deriving modal during key derivation (~3-6 seconds)
this.derivingModal.show('Unlocking vault');
try {
console.log('[login] Calling unlockVault...');
await this.#storage.unlockVault(this.loginPassword);
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
this.#router.navigateByUrl('/home/identity');
console.log('[login] unlockVault succeeded!');
} catch (error) {
console.error('[login] unlockVault FAILED:', error);
this.derivingModal.hide();
this.showInvalidPasswordAlert = true;
console.log(error);
window.setTimeout(() => {
this.showInvalidPasswordAlert = false;
}, 2000);
return;
}
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
this.#router.navigateByUrl('/home/identity');
}
/**

View File

@@ -2,6 +2,13 @@
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
// Extend Window interface for NIP-07
declare global {
interface Window {
nostr?: any;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable

View File

@@ -101,3 +101,30 @@ button {
border-color: var(--border);
color: var(--foreground);
}
// Bootstrap modal overrides - always use dark theme for modals
.modal-content {
background-color: #1a1a1a;
border-color: #3d3d3d;
color: #fafafa;
}
.modal-header {
border-bottom-color: #3d3d3d;
.modal-title {
color: #fafafa;
}
.btn-close {
filter: invert(1);
}
}
.modal-footer {
border-top-color: #3d3d3d;
}
.modal-body {
color: #fafafa;
}