2 Commits

Author SHA1 Message Date
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
ddb74c61b2 Release v0.0.9 - Add reckless mode with whitelisted apps
- Add reckless mode checkbox to auto-approve signing requests
- Implement whitelisted apps management page
- Reckless mode logic: allow all if whitelist empty, otherwise only whitelisted hosts
- Add shouldRecklessModeApprove() in background service worker
- Update default avatar to Plebeian Market Account icon
- Fix manifest version scripts to strip v prefix for browsers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:56:45 +01:00
68 changed files with 3488 additions and 239 deletions

View File

@@ -1,6 +1,7 @@
#!/bin/bash
version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json

View File

@@ -1,6 +1,7 @@
#!/bin/bash
version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"')
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebian-signer",
"version": "0.0.4",
"name": "plebeian-signer",
"version": "v0.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebian-signer",
"version": "0.0.4",
"name": "plebeian-signer",
"version": "v0.0.9",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
@@ -21,6 +21,7 @@
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
@@ -12320,6 +12321,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash-wasm": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz",
"integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==",
"license": "MIT"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v0.0.8",
"version": "v1.0.0",
"custom": {
"chrome": {
"version": "v0.0.8"
"version": "v1.0.0"
},
"firefox": {
"version": "v0.0.8"
"version": "v1.0.0"
}
},
"scripts": {
@@ -40,6 +40,7 @@
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -2,13 +2,16 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "v0.0.8",
"version": "1.0.0",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [
"windows",
"storage"
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": {
"default_popup": "index.html",
"default_icon": {

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -16,6 +16,8 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.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 = [
{
@@ -70,6 +72,14 @@ export const routes: Routes = [
path: 'new-identity',
component: NewIdentityComponent,
},
{
path: 'whitelisted-apps',
component: WhitelistedAppsComponent,
},
{
path: 'profile-edit',
component: ProfileEditComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,

View File

@@ -11,6 +11,30 @@
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
</button>
</div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
@let identities = sessionData?.identities ?? [];
@@ -34,7 +58,7 @@
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
(error)="$any($event.target).src = 'assets/person-fill.svg'"
(error)="$any($event.target).src = 'person-fill.svg'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button

View File

@@ -28,13 +28,66 @@
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;

View File

@@ -24,6 +24,10 @@ export class IdentitiesComponent implements OnInit {
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit {
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'assets/person-fill.svg';
return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
@@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit {
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
async onToggleRecklessMode() {
const newValue = !this.isRecklessMode;
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
}
onClickWhitelistedApps() {
this.#router.navigateByUrl('/whitelisted-apps');
}
}

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

@@ -0,0 +1,45 @@
<div class="custom-header">
<button class="back-btn" (click)="onClickBack()">
<i class="bi bi-chevron-left"></i>
</button>
<span class="text">Whitelisted Apps</span>
</div>
<div class="content">
<button
class="btn btn-primary whitelist-btn"
(click)="onClickWhitelistCurrentTab()"
>
<i class="bi bi-plus-lg"></i>
<span>Whitelist current tab</span>
</button>
<div class="hosts-list">
@if (whitelistedHosts.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">No whitelisted apps yet</span>
</div>
@if (isRecklessMode) {
<div class="warning-note">
<span>&#9888; All sites will be auto-approved without prompting</span>
</div>
}
}
@for (host of whitelistedHosts; track host) {
<div class="host-item">
<span class="host-name">{{ host }}</span>
<button
class="remove-btn"
title="Remove from whitelist"
(click)="onClickRemoveHost(host)"
>
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
</div>
<lib-toast #toast></lib-toast>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,134 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.custom-header {
padding: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
position: sticky;
top: 0;
z-index: 10;
.back-btn {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: start;
background: transparent;
border: none;
color: var(--foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
z-index: 1;
&:hover {
background: var(--background-light);
}
i {
font-size: 20px;
}
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-family: var(--font-heading);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.05rem;
justify-self: center;
}
}
.content {
padding: 0 var(--size) var(--size) var(--size);
flex-grow: 1;
display: flex;
flex-direction: column;
.whitelist-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--size);
}
.hosts-list {
flex-grow: 1;
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
.warning-note {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 8px;
text-align: center;
span {
font-size: 13px;
color: #ffc107;
}
}
.host-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
.host-name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--destructive);
background: var(--background-light-hover);
}
i {
font-size: 14px;
}
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
import { Component, inject, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
NavComponent,
StorageService,
ToastComponent,
} from '@common';
@Component({
selector: 'app-whitelisted-apps',
templateUrl: './whitelisted-apps.component.html',
styleUrl: './whitelisted-apps.component.scss',
imports: [ToastComponent, ConfirmComponent],
})
export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {
return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
}
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async onClickWhitelistCurrentTab() {
try {
// Get current active tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs.length === 0 || !tabs[0].url) {
this.toast.show('No active tab found');
return;
}
const url = new URL(tabs[0].url);
const host = url.host;
if (!host) {
this.toast.show('Cannot get host from current tab');
return;
}
// Check if already whitelisted
if (this.whitelistedHosts.includes(host)) {
this.toast.show(`${host} is already whitelisted`);
return;
}
await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
this.toast.show(`Added ${host} to whitelist`);
} catch (error) {
console.error('Error getting current tab:', error);
this.toast.show('Error getting current tab');
}
}
onClickRemoveHost(host: string) {
this.confirm.show(`Remove ${host} from whitelist?`, async () => {
await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
this.toast.show(`Removed ${host} from whitelist`);
});
}
onClickBack() {
this.#router.navigateByUrl('/home/identities');
}
}

View File

@@ -48,6 +48,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
const signerMetaHandler = new ChromeMetaHandler();
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
};
/**
* Check if reckless mode should auto-approve the request.
* Returns true if should auto-approve, false if should use normal permission flow.
*
* Logic:
* - If reckless mode is OFF → return false (use normal flow)
* - If reckless mode is ON and whitelist is empty → return true (approve all)
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
*/
export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
if (!signerMetaData.recklessMode) {
return false;
}
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
if (whitelistedHosts.length === 0) {
// Reckless mode ON, no whitelist → approve all
return true;
}
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
return whitelistedHosts.includes(host);
};
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {

View File

@@ -12,6 +12,7 @@ import {
nip44Encrypt,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
} from './background-common';
@@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const req = request as BackgroundRequestMessage;
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === false) {
throw new Error('Permission denied');
}
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
// Normal permission flow
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
if (permissionState === false) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
}
const relays: Relays = {};

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

View File

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

View File

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

View File

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

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

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

View 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;
}

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

@@ -8,7 +8,7 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
@@ -40,4 +40,53 @@ export abstract class SignerMetaHandler {
}
abstract clearData(keep: string[]): Promise<void>;
/**
* Sets the reckless mode and immediately saves it.
*/
async setRecklessMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
recklessMode: enabled,
};
} else {
this.#signerMetaData.recklessMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Adds a host to the whitelist and immediately saves it.
*/
async addWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
whitelistedHosts: [host],
};
} else {
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
if (!hosts.includes(host)) {
hosts.push(host);
this.#signerMetaData.whitelistedHosts = hosts;
}
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Removes a host from the whitelist and immediately saves it.
*/
async removeWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData?.whitelistedHosts) {
return;
}
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
(h) => h !== host
);
await this.saveFullData(this.#signerMetaData);
}
}

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;
@@ -92,6 +98,12 @@ export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
// Reckless mode: auto-approve all actions without prompting
recklessMode?: boolean;
// Whitelisted hosts: auto-approve all actions from these hosts
whitelistedHosts?: string[];
}
/**

View File

@@ -10,9 +10,12 @@ export * from './lib/constants/fallback-relays';
// Helpers
export * from './lib/helpers/crypto-helper';
export * from './lib/helpers/argon2-crypto';
export * from './lib/helpers/nostr-helper';
export * from './lib/helpers/text-helper';
export * from './lib/helpers/date-helper';
export * from './lib/helpers/websocket-auth';
export * from './lib/helpers/nip05-validator';
// Models
export * from './lib/models/nostr';
@@ -35,6 +38,7 @@ export * from './lib/components/toast/toast.component';
export * from './lib/components/nav-item/nav-item.component';
export * from './lib/components/pubkey/pubkey.component';
export * from './lib/components/relay-rw/relay-rw.component';
export * from './lib/components/deriving-modal/deriving-modal.component';
// Pipes
export * from './lib/pipes/visual-relay.pipe';

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -2,12 +2,15 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "v0.0.8",
"version": "1.0.0",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [
"storage"
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": {
"default_popup": "index.html",
"default_icon": {

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -16,6 +16,8 @@ import { WelcomeComponent } from './components/welcome/welcome.component';
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
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 = [
{
@@ -70,6 +72,14 @@ export const routes: Routes = [
path: 'new-identity',
component: NewIdentityComponent,
},
{
path: 'whitelisted-apps',
component: WhitelistedAppsComponent,
},
{
path: 'profile-edit',
component: ProfileEditComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,

View File

@@ -11,6 +11,30 @@
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
</button>
</div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
@let identities = sessionData?.identities ?? [];
@@ -34,7 +58,7 @@
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
(error)="$any($event.target).src = 'assets/person-fill.svg'"
(error)="$any($event.target).src = 'person-fill.svg'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button

View File

@@ -28,13 +28,66 @@
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;

View File

@@ -24,6 +24,10 @@ export class IdentitiesComponent implements OnInit {
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit {
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'assets/person-fill.svg';
return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
@@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit {
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
async onToggleRecklessMode() {
const newValue = !this.isRecklessMode;
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
}
onClickWhitelistedApps() {
this.#router.navigateByUrl('/whitelisted-apps');
}
}

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

@@ -0,0 +1,45 @@
<div class="custom-header">
<button class="back-btn" (click)="onClickBack()">
<i class="bi bi-chevron-left"></i>
</button>
<span class="text">Whitelisted Apps</span>
</div>
<div class="content">
<button
class="btn btn-primary whitelist-btn"
(click)="onClickWhitelistCurrentTab()"
>
<i class="bi bi-plus-lg"></i>
<span>Whitelist current tab</span>
</button>
<div class="hosts-list">
@if (whitelistedHosts.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">No whitelisted apps yet</span>
</div>
@if (isRecklessMode) {
<div class="warning-note">
<span>&#9888; All sites will be auto-approved without prompting</span>
</div>
}
}
@for (host of whitelistedHosts; track host) {
<div class="host-item">
<span class="host-name">{{ host }}</span>
<button
class="remove-btn"
title="Remove from whitelist"
(click)="onClickRemoveHost(host)"
>
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
</div>
<lib-toast #toast></lib-toast>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,134 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.custom-header {
padding: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
position: sticky;
top: 0;
z-index: 10;
.back-btn {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: start;
background: transparent;
border: none;
color: var(--foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
z-index: 1;
&:hover {
background: var(--background-light);
}
i {
font-size: 20px;
}
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-family: var(--font-heading);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.05rem;
justify-self: center;
}
}
.content {
padding: 0 var(--size) var(--size) var(--size);
flex-grow: 1;
display: flex;
flex-direction: column;
.whitelist-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--size);
}
.hosts-list {
flex-grow: 1;
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
.warning-note {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 8px;
text-align: center;
span {
font-size: 13px;
color: #ffc107;
}
}
.host-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
.host-name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--destructive);
background: var(--background-light-hover);
}
i {
font-size: 14px;
}
}
}
}
}
}

View File

@@ -0,0 +1,73 @@
import { Component, inject, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
NavComponent,
StorageService,
ToastComponent,
} from '@common';
import browser from 'webextension-polyfill';
@Component({
selector: 'app-whitelisted-apps',
templateUrl: './whitelisted-apps.component.html',
styleUrl: './whitelisted-apps.component.scss',
imports: [ToastComponent, ConfirmComponent],
})
export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {
return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
}
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async onClickWhitelistCurrentTab() {
try {
// Get current active tab
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (tabs.length === 0 || !tabs[0].url) {
this.toast.show('No active tab found');
return;
}
const url = new URL(tabs[0].url);
const host = url.host;
if (!host) {
this.toast.show('Cannot get host from current tab');
return;
}
// Check if already whitelisted
if (this.whitelistedHosts.includes(host)) {
this.toast.show(`${host} is already whitelisted`);
return;
}
await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
this.toast.show(`Added ${host} to whitelist`);
} catch (error) {
console.error('Error getting current tab:', error);
this.toast.show('Error getting current tab');
}
}
onClickRemoveHost(host: string) {
this.confirm.show(`Remove ${host} from whitelist?`, async () => {
await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
this.toast.show(`Removed ${host} from whitelist`);
});
}
onClickBack() {
this.#router.navigateByUrl('/home/identities');
}
}

View File

@@ -49,6 +49,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as unknown as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
const signerMetaHandler = new FirefoxMetaHandler();
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
};
/**
* Check if reckless mode should auto-approve the request.
* Returns true if should auto-approve, false if should use normal permission flow.
*
* Logic:
* - If reckless mode is OFF → return false (use normal flow)
* - If reckless mode is ON and whitelist is empty → return true (approve all)
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
*/
export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
if (!signerMetaData.recklessMode) {
return false;
}
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
if (whitelistedHosts.length === 0) {
// Reckless mode ON, no whitelist → approve all
return true;
}
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
return whitelistedHosts.includes(host);
};
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {

View File

@@ -12,6 +12,7 @@ import {
nip44Encrypt,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
} from './background-common';
@@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const req = request as BackgroundRequestMessage;
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === false) {
throw new Error('Permission denied');
}
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
// Normal permission flow
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
if (permissionState === false) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
}
const relays: Relays = {};

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

Binary file not shown.

Binary file not shown.