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>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.remove-all-btn {
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user