Files
plebeian-signer/projects/firefox/src/background-common.ts
mleku 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

380 lines
9.9 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSessionData,
BrowserSyncData,
BrowserSyncFlow,
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
} 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 { Buffer } from 'buffer';
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
};
export type PromptResponse =
| 'reject'
| 'reject-once'
| 'approve'
| 'approve-once';
export interface PromptResponseMessage {
id: string;
response: PromptResponse;
}
export interface BackgroundRequestMessage {
method: Nip07Method;
params: any;
host: string;
}
export const getBrowserSessionData = async function (): Promise<
BrowserSessionData | undefined
> {
const browserSessionData = await browser.storage.session.get(null);
if (Object.keys(browserSessionData).length === 0) {
return undefined;
}
return browserSessionData as unknown as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
const signerMetaHandler = new FirefoxMetaHandler();
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
};
/**
* Check if reckless mode should auto-approve the request.
* Returns true if should auto-approve, false if should use normal permission flow.
*
* Logic:
* - If reckless mode is OFF → return false (use normal flow)
* - If reckless mode is ON and whitelist is empty → return true (approve all)
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
*/
export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
if (!signerMetaData.recklessMode) {
return false;
}
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
if (whitelistedHosts.length === 0) {
// Reckless mode ON, no whitelist → approve all
return true;
}
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
return whitelistedHosts.includes(host);
};
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {
const signerMetaHandler = new FirefoxMetaHandler();
const signerMetaData =
(await signerMetaHandler.loadFullData()) as SignerMetaData;
let browserSyncData: BrowserSyncData | undefined;
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
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;
}
return browserSyncData;
};
export const savePermissionsToBrowserSyncStorage = async function (
permissions: Permission_ENCRYPTED[]
): Promise<void> {
const signerMetaHandler = new FirefoxMetaHandler();
const signerMetaData =
(await signerMetaHandler.loadFullData()) as SignerMetaData;
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
await browser.storage.local.set({ permissions });
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
await browser.storage.sync.set({ permissions });
}
};
export const checkPermissions = function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
host: string,
method: Nip07Method,
params: any
): boolean | undefined {
const permissions = browserSessionData.permissions.filter(
(x) =>
x.identityId === identity.id && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
if (method === 'getPublicKey') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'getRelays') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'signEvent') {
// Evaluate params.
const eventTemplate = params as EventTemplate;
if (
permissions.find(
(x) => x.methodPolicy === 'allow' && typeof x.kind === 'undefined'
)
) {
return true;
}
if (
permissions.some(
(x) => x.methodPolicy === 'allow' && x.kind === eventTemplate.kind
)
) {
return true;
}
if (
permissions.some(
(x) => x.methodPolicy === 'deny' && x.kind === eventTemplate.kind
)
) {
return false;
}
return undefined;
}
if (method === 'nip04.encrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'nip44.encrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'nip04.decrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'nip44.decrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
return undefined;
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
host: string,
method: Nip07Method,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
throw new Error(`Could not retrieve sync data`);
}
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
host,
method,
methodPolicy,
kind,
};
// Store session data
await browser.storage.session.set({
permissions: [...browserSessionData.permissions, permission],
});
// Encrypt permission to store in sync storage (depending on sync flow).
const encryptedPermission = await encryptPermission(
permission,
browserSessionData
);
await savePermissionsToBrowserSyncStorage([
...browserSyncData.permissions,
encryptedPermission,
]);
};
export const getPosition = async function (width: number, height: number) {
let left = 0;
let top = 0;
try {
const lastFocused = await browser.windows.getLastFocused();
if (
lastFocused &&
lastFocused.top !== undefined &&
lastFocused.left !== undefined &&
lastFocused.width !== undefined &&
lastFocused.height !== undefined
) {
// Position window in the center of the lastFocused window
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
} else {
console.error('Last focused window properties are undefined.');
}
} catch (error) {
console.error('Error getting window position:', error);
}
return {
top,
left,
};
};
export const signEvent = function (
eventTemplate: EventTemplate,
privkey: string
): Event {
return finalizeEvent(eventTemplate, NostrHelper.hex2bytes(privkey));
};
export const nip04Encrypt = async function (
privkey: string,
peerPubkey: string,
plaintext: string
): Promise<string> {
return await nip04.encrypt(
NostrHelper.hex2bytes(privkey),
peerPubkey,
plaintext
);
};
export const nip44Encrypt = async function (
privkey: string,
peerPubkey: string,
plaintext: string
): Promise<string> {
const key = nip44.v2.utils.getConversationKey(
NostrHelper.hex2bytes(privkey),
peerPubkey
);
return nip44.v2.encrypt(plaintext, key);
};
export const nip04Decrypt = async function (
privkey: string,
peerPubkey: string,
ciphertext: string
): Promise<string> {
return await nip04.decrypt(
NostrHelper.hex2bytes(privkey),
peerPubkey,
ciphertext
);
};
export const nip44Decrypt = async function (
privkey: string,
peerPubkey: string,
ciphertext: string
): Promise<string> {
const key = nip44.v2.utils.getConversationKey(
NostrHelper.hex2bytes(privkey),
peerPubkey
);
return nip44.v2.decrypt(ciphertext, key);
};
const encryptPermission = async function (
permission: Permission_DECRYPTED,
sessionData: BrowserSessionData
): Promise<Permission_ENCRYPTED> {
const encryptedPermission: Permission_ENCRYPTED = {
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(),
sessionData
);
}
return encryptedPermission;
};
const encrypt = async function (
value: string,
sessionData: BrowserSessionData
): Promise<string> {
// 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!);
};