4 Commits

Author SHA1 Message Date
woikos
87d76bb4a8 Release v1.1.1 - Add permission prompt queue system and batch actions
- Add single-active-prompt queue to prevent permission window spam
- Implement request deduplication using hash-based matching
- Add 30-second timeout for unanswered prompts with cleanup
- Add window close event handling for orphaned prompts
- Add queue size limit (100 requests max)
- Add "All Queued" row with Reject All/Approve All buttons
- Hide batch buttons when queue size is 1 or less
- Add 'reject-all' and 'approve-all' response types to PromptResponse

Files modified:
- package.json
- projects/chrome/public/prompt.html
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/chrome/src/prompt.ts
- projects/firefox/public/prompt.html
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts
- projects/firefox/src/prompt.ts
- releases/plebeian-signer-chrome-v1.1.1.zip
- releases/plebeian-signer-firefox-v1.1.1.zip

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:44:14 +01:00
woikos
57434681f9 Release v1.1.0 - Add WebLN API support for Lightning wallet integration
- Add window.webln API for web app Lightning wallet integration
- Implement webln.enable(), getInfo(), sendPayment(), makeInvoice() methods
- Add WebLN permission prompts with proper amount display for payments
- Dispatch webln:ready and webln:enabled events per WebLN standard
- Add NWC client caching for persistent wallet connections
- Implement inline BOLT11 invoice amount parsing
- Always prompt for sendPayment (security-critical, irreversible)
- Add signMessage/verifyMessage stubs that return "not supported"
- Fix response handling for undefined returns in content script

Files modified:
- projects/common/src/lib/models/nostr.ts (WeblnMethod, ExtensionMethod types)
- projects/common/src/lib/models/webln.ts (new - WebLN response types)
- projects/common/src/public-api.ts (export webln types)
- projects/{chrome,firefox}/src/plebian-signer-extension.ts (window.webln)
- projects/{chrome,firefox}/src/background.ts (WebLN handlers)
- projects/{chrome,firefox}/src/background-common.ts (WebLN permissions)
- projects/{chrome,firefox}/public/prompt.html (WebLN cards)
- projects/{chrome,firefox}/src/prompt.ts (WebLN method handling)
- projects/common/src/lib/services/storage/types.ts (ExtensionMethod type)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:02:05 +01:00
woikos
586e2ab23f Release v1.0.11 - Add dev mode with test prompt button on all headers
- Add Dev Mode toggle to settings that persists in vault metadata
- Add test permission prompt button () to all page headers when dev mode enabled
- Move devMode and onTestPrompt to NavComponent base class for inheritance
- Refactor all home components to extend NavComponent
- Simplify permission prompt layout: remove duplicate domain from header
- Convert permission descriptions to flowing single paragraphs
- Update header-buttons styling for consistent lock/magic button layout

Files modified:
- projects/common/src/lib/common/nav-component.ts (devMode, onTestPrompt)
- projects/common/src/lib/services/storage/types.ts (devMode property)
- projects/common/src/lib/services/storage/signer-meta-handler.ts (setDevMode)
- projects/common/src/lib/styles/_common.scss (header-buttons styling)
- projects/*/src/app/components/home/*/settings.component.* (dev mode UI)
- projects/*/src/app/components/home/*/*.component.* (extend NavComponent)
- projects/*/public/prompt.html (simplified layout)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:41:51 +01:00
woikos
5ca6eb177c Release v1.0.10 - Add unlock popup for locked vault
- Add unlock popup window that appears when vault is locked and a NIP-07
  request is made (similar to permission prompt popup)
- Implement standalone vault unlock logic in background script using
  Argon2id key derivation and AES-GCM decryption
- Queue pending NIP-07 requests while waiting for unlock, process after
  success
- Add unlock.html and unlock.ts for both Chrome and Firefox extensions

Files modified:
- package.json (version bump to v1.0.10)
- projects/chrome/public/unlock.html (new)
- projects/chrome/src/unlock.ts (new)
- projects/chrome/src/background.ts
- projects/chrome/src/background-common.ts
- projects/chrome/custom-webpack.config.ts
- projects/chrome/tsconfig.app.json
- projects/firefox/public/unlock.html (new)
- projects/firefox/src/unlock.ts (new)
- projects/firefox/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/custom-webpack.config.ts
- projects/firefox/tsconfig.app.json

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 06:23:36 +01:00
68 changed files with 3870 additions and 560 deletions

View File

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

View File

@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

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.9",
"version": "1.1.1",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,90 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
<div class="action-row" id="allQueuedRow">
<span class="action-label">All Queued</span>
<div class="action-buttons">
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>

View File

@@ -3,9 +3,9 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@@ -15,9 +15,8 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent implements OnInit {
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
@@ -27,11 +26,11 @@ export class BackupsComponent implements OnInit {
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.#storage.getSignerMetaHandler().getBackups();
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
@@ -39,14 +38,14 @@ export class BackupsComponent implements OnInit {
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
@@ -55,22 +54,22 @@ export class BackupsComponent implements OnInit {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.#storage.deleteVault(true);
await this.#storage.importVault(backupData);
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.#storage.isInitialized = false;
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
@@ -79,7 +78,7 @@ export class BackupsComponent implements OnInit {
}
async deleteBackup(backupId: string): Promise<void> {
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
@@ -120,7 +119,7 @@ export class BackupsComponent implements OnInit {
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
@@ -9,10 +9,9 @@ import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
@@ -93,7 +92,7 @@ export class BookmarksComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">

View File

@@ -19,9 +19,16 @@
background: var(--background);
position: relative;
.lock-btn,
.add-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
@@ -41,11 +48,8 @@
}
}
.lock-btn {
left: 0;
}
.add-btn {
position: absolute;
right: 0;
}

View File

@@ -4,6 +4,7 @@ import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -17,8 +18,8 @@ import {
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>

View File

@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -19,7 +19,7 @@ import {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
profile: ProfileMetadata | null = null;
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
@@ -82,17 +81,17 @@ export class IdentityComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
version = packageJson.custom.chrome.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,10 +1,39 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
@@ -13,20 +42,6 @@
</p>
</div>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="sam-flex-grow"></div>
<button
class="btn btn-danger"
(click)="

View File

@@ -16,6 +16,35 @@
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;

View File

@@ -12,6 +12,7 @@ import {
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
@Component({
selector: 'app-settings',
@@ -22,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
export class SettingsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
syncFlow: string | undefined;
override devMode = false;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
@@ -45,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
default:
break;
}
// Load dev mode setting
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
async onToggleDevMode(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.devMode = checked;
await this.#storage.getSignerMetaHandler().setDevMode(checked);
}
override async onTestPrompt() {
// Open a test permission prompt window
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
async onResetExtension() {

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
StorageService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
@@ -35,9 +35,8 @@ type WalletSection =
styleUrl: './wallet.component.scss',
imports: [CommonModule, FormsModule],
})
export class WalletComponent implements OnInit, OnDestroy {
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
@@ -195,7 +194,7 @@ export class WalletComponent implements OnInit, OnDestroy {
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.#storage.getSyncFlow();
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
@@ -937,7 +936,7 @@ export class WalletComponent implements OnInit, OnDestroy {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}

View File

@@ -17,7 +17,7 @@ export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {

View File

@@ -6,16 +6,40 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
Relay_DECRYPTED,
Relay_ENCRYPTED,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
deriveKeyArgon2,
ExtensionMethod,
WeblnMethod,
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
// Unlock request/response message types
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
@@ -24,8 +48,10 @@ export const debug = function (message: any) {
export type PromptResponse =
| 'reject'
| 'reject-once'
| 'reject-all' // P2: Reject all requests of this type from this host
| 'approve'
| 'approve-once';
| 'approve-once'
| 'approve-all'; // P2: Approve all requests of this type from this host
export interface PromptResponseMessage {
id: string;
@@ -33,7 +59,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -196,11 +222,51 @@ export const checkPermissions = function (
return undefined;
};
/**
* Check if a method is a WebLN method
*/
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
return method.startsWith('webln.');
};
/**
* Check WebLN permissions for a host.
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
* For sendPayment, always returns undefined (require user prompt for security).
*/
export const checkWeblnPermissions = function (
browserSessionData: BrowserSessionData,
host: string,
method: WeblnMethod
): boolean | undefined {
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
if (method === 'webln.sendPayment') {
return undefined;
}
// keysend also requires approval
if (method === 'webln.keysend') {
return undefined;
}
// For other WebLN methods, check stored permissions
// WebLN permissions use 'webln' as the identityId
const permissions = browserSessionData.permissions.filter(
(x) => x.identityId === 'webln' && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
return permissions.every((x) => x.methodPolicy === 'allow');
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
identity: Identity_DECRYPTED | null,
host: string,
method: Nip07Method,
method: ExtensionMethod,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
@@ -209,11 +275,14 @@ export const storePermission = async function (
throw new Error(`Could not retrieve sync data`);
}
// For WebLN methods, use 'webln' as identityId since wallet is global
const identityId = identity?.id ?? 'webln';
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
identityId,
host,
method,
method: method as Nip07Method, // Cast for storage compatibility
methodPolicy,
kind,
};
@@ -372,3 +441,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
// ==========================================
// Unlock Vault Logic (for background script)
// ==========================================
/**
* Decrypt a value using AES-GCM with pre-derived key (v2)
*/
async function 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);
}
/**
* Decrypt a value using PBKDF2 (v1)
*/
async function decryptV1(
encryptedBase64: string,
ivBase64: string,
password: string
): Promise<string> {
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
}
/**
* Generic decrypt function that handles both v1 and v2
*/
async function decryptValue(
encrypted: string,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<string> {
if (isV2) {
return decryptV2(encrypted, iv, keyOrPassword);
}
return decryptV1(encrypted, iv, keyOrPassword);
}
/**
* Parse decrypted value to the desired type
*/
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
switch (type) {
case 'number':
return parseInt(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
/**
* Decrypt an identity
*/
async function decryptIdentity(
identity: Identity_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Identity_DECRYPTED> {
return {
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
};
}
/**
* Decrypt a permission
*/
async function decryptPermission(
permission: Permission_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Permission_DECRYPTED> {
const decrypted: Permission_DECRYPTED = {
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
};
if (permission.kind) {
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
}
return decrypted;
}
/**
* Decrypt a relay
*/
async function decryptRelay(
relay: Relay_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Relay_DECRYPTED> {
return {
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
};
}
/**
* Decrypt an NWC connection
*/
async function decryptNwcConnection(
nwc: NwcConnection_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<NwcConnection_DECRYPTED> {
const decrypted: NwcConnection_DECRYPTED = {
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
};
if (nwc.lud16) {
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
}
if (nwc.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (nwc.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Decrypt a Cashu mint
*/
async function decryptCashuMint(
mint: CashuMint_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<CashuMint_DECRYPTED> {
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
const decrypted: CashuMint_DECRYPTED = {
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
proofs: JSON.parse(proofsJson),
};
if (mint.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Handle an unlock request from the unlock popup
*/
export async function handleUnlockRequest(
password: string
): Promise<{ success: boolean; error?: string }> {
try {
debug('handleUnlockRequest: Starting unlock...');
// Check if already unlocked
const existingSession = await getBrowserSessionData();
if (existingSession) {
debug('handleUnlockRequest: Already unlocked');
return { success: true };
}
// Get sync data
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
return { success: false, error: 'No vault data found' };
}
// Verify password
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
return { success: false, error: 'Invalid password' };
}
debug('handleUnlockRequest: Password verified');
// Detect vault version
const isV2 = !!browserSyncData.salt;
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
let keyOrPassword: string;
let vaultKey: string | undefined;
let vaultPassword: string | undefined;
if (isV2) {
// v2: Derive key with Argon2id (~3 seconds)
debug('handleUnlockRequest: Deriving Argon2id key...');
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
const keyBytes = await deriveKeyArgon2(password, saltBytes);
vaultKey = Buffer.from(keyBytes).toString('base64');
keyOrPassword = vaultKey;
debug('handleUnlockRequest: Key derived');
} else {
// v1: Use password directly
vaultPassword = password;
keyOrPassword = password;
}
// Decrypt identities
debug('handleUnlockRequest: Decrypting identities...');
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of browserSyncData.identities) {
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
decryptedIdentities.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
// Decrypt permissions
debug('handleUnlockRequest: Decrypting permissions...');
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of browserSyncData.permissions) {
try {
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
decryptedPermissions.push(decrypted);
} catch (e) {
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
}
}
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
// Decrypt relays
debug('handleUnlockRequest: Decrypting relays...');
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of browserSyncData.relays) {
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
decryptedRelays.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
// Decrypt NWC connections
debug('handleUnlockRequest: Decrypting NWC connections...');
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
for (const nwc of browserSyncData.nwcConnections ?? []) {
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
decryptedNwcConnections.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
// Decrypt Cashu mints
debug('handleUnlockRequest: Decrypting Cashu mints...');
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
for (const mint of browserSyncData.cashuMints ?? []) {
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
decryptedCashuMints.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
// Decrypt selectedIdentityId
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
decryptedSelectedIdentityId = await decryptValue(
browserSyncData.selectedIdentityId,
browserSyncData.iv,
keyOrPassword,
isV2
);
}
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
// Build session data
const browserSessionData: BrowserSessionData = {
vaultPassword: isV2 ? undefined : vaultPassword,
vaultKey: isV2 ? vaultKey : undefined,
iv: browserSyncData.iv,
salt: browserSyncData.salt,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
// Save session data
debug('handleUnlockRequest: Saving session data...');
await chrome.storage.session.set(browserSessionData);
debug('handleUnlockRequest: Unlock complete!');
return { success: true };
} catch (error: any) {
debug(`handleUnlockRequest: Error: ${error.message}`);
return { success: false, error: error.message || 'Unlock failed' };
}
}
/**
* Open the unlock popup window
*/
export async function openUnlockPopup(host?: string): Promise<void> {
const width = 375;
const height = 500;
const { top, left } = await getPosition(width, height);
const id = crypto.randomUUID();
let url = `unlock.html?id=${id}`;
if (host) {
url += `&host=${encodeURIComponent(host)}`;
}
await chrome.windows.create({
type: 'popup',
url,
height,
width,
top,
left,
});
}

View File

@@ -3,38 +3,336 @@ import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
// ==========================================
// Permission Prompt Queue System (P0)
// ==========================================
// Timeout for permission prompts (30 seconds)
const PROMPT_TIMEOUT_MS = 30000;
// Maximum number of queued permission requests (prevent DoS)
const MAX_PERMISSION_QUEUE_SIZE = 100;
// Track open prompts with metadata for cleanup
const openPrompts = new Map<
string,
{
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
}
>();
// Track if unlock popup is already open
let unlockPopupOpen = false;
// Queue of pending NIP-07 requests waiting for unlock
const pendingRequests: {
request: BackgroundRequestMessage;
resolve: (result: any) => void;
reject: (error: any) => void;
}[] = [];
// Queue for permission requests (only one prompt shown at a time)
interface PermissionQueueItem {
id: string;
url: string;
width: number;
height: number;
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
}
const permissionQueue: PermissionQueueItem[] = [];
let activePromptId: string | null = null;
/**
* Show the next permission prompt from the queue
*/
async function showNextPermissionPrompt(): Promise<void> {
if (activePromptId || permissionQueue.length === 0) {
return;
}
const next = permissionQueue[0];
activePromptId = next.id;
const { top, left } = await getPosition(next.width, next.height);
try {
const window = await browser.windows.create({
type: 'popup',
url: next.url,
height: next.height,
width: next.width,
top,
left,
});
const promptData = openPrompts.get(next.id);
if (promptData && window.id) {
promptData.windowId = window.id;
promptData.timeoutId = setTimeout(() => {
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
cleanupPrompt(next.id, 'timeout');
}, PROMPT_TIMEOUT_MS);
}
} catch (error) {
debug(`Failed to create prompt window: ${error}`);
cleanupPrompt(next.id, 'error');
}
}
/**
* Clean up a prompt and process the next one in queue
*/
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
const promptData = openPrompts.get(promptId);
if (promptData) {
if (promptData.timeoutId) {
clearTimeout(promptData.timeoutId);
}
if (reason !== 'response') {
promptData.reject(new Error(`Permission prompt ${reason}`));
}
openPrompts.delete(promptId);
}
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
if (queueIndex !== -1) {
permissionQueue.splice(queueIndex, 1);
}
if (activePromptId === promptId) {
activePromptId = null;
}
showNextPermissionPrompt();
}
/**
* Queue a permission prompt request
*/
function queuePermissionPrompt(
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
return new Promise((resolve, reject) => {
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
reject(new Error('Too many pending permission requests. Please try again later.'));
return;
}
const id = crypto.randomUUID();
const separator = urlWithoutId.includes('?') ? '&' : '?';
const url = `${urlWithoutId}${separator}id=${id}`;
openPrompts.set(id, { resolve, reject });
permissionQueue.push({ id, url, width, height, resolve, reject });
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
showNextPermissionPrompt();
});
}
// Listen for window close events to clean up orphaned prompts
browser.windows.onRemoved.addListener((windowId: number) => {
for (const [promptId, promptData] of openPrompts.entries()) {
if (promptData.windowId === windowId) {
debug(`Prompt window ${windowId} closed without response`);
cleanupPrompt(promptId, 'closed');
break;
}
}
});
// ==========================================
// Request Deduplication (P1)
// ==========================================
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
/**
* Generate a hash key for request deduplication
*/
function getRequestHash(host: string, method: string, params: any): string {
if (method === 'signEvent' && params?.kind !== undefined) {
return `${host}:${method}:kind${params.kind}`;
}
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
return `${host}:${method}:${params.peerPubkey}`;
}
return `${host}:${method}`;
}
/**
* Queue a permission prompt with deduplication
*/
function queuePermissionPromptDeduped(
host: string,
method: string,
params: any,
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
const hash = getRequestHash(host, method, params);
const existingPromise = pendingRequestPromises.get(hash);
if (existingPromise) {
debug(`Deduplicating request: ${hash}`);
return existingPromise;
}
const promise = queuePermissionPrompt(urlWithoutId, width, height)
.finally(() => {
pendingRequestPromises.delete(hash);
});
pendingRequestPromises.set(hash, promise);
debug(`New permission request: ${hash}`);
return promise;
}
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
// Handle unlock request from unlock popup
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
const unlockReq = message as UnlockRequestMessage;
debug('Processing unlock request');
const result = await handleUnlockRequest(unlockReq.password);
const response: UnlockResponseMessage = {
type: 'unlock-response',
id: unlockReq.id,
success: result.success,
error: result.error,
};
if (result.success) {
unlockPopupOpen = false;
// Process any pending NIP-07 requests
debug(`Processing ${pendingRequests.length} pending requests`);
while (pendingRequests.length > 0) {
const pending = pendingRequests.shift()!;
try {
const pendingResult = await processNip07Request(pending.request);
pending.resolve(pendingResult);
} catch (error) {
pending.reject(error);
}
}
}
return response;
}
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -43,18 +341,47 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const promptResponse = request as PromptResponseMessage;
const openPrompt = openPrompts.get(promptResponse.id);
if (!openPrompt) {
throw new Error(
'Prompt response could not be matched to any previous request.'
);
debug('Prompt response could not be matched (may have timed out)');
return;
}
openPrompt.resolve(promptResponse.response);
openPrompts.delete(promptResponse.id);
cleanupPrompt(promptResponse.id, 'response');
return;
}
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
// Vault is locked - open unlock popup and queue the request
const req = request as BackgroundRequestMessage;
debug('Vault locked, opening unlock popup');
if (!unlockPopupOpen) {
unlockPopupOpen = true;
await openUnlockPopup(req.host);
}
// Queue this request to be processed after unlock
return new Promise((resolve, reject) => {
pendingRequests.push({ request: req, resolve, reject });
});
}
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
* Process a NIP-07 request after vault is unlocked
*/
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +394,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -80,7 +405,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -90,29 +415,23 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
if (permissionState === undefined) {
// Ask user for permission.
// Ask user for permission (queued + deduplicated)
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,
});
});
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
debug(response);
// Handle permission storage based on response type
if (response === 'approve' || response === 'reject') {
// Store permission for this specific kind (if signEvent) or method
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
@@ -122,15 +441,34 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
} else if (response === 'approve-all') {
// P2: Store permission for ALL kinds/uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
policy,
req.params?.kind
'allow',
undefined // undefined kind = allow all kinds for signEvent
);
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
} else if (response === 'reject-all') {
// P2: Store deny permission for ALL uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
'deny',
undefined
);
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
}
if (['reject', 'reject-once'].includes(response)) {
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
@@ -212,4 +550,149 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission (queued + deduplicated)
const width = 375;
const height = 600;
// For sendPayment, include the invoice amount in the prompt data
let promptParams = req.params ?? {};
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
promptParams = { ...promptParams, amountSats };
}
const base64Event = Buffer.from(
JSON.stringify(promptParams, undefined, 2)
).toString('base64');
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
debug(response);
// Store permission for non-payment methods
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
null, // WebLN has no identity
req.host,
method,
policy
);
await backgroundLogPermissionStored(req.host, method, policy);
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
// P2: Store permission for all uses of this WebLN method
await storePermission(
browserSessionData,
null,
req.host,
method,
'allow'
);
await backgroundLogPermissionStored(req.host, method, 'allow');
debug(`Stored approve-all permission for ${method} from ${req.host}`);
}
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
throw new Error('Permission denied');
}
}
// Execute the WebLN method
let result: any;
const client = await getNwcClient(defaultConnection);
switch (method) {
case 'webln.getInfo': {
const info = await client.getInfo();
result = {
node: {
alias: info.alias,
pubkey: info.pubkey,
color: info.color,
},
} as GetInfoResponse;
debug('webln.getInfo result:');
debug(result);
return result;
}
case 'webln.sendPayment': {
const invoice = req.params.paymentRequest;
const payResult = await client.payInvoice({ invoice });
result = { preimage: payResult.preimage } as SendPaymentResponse;
debug('webln.sendPayment result:');
debug(result);
return result;
}
case 'webln.makeInvoice': {
// Convert sats to millisats (NWC uses millisats)
const amountSats = typeof req.params.amount === 'string'
? parseInt(req.params.amount, 10)
: req.params.amount ?? req.params.defaultAmount ?? 0;
const amountMsat = amountSats * 1000;
const invoiceResult = await client.makeInvoice({
amount: amountMsat,
description: req.params.defaultMemo,
});
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
debug('webln.makeInvoice result:');
debug(result);
return result;
}
case 'webln.keysend':
throw new Error('keysend is not yet supported');
default:
throw new Error(`Not supported WebLN method '${method}'.`);
}
}

View File

@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
import { ExtensionMethod } from '@common';
// Extend Window interface for NIP-07
// Extend Window interface for NIP-07 and WebLN
declare global {
interface Window {
nostr?: any;
webln?: any;
}
}
@@ -38,7 +39,7 @@ class Messenger {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
async request(method: ExtensionMethod, params: any): Promise<any> {
const id = generateUUID();
return new Promise((resolve, reject) => {
@@ -89,7 +90,7 @@ const nostr = {
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
async signEvent(event: EventTemplate): Promise<NostrEvent> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
@@ -158,6 +159,92 @@ const nostr = {
window.nostr = nostr as any;
// WebLN types (inline to avoid build issues with @common types in injected script)
interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
// Create a shared messenger instance for WebLN
const weblnMessenger = nostr.messenger;
const webln = {
enabled: false,
async enable(): Promise<void> {
debug('webln.enable received');
await weblnMessenger.request('webln.enable', {});
this.enabled = true;
debug('webln.enable completed');
// Dispatch webln:enabled event as per WebLN spec
window.dispatchEvent(new Event('webln:enabled'));
},
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
debug('webln.getInfo received');
const info = await weblnMessenger.request('webln.getInfo', {});
debug('webln.getInfo response:');
debug(info);
return info;
},
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
debug('webln.sendPayment received');
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
debug('webln.sendPayment response:');
debug(result);
return result;
},
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
debug('webln.keysend received');
const result = await weblnMessenger.request('webln.keysend', args);
debug('webln.keysend response:');
debug(result);
return result;
},
async makeInvoice(
args: string | number | RequestInvoiceArgs
): Promise<{ paymentRequest: string }> {
debug('webln.makeInvoice received');
// Normalize args to RequestInvoiceArgs
let normalizedArgs: RequestInvoiceArgs;
if (typeof args === 'string' || typeof args === 'number') {
normalizedArgs = { amount: args };
} else {
normalizedArgs = args;
}
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
debug('webln.makeInvoice response:');
debug(result);
return result;
},
signMessage(): Promise<{ message: string; signature: string }> {
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
},
verifyMessage(): Promise<void> {
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
},
};
window.webln = webln as any;
// Dispatch webln:ready event to signal that webln is available
// This is dispatched on document as per the WebLN standard
document.dispatchEvent(new Event('webln:ready'));
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const method = params.get('method') as ExtensionMethod;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
@@ -58,6 +58,26 @@ switch (method) {
title = 'Get Relays';
break;
case 'webln.enable':
title = 'Enable WebLN';
break;
case 'webln.getInfo':
title = 'Wallet Info';
break;
case 'webln.sendPayment':
title = 'Send Payment';
break;
case 'webln.makeInvoice':
title = 'Create Invoice';
break;
case 'webln.keysend':
title = 'Keysend Payment';
break;
default:
break;
}
@@ -185,6 +205,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
}
}
// WebLN card visibility
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
if (cardWeblnEnableElement) {
if (method !== 'webln.enable') {
cardWeblnEnableElement.style.display = 'none';
}
}
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
if (cardWeblnGetInfoElement) {
if (method !== 'webln.getInfo') {
cardWeblnGetInfoElement.style.display = 'none';
}
}
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
if (method === 'webln.sendPayment') {
// Display amount in sats
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
} else if (paymentAmountSpan) {
paymentAmountSpan.innerText = 'unknown amount';
}
// Show invoice in json card
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
}
} else {
cardWeblnSendPaymentElement.style.display = 'none';
card2WeblnSendPaymentElement.style.display = 'none';
}
}
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
if (cardWeblnMakeInvoiceElement) {
if (method === 'webln.makeInvoice') {
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
if (invoiceAmountSpan) {
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
if (amount) {
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
}
}
} else {
cardWeblnMakeInvoiceElement.style.display = 'none';
}
}
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
if (cardWeblnKeysendElement) {
if (method !== 'webln.keysend') {
cardWeblnKeysendElement.style.display = 'none';
}
}
//
// Functions
//
@@ -223,4 +302,21 @@ document.addEventListener('DOMContentLoaded', function () {
approveAlwaysButton?.addEventListener('click', () => {
deliver('approve');
});
const rejectAllButton = document.getElementById('rejectAllButton');
rejectAllButton?.addEventListener('click', () => {
deliver('reject-all');
});
const approveAllButton = document.getElementById('approveAllButton');
approveAllButton?.addEventListener('click', () => {
deliver('approve-all');
});
// Show/hide "All Queued" row based on queue size
const queueSize = parseInt(params.get('queueSize') || '0', 10);
const allQueuedRow = document.getElementById('allQueuedRow');
if (allQueuedRow && queueSize <= 1) {
allQueuedRow.style.display = 'none';
}
});

View File

@@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const host = params.get('host');
// Elements
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
const togglePasswordBtn = document.getElementById('togglePassword');
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
const derivingOverlay = document.getElementById('derivingOverlay');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const hostInfo = document.getElementById('hostInfo');
const hostSpan = document.getElementById('hostSpan');
// Show host info if available
if (host && hostInfo && hostSpan) {
hostSpan.innerText = host;
hostInfo.classList.remove('hidden');
}
// Toggle password visibility
togglePasswordBtn?.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordInput.type = 'password';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Enable/disable unlock button based on password input
passwordInput?.addEventListener('input', () => {
unlockBtn.disabled = !passwordInput.value;
});
// Handle enter key
passwordInput?.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && passwordInput.value) {
attemptUnlock();
}
});
// Handle unlock button click
unlockBtn?.addEventListener('click', attemptUnlock);
async function attemptUnlock() {
if (!passwordInput?.value) return;
// Show deriving overlay
derivingOverlay?.classList.remove('hidden');
errorAlert?.classList.add('hidden');
const message: UnlockRequestMessage = {
type: 'unlock-request',
id,
password: passwordInput.value,
};
try {
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
if (response.success) {
// Success - close the window
window.close();
} else {
// Failed - show error
derivingOverlay?.classList.add('hidden');
showError(response.error || 'Invalid password');
}
} catch (error) {
console.error('Failed to send unlock message:', error);
derivingOverlay?.classList.add('hidden');
showError('Failed to unlock vault');
}
}
function showError(message: string) {
if (errorAlert && errorMessage) {
errorMessage.innerText = message;
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 3000);
}
}
// Focus password input on load
document.addEventListener('DOMContentLoaded', () => {
passwordInput?.focus();
});

View File

@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
"src/options.ts"
"src/options.ts",
"src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}

View File

@@ -1,8 +1,29 @@
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { StorageService } from '../services/storage/storage.service';
import { Buffer } from 'buffer';
declare const chrome: {
windows: {
create: (options: {
type: string;
url: string;
width: number;
height: number;
left: number;
top: number;
}) => void;
};
};
export class NavComponent {
readonly #router = inject(Router);
protected readonly storage = inject(StorageService);
devMode = false;
constructor() {
this.devMode = this.storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
navigateBack() {
window.history.back();
@@ -11,4 +32,32 @@ export class NavComponent {
navigate(path: string) {
this.#router.navigate([path]);
}
onTestPrompt() {
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
}

View File

@@ -8,3 +8,12 @@ export type Nip07Method =
| 'nip44.decrypt';
export type Nip07MethodPolicy = 'allow' | 'deny';
export type WeblnMethod =
| 'webln.enable'
| 'webln.getInfo'
| 'webln.sendPayment'
| 'webln.makeInvoice'
| 'webln.keysend';
export type ExtensionMethod = Nip07Method | WeblnMethod;

View File

@@ -0,0 +1,41 @@
/**
* WebLN API Types
* Based on the WebLN specification: https://webln.dev/
*/
export interface WebLNNode {
alias?: string;
pubkey?: string;
color?: string;
}
export interface GetInfoResponse {
node: WebLNNode;
}
export interface SendPaymentResponse {
preimage: string;
}
export interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
export interface RequestInvoiceResponse {
paymentRequest: string;
}
export interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
export interface SignMessageResponse {
message: string;
signature: string;
}

View File

@@ -9,7 +9,7 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5;
/**
* Load the full data from the storage. If the storage is used for storing
@@ -58,6 +58,21 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets dev mode and immediately saves it.
*/
async setDevMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
devMode: enabled,
};
} else {
this.#signerMetaData.devMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Adds a host to the whitelist and immediately saves it.
*/

View File

@@ -1,10 +1,10 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
@@ -202,6 +202,9 @@ export interface SignerMetaData {
// User bookmarks
bookmarks?: Bookmark[];
// Dev mode: show test permission prompt button in settings
devMode?: boolean;
}
/**

View File

@@ -16,9 +16,16 @@
letter-spacing: 0.1rem;
}
.lock-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.lock-btn,
.header-btn {
background: transparent;
border: none;
padding: 8px;
@@ -37,6 +44,12 @@
font-size: 20px;
}
}
// For backwards compatibility with single lock-btn
> .lock-btn {
position: absolute;
left: 0;
}
}
.sam-footer-grid-2 {

View File

@@ -19,6 +19,7 @@ export * from './lib/helpers/nip05-validator';
// Models
export * from './lib/models/nostr';
export * from './lib/models/webln';
// Services (and related)
export * from './lib/services/storage/storage.service';

View File

@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.9",
"version": "1.1.1",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,90 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
<div class="action-row" id="allQueuedRow">
<span class="action-label">All Queued</span>
<div class="action-buttons">
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>

View File

@@ -3,9 +3,9 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@@ -15,9 +15,8 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent implements OnInit {
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
@@ -27,11 +26,11 @@ export class BackupsComponent implements OnInit {
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.#storage.getSignerMetaHandler().getBackups();
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
@@ -39,14 +38,14 @@ export class BackupsComponent implements OnInit {
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
@@ -55,22 +54,22 @@ export class BackupsComponent implements OnInit {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.#storage.deleteVault(true);
await this.#storage.importVault(backupData);
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.#storage.isInitialized = false;
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
@@ -79,7 +78,7 @@ export class BackupsComponent implements OnInit {
}
async deleteBackup(backupId: string): Promise<void> {
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
@@ -120,7 +119,7 @@ export class BackupsComponent implements OnInit {
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { FirefoxMetaHandler } from '../../../common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
@@ -10,10 +10,9 @@ import browser from 'webextension-polyfill';
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new FirefoxMetaHandler();
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
@@ -94,7 +93,7 @@ export class BookmarksComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">

View File

@@ -19,9 +19,16 @@
background: var(--background);
position: relative;
.lock-btn,
.add-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
@@ -41,11 +48,8 @@
}
}
.lock-btn {
left: 0;
}
.add-btn {
position: absolute;
right: 0;
}

View File

@@ -4,6 +4,7 @@ import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -17,8 +18,8 @@ import {
templateUrl: './identities.component.html',
styleUrl: './identities.component.scss',
})
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>

View File

@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -19,7 +19,7 @@ import {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
profile: ProfileMetadata | null = null;
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
@@ -82,17 +81,17 @@ export class IdentityComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
version = packageJson.custom.firefox.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,10 +1,39 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
@@ -13,25 +42,11 @@
</p>
</div>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="sam-flex-grow"></div>
<button
class="btn btn-danger"
(click)="
confirm.show(
'Do you really want to reset your extension? All data will be lost.',
'Do you really want to reset your extension? Every data will be lost.',
onResetExtension.bind(this)
)
"

View File

@@ -16,6 +16,35 @@
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;

View File

@@ -11,6 +11,8 @@ import {
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
import browser from 'webextension-polyfill';
@Component({
selector: 'app-settings',
@@ -21,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
export class SettingsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
syncFlow: string | undefined;
override devMode = false;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
@@ -44,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
default:
break;
}
// Load dev mode setting
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
async onToggleDevMode(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.devMode = checked;
await this.#storage.getSignerMetaHandler().setDevMode(checked);
}
override async onTestPrompt() {
// Open a test permission prompt window
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
browser.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
async onResetExtension() {

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
StorageService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
@@ -35,9 +35,8 @@ type WalletSection =
styleUrl: './wallet.component.scss',
imports: [CommonModule, FormsModule],
})
export class WalletComponent implements OnInit, OnDestroy {
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
@@ -195,7 +194,7 @@ export class WalletComponent implements OnInit, OnDestroy {
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.#storage.getSyncFlow();
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
@@ -937,7 +936,7 @@ export class WalletComponent implements OnInit, OnDestroy {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}

View File

@@ -18,7 +18,7 @@ export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {

View File

@@ -6,16 +6,40 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
Relay_DECRYPTED,
Relay_ENCRYPTED,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
deriveKeyArgon2,
ExtensionMethod,
WeblnMethod,
} from '@common';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
import browser from 'webextension-polyfill';
// Unlock request/response message types
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -25,8 +49,10 @@ export const debug = function (message: any) {
export type PromptResponse =
| 'reject'
| 'reject-once'
| 'reject-all' // P2: Reject all requests of this type from this host
| 'approve'
| 'approve-once';
| 'approve-once'
| 'approve-all'; // P2: Approve all requests of this type from this host
export interface PromptResponseMessage {
id: string;
@@ -34,7 +60,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -96,13 +122,9 @@ export const getBrowserSyncData = async function (): Promise<
let browserSyncData: BrowserSyncData | undefined;
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
browserSyncData = (await browser.storage.local.get(
null
)) as unknown as BrowserSyncData;
browserSyncData = (await browser.storage.local.get(null)) as unknown as BrowserSyncData;
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
browserSyncData = (await browser.storage.sync.get(
null
)) as unknown as BrowserSyncData;
browserSyncData = (await browser.storage.sync.get(null)) as unknown as BrowserSyncData;
}
return browserSyncData;
@@ -201,11 +223,51 @@ export const checkPermissions = function (
return undefined;
};
/**
* Check if a method is a WebLN method
*/
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
return method.startsWith('webln.');
};
/**
* Check WebLN permissions for a host.
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
* For sendPayment, always returns undefined (require user prompt for security).
*/
export const checkWeblnPermissions = function (
browserSessionData: BrowserSessionData,
host: string,
method: WeblnMethod
): boolean | undefined {
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
if (method === 'webln.sendPayment') {
return undefined;
}
// keysend also requires approval
if (method === 'webln.keysend') {
return undefined;
}
// For other WebLN methods, check stored permissions
// WebLN permissions use 'webln' as the identityId
const permissions = browserSessionData.permissions.filter(
(x) => x.identityId === 'webln' && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
return permissions.every((x) => x.methodPolicy === 'allow');
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
identity: Identity_DECRYPTED | null,
host: string,
method: Nip07Method,
method: ExtensionMethod,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
@@ -214,11 +276,14 @@ export const storePermission = async function (
throw new Error(`Could not retrieve sync data`);
}
// For WebLN methods, use 'webln' as identityId since wallet is global
const identityId = identity?.id ?? 'webln';
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
identityId,
host,
method,
method: method as Nip07Method, // Cast for storage compatibility
methodPolicy,
kind,
};
@@ -377,3 +442,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
// ==========================================
// Unlock Vault Logic (for background script)
// ==========================================
/**
* Decrypt a value using AES-GCM with pre-derived key (v2)
*/
async function 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);
}
/**
* Decrypt a value using PBKDF2 (v1)
*/
async function decryptV1(
encryptedBase64: string,
ivBase64: string,
password: string
): Promise<string> {
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
}
/**
* Generic decrypt function that handles both v1 and v2
*/
async function decryptValue(
encrypted: string,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<string> {
if (isV2) {
return decryptV2(encrypted, iv, keyOrPassword);
}
return decryptV1(encrypted, iv, keyOrPassword);
}
/**
* Parse decrypted value to the desired type
*/
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
switch (type) {
case 'number':
return parseInt(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
/**
* Decrypt an identity
*/
async function decryptIdentity(
identity: Identity_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Identity_DECRYPTED> {
return {
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
};
}
/**
* Decrypt a permission
*/
async function decryptPermission(
permission: Permission_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Permission_DECRYPTED> {
const decrypted: Permission_DECRYPTED = {
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
};
if (permission.kind) {
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
}
return decrypted;
}
/**
* Decrypt a relay
*/
async function decryptRelay(
relay: Relay_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Relay_DECRYPTED> {
return {
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
};
}
/**
* Decrypt an NWC connection
*/
async function decryptNwcConnection(
nwc: NwcConnection_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<NwcConnection_DECRYPTED> {
const decrypted: NwcConnection_DECRYPTED = {
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
};
if (nwc.lud16) {
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
}
if (nwc.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (nwc.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Decrypt a Cashu mint
*/
async function decryptCashuMint(
mint: CashuMint_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<CashuMint_DECRYPTED> {
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
const decrypted: CashuMint_DECRYPTED = {
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
proofs: JSON.parse(proofsJson),
};
if (mint.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Handle an unlock request from the unlock popup
*/
export async function handleUnlockRequest(
password: string
): Promise<{ success: boolean; error?: string }> {
try {
debug('handleUnlockRequest: Starting unlock...');
// Check if already unlocked
const existingSession = await getBrowserSessionData();
if (existingSession) {
debug('handleUnlockRequest: Already unlocked');
return { success: true };
}
// Get sync data
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
return { success: false, error: 'No vault data found' };
}
// Verify password
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
return { success: false, error: 'Invalid password' };
}
debug('handleUnlockRequest: Password verified');
// Detect vault version
const isV2 = !!browserSyncData.salt;
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
let keyOrPassword: string;
let vaultKey: string | undefined;
let vaultPassword: string | undefined;
if (isV2) {
// v2: Derive key with Argon2id (~3 seconds)
debug('handleUnlockRequest: Deriving Argon2id key...');
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
const keyBytes = await deriveKeyArgon2(password, saltBytes);
vaultKey = Buffer.from(keyBytes).toString('base64');
keyOrPassword = vaultKey;
debug('handleUnlockRequest: Key derived');
} else {
// v1: Use password directly
vaultPassword = password;
keyOrPassword = password;
}
// Decrypt identities
debug('handleUnlockRequest: Decrypting identities...');
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of browserSyncData.identities) {
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
decryptedIdentities.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
// Decrypt permissions
debug('handleUnlockRequest: Decrypting permissions...');
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of browserSyncData.permissions) {
try {
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
decryptedPermissions.push(decrypted);
} catch (e) {
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
}
}
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
// Decrypt relays
debug('handleUnlockRequest: Decrypting relays...');
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of browserSyncData.relays) {
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
decryptedRelays.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
// Decrypt NWC connections
debug('handleUnlockRequest: Decrypting NWC connections...');
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
for (const nwc of browserSyncData.nwcConnections ?? []) {
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
decryptedNwcConnections.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
// Decrypt Cashu mints
debug('handleUnlockRequest: Decrypting Cashu mints...');
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
for (const mint of browserSyncData.cashuMints ?? []) {
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
decryptedCashuMints.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
// Decrypt selectedIdentityId
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
decryptedSelectedIdentityId = await decryptValue(
browserSyncData.selectedIdentityId,
browserSyncData.iv,
keyOrPassword,
isV2
);
}
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
// Build session data
const browserSessionData: BrowserSessionData = {
vaultPassword: isV2 ? undefined : vaultPassword,
vaultKey: isV2 ? vaultKey : undefined,
iv: browserSyncData.iv,
salt: browserSyncData.salt,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
// Save session data
debug('handleUnlockRequest: Saving session data...');
await browser.storage.session.set(browserSessionData as unknown as Record<string, unknown>);
debug('handleUnlockRequest: Unlock complete!');
return { success: true };
} catch (error: any) {
debug(`handleUnlockRequest: Error: ${error.message}`);
return { success: false, error: error.message || 'Unlock failed' };
}
}
/**
* Open the unlock popup window
*/
export async function openUnlockPopup(host?: string): Promise<void> {
const width = 375;
const height = 500;
const { top, left } = await getPosition(width, height);
const id = crypto.randomUUID();
let url = `unlock.html?id=${id}`;
if (host) {
url += `&host=${encodeURIComponent(host)}`;
}
await browser.windows.create({
type: 'popup',
url,
height,
width,
top,
left,
});
}

View File

@@ -3,38 +3,336 @@ import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
// ==========================================
// Permission Prompt Queue System (P0)
// ==========================================
// Timeout for permission prompts (30 seconds)
const PROMPT_TIMEOUT_MS = 30000;
// Maximum number of queued permission requests (prevent DoS)
const MAX_PERMISSION_QUEUE_SIZE = 100;
// Track open prompts with metadata for cleanup
const openPrompts = new Map<
string,
{
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
}
>();
// Track if unlock popup is already open
let unlockPopupOpen = false;
// Queue of pending NIP-07 requests waiting for unlock
const pendingRequests: {
request: BackgroundRequestMessage;
resolve: (result: any) => void;
reject: (error: any) => void;
}[] = [];
// Queue for permission requests (only one prompt shown at a time)
interface PermissionQueueItem {
id: string;
url: string;
width: number;
height: number;
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
}
const permissionQueue: PermissionQueueItem[] = [];
let activePromptId: string | null = null;
/**
* Show the next permission prompt from the queue
*/
async function showNextPermissionPrompt(): Promise<void> {
if (activePromptId || permissionQueue.length === 0) {
return;
}
const next = permissionQueue[0];
activePromptId = next.id;
const { top, left } = await getPosition(next.width, next.height);
try {
const window = await browser.windows.create({
type: 'popup',
url: next.url,
height: next.height,
width: next.width,
top,
left,
});
const promptData = openPrompts.get(next.id);
if (promptData && window.id) {
promptData.windowId = window.id;
promptData.timeoutId = setTimeout(() => {
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
cleanupPrompt(next.id, 'timeout');
}, PROMPT_TIMEOUT_MS);
}
} catch (error) {
debug(`Failed to create prompt window: ${error}`);
cleanupPrompt(next.id, 'error');
}
}
/**
* Clean up a prompt and process the next one in queue
*/
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
const promptData = openPrompts.get(promptId);
if (promptData) {
if (promptData.timeoutId) {
clearTimeout(promptData.timeoutId);
}
if (reason !== 'response') {
promptData.reject(new Error(`Permission prompt ${reason}`));
}
openPrompts.delete(promptId);
}
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
if (queueIndex !== -1) {
permissionQueue.splice(queueIndex, 1);
}
if (activePromptId === promptId) {
activePromptId = null;
}
showNextPermissionPrompt();
}
/**
* Queue a permission prompt request
*/
function queuePermissionPrompt(
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
return new Promise((resolve, reject) => {
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
reject(new Error('Too many pending permission requests. Please try again later.'));
return;
}
const id = crypto.randomUUID();
const separator = urlWithoutId.includes('?') ? '&' : '?';
const url = `${urlWithoutId}${separator}id=${id}`;
openPrompts.set(id, { resolve, reject });
permissionQueue.push({ id, url, width, height, resolve, reject });
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
showNextPermissionPrompt();
});
}
// Listen for window close events to clean up orphaned prompts
browser.windows.onRemoved.addListener((windowId: number) => {
for (const [promptId, promptData] of openPrompts.entries()) {
if (promptData.windowId === windowId) {
debug(`Prompt window ${windowId} closed without response`);
cleanupPrompt(promptId, 'closed');
break;
}
}
});
// ==========================================
// Request Deduplication (P1)
// ==========================================
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
/**
* Generate a hash key for request deduplication
*/
function getRequestHash(host: string, method: string, params: any): string {
if (method === 'signEvent' && params?.kind !== undefined) {
return `${host}:${method}:kind${params.kind}`;
}
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
return `${host}:${method}:${params.peerPubkey}`;
}
return `${host}:${method}`;
}
/**
* Queue a permission prompt with deduplication
*/
function queuePermissionPromptDeduped(
host: string,
method: string,
params: any,
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
const hash = getRequestHash(host, method, params);
const existingPromise = pendingRequestPromises.get(hash);
if (existingPromise) {
debug(`Deduplicating request: ${hash}`);
return existingPromise;
}
const promise = queuePermissionPrompt(urlWithoutId, width, height)
.finally(() => {
pendingRequestPromises.delete(hash);
});
pendingRequestPromises.set(hash, promise);
debug(`New permission request: ${hash}`);
return promise;
}
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
// Handle unlock request from unlock popup
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
const unlockReq = message as UnlockRequestMessage;
debug('Processing unlock request');
const result = await handleUnlockRequest(unlockReq.password);
const response: UnlockResponseMessage = {
type: 'unlock-response',
id: unlockReq.id,
success: result.success,
error: result.error,
};
if (result.success) {
unlockPopupOpen = false;
// Process any pending NIP-07 requests
debug(`Processing ${pendingRequests.length} pending requests`);
while (pendingRequests.length > 0) {
const pending = pendingRequests.shift()!;
try {
const pendingResult = await processNip07Request(pending.request);
pending.resolve(pendingResult);
} catch (error) {
pending.reject(error);
}
}
}
return response;
}
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -43,18 +341,47 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const promptResponse = request as PromptResponseMessage;
const openPrompt = openPrompts.get(promptResponse.id);
if (!openPrompt) {
throw new Error(
'Prompt response could not be matched to any previous request.'
);
debug('Prompt response could not be matched (may have timed out)');
return;
}
openPrompt.resolve(promptResponse.response);
openPrompts.delete(promptResponse.id);
cleanupPrompt(promptResponse.id, 'response');
return;
}
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
// Vault is locked - open unlock popup and queue the request
const req = request as BackgroundRequestMessage;
debug('Vault locked, opening unlock popup');
if (!unlockPopupOpen) {
unlockPopupOpen = true;
await openUnlockPopup(req.host);
}
// Queue this request to be processed after unlock
return new Promise((resolve, reject) => {
pendingRequests.push({ request: req, resolve, reject });
});
}
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
* Process a NIP-07 request after vault is unlocked
*/
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +394,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -80,7 +405,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -90,29 +415,23 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
if (permissionState === undefined) {
// Ask user for permission.
// Ask user for permission (queued + deduplicated)
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,
});
});
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
debug(response);
// Handle permission storage based on response type
if (response === 'approve' || response === 'reject') {
// Store permission for this specific kind (if signEvent) or method
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
@@ -122,15 +441,34 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
} else if (response === 'approve-all') {
// P2: Store permission for ALL kinds/uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
policy,
req.params?.kind
'allow',
undefined // undefined kind = allow all kinds for signEvent
);
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
} else if (response === 'reject-all') {
// P2: Store deny permission for ALL uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
'deny',
undefined
);
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
}
if (['reject', 'reject-once'].includes(response)) {
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
@@ -212,4 +550,149 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission (queued + deduplicated)
const width = 375;
const height = 600;
// For sendPayment, include the invoice amount in the prompt data
let promptParams = req.params ?? {};
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
promptParams = { ...promptParams, amountSats };
}
const base64Event = Buffer.from(
JSON.stringify(promptParams, undefined, 2)
).toString('base64');
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
debug(response);
// Store permission for non-payment methods
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
null, // WebLN has no identity
req.host,
method,
policy
);
await backgroundLogPermissionStored(req.host, method, policy);
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
// P2: Store permission for all uses of this WebLN method
await storePermission(
browserSessionData,
null,
req.host,
method,
'allow'
);
await backgroundLogPermissionStored(req.host, method, 'allow');
debug(`Stored approve-all permission for ${method} from ${req.host}`);
}
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
throw new Error('Permission denied');
}
}
// Execute the WebLN method
let result: any;
const client = await getNwcClient(defaultConnection);
switch (method) {
case 'webln.getInfo': {
const info = await client.getInfo();
result = {
node: {
alias: info.alias,
pubkey: info.pubkey,
color: info.color,
},
} as GetInfoResponse;
debug('webln.getInfo result:');
debug(result);
return result;
}
case 'webln.sendPayment': {
const invoice = req.params.paymentRequest;
const payResult = await client.payInvoice({ invoice });
result = { preimage: payResult.preimage } as SendPaymentResponse;
debug('webln.sendPayment result:');
debug(result);
return result;
}
case 'webln.makeInvoice': {
// Convert sats to millisats (NWC uses millisats)
const amountSats = typeof req.params.amount === 'string'
? parseInt(req.params.amount, 10)
: req.params.amount ?? req.params.defaultAmount ?? 0;
const amountMsat = amountSats * 1000;
const invoiceResult = await client.makeInvoice({
amount: amountMsat,
description: req.params.defaultMemo,
});
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
debug('webln.makeInvoice result:');
debug(result);
return result;
}
case 'webln.keysend':
throw new Error('keysend is not yet supported');
default:
throw new Error(`Not supported WebLN method '${method}'.`);
}
}

View File

@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
import { ExtensionMethod } from '@common';
// Extend Window interface for NIP-07
// Extend Window interface for NIP-07 and WebLN
declare global {
interface Window {
nostr?: any;
webln?: any;
}
}
@@ -38,7 +39,7 @@ class Messenger {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
async request(method: ExtensionMethod, params: any): Promise<any> {
const id = generateUUID();
return new Promise((resolve, reject) => {
@@ -89,7 +90,7 @@ const nostr = {
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
async signEvent(event: EventTemplate): Promise<NostrEvent> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
@@ -158,6 +159,92 @@ const nostr = {
window.nostr = nostr as any;
// WebLN types (inline to avoid build issues with @common types in injected script)
interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
// Create a shared messenger instance for WebLN
const weblnMessenger = nostr.messenger;
const webln = {
enabled: false,
async enable(): Promise<void> {
debug('webln.enable received');
await weblnMessenger.request('webln.enable', {});
this.enabled = true;
debug('webln.enable completed');
// Dispatch webln:enabled event as per WebLN spec
window.dispatchEvent(new Event('webln:enabled'));
},
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
debug('webln.getInfo received');
const info = await weblnMessenger.request('webln.getInfo', {});
debug('webln.getInfo response:');
debug(info);
return info;
},
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
debug('webln.sendPayment received');
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
debug('webln.sendPayment response:');
debug(result);
return result;
},
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
debug('webln.keysend received');
const result = await weblnMessenger.request('webln.keysend', args);
debug('webln.keysend response:');
debug(result);
return result;
},
async makeInvoice(
args: string | number | RequestInvoiceArgs
): Promise<{ paymentRequest: string }> {
debug('webln.makeInvoice received');
// Normalize args to RequestInvoiceArgs
let normalizedArgs: RequestInvoiceArgs;
if (typeof args === 'string' || typeof args === 'number') {
normalizedArgs = { amount: args };
} else {
normalizedArgs = args;
}
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
debug('webln.makeInvoice response:');
debug(result);
return result;
},
signMessage(): Promise<{ message: string; signature: string }> {
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
},
verifyMessage(): Promise<void> {
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
},
};
window.webln = webln as any;
// Dispatch webln:ready event to signal that webln is available
// This is dispatched on document as per the WebLN standard
document.dispatchEvent(new Event('webln:ready'));
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const method = params.get('method') as ExtensionMethod;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
@@ -58,6 +58,26 @@ switch (method) {
title = 'Get Relays';
break;
case 'webln.enable':
title = 'Enable WebLN';
break;
case 'webln.getInfo':
title = 'Wallet Info';
break;
case 'webln.sendPayment':
title = 'Send Payment';
break;
case 'webln.makeInvoice':
title = 'Create Invoice';
break;
case 'webln.keysend':
title = 'Keysend Payment';
break;
default:
break;
}
@@ -186,6 +206,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
}
}
// WebLN card visibility
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
if (cardWeblnEnableElement) {
if (method !== 'webln.enable') {
cardWeblnEnableElement.style.display = 'none';
}
}
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
if (cardWeblnGetInfoElement) {
if (method !== 'webln.getInfo') {
cardWeblnGetInfoElement.style.display = 'none';
}
}
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
if (method === 'webln.sendPayment') {
// Display amount in sats
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
} else if (paymentAmountSpan) {
paymentAmountSpan.innerText = 'unknown amount';
}
// Show invoice in json card
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
}
} else {
cardWeblnSendPaymentElement.style.display = 'none';
card2WeblnSendPaymentElement.style.display = 'none';
}
}
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
if (cardWeblnMakeInvoiceElement) {
if (method === 'webln.makeInvoice') {
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
if (invoiceAmountSpan) {
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
if (amount) {
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
}
}
} else {
cardWeblnMakeInvoiceElement.style.display = 'none';
}
}
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
if (cardWeblnKeysendElement) {
if (method !== 'webln.keysend') {
cardWeblnKeysendElement.style.display = 'none';
}
}
//
// Functions
//
@@ -224,4 +303,21 @@ document.addEventListener('DOMContentLoaded', function () {
approveAlwaysButton?.addEventListener('click', () => {
deliver('approve');
});
const rejectAllButton = document.getElementById('rejectAllButton');
rejectAllButton?.addEventListener('click', () => {
deliver('reject-all');
});
const approveAllButton = document.getElementById('approveAllButton');
approveAllButton?.addEventListener('click', () => {
deliver('approve-all');
});
// Show/hide "All Queued" row based on queue size
const queueSize = parseInt(params.get('queueSize') || '0', 10);
const allQueuedRow = document.getElementById('allQueuedRow');
if (allQueuedRow && queueSize <= 1) {
allQueuedRow.style.display = 'none';
}
});

View File

@@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const host = params.get('host');
// Elements
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
const togglePasswordBtn = document.getElementById('togglePassword');
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
const derivingOverlay = document.getElementById('derivingOverlay');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const hostInfo = document.getElementById('hostInfo');
const hostSpan = document.getElementById('hostSpan');
// Show host info if available
if (host && hostInfo && hostSpan) {
hostSpan.innerText = host;
hostInfo.classList.remove('hidden');
}
// Toggle password visibility
togglePasswordBtn?.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordInput.type = 'password';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Enable/disable unlock button based on password input
passwordInput?.addEventListener('input', () => {
unlockBtn.disabled = !passwordInput.value;
});
// Handle enter key
passwordInput?.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && passwordInput.value) {
attemptUnlock();
}
});
// Handle unlock button click
unlockBtn?.addEventListener('click', attemptUnlock);
async function attemptUnlock() {
if (!passwordInput?.value) return;
// Show deriving overlay
derivingOverlay?.classList.remove('hidden');
errorAlert?.classList.add('hidden');
const message: UnlockRequestMessage = {
type: 'unlock-request',
id,
password: passwordInput.value,
};
try {
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
if (response.success) {
// Success - close the window
window.close();
} else {
// Failed - show error
derivingOverlay?.classList.add('hidden');
showError(response.error || 'Invalid password');
}
} catch (error) {
console.error('Failed to send unlock message:', error);
derivingOverlay?.classList.add('hidden');
showError('Failed to unlock vault');
}
}
function showError(message: string) {
if (errorAlert && errorMessage) {
errorMessage.innerText = message;
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 3000);
}
}
// Focus password input on load
document.addEventListener('DOMContentLoaded', () => {
passwordInput?.focus();
});

View File

@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
"src/options.ts"
"src/options.ts",
"src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}