5 Commits

Author SHA1 Message Date
b7bedf085a Release v1.0.5 - Update release command to clean old zips
- Updated /release command to delete old zip files before creating new ones
- Ensures releases/ folder only contains the latest version

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:50:17 +01:00
ff82e41012 Update release command to delete old zips before creating new ones
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:44:56 +01:00
45b1fb58e9 Release v1.0.4 - Add logging system, lock button, and emoji navigation
- Comprehensive logging system with chrome.storage.session persistence
- NIP-07 action logging in background scripts with standalone functions
- Vault operation logging (unlock, lock, create, reset, import, export)
- Profile and bookmark operation logging
- Logs page with refresh functionality and category icons
- Lock button (🔒) in navigation bar to quickly lock vault
- Reduced nav bar size (40px height, 16px font) with emoji icons
- Reordered navigation: You, Permissions, Bookmarks, Logs, About, Lock
- Bookmarks functionality for saving frequently used Nostr apps
- Fixed lock/unlock flow by properly clearing in-memory session data

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:42:19 +01:00
b535a7b967 Release v1.0.3 - Add zip file creation to release process
- Update /release command to create zip files in releases/ folder
- Add v1.0.3 zip files for both Chrome and Firefox extensions

Files modified:
- .claude/commands/release.md
- releases/plebeian-signer-chrome-v1.0.3.zip (new)
- releases/plebeian-signer-firefox-v1.0.3.zip (new)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:08:34 +01:00
abd4a21f8f Release v1.0.2 - Fix Buffer polyfill race condition in prompt
- Fix race condition where permission prompts failed on first request
  due to Buffer polyfill not being initialized during module evaluation
- Replace Buffer.from() with native browser APIs (atob + TextDecoder)
  in prompt.ts for reliable base64 decoding
- Add debug logging to reckless mode approval checks
- Update permission encryption to support v2 vault key format
- Enhance LoggerService with warn/error/debug methods and log storage
- Add logs component for viewing extension activity
- Simplify deriving modal component
- Rename icon files from gooti to plebian-signer
- Update permissions component with improved styling

Files modified:
- projects/chrome/src/prompt.ts
- projects/firefox/src/prompt.ts
- projects/*/src/background-common.ts
- projects/common/src/lib/services/logger/logger.service.ts
- projects/*/src/app/components/home/logs/ (new)
- projects/*/public/*.svg, *.png (renamed)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 08:52:44 +01:00
70 changed files with 1796 additions and 234 deletions

View File

@@ -42,25 +42,34 @@ This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`)
```
If any step fails, fix issues before proceeding.
6. **Compose a commit message** following this format:
6. **Create release zip files** in the `releases/` folder:
```
mkdir -p releases
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
```
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
7. **Compose a commit message** following this format:
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")
- Blank line
- Bullet points describing each significant change
- "Files modified:" section listing affected files
- Footer with Claude Code attribution
7. **Stage all changes** with `git add -A`
8. **Stage all changes** with `git add -A`
8. **Create the commit** with the composed message
9. **Create the commit** with the composed message
9. **Create a git tag** matching the version (e.g., `v0.0.8`)
10. **Create a git tag** matching the version (e.g., `v0.0.8`)
10. **Push to origin** with tags:
11. **Push to origin** with tags:
```
git push origin main --tags
```
11. **Report completion** with the new version and commit hash
12. **Report completion** with the new version and commit hash
## Important:
- This is a browser extension with separate Chrome and Firefox builds

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.1",
"version": "v1.0.5",
"custom": {
"chrome": {
"version": "v1.0.1"
"version": "v1.0.5"
},
"firefox": {
"version": "v1.0.1"
"version": "v1.0.5"
}
},
"scripts": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "1.0.1",
"version": "1.0.5",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -226,7 +226,7 @@
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectButton" type="button" class="btn btn-secondary">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
@@ -239,16 +239,16 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectJustOnceButton" class="dropdown-item">
just once
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
</div>
<div class="btn-group">
<button id="approveButton" type="button" class="btn btn-primary">
Approve
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
@@ -260,8 +260,8 @@
</button>
<ul class="dropdown-menu">
<li>
<button id="approveJustOnceButton" class="dropdown-item" href="#">
just once
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>

View File

@@ -9,6 +9,8 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
import { SettingsComponent } from './components/home/settings/settings.component';
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
@@ -66,6 +68,14 @@ export const routes: Routes = [
path: 'settings',
component: SettingsComponent,
},
{
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
],
},
{

View File

@@ -7,7 +7,12 @@
<span class="text-muted" style="font-size: 12px">
Nothing configured so far.
</span>
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
} @else {
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
Remove All Permissions
</button>
}
@for(hostPermissions of hostsPermissions; track hostPermissions) {
<div class="permissions-card">
<span style="margin-bottom: 4px; font-weight: 500">
{{ hostPermissions.host }}

View File

@@ -17,6 +17,10 @@
top: 0;
}
.remove-all-btn {
margin-bottom: var(--size);
}
.permissions-card {
background: var(--background-light);
border-radius: 8px;

View File

@@ -41,6 +41,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
this.#buildHostsPermissions(this.identity?.id);
}
async onClickRemoveAllPermissions() {
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
for (const permission of allPermissions) {
await this.#storage.deletePermission(permission.id);
}
this.#buildHostsPermissions(this.identity?.id);
}
#initialize(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -28,7 +28,7 @@
</div>
<div class="info-banner">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>

View File

@@ -0,0 +1,36 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="bookmarks-header">
<span class="bookmarks-title">Bookmarks</span>
<button class="btn btn-primary btn-sm" (click)="onBookmarkThisPage()">
<span class="emoji">🔖</span> Bookmark This Page
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,92 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
overflow: hidden;
}
.bookmarks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
.bookmarks-title {
font-weight: 600;
font-size: 1.1rem;
}
.bookmarks-container {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,90 @@
import { Component, inject, OnInit } from '@angular/core';
import { Bookmark, LoggerService, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
bookmarks: Bookmark[] = [];
isLoading = true;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
chrome.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
}

View File

@@ -9,7 +9,7 @@
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<span class="emoji">👤</span>
</a>
<a
@@ -18,7 +18,7 @@
routerLinkActive="active"
title="Identities"
>
<i class="bi bi-people-fill"></i>
<span class="emoji">👥</span>
</a>
<a
@@ -27,10 +27,22 @@
routerLinkActive="active"
title="Settings"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</a>
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
<span class="emoji">🔖</span>
</a>
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
<span class="emoji">🪵</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
</a>
<button class="tab" (click)="onClickLock()" title="Lock">
<span class="emoji">🔒</span>
</button>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 40px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 40px;
min-height: 40px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,10 +23,10 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-size: 16px;
color: var(--muted-foreground);
border-top: 3px solid transparent;
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
@@ -38,7 +38,7 @@
&.active {
color: var(--foreground);
border-top: 3px solid var(--primary);
border-top: 2px solid var(--primary);
}
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { RouterModule, RouterOutlet } from '@angular/router';
import { Component, inject } from '@angular/core';
import { Router, RouterModule, RouterOutlet } from '@angular/router';
import { LoggerService, StorageService } from '@common';
@Component({
selector: 'app-home',
@@ -7,4 +8,14 @@ import { RouterModule, RouterOutlet } from '@angular/router';
styleUrl: './home.component.scss',
imports: [RouterOutlet, RouterModule],
})
export class HomeComponent {}
export class HomeComponent {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #logger = inject(LoggerService);
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -31,7 +31,7 @@
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</button>
</div>
@@ -62,7 +62,7 @@
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="gear"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>

View File

@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#loadData();
@@ -136,13 +138,16 @@ export class IdentityComponent implements OnInit {
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}

View File

@@ -0,0 +1,20 @@
<div class="logs-header">
<span class="logs-title">Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
}
</div>

View File

@@ -0,0 +1,88 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
.logs-actions {
display: flex;
gap: 8px;
}
.logs-title {
font-weight: 600;
font-size: 1.1rem;
}
.logs-container {
flex: 1;
overflow-y: auto;
background: var(--background-light);
border-radius: 8px;
padding: var(--size-h);
}
.logs-empty {
color: var(--muted-foreground);
text-align: center;
padding: var(--size);
}
.log-entry {
font-family: var(--font-sans);
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
gap: 8px;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
color: #ff6b6b;
}
&.log-warn {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
&.log-debug {
background: rgba(108, 117, 125, 0.15);
color: #adb5bd;
}
&.log-info {
background: rgba(13, 110, 253, 0.1);
color: var(--foreground);
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}

View File

@@ -0,0 +1,43 @@
import { Component, inject, OnInit } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
readonly #logger = inject(LoggerService);
get logs(): LogEntry[] {
return this.#logger.logs;
}
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
return 'log-info';
}
}
}

View File

@@ -4,6 +4,7 @@ import {
BrowserSyncFlow,
ConfirmComponent,
DateHelper,
LoggerService,
NavComponent,
StartupService,
StorageService,
@@ -21,6 +22,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
const vault = JSON.stringify(
@@ -44,6 +46,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
async onResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -69,6 +72,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
await this.#storage.deleteVault(true);
await this.#storage.importVault(vault);
this.#logger.logVaultImport(file.name);
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -84,6 +88,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
const fileName = `Plebeian Signer Chrome - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
this.#logger.logVaultExport(fileName);
}
#downloadJson(jsonString: string, fileName: string) {

View File

@@ -1,7 +1,7 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
@@ -16,6 +16,7 @@ export class NewComponent extends NavComponent {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
@@ -35,6 +36,7 @@ export class NewComponent extends NavComponent {
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();

View File

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
LoggerService,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -28,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
@@ -69,6 +71,7 @@ export class VaultLoginComponent implements AfterViewInit {
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
this.#logger.logVaultUnlock();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
@@ -102,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
async onClickResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {

View File

@@ -14,6 +14,7 @@ import {
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -66,6 +67,8 @@ export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
if (!signerMetaData.recklessMode) {
return false;
@@ -223,8 +226,7 @@ export const storePermission = async function (
// Encrypt permission to store in sync storage (depending on sync flow).
const encryptedPermission = await encryptPermission(
permission,
browserSessionData.iv,
browserSessionData.vaultPassword as string
browserSessionData
);
await savePermissionsToBrowserSyncStorage([
@@ -321,22 +323,20 @@ export const nip44Decrypt = async function (
const encryptPermission = async function (
permission: Permission_DECRYPTED,
iv: string,
password: string
sessionData: BrowserSessionData
): Promise<Permission_ENCRYPTED> {
const encryptedPermission: Permission_ENCRYPTED = {
id: await encrypt(permission.id, iv, password),
identityId: await encrypt(permission.identityId, iv, password),
host: await encrypt(permission.host, iv, password),
method: await encrypt(permission.method, iv, password),
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
id: await encrypt(permission.id, sessionData),
identityId: await encrypt(permission.identityId, sessionData),
host: await encrypt(permission.host, sessionData),
method: await encrypt(permission.method, sessionData),
methodPolicy: await encrypt(permission.methodPolicy, sessionData),
};
if (typeof permission.kind !== 'undefined') {
encryptedPermission.kind = await encrypt(
permission.kind.toString(),
iv,
password
sessionData
);
}
@@ -345,8 +345,30 @@ const encryptPermission = async function (
const encrypt = async function (
value: string,
iv: string,
password: string
sessionData: BrowserSessionData
): Promise<string> {
return await CryptoHelper.encrypt(value, iv, password);
// v2: Use pre-derived key with AES-GCM directly
if (sessionData.vaultKey) {
const keyBytes = Buffer.from(sessionData.vaultKey, 'base64');
const iv = Buffer.from(sessionData.iv, '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(value)
);
return Buffer.from(cipherText).toString('base64');
}
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
@@ -67,6 +71,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
@@ -78,6 +83,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
req.method,
req.params
);
debug(`permissionState result: ${permissionState}`);
if (permissionState === false) {
throw new Error('Permission denied');
@@ -107,17 +113,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
debug(response);
if (response === 'approve' || response === 'reject') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
req.host,
req.method,
policy,
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -126,46 +143,71 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
let result: any;
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return result;
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
result = signEvent(req.params, currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
kind: req.params?.kind,
});
return result;
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
result = await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.encrypt':
return await nip44Encrypt(
result = await nip44Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip04.decrypt':
return await nip04Decrypt(
result = await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.decrypt':
return await nip44Decrypt(
result = await nip44Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
default:
throw new Error(`Not supported request method '${req.method}'.`);

View File

@@ -1,14 +1,32 @@
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
import { Nip07Method } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
* Decode base64 string to UTF-8 using native browser APIs.
* This avoids race conditions with the Buffer polyfill initialization.
*/
function base64ToUtf8(base64: string): string {
const binaryString = atob(base64);
const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
const event = Buffer.from(params.get('event') as string, 'base64').toString();
let event = '{}';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let eventParsed: any = {};
try {
event = base64ToUtf8(params.get('event') as string);
eventParsed = JSON.parse(event);
} catch (e) {
console.error('Failed to parse event:', e);
}
let title = '';
switch (method) {
@@ -62,8 +80,8 @@ Array.from(document.getElementsByClassName('host-INSERT')).forEach(
);
const kindSpanElement = document.getElementById('kindSpan');
if (kindSpanElement) {
kindSpanElement.innerText = JSON.parse(event).kind;
if (kindSpanElement && eventParsed.kind !== undefined) {
kindSpanElement.innerText = eventParsed.kind;
}
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
@@ -108,9 +126,8 @@ if (cardNip04EncryptElement && card2Nip04EncryptElement) {
'card2Nip04Encrypt_text'
);
if (card2Nip04Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip04EncryptElement.style.display = 'none';
@@ -126,9 +143,8 @@ if (cardNip44EncryptElement && card2Nip44EncryptElement) {
'card2Nip44Encrypt_text'
);
if (card2Nip44Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip44EncryptElement.style.display = 'none';
@@ -143,9 +159,8 @@ if (cardNip04DecryptElement && card2Nip04DecryptElement) {
'card2Nip04Decrypt_text'
);
if (card2Nip04Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip04DecryptElement.style.display = 'none';
@@ -161,9 +176,8 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
'card2Nip44Decrypt_text'
);
if (card2Nip44Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip44DecryptElement.style.display = 'none';
@@ -175,36 +189,38 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
// Functions
//
function deliver(response: PromptResponse) {
async function deliver(response: PromptResponse) {
const message: PromptResponseMessage = {
id,
response,
};
browser.runtime.sendMessage(message);
try {
await browser.runtime.sendMessage(message);
} catch (error) {
console.error('Failed to send message:', error);
}
window.close();
}
document.addEventListener('DOMContentLoaded', function () {
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
rejectJustOnceButton?.addEventListener('click', () => {
const rejectOnceButton = document.getElementById('rejectOnceButton');
rejectOnceButton?.addEventListener('click', () => {
deliver('reject-once');
});
const rejectButton = document.getElementById('rejectButton');
rejectButton?.addEventListener('click', () => {
const rejectAlwaysButton = document.getElementById('rejectAlwaysButton');
rejectAlwaysButton?.addEventListener('click', () => {
deliver('reject');
});
const approveJustOnceButton = document.getElementById(
'approveJustOnceButton'
);
approveJustOnceButton?.addEventListener('click', () => {
const approveOnceButton = document.getElementById('approveOnceButton');
approveOnceButton?.addEventListener('click', () => {
deliver('approve-once');
});
const approveButton = document.getElementById('approveButton');
approveButton?.addEventListener('click', () => {
const approveAlwaysButton = document.getElementById('approveAlwaysButton');
approveAlwaysButton?.addEventListener('click', () => {
deliver('approve');
});
});

View File

@@ -3,8 +3,7 @@
<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>
<p class="deriving-note">This may take a few seconds</p>
</div>
</div>
}

View File

@@ -30,14 +30,6 @@
}
}
.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;

View File

@@ -1,23 +1,16 @@
import {
Component,
OnDestroy,
} from '@angular/core';
import { Component } from '@angular/core';
@Component({
selector: 'app-deriving-modal',
templateUrl: './deriving-modal.component.html',
styleUrl: './deriving-modal.component.scss',
})
export class DerivingModalComponent implements OnDestroy {
export class DerivingModalComponent {
visible = false;
elapsed = 0;
message = 'Deriving encryption key';
#startTime: number | null = null;
#animationFrame: number | null = null;
/**
* Show the deriving modal and start the timer
* Show the deriving modal
* @param message Optional custom message
*/
show(message?: string): void {
@@ -25,35 +18,12 @@ export class DerivingModalComponent implements OnDestroy {
this.message = message;
}
this.visible = true;
this.elapsed = 0;
this.#startTime = performance.now();
this.#updateTimer();
}
/**
* Hide the modal and stop the timer
* Hide the modal
*/
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

@@ -1,3 +1,7 @@
<div class="icon-button">
<i [class]="'bi bi-' + icon"></i>
@if (isEmoji) {
<span class="emoji">{{ icon }}</span>
} @else {
<i [class]="'bi bi-' + icon"></i>
}
</div>

View File

@@ -9,4 +9,9 @@ import { Component, Input } from '@angular/core';
})
export class IconButtonComponent {
@Input({ required: true }) icon!: string;
get isEmoji(): boolean {
// Check if the icon is an emoji (starts with a non-ASCII character)
return this.icon.length > 0 && this.icon.charCodeAt(0) > 255;
}
}

View File

@@ -1,22 +1,424 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
declare const chrome: any;
export type LogCategory =
| 'nip07'
| 'permission'
| 'vault'
| 'profile'
| 'bookmark'
| 'system';
export interface LogEntry {
timestamp: Date;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
// Serializable format for storage
interface StoredLogEntry {
timestamp: string;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
const LOGS_STORAGE_KEY = 'extensionLogs';
@Injectable({
providedIn: 'root',
})
export class LoggerService {
#namespace: string | undefined;
#logs: LogEntry[] = [];
#maxLogs = 500;
initialize(namespace: string): void {
this.#namespace = namespace;
get logs(): LogEntry[] {
return this.#logs;
}
log(value: any) {
async initialize(namespace: string): Promise<void> {
this.#namespace = namespace;
await this.#loadLogsFromStorage();
}
async #loadLogsFromStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
if (result[LOGS_STORAGE_KEY]) {
// Convert stored format back to LogEntry with Date objects
this.#logs = (result[LOGS_STORAGE_KEY] as StoredLogEntry[]).map(
(entry) => ({
...entry,
timestamp: new Date(entry.timestamp),
})
);
}
}
} catch (error) {
console.error('Failed to load logs from storage:', error);
}
}
async #saveLogsToStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
// Convert Date to ISO string for storage
const storedLogs: StoredLogEntry[] = this.#logs.map((entry) => ({
...entry,
timestamp: entry.timestamp.toISOString(),
}));
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: storedLogs });
}
} catch (error) {
console.error('Failed to save logs to storage:', error);
}
}
async refreshLogs(): Promise<void> {
await this.#loadLogsFromStorage();
}
// ============================================
// Generic logging methods
// ============================================
log(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('log', 'system', '📝', value, data);
this.#consoleLog('log', value);
}
warn(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('warn', 'system', '⚠️', value, data);
this.#consoleLog('warn', value);
}
error(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('error', 'system', '❌', value, data);
this.#consoleLog('error', value);
}
debug(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('debug', 'system', '🔍', value, data);
this.#consoleLog('debug', value);
}
// ============================================
// NIP-07 Action Logging
// ============================================
logNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
) {
this.#assureInitialized();
const approvalType = autoApproved ? 'auto-approved' : approved ? 'approved' : 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
this.#addLog('log', 'nip07', icon, message, {
method,
host,
approved,
autoApproved,
...details,
});
this.#consoleLog('log', message);
}
logNip07GetPublicKey(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getPublicKey', host, approved, autoApproved);
}
logNip07SignEvent(
host: string,
kind: number,
approved: boolean,
autoApproved: boolean
) {
this.logNip07Action('signEvent', host, approved, autoApproved, { kind });
}
logNip07Encrypt(
method: 'nip04.encrypt' | 'nip44.encrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07Decrypt(
method: 'nip04.decrypt' | 'nip44.decrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07GetRelays(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getRelays', host, approved, autoApproved);
}
// ============================================
// Permission Logging
// ============================================
logPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
) {
this.#assureInitialized();
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', icon, message, { host, method, policy, kind });
this.#consoleLog('log', message);
}
logPermissionDeleted(host: string, method: string, kind?: number) {
this.#assureInitialized();
let message = `Permission deleted: ${method} for ${host}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', '🗑️', message, { host, method, kind });
this.#consoleLog('log', message);
}
// ============================================
// Vault Operations Logging
// ============================================
logVaultUnlock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔓', 'Vault unlocked', undefined);
this.#consoleLog('log', 'Vault unlocked');
}
logVaultLock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔒', 'Vault locked', undefined);
this.#consoleLog('log', 'Vault locked');
}
logVaultCreated() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🆕', 'Vault created', undefined);
this.#consoleLog('log', 'Vault created');
}
logVaultExport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📤', `Vault exported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault exported: ${fileName}`);
}
logVaultImport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📥', `Vault imported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault imported: ${fileName}`);
}
logVaultReset() {
this.#assureInitialized();
this.#addLog('warn', 'vault', '🗑️', 'Extension reset', undefined);
this.#consoleLog('warn', 'Extension reset');
}
// ============================================
// Profile Operations Logging
// ============================================
logProfileFetchError(pubkey: string, error: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to fetch profile for ${shortPubkey}: ${error}`, {
pubkey,
error,
});
this.#consoleLog('error', `Failed to fetch profile for ${shortPubkey}: ${error}`);
}
logProfileParseError(pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to parse profile content for ${shortPubkey}`, {
pubkey,
});
this.#consoleLog('error', `Failed to parse profile content for ${shortPubkey}`);
}
logNip05ValidationError(nip05: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'profile', '🔗', `NIP-05 validation failed for ${nip05}: ${error}`, {
nip05,
error,
});
this.#consoleLog('error', `NIP-05 validation failed for ${nip05}: ${error}`);
}
logNip05ValidationSuccess(nip05: string, pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('log', 'profile', '✓', `NIP-05 verified: ${nip05}${shortPubkey}`, {
nip05,
pubkey,
});
this.#consoleLog('log', `NIP-05 verified: ${nip05}${shortPubkey}`);
}
logProfileEdit(identityNick: string, field: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '✏️', `Profile edited: ${identityNick} - ${field}`, {
identityNick,
field,
});
this.#consoleLog('log', `Profile edited: ${identityNick} - ${field}`);
}
logIdentityCreated(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '🆕', `Identity created: ${nick}`, { nick });
this.#consoleLog('log', `Identity created: ${nick}`);
}
logIdentityDeleted(nick: string) {
this.#assureInitialized();
this.#addLog('warn', 'profile', '🗑️', `Identity deleted: ${nick}`, { nick });
this.#consoleLog('warn', `Identity deleted: ${nick}`);
}
logIdentitySelected(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '👆', `Identity selected: ${nick}`, { nick });
this.#consoleLog('log', `Identity selected: ${nick}`);
}
// ============================================
// Bookmark Operations Logging
// ============================================
logBookmarkAdded(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🔖', `Bookmark added: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark added: ${title}`);
}
logBookmarkRemoved(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🗑️', `Bookmark removed: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark removed: ${title}`);
}
// ============================================
// System/Error Logging
// ============================================
logRelayFetchError(identityNick: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '📡', `Failed to fetch relays for ${identityNick}: ${error}`, {
identityNick,
error,
});
this.#consoleLog('error', `Failed to fetch relays for ${identityNick}: ${error}`);
}
logStorageError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '💾', `Storage error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Storage error (${operation}): ${error}`);
}
logCryptoError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '🔐', `Crypto error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Crypto error (${operation}): ${error}`);
}
// ============================================
// Internal methods
// ============================================
async clear(): Promise<void> {
this.#logs = [];
await this.#saveLogsToStorage();
}
#addLog(
level: LogEntry['level'],
category: LogCategory,
icon: string,
message: any,
data?: any
) {
const entry: LogEntry = {
timestamp: new Date(),
level,
category,
icon,
message: typeof message === 'string' ? message : JSON.stringify(message),
data,
};
this.#logs.unshift(entry);
// Limit stored logs
if (this.#logs.length > this.#maxLogs) {
this.#logs.pop();
}
// Save to storage asynchronously (don't block)
this.#saveLogsToStorage();
}
#consoleLog(level: 'log' | 'warn' | 'error' | 'debug', message: string) {
const nowString = new Date().toLocaleString();
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
const formattedMsg = `[${this.#namespace} - ${nowString}] ${message}`;
switch (level) {
case 'warn':
console.warn(formattedMsg);
break;
case 'error':
console.error(formattedMsg);
break;
case 'debug':
console.debug(formattedMsg);
break;
default:
console.log(formattedMsg);
}
}
#assureInitialized() {
@@ -27,3 +429,87 @@ export class LoggerService {
}
}
}
// ============================================
// Standalone functions for background script
// (Background script runs in different context without Angular DI)
// ============================================
export async function backgroundLog(
category: LogCategory,
icon: string,
level: LogEntry['level'],
message: string,
data?: any
): Promise<void> {
try {
if (typeof chrome === 'undefined' || !chrome.storage?.session) {
console.log(`[Background] ${message}`);
return;
}
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
const existingLogs: StoredLogEntry[] = result[LOGS_STORAGE_KEY] || [];
const newEntry: StoredLogEntry = {
timestamp: new Date().toISOString(),
level,
category,
icon,
message,
data,
};
const updatedLogs = [newEntry, ...existingLogs].slice(0, 500);
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: updatedLogs });
} catch (error) {
console.error('Failed to add background log:', error);
}
}
export async function backgroundLogNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
): Promise<void> {
const approvalType = autoApproved
? 'auto-approved'
: approved
? 'approved'
: 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
await backgroundLog('nip07', icon, 'log', message, {
method,
host,
approved,
autoApproved,
...details,
});
}
export async function backgroundLogPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
): Promise<void> {
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
await backgroundLog('permission', icon, 'log', message, {
host,
method,
policy,
kind,
});
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { SimplePool } from 'nostr-tools/pool';
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
import { LoggerService } from '../logger/logger.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const chrome: any;
@@ -14,6 +15,7 @@ const STORAGE_KEY = 'profileMetadataCache';
providedIn: 'root',
})
export class ProfileMetadataService {
readonly #logger = inject(LoggerService);
#cache: ProfileMetadataCache = {};
#pool: SimplePool | null = null;
#fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
@@ -52,7 +54,8 @@ export class ProfileMetadataService {
}
}
} catch (error) {
console.error('Failed to load profile cache from storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('load profile cache', errorMsg);
}
}
@@ -65,7 +68,8 @@ export class ProfileMetadataService {
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
}
} catch (error) {
console.error('Failed to save profile cache to storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('save profile cache', errorMsg);
}
}
@@ -209,7 +213,7 @@ export class ProfileMetadataService {
this.#cache[pubkey] = profile;
results.set(pubkey, profile);
} catch {
console.error(`Failed to parse profile for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
results.set(pubkey, null);
}
}
@@ -225,7 +229,8 @@ export class ProfileMetadataService {
await this.#saveCacheToStorage();
} catch (error) {
console.error('Failed to fetch profiles:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError('multiple', errorMsg);
// Set null for all unfetched pubkeys on error
for (const pubkey of uncachedPubkeys) {
if (!results.has(pubkey)) {
@@ -283,11 +288,12 @@ export class ProfileMetadataService {
return profile;
} catch {
console.error(`Failed to parse profile content for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
return null;
}
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError(pubkey, errorMsg);
return null;
}
}

View File

@@ -20,6 +20,10 @@ export abstract class BrowserSessionHandler {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
}
clearInMemoryData() {
this.#browserSessionData = undefined;
}
/**
* Persist the full data to the session data storage.
*

View File

@@ -146,12 +146,17 @@ export const decryptPermissions = async function (
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of permissions) {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
try {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
} catch (error) {
// Skip corrupted permissions (e.g., encrypted with wrong key)
console.warn('[vault] Skipping corrupted permission:', error);
}
}
return decryptedPermissions;

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSyncFlow, SignerMetaData } from './types';
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
@@ -8,7 +8,7 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
/**
* 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),
@@ -89,4 +89,26 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets the bookmarks array and immediately saves it.
*/
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
bookmarks,
};
} else {
this.#signerMetaData.bookmarks = bookmarks;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets the current bookmarks.
*/
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
}
}

View File

@@ -124,6 +124,14 @@ export class StorageService {
this.isInitialized = false;
}
async lockVault(): Promise<void> {
this.assureIsInitialized();
await this.getBrowserSessionHandler().clearData();
this.getBrowserSessionHandler().clearInMemoryData();
// Note: We don't set isInitialized = false here because the sync data
// (encrypted vault) is still loaded and we need it to unlock again
}
async unlockVault(password: string): Promise<void> {
await unlockVault.call(this, password);
}

View File

@@ -94,6 +94,16 @@ export const SIGNER_META_DATA_KEY = {
vaultSnapshots: 'vaultSnapshots',
};
/**
* Bookmark entry for storing user bookmarks
*/
export interface Bookmark {
id: string;
url: string;
title: string;
createdAt: number;
}
export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
@@ -104,6 +114,9 @@ export interface SignerMetaData {
// Whitelisted hosts: auto-approve all actions from these hosts
whitelistedHosts?: string[];
// User bookmarks
bookmarks?: Bookmark[];
}
/**

View File

@@ -84,3 +84,11 @@ h2.font-heading {
h3.font-heading {
font-size: 1.4rem;
}
// Emoji styling
.emoji {
font-family: var(--font-emoji);
font-style: normal;
font-weight: normal;
line-height: 1;
}

View File

@@ -16,6 +16,7 @@
--font-sans: 'IBM Plex Mono', monospace;
--font-heading: 'reglisse', sans-serif;
--font-theylive: 'theylive', sans-serif;
--font-emoji: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Android Emoji', sans-serif;
// Border radius (from market)
--radius: 0.25rem;

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.1",
"version": "1.0.5",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -226,7 +226,7 @@
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectButton" type="button" class="btn btn-secondary">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
@@ -239,16 +239,16 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectJustOnceButton" class="dropdown-item">
just once
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
</div>
<div class="btn-group">
<button id="approveButton" type="button" class="btn btn-primary">
Approve
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
@@ -260,8 +260,8 @@
</button>
<ul class="dropdown-menu">
<li>
<button id="approveJustOnceButton" class="dropdown-item" href="#">
just once
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>

View File

@@ -6,6 +6,8 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
import { SettingsComponent } from './components/home/settings/settings.component';
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
@@ -66,6 +68,14 @@ export const routes: Routes = [
path: 'settings',
component: SettingsComponent,
},
{
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
],
},
{

View File

@@ -10,7 +10,12 @@
<span class="text-muted" style="font-size: 12px">
Nothing configured so far.
</span>
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
} @else {
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
Remove All Permissions
</button>
}
@for(hostPermissions of hostsPermissions; track hostPermissions) {
<div class="permissions-card">
<span style="margin-bottom: 4px; font-weight: 500">
{{ hostPermissions.host }}

View File

@@ -17,6 +17,10 @@
top: 0;
}
.remove-all-btn {
margin-bottom: var(--size);
}
.permissions-card {
background: var(--background-light);
border-radius: 8px;

View File

@@ -35,6 +35,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
this.#buildHostsPermissions(this.identity?.id);
}
async onClickRemoveAllPermissions() {
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
for (const permission of allPermissions) {
await this.#storage.deletePermission(permission.id);
}
this.#buildHostsPermissions(this.identity?.id);
}
#initialize(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -28,7 +28,7 @@
</div>
<div class="info-banner">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>

View File

@@ -0,0 +1,36 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="bookmarks-header">
<span class="bookmarks-title">Bookmarks</span>
<button class="btn btn-primary btn-sm" (click)="onBookmarkThisPage()">
<span class="emoji">🔖</span> Bookmark This Page
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,92 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
overflow: hidden;
}
.bookmarks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
.bookmarks-title {
font-weight: 600;
font-size: 1.1rem;
}
.bookmarks-container {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,91 @@
import { Component, inject, OnInit } from '@angular/core';
import { Bookmark, LoggerService, SignerMetaData } from '@common';
import { FirefoxMetaHandler } from '../../../common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new FirefoxMetaHandler();
bookmarks: Bookmark[] = [];
isLoading = true;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
browser.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
}

View File

@@ -9,7 +9,7 @@
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<span class="emoji">👤</span>
</a>
<a
@@ -18,7 +18,7 @@
routerLinkActive="active"
title="Identities"
>
<i class="bi bi-people-fill"></i>
<span class="emoji">👥</span>
</a>
<a
@@ -27,10 +27,22 @@
routerLinkActive="active"
title="Settings"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</a>
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
<span class="emoji">🔖</span>
</a>
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
<span class="emoji">🪵</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
</a>
<button class="tab" (click)="onClickLock()" title="Lock">
<span class="emoji">🔒</span>
</button>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 40px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 40px;
min-height: 40px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,10 +23,10 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-size: 16px;
color: var(--muted-foreground);
border-top: 3px solid transparent;
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
@@ -38,7 +38,7 @@
&.active {
color: var(--foreground);
border-top: 3px solid var(--primary);
border-top: 2px solid var(--primary);
}
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { RouterModule, RouterOutlet } from '@angular/router';
import { Component, inject } from '@angular/core';
import { Router, RouterModule, RouterOutlet } from '@angular/router';
import { LoggerService, StorageService } from '@common';
@Component({
selector: 'app-home',
@@ -7,4 +8,14 @@ import { RouterModule, RouterOutlet } from '@angular/router';
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {}
export class HomeComponent {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #logger = inject(LoggerService);
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -31,7 +31,7 @@
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</button>
</div>
@@ -62,7 +62,7 @@
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="gear"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>

View File

@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#loadData();
@@ -136,13 +138,16 @@ export class IdentityComponent implements OnInit {
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}

View File

@@ -0,0 +1,20 @@
<div class="logs-header">
<span class="logs-title">Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
}
</div>

View File

@@ -0,0 +1,88 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
.logs-actions {
display: flex;
gap: 8px;
}
.logs-title {
font-weight: 600;
font-size: 1.1rem;
}
.logs-container {
flex: 1;
overflow-y: auto;
background: var(--background-light);
border-radius: 8px;
padding: var(--size-h);
}
.logs-empty {
color: var(--muted-foreground);
text-align: center;
padding: var(--size);
}
.log-entry {
font-family: var(--font-sans);
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
gap: 8px;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
color: #ff6b6b;
}
&.log-warn {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
&.log-debug {
background: rgba(108, 117, 125, 0.15);
color: #adb5bd;
}
&.log-info {
background: rgba(13, 110, 253, 0.1);
color: var(--foreground);
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}

View File

@@ -0,0 +1,43 @@
import { Component, inject, OnInit } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
readonly #logger = inject(LoggerService);
get logs(): LogEntry[] {
return this.#logger.logs;
}
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
return 'log-info';
}
}
}

View File

@@ -3,6 +3,7 @@ import {
BrowserSyncFlow,
ConfirmComponent,
DateHelper,
LoggerService,
NavComponent,
StartupService,
StorageService,
@@ -20,6 +21,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
const vault = JSON.stringify(
@@ -43,6 +45,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
async onResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -58,6 +61,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
const fileName = `Plebeian Signer Firefox - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
this.#logger.logVaultExport(fileName);
}
#downloadJson(jsonString: string, fileName: string) {

View File

@@ -1,7 +1,7 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
@@ -16,6 +16,7 @@ export class NewComponent extends NavComponent {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
@@ -35,6 +36,7 @@ export class NewComponent extends NavComponent {
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();

View File

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
LoggerService,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -28,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
@@ -69,6 +71,7 @@ export class VaultLoginComponent implements AfterViewInit {
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
this.#logger.logVaultUnlock();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
@@ -102,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
async onClickResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {

View File

@@ -15,6 +15,7 @@ import {
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -67,6 +68,8 @@ export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
if (!signerMetaData.recklessMode) {
return false;
@@ -228,8 +231,7 @@ export const storePermission = async function (
// Encrypt permission to store in sync storage (depending on sync flow).
const encryptedPermission = await encryptPermission(
permission,
browserSessionData.iv,
browserSessionData.vaultPassword as string
browserSessionData
);
await savePermissionsToBrowserSyncStorage([
@@ -326,22 +328,20 @@ export const nip44Decrypt = async function (
const encryptPermission = async function (
permission: Permission_DECRYPTED,
iv: string,
password: string
sessionData: BrowserSessionData
): Promise<Permission_ENCRYPTED> {
const encryptedPermission: Permission_ENCRYPTED = {
id: await encrypt(permission.id, iv, password),
identityId: await encrypt(permission.identityId, iv, password),
host: await encrypt(permission.host, iv, password),
method: await encrypt(permission.method, iv, password),
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
id: await encrypt(permission.id, sessionData),
identityId: await encrypt(permission.identityId, sessionData),
host: await encrypt(permission.host, sessionData),
method: await encrypt(permission.method, sessionData),
methodPolicy: await encrypt(permission.methodPolicy, sessionData),
};
if (typeof permission.kind !== 'undefined') {
encryptedPermission.kind = await encrypt(
permission.kind.toString(),
iv,
password
sessionData
);
}
@@ -350,8 +350,30 @@ const encryptPermission = async function (
const encrypt = async function (
value: string,
iv: string,
password: string
sessionData: BrowserSessionData
): Promise<string> {
return await CryptoHelper.encrypt(value, iv, password);
// v2: Use pre-derived key with AES-GCM directly
if (sessionData.vaultKey) {
const keyBytes = Buffer.from(sessionData.vaultKey, 'base64');
const iv = Buffer.from(sessionData.iv, '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(value)
);
return Buffer.from(cipherText).toString('base64');
}
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
@@ -67,6 +71,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
@@ -78,6 +83,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
req.method,
req.params
);
debug(`permissionState result: ${permissionState}`);
if (permissionState === false) {
throw new Error('Permission denied');
@@ -107,17 +113,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
debug(response);
if (response === 'approve' || response === 'reject') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
req.host,
req.method,
policy,
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -126,46 +143,71 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
let result: any;
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return result;
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
result = signEvent(req.params, currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
kind: req.params?.kind,
});
return result;
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
result = await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.encrypt':
return await nip44Encrypt(
result = await nip44Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip04.decrypt':
return await nip04Decrypt(
result = await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.decrypt':
return await nip44Decrypt(
result = await nip44Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
default:
throw new Error(`Not supported request method '${req.method}'.`);

View File

@@ -1,14 +1,32 @@
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
import { Nip07Method } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
* Decode base64 string to UTF-8 using native browser APIs.
* This avoids race conditions with the Buffer polyfill initialization.
*/
function base64ToUtf8(base64: string): string {
const binaryString = atob(base64);
const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
const event = Buffer.from(params.get('event') as string, 'base64').toString();
let event = '{}';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let eventParsed: any = {};
try {
event = base64ToUtf8(params.get('event') as string);
eventParsed = JSON.parse(event);
} catch (e) {
console.error('Failed to parse event:', e);
}
let title = '';
switch (method) {
@@ -62,8 +80,8 @@ Array.from(document.getElementsByClassName('host-INSERT')).forEach(
);
const kindSpanElement = document.getElementById('kindSpan');
if (kindSpanElement) {
kindSpanElement.innerText = JSON.parse(event).kind;
if (kindSpanElement && eventParsed.kind !== undefined) {
kindSpanElement.innerText = eventParsed.kind;
}
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
@@ -108,9 +126,8 @@ if (cardNip04EncryptElement && card2Nip04EncryptElement) {
'card2Nip04Encrypt_text'
);
if (card2Nip04Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip04EncryptElement.style.display = 'none';
@@ -126,9 +143,8 @@ if (cardNip44EncryptElement && card2Nip44EncryptElement) {
'card2Nip44Encrypt_text'
);
if (card2Nip44Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip44EncryptElement.style.display = 'none';
@@ -144,9 +160,8 @@ if (cardNip04DecryptElement && card2Nip04DecryptElement) {
'card2Nip04Decrypt_text'
);
if (card2Nip04Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip04DecryptElement.style.display = 'none';
@@ -162,9 +177,8 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
'card2Nip44Decrypt_text'
);
if (card2Nip44Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip44DecryptElement.style.display = 'none';
@@ -176,36 +190,38 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
// Functions
//
function deliver(response: PromptResponse) {
async function deliver(response: PromptResponse) {
const message: PromptResponseMessage = {
id,
response,
};
browser.runtime.sendMessage(message);
try {
await browser.runtime.sendMessage(message);
} catch (error) {
console.error('Failed to send message:', error);
}
window.close();
}
document.addEventListener('DOMContentLoaded', function () {
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
rejectJustOnceButton?.addEventListener('click', () => {
const rejectOnceButton = document.getElementById('rejectOnceButton');
rejectOnceButton?.addEventListener('click', () => {
deliver('reject-once');
});
const rejectButton = document.getElementById('rejectButton');
rejectButton?.addEventListener('click', () => {
const rejectAlwaysButton = document.getElementById('rejectAlwaysButton');
rejectAlwaysButton?.addEventListener('click', () => {
deliver('reject');
});
const approveJustOnceButton = document.getElementById(
'approveJustOnceButton'
);
approveJustOnceButton?.addEventListener('click', () => {
const approveOnceButton = document.getElementById('approveOnceButton');
approveOnceButton?.addEventListener('click', () => {
deliver('approve-once');
});
const approveButton = document.getElementById('approveButton');
approveButton?.addEventListener('click', () => {
const approveAlwaysButton = document.getElementById('approveAlwaysButton');
approveAlwaysButton?.addEventListener('click', () => {
deliver('approve');
});
});