Files
plebeian-signer/projects/firefox/src/app/components/profile-edit/profile-edit.component.ts
mleku ebe2b695cc 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>
2025-12-19 12:30:10 +01:00

327 lines
10 KiB
TypeScript

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');
}
}