2 Commits

Author SHA1 Message Date
b535a7b967 Release v1.0.3 - Add zip file creation to release process
- Update /release command to create zip files in releases/ folder
- Add v1.0.3 zip files for both Chrome and Firefox extensions

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,4 +33,8 @@
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
</a>
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
<span style="font-size: 1.2rem">🪵</span>
</a>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { Component, inject } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe, JsonPipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe, JsonPipe],
})
export class LogsComponent {
readonly #logger = inject(LoggerService);
get logs(): LogEntry[] {
return this.#logger.logs;
}
onClear() {
this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
return 'log-info';
}
}
}

View File

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

View File

@@ -67,6 +67,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
@@ -78,6 +79,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
req.method,
req.params
);
debug(`permissionState result: ${permissionState}`);
if (permissionState === false) {
throw new Error('Permission denied');

View File

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

View File

@@ -3,8 +3,7 @@
<div class="deriving-modal">
<div class="deriving-spinner"></div>
<h3>{{ message }}</h3>
<div class="deriving-timer">{{ elapsed.toFixed(1) }}s</div>
<p class="deriving-note">This may take 3-6 seconds for security</p>
<p class="deriving-note">This may take a few seconds</p>
</div>
</div>
}

View File

@@ -30,14 +30,6 @@
}
}
.deriving-timer {
font-size: 2.5rem;
font-weight: bold;
color: #ff3eb5;
font-family: monospace;
margin: 0.5rem 0;
}
.deriving-note {
margin: 0.5rem 0 0;
color: #a1a1a1;

View File

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

View File

@@ -1,24 +1,76 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
export interface LogEntry {
timestamp: Date;
level: 'log' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
}
@Injectable({
providedIn: 'root',
})
export class LoggerService {
#namespace: string | undefined;
#logs: LogEntry[] = [];
#maxLogs = 500;
get logs(): LogEntry[] {
return this.#logs;
}
initialize(namespace: string): void {
this.#namespace = namespace;
}
log(value: any) {
log(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('log', value, data);
const nowString = new Date().toLocaleString();
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
}
warn(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('warn', value, data);
const nowString = new Date().toLocaleString();
console.warn(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
}
error(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('error', value, data);
const nowString = new Date().toLocaleString();
console.error(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
}
debug(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('debug', value, data);
const nowString = new Date().toLocaleString();
console.debug(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
}
clear() {
this.#logs = [];
}
#addLog(level: LogEntry['level'], message: any, data?: any) {
const entry: LogEntry = {
timestamp: new Date(),
level,
message: typeof message === 'string' ? message : JSON.stringify(message),
data,
};
this.#logs.unshift(entry);
// Limit stored logs
if (this.#logs.length > this.#maxLogs) {
this.#logs.pop();
}
}
#assureInitialized() {
if (!this.#namespace) {
throw new Error(

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,4 +33,8 @@
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
</a>
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
<span style="font-size: 1.2rem">🪵</span>
</a>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { Component, inject } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe, JsonPipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe, JsonPipe],
})
export class LogsComponent {
readonly #logger = inject(LoggerService);
get logs(): LogEntry[] {
return this.#logger.logs;
}
onClear() {
this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
return 'log-info';
}
}
}

View File

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

View File

@@ -67,6 +67,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
@@ -78,6 +79,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
req.method,
req.params
);
debug(`permissionState result: ${permissionState}`);
if (permissionState === false) {
throw new Error('Permission denied');

View File

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

Binary file not shown.

Binary file not shown.