From 6c43a6081032521b5f68eb181f11e1e76e2ae1d2 Mon Sep 17 00:00:00 2001 From: DEV Sam Hayes Date: Fri, 7 Feb 2025 22:26:34 +0100 Subject: [PATCH] migrate background related things from chrome --- projects/firefox/public/manifest.json | 27 +- projects/firefox/public/prompt.html | 221 ++++++++++++++ projects/firefox/src/background-common.ts | 288 +++++++++++++++++++ projects/firefox/src/background.ts | 148 ++++++++++ projects/firefox/src/gooti-content-script.ts | 42 +++ projects/firefox/src/gooti-extension.ts | 128 +++++++++ projects/firefox/src/prompt.ts | 167 +++++++++++ 7 files changed, 1002 insertions(+), 19 deletions(-) create mode 100644 projects/firefox/src/background-common.ts diff --git a/projects/firefox/public/manifest.json b/projects/firefox/public/manifest.json index 31bb874..e5d2098 100644 --- a/projects/firefox/public/manifest.json +++ b/projects/firefox/public/manifest.json @@ -5,37 +5,26 @@ "version": "0.0.1", "homepage_url": "https://getgooti.com", "options_page": "options.html", - "permissions": [ - "storage" - ], + "permissions": ["storage"], "action": { "default_popup": "index.html", "default_icon": "gooti-with-bg.png" }, "background": { - "scripts": [ - "background.js" - ] + "scripts": ["background.js"] }, "content_scripts": [ { - "run_at": "document_end", - "matches": [ - "" - ], - "js": [ - "gooti-content-script.js" - ] + "run_at": "document_start", + "matches": [""], + "js": ["gooti-content-script.js"], + "all_frames": true } ], "web_accessible_resources": [ { - "resources": [ - "gooti-extension.js" - ], - "matches": [ - "" - ] + "resources": ["gooti-extension.js"], + "matches": [""] } ], "browser_specific_settings": { diff --git a/projects/firefox/public/prompt.html b/projects/firefox/public/prompt.html index e69de29..22256a9 100644 --- a/projects/firefox/public/prompt.html +++ b/projects/firefox/public/prompt.html @@ -0,0 +1,221 @@ + + + + + Gooti + + + + + +
+
+
+ +
+ + + + +
+ + + is requesting permission to
+
+ read your public key
+
+ + for the selected identity + + +
+
+ + +
+ + + is requesting permission to
+
+ read your relays
+
+ + for the selected identity + + +
+
+ + +
+ + + is requesting permission to
+
+ sign an event (kind + )
+
+ + for the selected identity + + +
+
+ + +
+
+
+ + +
+ + + is requesting permission to
+
+ encrypt a text (NIP04)
+
+ + for the selected identity + + +
+
+ + +
+
+
+ + +
+ + + is requesting permission to
+
+ decrypt a text (NIP04)
+
+ + for the selected identity + + +
+
+ + +
+
+
+
+ + + + + +
+ + + diff --git a/projects/firefox/src/background-common.ts b/projects/firefox/src/background-common.ts new file mode 100644 index 0000000..d942174 --- /dev/null +++ b/projects/firefox/src/background-common.ts @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + BrowserSessionData, + BrowserSyncData, + BrowserSyncFlow, + CryptoHelper, + GootiMetaData, + Identity_DECRYPTED, + Nip07Method, + Nip07MethodPolicy, + NostrHelper, + Permission_DECRYPTED, + Permission_ENCRYPTED, +} from '@common'; +import { Event, EventTemplate, finalizeEvent, nip04 } from 'nostr-tools'; +import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler'; +import browser from 'webextension-polyfill'; + +export const debug = function (message: any) { + const dateString = new Date().toISOString(); + console.log(`[Gooti - ${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 getBrowserSyncData = async function (): Promise< + BrowserSyncData | undefined +> { + const gootiMetaHandler = new FirefoxMetaHandler(); + const gootiMetaData = + (await gootiMetaHandler.loadFullData()) as GootiMetaData; + + let browserSyncData: BrowserSyncData | undefined; + + if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) { + browserSyncData = (await browser.storage.local.get( + null + )) as unknown as BrowserSyncData; + } else if (gootiMetaData.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 { + const gootiMetaHandler = new FirefoxMetaHandler(); + const gootiMetaData = + (await gootiMetaHandler.loadFullData()) as GootiMetaData; + + if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) { + await browser.storage.local.set({ permissions }); + } else if (gootiMetaData.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 === 'nip04.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.iv, + browserSessionData.vaultPassword as string + ); + + 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 { + return await nip04.encrypt( + NostrHelper.hex2bytes(privkey), + peerPubkey, + plaintext + ); +}; + +export const nip04Decrypt = async function ( + privkey: string, + peerPubkey: string, + ciphertext: string +): Promise { + return await nip04.decrypt( + NostrHelper.hex2bytes(privkey), + peerPubkey, + ciphertext + ); +}; + +const encryptPermission = async function ( + permission: Permission_DECRYPTED, + iv: string, + password: string +): Promise { + 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), + }; + + if (typeof permission.kind !== 'undefined') { + encryptedPermission.kind = await encrypt( + permission.kind.toString(), + iv, + password + ); + } + + return encryptedPermission; +}; + +const encrypt = async function ( + value: string, + iv: string, + password: string +): Promise { + return await CryptoHelper.encrypt(value, iv, password); +}; diff --git a/projects/firefox/src/background.ts b/projects/firefox/src/background.ts index e69de29..08113aa 100644 --- a/projects/firefox/src/background.ts +++ b/projects/firefox/src/background.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NostrHelper } from '@common'; +import { + BackgroundRequestMessage, + checkPermissions, + debug, + getBrowserSessionData, + getPosition, + nip04Decrypt, + nip04Encrypt, + PromptResponse, + PromptResponseMessage, + signEvent, + storePermission, +} from './background-common'; +import browser from 'webextension-polyfill'; +import { Buffer } from 'buffer'; + +type Relays = Record; + +const openPrompts = new Map< + string, + { + resolve: (response: PromptResponse) => void; + reject: (reason?: any) => void; + } +>(); + +browser.runtime.onMessage.addListener(async (message /*, sender*/) => { + debug('Message received'); + const request = message as BackgroundRequestMessage | PromptResponseMessage; + debug(request); + + if ((request as PromptResponseMessage)?.id) { + // Handle prompt response + 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.' + ); + } + + openPrompt.resolve(promptResponse.response); + openPrompts.delete(promptResponse.id); + return; + } + + const browserSessionData = await getBrowserSessionData(); + + if (!browserSessionData) { + throw new Error('Gooti vault not unlocked by the user.'); + } + + const currentIdentity = browserSessionData.identities.find( + (x) => x.id === browserSessionData.selectedIdentityId + ); + + if (!currentIdentity) { + throw new Error('No Nostr identity available at endpoint.'); + } + + const req = request as BackgroundRequestMessage; + const permissionState = checkPermissions( + browserSessionData, + currentIdentity, + req.host, + req.method, + req.params + ); + + if (permissionState === false) { + throw new Error('Permission denied'); + } + + if (permissionState === undefined) { + // Ask user for permission. + 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((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, + }); + }); + debug(response); + if (response === 'approve' || response === 'reject') { + await storePermission( + browserSessionData, + currentIdentity, + req.host, + req.method, + response === 'approve' ? 'allow' : 'deny', + req.params?.kind + ); + } + + if (['reject', 'reject-once'].includes(response)) { + throw new Error('Permission denied'); + } + } else { + debug('Request allowed (via saved permission).'); + } + + const relays: Relays = {}; + switch (req.method) { + case 'getPublicKey': + return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey); + + case 'signEvent': + return signEvent(req.params, currentIdentity.privkey); + + case 'getRelays': + browserSessionData.relays.forEach((x) => { + relays[x.url] = { read: x.read, write: x.write }; + }); + return relays; + + case 'nip04.encrypt': + return await nip04Encrypt( + currentIdentity.privkey, + req.params.peerPubkey, + req.params.plaintext + ); + + case 'nip04.decrypt': + return await nip04Decrypt( + currentIdentity.privkey, + req.params.peerPubkey, + req.params.ciphertext + ); + + default: + throw new Error(`Not supported request method '${req.method}'.`); + } +}); diff --git a/projects/firefox/src/gooti-content-script.ts b/projects/firefox/src/gooti-content-script.ts index e69de29..6044d1a 100644 --- a/projects/firefox/src/gooti-content-script.ts +++ b/projects/firefox/src/gooti-content-script.ts @@ -0,0 +1,42 @@ +import browser from 'webextension-polyfill'; +import { BackgroundRequestMessage } from './background-common'; + +// Inject the script that will provide window.nostr +// The script needs to run before any other scripts from the real +// page run (and maybe check for window.nostr). +const script = document.createElement('script'); +script.setAttribute('async', 'false'); +script.setAttribute('type', 'text/javascript'); +script.setAttribute('src', browser.runtime.getURL('gooti-extension.js')); +(document.head || document.documentElement).appendChild(script); + +// listen for messages from that script +window.addEventListener('message', async (message) => { + // We will also receive our own messages, that we sent. + // We have to ignore them (they will not have a params field). + + if (message.source !== window) return; + if (!message.data) return; + if (!message.data.params) return; + if (message.data.ext !== 'gooti') return; + + // pass on to background + let response; + try { + const request: BackgroundRequestMessage = { + method: message.data.method, + params: message.data.params, + host: location.host, + }; + + response = await browser.runtime.sendMessage(request); + } catch (error) { + response = { error }; + } + + // return response + window.postMessage( + { id: message.data.id, ext: 'gooti', response }, + message.origin + ); +}); diff --git a/projects/firefox/src/gooti-extension.ts b/projects/firefox/src/gooti-extension.ts index e69de29..0c19f55 100644 --- a/projects/firefox/src/gooti-extension.ts +++ b/projects/firefox/src/gooti-extension.ts @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Event, EventTemplate } from 'nostr-tools'; +import { Nip07Method } from '@common'; + +type Relays = Record; + +class Messenger { + #requests = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (reason: any) => void; + } + >(); + + constructor() { + window.addEventListener('message', this.#handleCallResponse.bind(this)); + } + + async request(method: Nip07Method, params: any): Promise { + const id = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + this.#requests.set(id, { resolve, reject }); + window.postMessage( + { + id, + ext: 'gooti', + method, + params, + }, + '*' + ); + }); + } + + #handleCallResponse(message: MessageEvent) { + // We also will receive our own messages, that we sent. + // We have to ignore them (they will not have a response field). + if ( + !message.data || + message.data.response === null || + message.data.response === undefined || + message.data.ext !== 'gooti' || + !this.#requests.has(message.data.id) + ) { + return; + } + + if (message.data.response.error) { + this.#requests.get(message.data.id)?.reject(message.data.response.error); + } else { + this.#requests.get(message.data.id)?.resolve(message.data.response); + } + + this.#requests.delete(message.data.id); + } +} + +const nostr = { + messenger: new Messenger(), + + async getPublicKey(): Promise { + debug('getPublicKey received'); + const pubkey = await this.messenger.request('getPublicKey', {}); + debug(`getPublicKey response:`); + debug(pubkey); + return pubkey; + }, + + async signEvent(event: EventTemplate): Promise { + debug('signEvent received'); + const signedEvent = await this.messenger.request('signEvent', event); + debug('signEvent response:'); + debug(signedEvent); + return signedEvent; + }, + + async getRelays(): Promise { + debug('getRelays received'); + const relays = (await this.messenger.request('getRelays', {})) as Relays; + debug('getRelays response:'); + debug(relays); + return relays; + }, + + nip04: { + that: this, + + async encrypt(peerPubkey: string, plaintext: string): Promise { + debug('nip04.encrypt received'); + const ciphertext = (await nostr.messenger.request('nip04.encrypt', { + peerPubkey, + plaintext, + })) as string; + debug('nip04.encrypt response:'); + debug(ciphertext); + return ciphertext; + }, + + async decrypt(peerPubkey: string, ciphertext: string): Promise { + debug('nip04.decrypt received'); + const plaintext = (await nostr.messenger.request('nip04.decrypt', { + peerPubkey, + ciphertext, + })) as string; + debug('nip04.decrypt response:'); + debug(plaintext); + return plaintext; + }, + }, + + // nip44: { + // async encrypt(peer, plaintext) { + // return window.nostr._call('nip44.encrypt', { peer, plaintext }); + // }, + + // async decrypt(peer, ciphertext) { + // return window.nostr._call('nip44.decrypt', { peer, ciphertext }); + // }, + // }, +}; + +window.nostr = nostr as any; + +const debug = function (value: any) { + console.log(JSON.stringify(value)); +}; diff --git a/projects/firefox/src/prompt.ts b/projects/firefox/src/prompt.ts index e69de29..0032df3 100644 --- a/projects/firefox/src/prompt.ts +++ b/projects/firefox/src/prompt.ts @@ -0,0 +1,167 @@ +import browser from 'webextension-polyfill'; +import { Buffer } from 'buffer'; +import { Nip07Method } from '@common'; +import { PromptResponse, PromptResponseMessage } from './background-common'; + +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 title = ''; +switch (method) { + case 'getPublicKey': + title = 'Get Public Key'; + break; + + case 'signEvent': + title = 'Sign Event'; + break; + + case 'nip04.encrypt': + title = 'Encrypt'; + break; + + case 'nip04.decrypt': + title = 'Decrypt'; + break; + + case 'getRelays': + title = 'Get Relays'; + break; + + default: + break; +} + +const titleSpanElement = document.getElementById('titleSpan'); +if (titleSpanElement) { + titleSpanElement.innerText = title; +} + +Array.from(document.getElementsByClassName('nick-INSERT')).forEach( + (element) => { + (element as HTMLElement).innerText = nick; + } +); + +Array.from(document.getElementsByClassName('host-INSERT')).forEach( + (element) => { + (element as HTMLElement).innerText = host; + } +); + +const kindSpanElement = document.getElementById('kindSpan'); +if (kindSpanElement) { + kindSpanElement.innerText = JSON.parse(event).kind; +} + +const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey'); +if (cardGetPublicKeyElement) { + if (method === 'getPublicKey') { + // Do nothing. + } else { + cardGetPublicKeyElement.style.display = 'none'; + } +} + +const cardGetRelaysElement = document.getElementById('cardGetRelays'); +if (cardGetRelaysElement) { + if (method === 'getRelays') { + // Do nothing. + } else { + cardGetRelaysElement.style.display = 'none'; + } +} + +const cardSignEventElement = document.getElementById('cardSignEvent'); +const card2SignEventElement = document.getElementById('card2SignEvent'); +if (cardSignEventElement && card2SignEventElement) { + if (method === 'signEvent') { + const card2SignEvent_jsonElement = document.getElementById( + 'card2SignEvent_json' + ); + if (card2SignEvent_jsonElement) { + card2SignEvent_jsonElement.innerText = event; + } + } else { + cardSignEventElement.style.display = 'none'; + card2SignEventElement.style.display = 'none'; + } +} + +const cardNip04EncryptElement = document.getElementById('cardNip04Encrypt'); +const card2Nip04EncryptElement = document.getElementById('card2Nip04Encrypt'); +if (cardNip04EncryptElement && card2Nip04EncryptElement) { + if (method === 'nip04.encrypt') { + const card2Nip04Encrypt_textElement = document.getElementById( + 'card2Nip04Encrypt_text' + ); + if (card2Nip04Encrypt_textElement) { + const eventObject: { peerPubkey: string; plaintext: string } = + JSON.parse(event); + card2Nip04Encrypt_textElement.innerText = eventObject.plaintext; + } + } else { + cardNip04EncryptElement.style.display = 'none'; + card2Nip04EncryptElement.style.display = 'none'; + } +} + +const cardNip04DecryptElement = document.getElementById('cardNip04Decrypt'); +const card2Nip04DecryptElement = document.getElementById('card2Nip04Decrypt'); +if (cardNip04DecryptElement && card2Nip04DecryptElement) { + if (method === 'nip04.decrypt') { + const card2Nip04Decrypt_textElement = document.getElementById( + 'card2Nip04Decrypt_text' + ); + if (card2Nip04Decrypt_textElement) { + const eventObject: { peerPubkey: string; ciphertext: string } = + JSON.parse(event); + card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext; + } + } else { + cardNip04DecryptElement.style.display = 'none'; + card2Nip04DecryptElement.style.display = 'none'; + } +} + +// +// Functions +// + +function deliver(response: PromptResponse) { + const message: PromptResponseMessage = { + id, + response, + }; + + browser.runtime.sendMessage(message); + window.close(); +} + +document.addEventListener('DOMContentLoaded', function () { + const rejectJustOnceButton = document.getElementById('rejectJustOnceButton'); + rejectJustOnceButton?.addEventListener('click', () => { + deliver('reject-once'); + }); + + const rejectButton = document.getElementById('rejectButton'); + rejectButton?.addEventListener('click', () => { + deliver('reject'); + }); + + const approveJustOnceButton = document.getElementById( + 'approveJustOnceButton' + ); + approveJustOnceButton?.addEventListener('click', () => { + deliver('approve-once'); + }); + + const approveButton = document.getElementById('approveButton'); + approveButton?.addEventListener('click', () => { + deliver('approve'); + }); +});