diff --git a/.changeset/slow-geckos-fold.md b/.changeset/slow-geckos-fold.md new file mode 100644 index 00000000..75c59dea --- /dev/null +++ b/.changeset/slow-geckos-fold.md @@ -0,0 +1,5 @@ +--- +'chrome-extension': minor +--- + +fulfill disconnect interface diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index be8031a2..ff10d584 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -17,7 +17,11 @@ "content_scripts": [ { "matches": [""], - "js": ["injected-connection-port.js"], + "js": [ + "injected-connection-port.js", + "injected-disconnect-listener.js", + "injected-request-listener.js" + ], "run_at": "document_start" }, { @@ -25,11 +29,6 @@ "js": ["injected-penumbra-global.js"], "run_at": "document_start", "world": "MAIN" - }, - { - "matches": [""], - "js": ["injected-request-listener.js"], - "run_at": "document_start" } ], "options_ui": { @@ -41,7 +40,7 @@ }, "web_accessible_resources": [ { - "resources": ["manifest.json"], + "resources": ["manifest.json", "favicon/*"], "matches": [""] } ], diff --git a/apps/extension/src/content-scripts/injected-connection-port.ts b/apps/extension/src/content-scripts/injected-connection-port.ts index 6ffa769d..7cb69b85 100644 --- a/apps/extension/src/content-scripts/injected-connection-port.ts +++ b/apps/extension/src/content-scripts/injected-connection-port.ts @@ -13,7 +13,7 @@ const initOnce = ( // script in the same extension using chrome.tabs.sendMessage sender: chrome.runtime.MessageSender, // this handler will only ever send an empty response - emptyResponse: (no?: never) => void, + respond: (no?: never) => void, ) => { if (req !== PraxConnection.Init) { // boolean return in handlers signals intent to respond @@ -31,7 +31,7 @@ const initOnce = ( window.postMessage({ [PRAX]: port } satisfies PraxMessage, '/', [port]); // handler is done - emptyResponse(); + respond(); // boolean return in handlers signals intent to respond return true; diff --git a/apps/extension/src/content-scripts/injected-disconnect-listener.ts b/apps/extension/src/content-scripts/injected-disconnect-listener.ts new file mode 100644 index 00000000..f2b67980 --- /dev/null +++ b/apps/extension/src/content-scripts/injected-disconnect-listener.ts @@ -0,0 +1,10 @@ +import { isPraxEndMessageEvent } from './message-event'; +import { PraxConnection } from '../message/prax'; + +const handleDisconnect = (ev: MessageEvent) => { + if (ev.origin === window.origin && isPraxEndMessageEvent(ev)) { + window.removeEventListener('message', handleDisconnect); + void chrome.runtime.sendMessage(PraxConnection.Disconnect); + } +}; +window.addEventListener('message', handleDisconnect); diff --git a/apps/extension/src/content-scripts/injected-penumbra-global.ts b/apps/extension/src/content-scripts/injected-penumbra-global.ts index c9d3f4da..4b36ffcd 100644 --- a/apps/extension/src/content-scripts/injected-penumbra-global.ts +++ b/apps/extension/src/content-scripts/injected-penumbra-global.ts @@ -5,98 +5,212 @@ * * The global is identified by `Symbol.for('penumbra')` and consists of a record * with string keys referring to `PenumbraInjection` objects that contain a - * simple api. The identifiers on this record should be unique and correspond to - * an id in a manifest, and providers should provide a link to the manifest in - * their record entry. + * simple API. The identifiers on this record should be unique, and correspond + * to a browser extension id. Providers should provide a link to their extension + * manifest in their record entry. * - * The global is frozen to prevent mutation, but you should consider that the + * The global is frozen to discourage mutation, but you should consider that the * global and everything on it is only as trustable as the scripts running on - * the page. Imports, includes, and packages your webapp depends on could all - * mutate or preempt the global. User-agent injections like userscripts or other - * content scripts could interfere or intercept connections. + * the page. Imports, requires, includes, script tags, packages your webapp + * depends on, userscripts, or other extensions' content scripts could all + * mutate or preempt this, and all have the power to interfere or intercept + * connections. */ -import { PenumbraInjection, PenumbraRequestFailure, PenumbraSymbol } from '@penumbra-zone/client'; -import { isPraxFailureMessageEvent, isPraxPortMessageEvent, PraxMessage } from './message-event'; +import { PenumbraInjection, PenumbraSymbol } from '@penumbra-zone/client'; + +import { + isPraxFailureMessageEvent, + isPraxPortMessageEvent, + PraxMessage, + unwrapPraxMessageEvent, +} from './message-event'; import { PraxConnection } from '../message/prax'; -const request = Promise.withResolvers(); +type PromiseSettledResultStatus = PromiseSettledResult['status']; -// this is just withResolvers, plus a sync-queryable state attribute -const connection = Object.assign(Promise.withResolvers(), { state: false }); -connection.promise.then( - () => { - connection.state = true; - request.resolve(); - }, - () => { - connection.state = false; - request.reject(); - }, -); +class PraxInjection { + private static singleton?: PraxInjection = new PraxInjection(); + + public static get penumbra() { + return new PraxInjection().injection; + } + + private manifestUrl = `${PRAX_ORIGIN}/manifest.json`; + private _request = Promise.withResolvers(); + private _connect = Promise.withResolvers(); + private _disconnect = Promise.withResolvers(); + + private connectState?: PromiseSettledResultStatus; + private requestState?: PromiseSettledResultStatus; + private disconnectState?: PromiseSettledResultStatus; + + private injection: Readonly = Object.freeze({ + disconnect: () => this.endConnection(), + connect: () => (this.state() !== false ? this._connect.promise : this.connectionFailure), + isConnected: () => this.state(), + request: () => this.postRequest(), + manifest: String(this.manifestUrl), + }); + + private constructor() { + if (PraxInjection.singleton) { + return PraxInjection.singleton; + } + + window.addEventListener('message', this.connectionListener); + + void this._connect.promise + .then( + () => (this.connectState ??= 'fulfilled'), + () => (this.connectState ??= 'rejected'), + ) + .finally(() => window.removeEventListener('message', this.connectionListener)); + + void this._disconnect.promise.then( + () => (this.disconnectState = 'fulfilled'), + () => (this.disconnectState = 'rejected'), + ); + + void this._request.promise.then( + () => (this.requestState = 'fulfilled'), + () => (this.requestState = 'rejected'), + ); + } -// this resolves the connection promise when the isolated port script indicates -const connectionListener = (msg: MessageEvent) => { - if (isPraxPortMessageEvent(msg) && msg.origin === window.origin) { - // @ts-expect-error - ts can't understand the injected string - const praxPort: unknown = msg.data[PRAX]; - if (praxPort instanceof MessagePort) connection.resolve(praxPort); + /** + * Calling this function will synchronously return a unified + * true/false/undefined answer to the page connection state of this provider. + * + * `true` indicates active connection. + * `false` indicates connection is closed or rejected. + * `undefined` indicates connection may be attempted. + */ + private state(): boolean | undefined { + if (this.disconnectState !== undefined) { + return false; + } + if (this.requestState === 'rejected') { + return false; + } + switch (this.connectState) { + case 'rejected': + return false; + case 'fulfilled': + return true; + case undefined: + return undefined; + } } -}; -window.addEventListener('message', connectionListener); -void connection.promise.finally(() => window.removeEventListener('message', connectionListener)); - -// declared outside of postRequest to prevent attaching multiple identical listeners -const requestResponseListener = (msg: MessageEvent) => { - if (msg.origin === window.origin) { - if (isPraxFailureMessageEvent(msg)) { - // @ts-expect-error - ts can't understand the injected string - const status: unknown = msg.data[PRAX]; - const failure = new Error('Connection request failed'); - failure.cause = - typeof status === 'string' && status in PenumbraRequestFailure - ? status - : `Unknown failure: ${String(status)}`; - request.reject(failure); + + // this listener will resolve the connection promise AND request promise when + // the isolated content script injected-connection-port sends a `MessagePort` + private connectionListener = (msg: MessageEvent) => { + if (msg.origin === window.origin && isPraxPortMessageEvent(msg)) { + const praxPort = unwrapPraxMessageEvent(msg); + this._connect.resolve(praxPort); + this._request.resolve(); } + }; + + // this listener only rejects the request promise. success of the request + // promise is indicated by the connection promise being resolved. + private requestFailureListener = (msg: MessageEvent) => { + if (msg.origin === window.origin && isPraxFailureMessageEvent(msg)) { + const cause = unwrapPraxMessageEvent(msg); + const failure = new Error('Connection request failed', { cause }); + this._request.reject(failure); + } + }; + + // always reject with the most important reason at time of access + // 1. disconnect + // 2. connection failure + // 3. request + private get connectionFailure() { + // Promise.race checks in order of the list index. so if more than one + // promise in the list is already settled, it responds with the result of + // the earlier index + return Promise.race([ + // rejects with disconnect failure, or 'Disconnected' if disconnect was successful + this._disconnect.promise.then(() => Promise.reject(Error('Disconnected'))), + // rejects with connect failure, never resolves + this._connect.promise.then(() => new Promise(() => null)), + // rejects with previous failure, or 'Disconnected' if request was successful + this._request.promise.then(() => Promise.reject(Error('Disconnected'))), + // this should be unreachable + Promise.reject(Error('Unknown failure')), + ]); } -}; -// Called to request a connection to the extension. -const postRequest = () => { - if (!connection.state) { - window.addEventListener('message', requestResponseListener); + private postRequest() { + const state = this.state(); + if (state === true) { + // connection is already active + this._request.resolve(); + } else if (state === false) { + // connection is already failed + const failure = this.connectionFailure; + failure.catch((u: unknown) => this._request.reject(u)); + // a previous request may have succeeded, so return the failure directly + return failure; + } else { + // no request made yet. attach listener and emit + window.addEventListener('message', this.requestFailureListener); + void this._request.promise.finally(() => + window.removeEventListener('message', this.requestFailureListener), + ); + window.postMessage( + { + [PRAX]: PraxConnection.Request, + } satisfies PraxMessage, + window.origin, + ); + } + + return this._request.promise; + } + + private endConnection() { + // attempt actual disconnect + void this._connect.promise + .then( + port => { + port.postMessage(false); + port.close(); + }, + (e: unknown) => console.warn('Could not attempt disconnect', e), + ) + .catch((e: unknown) => console.error('Disconnect failed', e)); window.postMessage( - { - [PRAX]: PraxConnection.Request, - } satisfies PraxMessage, - window.origin, + { [PRAX]: PraxConnection.Disconnect } satisfies PraxMessage, + '/', ); - request.promise - .catch((e: unknown) => connection.reject(e)) - .finally(() => window.removeEventListener('message', requestResponseListener)); + + // resolve the promise by state + const state = this.state(); + if (state === true) { + this._disconnect.resolve(); + } else if (state === false) { + this._disconnect.reject(Error('Connection already inactive')); + } else { + this._disconnect.reject(Error('Connection not yet active')); + } + + return this._disconnect.promise; } - return request.promise; -}; - -// the actual object we attach to the global record, frozen -const praxProvider: PenumbraInjection = Object.freeze({ - manifest: `${PRAX_ORIGIN}/manifest.json`, - connect: () => connection.promise, - disconnect: () => Promise.resolve(), // TODO: PR in progress - isConnected: () => connection.state, - request: () => postRequest(), -}); - -// if the global isn't present, create it. -if (!window[PenumbraSymbol]) { - Object.defineProperty(window, PenumbraSymbol, { value: {}, writable: false }); } -// reveal -Object.defineProperty(window[PenumbraSymbol], PRAX_ORIGIN, { - value: praxProvider, - writable: false, - enumerable: true, -}); +// inject prax +Object.defineProperty( + window[PenumbraSymbol] ?? + // create the global if not present + Object.defineProperty(window, PenumbraSymbol, { value: {}, writable: false })[PenumbraSymbol], + PRAX_ORIGIN, + { + value: PraxInjection.penumbra, + writable: false, + enumerable: true, + }, +); diff --git a/apps/extension/src/content-scripts/message-event.ts b/apps/extension/src/content-scripts/message-event.ts index 9ab7139e..010c8522 100644 --- a/apps/extension/src/content-scripts/message-event.ts +++ b/apps/extension/src/content-scripts/message-event.ts @@ -4,30 +4,33 @@ import { PraxConnection } from '../message/prax'; // @ts-expect-error - ts can't understand the injected string // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type PraxMessage = { [PRAX]: T }; +export type PraxMessageEvent = MessageEvent>; -export const isPraxMessageEvent = (ev: MessageEvent): ev is MessageEvent => - isPraxMessageEventData(ev.data); +export const unwrapPraxMessageEvent = (ev: PraxMessageEvent): T => + // @ts-expect-error - ts can't understand the injected string + ev.data[PRAX] as T; -const isPraxMessageEventData = (p: unknown): p is PraxMessage => - typeof p === 'object' && p != null && PRAX in p; +export const isPraxMessageEvent = (ev: MessageEvent): ev is PraxMessageEvent => + typeof ev.data === 'object' && ev.data !== null && PRAX in ev.data; + +export const isPraxPortMessageEvent = ( + ev: MessageEvent, +): ev is PraxMessageEvent => + isPraxMessageEvent(ev) && unwrapPraxMessageEvent(ev) instanceof MessagePort; export const isPraxRequestMessageEvent = ( ev: MessageEvent, -): ev is MessageEvent> => - // @ts-expect-error - ts can't understand the injected string - isPraxMessageEventData(ev.data) && ev.data[PRAX] === PraxConnection.Request; +): ev is PraxMessageEvent => + isPraxMessageEvent(ev) && unwrapPraxMessageEvent(ev) === PraxConnection.Request; -export const isPraxFailureMessageEvent = ( +export const isPraxEndMessageEvent = ( ev: MessageEvent, -): ev is MessageEvent> => { - if (!isPraxMessageEventData(ev.data)) return false; - // @ts-expect-error - ts can't understand the injected string - const status = ev.data[PRAX] as unknown; - return typeof status === 'string' && status in PenumbraRequestFailure; -}; +): ev is PraxMessageEvent => + isPraxMessageEvent(ev) && unwrapPraxMessageEvent(ev) === PraxConnection.Disconnect; -export const isPraxPortMessageEvent = ( +export const isPraxFailureMessageEvent = ( ev: MessageEvent, -): ev is MessageEvent> => - // @ts-expect-error - ts can't understand the injected string - isPraxMessageEventData(ev.data) && ev.data[PRAX] instanceof MessagePort; +): ev is PraxMessageEvent => + isPraxMessageEvent(ev) && + typeof unwrapPraxMessageEvent(ev) === 'string' && + unwrapPraxMessageEvent(ev) in PenumbraRequestFailure; diff --git a/apps/extension/src/listeners/index.ts b/apps/extension/src/listeners/index.ts index d2a446a0..bd307110 100644 --- a/apps/extension/src/listeners/index.ts +++ b/apps/extension/src/listeners/index.ts @@ -1,3 +1,4 @@ import './message-external'; +import './message-prax-disconnect'; import './message-prax-init'; import './message-prax-request'; diff --git a/apps/extension/src/listeners/message-prax-disconnect.ts b/apps/extension/src/listeners/message-prax-disconnect.ts new file mode 100644 index 00000000..5da30644 --- /dev/null +++ b/apps/extension/src/listeners/message-prax-disconnect.ts @@ -0,0 +1,25 @@ +import { PraxConnection } from '../message/prax'; +import { disconnectSender } from '../senders/disconnect'; +import { assertValidSender } from '../senders/validate'; + +// listen for page requests for disconnect +chrome.runtime.onMessage.addListener( + ( + req, + unvalidatedSender, + // this handler will only ever send an empty response + respond: (no?: never) => void, + ) => { + if (req !== PraxConnection.Disconnect) { + // boolean return in handlers signals intent to respond + return false; + } + + const validSender = assertValidSender(unvalidatedSender); + disconnectSender(validSender); + respond(); + + // boolean return in handlers signals intent to respond + return true; + }, +); diff --git a/apps/extension/src/listeners/message-prax-init.ts b/apps/extension/src/listeners/message-prax-init.ts index c07712f9..c00cec39 100644 --- a/apps/extension/src/listeners/message-prax-init.ts +++ b/apps/extension/src/listeners/message-prax-init.ts @@ -1,6 +1,6 @@ import { PraxConnection } from '../message/prax'; -import { alreadyApprovedOrigin } from '../origins/approve-origin'; -import { assertValidSender } from '../origins/valid-sender'; +import { alreadyApprovedSender } from '../senders/approve'; +import { assertValidSender } from '../senders/validate'; // listen for page init chrome.runtime.onMessage.addListener( @@ -8,7 +8,7 @@ chrome.runtime.onMessage.addListener( req, unvalidatedSender, // this handler will only ever send an empty response - emptyResponse: (no?: never) => void, + resepond: (no?: never) => void, ) => { if (req !== PraxConnection.Init) { // boolean return in handlers signals intent to respond @@ -18,18 +18,17 @@ chrome.runtime.onMessage.addListener( const validSender = assertValidSender(unvalidatedSender); void (async () => { - const alreadyApproved = await alreadyApprovedOrigin(validSender.origin); - if (alreadyApproved) { + const alreadyApproved = await alreadyApprovedSender(validSender); + if (alreadyApproved) void chrome.tabs.sendMessage(validSender.tab.id, PraxConnection.Init, { // init only the specific document frameId: validSender.frameId, documentId: validSender.documentId, }); - } })(); // handler is done - emptyResponse(); + resepond(); // boolean return in handlers signals intent to respond return true; diff --git a/apps/extension/src/listeners/message-prax-request.ts b/apps/extension/src/listeners/message-prax-request.ts index 48a71f29..b5b5153a 100644 --- a/apps/extension/src/listeners/message-prax-request.ts +++ b/apps/extension/src/listeners/message-prax-request.ts @@ -2,8 +2,8 @@ import { Code, ConnectError } from '@connectrpc/connect'; import { PenumbraRequestFailure } from '@penumbra-zone/client'; import { UserChoice } from '@penumbra-zone/types/user-choice'; import { PraxConnection } from '../message/prax'; -import { approveOrigin } from '../origins/approve-origin'; -import { assertValidSender } from '../origins/valid-sender'; +import { approveSender } from '../senders/approve'; +import { assertValidSender } from '../senders/validate'; // listen for page requests for approval chrome.runtime.onMessage.addListener( @@ -20,17 +20,15 @@ chrome.runtime.onMessage.addListener( const validSender = assertValidSender(unvalidatedSender); - void approveOrigin(validSender).then( + void approveSender(validSender).then( status => { // origin is already known, or popup choice was made if (status === UserChoice.Approved) { - // approval will trigger init (separate message, not a response) void chrome.tabs.sendMessage(validSender.tab.id, PraxConnection.Init, { // init only the specific document frameId: validSender.frameId, documentId: validSender.documentId, }); - // handler is done respond(); } else { diff --git a/apps/extension/src/message/prax.ts b/apps/extension/src/message/prax.ts index 0dab2ac9..b087f958 100644 --- a/apps/extension/src/message/prax.ts +++ b/apps/extension/src/message/prax.ts @@ -1,4 +1,5 @@ export enum PraxConnection { Init = 'Init', Request = 'Request', + Disconnect = 'Disconnect', } diff --git a/apps/extension/src/origins/approve-origin.ts b/apps/extension/src/origins/approve-origin.ts deleted file mode 100644 index 48ebdd9d..00000000 --- a/apps/extension/src/origins/approve-origin.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { localExtStorage } from '../storage/local'; -import { OriginApproval, PopupType } from '../message/popup'; -import { popup } from '../popup'; -import { UserChoice } from '@penumbra-zone/types/user-choice'; -import { produce } from 'immer'; -import { OriginRecord } from '../storage/types'; - -const getOriginRecord = async (getOrigin?: string) => { - if (!getOrigin) return undefined; - const knownSites = await localExtStorage.get('knownSites'); - const existingRecords = knownSites.filter(record => record.origin === getOrigin); - if (!existingRecords.length) { - return undefined; - } else if (existingRecords.length === 1) { - return existingRecords[0]; - } else { - // TODO: It's likely that an array is not the best data structure for this in storage. Should revisit later. - throw new Error(`There are multiple records for origin: ${getOrigin}`); - } -}; - -const upsertOriginRecord = async (proposal: OriginRecord) => { - const knownSites = await localExtStorage.get('knownSites'); - const newKnownSites = produce(knownSites, allRecords => { - const match = allRecords.find(r => r.origin === proposal.origin); - if (!match) { - allRecords.push(proposal); - } else { - match.choice = proposal.choice; - match.date = proposal.date; - } - }); - await localExtStorage.set('knownSites', newKnownSites); -}; - -export const alreadyApprovedOrigin = async (getOrigin: string): Promise => - getOriginRecord(getOrigin).then(r => r?.choice === UserChoice.Approved); - -/** - * Obtain the approval status of an origin, for use by connection request - * handler. Input origins should already be validated. - * - * @param sender A sender that has already been validated - * @returns The user's choice about the origin, from storage or fresh off the popup - */ -export const approveOrigin = async (sender: { - origin: string; - tab: chrome.tabs.Tab; -}): Promise => { - const existingRecord = await getOriginRecord(sender.origin); - - switch (existingRecord?.choice) { - case UserChoice.Approved: - case UserChoice.Ignored: - return existingRecord.choice; - - case UserChoice.Denied: - case undefined: { - const popupResponse = await popup({ - type: PopupType.OriginApproval, - request: { - origin: sender.origin, - favIconUrl: sender.tab.favIconUrl, - title: sender.tab.title, - lastRequest: existingRecord?.date, - }, - }); - - // if user interacted with popup, update record - if (popupResponse) { - await upsertOriginRecord(popupResponse); - } - - // return choice, or default denial - return popupResponse?.choice ?? UserChoice.Denied; - } - } -}; diff --git a/apps/extension/src/origins/approve-origin.test.ts b/apps/extension/src/senders/approve.test.ts similarity index 90% rename from apps/extension/src/origins/approve-origin.test.ts rename to apps/extension/src/senders/approve.test.ts index 00aef95f..74d63376 100644 --- a/apps/extension/src/origins/approve-origin.test.ts +++ b/apps/extension/src/senders/approve.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { approveOrigin } from './approve-origin'; +import { approveSender } from './approve'; import { UserChoice } from '@penumbra-zone/types/user-choice'; import { OriginRecord } from '../storage/types'; import { PopupType } from '../message/popup'; @@ -25,7 +25,7 @@ describe('origin approvals', () => { vi.clearAllMocks(); }); - describe('approveOrigin', () => { + describe('approveSender', () => { it('prompts and stores choice for a new origin', async () => { mockLocalStorage.get.mockReturnValue(Promise.resolve([])); const messageSender = { origin: 'mock://unknown.example.com', tab: mockTab }; @@ -38,7 +38,7 @@ describe('origin approvals', () => { mockPopup.mockResolvedValue(newOriginRecord); - const choice = await approveOrigin(messageSender); + const choice = await approveSender(messageSender); expect(mockLocalStorage.set).toHaveBeenCalledWith('knownSites', [newOriginRecord]); expect(choice).toBe(UserChoice.Approved); }); @@ -49,7 +49,7 @@ describe('origin approvals', () => { ); const messageSender = { origin: 'mock://ignored.example.com', tab: mockTab }; - const choice = await approveOrigin(messageSender); + const choice = await approveSender(messageSender); expect(choice).toBe(UserChoice.Ignored); }); @@ -62,7 +62,7 @@ describe('origin approvals', () => { ); const messageSender = { origin: 'mock://duplicate.example.com', tab: mockTab }; - await expect(approveOrigin(messageSender)).rejects.toThrow( + await expect(approveSender(messageSender)).rejects.toThrow( 'There are multiple records for origin', ); }); @@ -72,7 +72,7 @@ describe('origin approvals', () => { const messageSender = { origin: 'mock://unknown.example.com', tab: mockTab }; mockPopup.mockResolvedValue(undefined); - const choice = await approveOrigin(messageSender); + const choice = await approveSender(messageSender); expect(choice).toBe(UserChoice.Denied); }); @@ -85,7 +85,7 @@ describe('origin approvals', () => { origin: 'mock://unknown.example.com', } satisfies OriginRecord); - const choice = await approveOrigin(messageSender); + const choice = await approveSender(messageSender); expect(choice).toBe(UserChoice.Denied); }); @@ -103,7 +103,7 @@ describe('origin approvals', () => { mockPopup.mockResolvedValue(newOriginRecord); - const choice = await approveOrigin(messageSender); + const choice = await approveSender(messageSender); expect(mockLocalStorage.set).toHaveBeenCalledWith('knownSites', [newOriginRecord]); expect(choice).toBe(UserChoice.Approved); }); @@ -117,7 +117,7 @@ describe('origin approvals', () => { origin: 'mock://popuptest.example.com', } satisfies OriginRecord); - await approveOrigin(messageSender); + await approveSender(messageSender); expect(mockPopup).toHaveBeenCalledWith({ type: PopupType.OriginApproval, @@ -146,7 +146,7 @@ describe('origin approvals', () => { } satisfies OriginRecord; mockPopup.mockResolvedValue(newOriginRecord); - await approveOrigin(messageSender); + await approveSender(messageSender); expect(mockLocalStorage.set).toHaveBeenCalledWith('knownSites', [ existingOriginRecord, diff --git a/apps/extension/src/senders/approve.ts b/apps/extension/src/senders/approve.ts new file mode 100644 index 00000000..c6742743 --- /dev/null +++ b/apps/extension/src/senders/approve.ts @@ -0,0 +1,54 @@ +import { OriginApproval, PopupType } from '../message/popup'; +import { popup } from '../popup'; +import { UserChoice } from '@penumbra-zone/types/user-choice'; +import { getOriginRecord, upsertOriginRecord } from '../storage/origin'; + +/** + * Obtain approval status from storage, as boolean. + * + * @param validSender A sender that has already been validated + * @returns true if an existing record indicates this sender is approved + */ +export const alreadyApprovedSender = async (validSender: { origin: string }): Promise => + getOriginRecord(validSender.origin).then(r => r?.choice === UserChoice.Approved); + +/** + * Obtain the approval status of an origin, for use by connection request + * handler. Input origins should already be validated. + * + * @param approve A sender that has already been validated + * @returns The user's choice about the origin, from storage or fresh off the popup + */ +export const approveSender = async (approve: { + origin: string; + tab: chrome.tabs.Tab; +}): Promise => { + const existingRecord = await getOriginRecord(approve.origin); + + switch (existingRecord?.choice) { + case UserChoice.Approved: + case UserChoice.Ignored: + return existingRecord.choice; + + case UserChoice.Denied: + case undefined: { + const popupResponse = await popup({ + type: PopupType.OriginApproval, + request: { + origin: approve.origin, + favIconUrl: approve.tab.favIconUrl, + title: approve.tab.title, + lastRequest: existingRecord?.date, + }, + }); + + // if user interacted with popup, update record + if (popupResponse) { + await upsertOriginRecord(popupResponse); + } + + // return choice, or default denial + return popupResponse?.choice ?? UserChoice.Denied; + } + } +}; diff --git a/apps/extension/src/senders/disconnect.ts b/apps/extension/src/senders/disconnect.ts new file mode 100644 index 00000000..ffe2f603 --- /dev/null +++ b/apps/extension/src/senders/disconnect.ts @@ -0,0 +1,18 @@ +import { alreadyApprovedSender } from './approve'; +import { removeOriginRecord } from '../storage/origin'; + +/** + * Remove the approval status of an approved origin, for use by disconnect + * request handler. Input origins should already be validated. + * + * Only approved senders may request removal of their existing approval. + * + * @param disconnect A sender that has already been validated + * @returns void + */ + +export const disconnectSender = (disconnect: { origin: string }) => + void alreadyApprovedSender(disconnect).then(hasApproval => { + if (!hasApproval) throw new Error('Sender does not possess approval'); + else void removeOriginRecord(disconnect.origin); + }); diff --git a/apps/extension/src/origins/valid-sender.test.ts b/apps/extension/src/senders/validate.test.ts similarity index 98% rename from apps/extension/src/origins/valid-sender.test.ts rename to apps/extension/src/senders/validate.test.ts index 4c563241..8df6f0f9 100644 --- a/apps/extension/src/origins/valid-sender.test.ts +++ b/apps/extension/src/senders/validate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { assertValidSender } from './valid-sender'; +import { assertValidSender } from './validate'; describe('assertValidSender', () => { const mockValid: chrome.runtime.MessageSender = { diff --git a/apps/extension/src/origins/valid-sender.ts b/apps/extension/src/senders/validate.ts similarity index 100% rename from apps/extension/src/origins/valid-sender.ts rename to apps/extension/src/senders/validate.ts diff --git a/apps/extension/src/storage/origin.ts b/apps/extension/src/storage/origin.ts new file mode 100644 index 00000000..b53b9360 --- /dev/null +++ b/apps/extension/src/storage/origin.ts @@ -0,0 +1,43 @@ +import { produce } from 'immer'; +import { localExtStorage } from './local'; +import { OriginRecord } from './types'; + +export const getOriginRecord = async (getOrigin?: string) => { + if (!getOrigin) return undefined; + const knownSites = await localExtStorage.get('knownSites'); + + const matchRecords = knownSites.filter(r => r.origin === getOrigin); + if (matchRecords.length > 1) + throw new Error(`There are multiple records for origin: ${getOrigin}`); + + return matchRecords[0]; +}; + +export const upsertOriginRecord = async (proposal: OriginRecord) => { + const knownSites = await localExtStorage.get('knownSites'); + + const newKnownSites = produce(knownSites, allRecords => { + const match = allRecords.find(r => r.origin === proposal.origin); + if (!match) { + allRecords.push(proposal); + } else { + // already matched + // match.origin = proposal.origin; + match.choice = proposal.choice; + match.date = proposal.date; + } + }); + + await localExtStorage.set('knownSites', newKnownSites); +}; + +export const removeOriginRecord = async (removeOrigin: string): Promise => { + const knownSites = await localExtStorage.get('knownSites'); + + const newKnownSites = produce(knownSites, allRecords => { + const matchIndex = allRecords.findIndex(r => r.origin === removeOrigin); + if (matchIndex !== -1) allRecords.splice(matchIndex, 1); + }); + + await localExtStorage.set('knownSites', newKnownSites); +}; diff --git a/apps/extension/src/storage/types.ts b/apps/extension/src/storage/types.ts index 1df3b80a..bb43066b 100644 --- a/apps/extension/src/storage/types.ts +++ b/apps/extension/src/storage/types.ts @@ -22,6 +22,7 @@ export interface LocalStorageState { frontendUrl: string | undefined; passwordKeyPrint: KeyPrintJson | undefined; fullSyncHeight: number | undefined; + // TODO: It's likely that an array is not the best data structure for this in storage. Should revisit later. knownSites: OriginRecord[]; params: Stringified | undefined; numeraires: Stringified[]; diff --git a/apps/extension/webpack.config.ts b/apps/extension/webpack.config.ts index ab22fd08..34e9f5ee 100644 --- a/apps/extension/webpack.config.ts +++ b/apps/extension/webpack.config.ts @@ -36,6 +36,7 @@ const config: webpack.Configuration = { 'injected-connection-port': path.join(injectDir, 'injected-connection-port.ts'), 'injected-penumbra-global': path.join(injectDir, 'injected-penumbra-global.ts'), 'injected-request-listener': path.join(injectDir, 'injected-request-listener.ts'), + 'injected-disconnect-listener': path.join(injectDir, 'injected-disconnect-listener.ts'), 'offscreen-handler': path.join(entryDir, 'offscreen-handler.ts'), 'page-root': path.join(entryDir, 'page-root.tsx'), 'popup-root': path.join(entryDir, 'popup-root.tsx'),