Compare commits

...

28 Commits

Author SHA1 Message Date
mac
727e147690 Update manifest.json 2024-10-10 21:24:16 +02:00
Tanner
2db735e04f Multiple Redactions during Notarization process (#81) 2024-10-09 05:33:47 -04:00
Hendrik Eeckhaut
f6e582016b feat: add button to open extension in a browser page and various UI updates (#99) 2024-10-09 04:56:04 -04:00
Hendrik Eeckhaut
21ebcd1a11 Added documentation (#96)
* Added documentation

* formatting
2024-10-08 10:24:05 -04:00
Hendrik Eeckhaut
5063d6cb45 chore: refactor to use constants (#104) 2024-10-08 10:22:13 -04:00
Hendrik Eeckhaut
3d5e3ce4ac chore: remove Max transcript size and refactor to use constants (#106)
* chore: refactor to use constants

* chore: remove stale max_transcript_size

closes #105
2024-10-08 10:21:41 -04:00
tsukino
d47cf0d8ea feat: integrate with alpha.7 (#102) 2024-10-04 05:37:41 -04:00
Tanner
25f35d0051 feat: moving requestbuilder.tsx from utils to it's own component (#92) 2024-08-28 04:10:28 -04:00
tsukino
5ccdd9b06a feat: add default plugins to extension bundle (#93) 2024-08-28 04:05:56 -04:00
Hendrik Eeckhaut
186f77d3cb fix: add body to request (#89) 2024-08-23 03:22:21 -04:00
Hendrik Eeckhaut
08c4f74479 chore: moved all example plugins to the boilerplate repository (#90)
https://github.com/tlsnotary/tlsn-plugin-boilerplate
2024-08-23 03:21:53 -04:00
tsukino
737cc10af7 fix: add default empty array for secret values (#86) 2024-08-22 04:36:24 -04:00
tsukino
d1cbc34126 fix: empty body string should be undefined (#85) 2024-08-21 10:52:39 -04:00
Tanner
30a7b7b36b feat: adding connection status to requests store (#82) 2024-08-21 07:12:30 -04:00
tsukino
4c78625f12 fix: update logging level to work with alpha6; update verify method in background; add version to option page (#79) 2024-08-14 07:57:27 -04:00
tsukino
d15d021b4a feat: upgrade to alpha.6 (#78)
* feat: integrate with alpha.6

* fix: replace deleted type

* chore: update package-lock
2024-08-13 12:50:51 -04:00
Tanner
be27560631 Content Script disconnect (#75)
* feat: Modal to disconnect from origin

* refactor: styling

* chore: linting

* refactor: styling connections modal

* feat: moved ConnectionDetailsModal to its own component

* chore: linting

* fix: styling update

---------

Co-authored-by: tsukino <0xtsukino@gmail.com>
2024-08-13 12:43:22 -04:00
Ryan MacArthur
a018acb7bf [chore] MV3 migration: remove persistent key from background' (#76) 2024-08-13 11:00:26 -04:00
Tanner
d9dacdfb14 feat: adding disclaimer readme to example plugins (#74) 2024-07-13 12:58:04 +02:00
tsukino
998c9c091e fix: overwrite headers and cookies (#73) 2024-07-12 13:27:13 +02:00
tsukino
c04556620c fix: overwrite metadata when installing plugin (#72) 2024-07-04 03:26:28 -04:00
Tanner
4b473273dc fix: Request Builder fixes (#65)
* feat: wip fixing requestbuilder

* refactor: refactored RequestBuilder and RequestDetail components

* refactor: WIP requestbuilder/responsedetail methods working now

* refactor: dynamically change content-type for body

* refactor: formatting body for request

* feat: adding notarize button to RequestBuilder

* feat: adding x-www-form-urlencoded option to body

* refactor: major refactor of request builder

* feat: added parseResponse function; refactor: adjusted formatForRequest to properly handle different body types

* refactor: improving styling and readability

* refactor: fixing method/type logic for body and headers

* refactor: cleaning up code in requestbuilder utils

* chore: wip

* refactor: fixing GET/HEAD sending a body

* refactor: fix for type and method changes

* refactor: fixing more bugs with GET/POST & x-www-form-urlencoded requests

* feat: update content-type silent value instead of removing on method change
2024-07-04 02:10:37 -04:00
tsukino
585a8f2d3d feat: add injected client to content script (#69)
* feat: connection approval screen and injected script

* fix: favicon and origin on connection request

* feat: add get history to injected client

* feat: add get history approval ux

* fix: styling fix

* feat: add get proof

* fix: throw error when proof not found

* wip: adding notarize

* feat: add notarization request

* feat: add metadata to notarization request

* feat: add install plugin content script

* feat: add get plugins content script

* feat: add execute plugin

* fix: remove unused method
2024-07-04 02:06:35 -04:00
Hendrik Eeckhaut
1c29fee920 Add example plugin in Typescript (#71)
* WIP: plugins in typescript

* Convert icon to base64 with esbuild step

* code clean up

* Added comments + move config to the front

* Corrected package.json (name, description, etc)

* Call extism-js in esbuild.js

* Moved config file to separate json file

+ various cleanups
2024-06-25 06:02:12 -04:00
Hendrik Eeckhaut
884e55dccf feat: Twitter DM plugin + use minimatch to allow patterns in config request urls (#66) 2024-06-17 06:17:21 -04:00
Tanner
87e96c0f50 feat: Display Plugin Information (#62)
* feat: added info button and confirmation to remove button

* refactor: working on plugin info modal

* refactor: adding remove and plugininfo to callback dependencies

* refactor: working on plugin info modal

* feat: show plugin information on plugin upload

* refactor: Plugin info and plugin installation modals restyled

* refactor: linting

* refactor: plugin installation modal refactor

* refactor: plugin upload refactor

* wip: restyling and add user friendly text

* fix: restyling and retexting

* refactor PluginInfo

* fix error message and twitter profile plugin

---------

Co-authored-by: John Shaw <codetrauma@Johns-MacBook-Pro.local>
Co-authored-by: tsukino <0xtsukino@gmail.com>
2024-06-17 04:19:40 -04:00
Hendrik Eeckhaut
3b8cd0fba3 fix: off by one error + updated README 2024-06-13 11:21:50 +02:00
tsukino
c41b4ff401 feat: add redaction back to plugin (#64)
* feat: add redaction back to plugin

* chore: compile wasm

* fix: pass in correct request body
2024-06-11 10:19:09 -04:00
76 changed files with 7988 additions and 11478 deletions

View File

@@ -42,7 +42,7 @@ at your option.
```
$ git clone https://github.com/novnc/websockify && cd websockify
$ ./docker/build.sh
$ docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
$ docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
```
## Running Websockify Docker Image

6529
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tlsn-extension",
"version": "0.1.0.5",
"version": "0.1.0.700",
"license": "MIT",
"repository": {
"type": "git",
@@ -27,6 +27,7 @@
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.6.2",
"level": "^8.0.0",
"minimatch": "^9.0.4",
"node-cache": "^5.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -37,7 +38,8 @@
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"tailwindcss": "^3.3.3",
"tlsn-js": "0.1.0-alpha.5.3"
"tlsn-js": "0.1.0-alpha.7.1",
"tlsn-js-v5": "npm:tlsn-js@0.1.0-alpha.5.4"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@@ -1,48 +0,0 @@
# Plugin Development for the TLSNotary Browser Extension
This folder is dedicated to the development of plugins for the TLSNotary browser extension, utilizing the Extism framework. Currently, the folder includes a TypeScript-based plugin example, `twitter_profile`, with plans to add more plugins showcasing different programming languages and functionalities.
## Installation of Extism-js
1. **Download and Install Extism-js**: Begin by setting up `extism-js`, which enables you to compile and manage your plugins. Run these commands to download and install it:
```sh
curl -O https://raw.githubusercontent.com/extism/js-pdk/main/install.sh
sh install.sh
```
This script installs the Extism JavaScript Plugin Development Kit from its GitHub repository, preparing your environment for plugin compilation.
## Building the Twitter Profile Plugin
Navigate to the `twitter_profile` directory within this folder and run the following command to build the plugin:
```sh
extism-js index.js -i index.d.ts -o index.wasm
```
This command compiles the TypeScript code in index.js into a WebAssembly module, ready for integration with the TLSNotary extension.
### Running the Twitter Plugin Example:
1. Build the `twitter_profile` plugin as explained above.
2. Build and install the `tlsn-extension` as documented in the [main README.md](../README.md).
3. [Run a local notary server](https://github.com/tlsnotary/tlsn/blob/main/notary-server/README.md), ensuring `TLS` is disabled in the [config file](https://github.com/tlsnotary/tlsn/blob/main/notary-server/config/config.yaml#L18).
4. Install the plugin: Click the **Add a Plugin (+)** button and select the `index.wasm` file you built in step 1. A **Twitter Profile** button should then appear below the default buttons.
5. Click the **Twitter Profile** button. This action opens the Twitter webpage along with a TLSNotary sidebar.
6. Follow the steps in the TLSNotary sidebar.
7. Access the TLSNotary results by clicking the **History** button in the TLSNotary extension.
## Future Plugins
This directory will be expanded with more plugins designed to demonstrate the functionality of the TLSNotary extension. Plugins enable flexible use of the TLSNotary across a broad range of applications. The use of Extism facilitates plugin development in various languages, further enhancing flexibility.
## Create an icon
1. resize to 320x320 pixels:
```sh
convert icon.png -resize 320x320! icon_320.png
```
2. convert to base64
```sh
base64 -i icon_320.png -o icon_320.txt
```

View File

@@ -1,4 +0,0 @@
declare module 'main' {
// Extism exports take no params and return an I32
export function hello(): I32;
}

View File

@@ -1,6 +0,0 @@
function hello() {
const name = Host.inputString();
Host.outputString(`Hello, ${name}`);
}
module.exports = { hello };

Binary file not shown.

View File

@@ -1,14 +0,0 @@
declare module 'main' {
// Extism exports take no params and return an I32
export function start(): I32;
export function two(): I32;
export function three(): I32;
export function config(): I32;
}
declare module 'extism:host' {
interface user {
redirect(ptr: I64): void;
notarize(ptr: I64): I64;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +0,0 @@
declare module 'main' {
// Extism exports take no params and return an I32
export function start(): I32;
export function two(): I32;
export function three(): I32;
export function config(): I32;
}
declare module 'extism:host' {
interface user {
redirect(ptr: I64): void;
notarize(ptr: I64): I64;
}
}

File diff suppressed because one or more lines are too long

25
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ dependencies:
level:
specifier: ^8.0.0
version: 8.0.1
minimatch:
specifier: ^9.0.4
version: 9.0.4
node-cache:
specifier: ^5.1.2
version: 5.1.2
@@ -69,8 +72,11 @@ dependencies:
specifier: ^3.3.3
version: 3.4.3
tlsn-js:
specifier: 0.1.0-alpha.5.3
version: 0.1.0-alpha.5.3
specifier: 0.1.0-alpha.7.1
version: 0.1.0-alpha.7.1
tlsn-js-v5:
specifier: npm:tlsn-js@0.1.0-alpha.5.4
version: /tlsn-js@0.1.0-alpha.5.4
devDependencies:
'@babel/core':
@@ -7968,13 +7974,24 @@ packages:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
dev: true
/tlsn-js@0.1.0-alpha.5.3:
resolution: {integrity: sha512-eTSCQ6MaH8mN2oCfLsQDe/mdfTlIq74MbKVesV6M01C2SRE0FJcVlTWMsnT3L+wOpvOlvsiBeCWV7T9m/YMzew==}
/tlsn-js@0.1.0-alpha.5.4:
resolution: {integrity: sha512-qbqaDjApXarohn/XMJrxMsNHYTCzy+pw0Fc8gtPPN17PLE+1DwwLTXAAhnxYqYQyo3+Hmy+ODd4C02+KCwRWmg==}
engines: {node: '>= 16.20.2'}
dependencies:
comlink: 4.4.1
dev: false
/tlsn-js@0.1.0-alpha.7.1:
resolution: {integrity: sha512-EWdRp1VQBfdre8jehJgmDjtDvt01ZL1JWbcscctnFTLIIwMYS7IBxU07UYG0NMZFXeTE8PlrUDEVwEl1+vla+g==}
engines: {node: '>= 16.20.2'}
dependencies:
tlsn-wasm: 0.1.0-alpha.7.2
dev: false
/tlsn-wasm@0.1.0-alpha.7.2:
resolution: {integrity: sha512-NzrDfOxmFtMHDb4lmMsx6RaS6F+IVXEHxK0zow8jpnx+NryuJ+qnp4380Lq0uj61w/Yuq+yzOhzFe6Bpeo59dA==}
dev: false
/tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}

Binary file not shown.

View File

@@ -0,0 +1,70 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
useActiveTabUrl,
setConnection,
useIsConnected,
} from '../../reducers/requests';
import Modal, { ModalHeader, ModalContent } from '../../components/Modal/Modal';
import { deleteConnection, getConnection } from '../../entries/Background/db';
const ConnectionDetailsModal = (props: {
showConnectionDetails: boolean;
setShowConnectionDetails: (show: boolean) => void;
}) => {
const dispatch = useDispatch();
const activeTabOrigin = useActiveTabUrl();
const connected = useIsConnected();
useEffect(() => {
(async () => {
if (activeTabOrigin) {
const isConnected: boolean | null = await getConnection(
activeTabOrigin.origin,
);
dispatch(setConnection(!!isConnected));
}
})();
}, [activeTabOrigin, dispatch]);
const handleDisconnect = useCallback(async () => {
if (activeTabOrigin?.origin) {
await deleteConnection(activeTabOrigin.origin);
props.setShowConnectionDetails(false);
dispatch(setConnection(false));
}
}, [activeTabOrigin?.origin, dispatch, props]);
return (
<Modal
onClose={() => props.setShowConnectionDetails(false)}
className="flex flex-col gap-2 items-center text-base cursor-default justify-center mx-4 min-h-24"
>
<ModalHeader
className="w-full rounded-t-lg pb-0 border-b-0"
onClose={() => props.setShowConnectionDetails(false)}
>
<span className="text-lg font-semibold">
{activeTabOrigin?.hostname || 'Connections'}
</span>
</ModalHeader>
<ModalContent className="w-full gap-2 flex-grow flex flex-col items-center justify-between px-4 pt-0 pb-4">
<div className="flex flex-row gap-2 items-start w-full text-xs font-semibold text-slate-800">
{connected
? 'TLSN Extension is connected to this site.'
: 'TLSN Extension is not connected to this site. To connect to this site, find and click the connect button.'}
</div>
{connected && (
<button
className="button disabled:opacity-50 self-end"
onClick={handleDisconnect}
>
Disconnect
</button>
)}
</ModalContent>
</Modal>
);
};
export default ConnectionDetailsModal;

View File

@@ -0,0 +1,115 @@
import React, {
MouseEventHandler,
ReactElement,
ReactNode,
useCallback,
useState,
} from 'react';
import Icon from '../Icon';
import browser from 'webextension-polyfill';
import classNames from 'classnames';
import { useNavigate } from 'react-router';
import PluginUploadInfo from '../PluginInfo';
export function MenuIcon(): ReactElement {
const [opened, setOpen] = useState(false);
const toggleMenu = useCallback(() => {
setOpen(!opened);
}, [opened]);
return (
<div className="relative">
{opened && (
<>
<div
className="fixed top-0 left-0 w-screen h-screen z-10"
onClick={toggleMenu}
/>
<Menu opened={opened} setOpen={setOpen} />
</>
)}
<Icon
fa="fa-solid fa-bars"
className="text-slate-500 hover:text-slate-700 active:text-slate-900 cursor-pointer z-20"
onClick={toggleMenu}
/>
</div>
);
}
export default function Menu(props: {
opened: boolean;
setOpen: (opened: boolean) => void;
}): ReactElement {
const navigate = useNavigate();
const openExtensionInPage = () => {
props.setOpen(false);
browser.tabs.create({
url: `chrome-extension://${chrome.runtime.id}/popup.html`,
});
};
return (
<div className="absolute top-[100%] right-0 rounded-md z-20">
<div className="flex flex-col bg-slate-200 w-40 shadow rounded-md py">
<MenuRow
fa="fa-solid fa-plus"
className="relative"
onClick={() => {
props.setOpen(false);
}}
>
<PluginUploadInfo />
<span>Install Plugin</span>
</MenuRow>
<MenuRow
fa="fa-solid fa-toolbox"
className="border-b border-slate-300"
onClick={() => {
props.setOpen(false);
navigate('/plugins');
}}
>
Plugins
</MenuRow>
<MenuRow
className="lg:hidden"
fa="fa-solid fa-up-right-and-down-left-from-center"
onClick={openExtensionInPage}
>
Expand
</MenuRow>
<MenuRow
fa="fa-solid fa-gear"
onClick={() => {
props.setOpen(false);
navigate('/options');
}}
>
Options
</MenuRow>
</div>
</div>
);
}
function MenuRow(props: {
fa: string;
children?: ReactNode;
onClick?: MouseEventHandler;
className?: string;
}): ReactElement {
return (
<div
className={classNames(
'flex flex-row items-center py-3 px-4 gap-2 hover:bg-slate-300 cursor-pointer text-slate-800 hover:text-slate-900 font-semibold',
props.className,
)}
onClick={props.onClick}
>
<Icon size={0.875} fa={props.fa} />
{props.children}
</div>
);
}

View File

@@ -37,13 +37,19 @@ export default function Modal(props: Props): ReactElement {
}
type HeaderProps = {
className?: string;
onClose?: () => void;
children: ReactNode;
children?: ReactNode;
};
export function ModalHeader(props: HeaderProps): ReactElement {
return (
<div className={classNames('border-b modal__header border-gray-100')}>
<div
className={classNames(
'border-b modal__header border-gray-100',
props.className,
)}
>
<div className="modal__header__title">{props.children}</div>
<div className="modal__header__content">
{props.onClose && (

View File

@@ -0,0 +1,20 @@
.custom-modal {
height: 100%;
max-width: 800px;
max-height: 100vh;
display: flex;
margin: 0 auto;
flex-direction: column;
}
.custom-modal-content {
flex-grow: 2;
overflow-y: auto;
max-height: 90%;
}
.modal__overlay {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,208 @@
import React, {
ChangeEvent,
Children,
MouseEventHandler,
ReactElement,
ReactNode,
useCallback,
useState,
} from 'react';
import { makePlugin, getPluginConfig } from '../../utils/misc';
import { addPlugin } from '../../utils/rpc';
import Modal, {
ModalHeader,
ModalContent,
ModalFooter,
} from '../../components/Modal/Modal';
import type { PluginConfig } from '../../utils/misc';
import './index.scss';
import logo from '../../assets/img/icon-128.png';
import {
HostFunctionsDescriptions,
MultipleParts,
PermissionDescription,
} from '../../utils/plugins';
import { ErrorModal } from '../ErrorModal';
import classNames from 'classnames';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
export default function PluginUploadInfo(): ReactElement {
const [error, showError] = useState('');
const [pluginBuffer, setPluginBuffer] = useState<ArrayBuffer | any>(null);
const [pluginContent, setPluginContent] = useState<PluginConfig | null>(null);
const onAddPlugin = useCallback(
async (evt: React.MouseEvent<HTMLButtonElement>) => {
try {
await addPlugin(Buffer.from(pluginBuffer).toString('hex'));
setPluginContent(null);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
}
},
[pluginContent, pluginBuffer],
);
const onPluginInfo = useCallback(
async (evt: ChangeEvent<HTMLInputElement>) => {
if (!evt.target.files) return;
try {
const [file] = evt.target.files;
const arrayBuffer = await file.arrayBuffer();
const plugin = await makePlugin(arrayBuffer);
setPluginContent(await getPluginConfig(plugin));
setPluginBuffer(arrayBuffer);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
} finally {
evt.target.value = '';
}
},
[setPluginContent, setPluginBuffer],
);
const onClose = useCallback(() => {
setPluginContent(null);
setPluginBuffer(null);
}, []);
return (
<>
<input
className="opacity-0 absolute top-0 right-0 h-full w-full cursor-pointer"
type="file"
onChange={onPluginInfo}
/>
{error && <ErrorModal onClose={() => showError('')} message={error} />}
{pluginContent && (
<PluginInfoModal
pluginContent={pluginContent}
onClose={onClose}
onAddPlugin={onAddPlugin}
/>
)}
</>
);
}
export function PluginInfoModalHeader(props: {
className?: string;
children: ReactNode | ReactNode[];
}) {
return <div className={props.className}>{props.children}</div>;
}
export function PluginInfoModalContent(props: {
className?: string;
children: ReactNode | ReactNode[];
}) {
return <div className={props.className}>{props.children}</div>;
}
export function PluginInfoModal(props: {
pluginContent: PluginConfig;
onClose: () => void;
onAddPlugin?: MouseEventHandler;
children?: ReactNode | ReactNode[];
}) {
const { pluginContent, onClose, onAddPlugin, children } = props;
const header = Children.toArray(children).filter(
(c: any) => c.type.name === 'PluginInfoModalHeader',
)[0];
const content = Children.toArray(children).filter(
(c: any) => c.type.name === 'PluginInfoModalContent',
)[0];
return (
<Modal
onClose={onClose}
className="custom-modal !rounded-none flex items-center justify-center gap-4 cursor-default"
>
<ModalHeader className="w-full p-2 border-gray-200 text-gray-500">
{header || (
<div className="flex flex-row items-end justify-start gap-2">
<img className="h-5" src={logo || DefaultPluginIcon} alt="logo" />
<span className="font-semibold">{`Installing ${pluginContent.title}`}</span>
</div>
)}
</ModalHeader>
<ModalContent className="flex flex-col flex-grow-0 flex-shrink-0 items-center px-8 py-2 gap-2 w-full max-h-none">
{content || (
<>
<img
className="w-12 h-12"
src={pluginContent.icon || DefaultPluginIcon}
alt="Plugin Icon"
/>
<span className="text-3xl text-center">
<span>
<span className="text-blue-600 font-semibold">
{pluginContent.title}
</span>{' '}
wants access to your browser
</span>
</span>
</>
)}
</ModalContent>
<div className="flex-grow flex-shrink overflow-y-auto w-full px-8">
<PluginPermissions pluginContent={pluginContent} />
</div>
<ModalFooter className="flex justify-end gap-2 p-4">
<button className="button" onClick={onClose}>
Cancel
</button>
{onAddPlugin && (
<button className="button button--primary" onClick={onAddPlugin}>
Allow
</button>
)}
</ModalFooter>
</Modal>
);
}
export function PluginPermissions({
pluginContent,
className,
}: {
pluginContent: PluginConfig;
className?: string;
}) {
return (
<div className={classNames('flex flex-col p-2 gap-5', className)}>
{pluginContent.hostFunctions?.map((hostFunction: string) => {
const HFComponent = HostFunctionsDescriptions[hostFunction];
return <HFComponent key={hostFunction} {...pluginContent} />;
})}
{pluginContent.cookies && (
<PermissionDescription fa="fa-solid fa-cookie-bite">
<span className="cursor-default">
<span className="mr-1">Access cookies from</span>
<MultipleParts parts={pluginContent.cookies} />
</span>
</PermissionDescription>
)}
{pluginContent.headers && (
<PermissionDescription fa="fa-solid fa-envelope">
<span className="cursor-default">
<span className="mr-1">Access headers from</span>
<MultipleParts parts={pluginContent.headers} />
</span>
</PermissionDescription>
)}
{pluginContent.requests && (
<PermissionDescription fa="fa-solid fa-globe">
<span className="cursor-default">
<span className="mr-1">Submit network requests to</span>
<MultipleParts
parts={pluginContent?.requests.map(({ url }) => url)}
/>
</span>
</PermissionDescription>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@
padding: 0;
overflow: hidden;
transition: 200ms opacity;
}
&:hover {
@@ -20,4 +21,25 @@
}
}
}
}
}
.custom-modal {
width: 100vw;
height: 100vh;
max-width: 800px;
max-height: 90vh;
display: flex;
margin: 1rem auto;
flex-direction: column;
}
.custom-modal-content {
flex-grow: 2;
overflow-y: auto;
max-height: 90%;
}
.modal__overlay {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -1,17 +1,11 @@
import React, {
ChangeEvent,
MouseEventHandler,
ReactElement,
useCallback,
useEffect,
useState,
} from 'react';
import {
fetchPluginHashes,
removePlugin,
fetchPluginConfigByHash,
runPlugin,
} from '../../utils/rpc';
import { fetchPluginHashes, removePlugin, runPlugin } from '../../utils/rpc';
import { usePluginHashes } from '../../reducers/plugins';
import { PluginConfig } from '../../utils/misc';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
@@ -20,6 +14,12 @@ import Icon from '../Icon';
import './index.scss';
import browser from 'webextension-polyfill';
import { ErrorModal } from '../ErrorModal';
import {
PluginInfoModal,
PluginInfoModalContent,
PluginInfoModalHeader,
} from '../PluginInfo';
import { getPluginConfigByHash } from '../../entries/Background/db';
export function PluginList(props: { className?: string }): ReactElement {
const hashes = usePluginHashes();
@@ -29,7 +29,9 @@ export function PluginList(props: { className?: string }): ReactElement {
}, []);
return (
<div className={classNames('flex flex-col flex-nowrap', props.className)}>
<div
className={classNames('flex flex-col flex-nowrap gap-1', props.className)}
>
{!hashes.length && (
<div className="flex flex-col items-center justify-center text-slate-400 cursor-default select-none">
<div>No available plugins</div>
@@ -48,9 +50,11 @@ export function Plugin(props: {
}): ReactElement {
const [error, showError] = useState('');
const [config, setConfig] = useState<PluginConfig | null>(null);
const [pluginInfo, showPluginInfo] = useState(false);
const [remove, showRemove] = useState(false);
const onClick = useCallback(async () => {
if (!config) return;
if (!config || remove) return;
try {
await runPlugin(props.hash, 'start');
@@ -69,11 +73,11 @@ export function Plugin(props: {
} catch (e: any) {
showError(e.message);
}
}, [props.hash, config]);
}, [props.hash, config, remove]);
useEffect(() => {
(async function () {
setConfig(await fetchPluginConfigByHash(props.hash));
setConfig(await getPluginConfigByHash(props.hash));
})();
}, [props.hash]);
@@ -81,33 +85,128 @@ export function Plugin(props: {
(e) => {
e.stopPropagation();
removePlugin(props.hash);
showRemove(false);
},
[props.hash],
[props.hash, remove],
);
const onConfirmRemove: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
showRemove(true);
},
[props.hash, remove],
);
const onPluginInfo: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
showPluginInfo(true);
},
[props.hash, pluginInfo],
);
if (!config) return <></>;
return (
<button
<div
className={classNames(
'flex flex-row border rounded border-slate-300 p-2 gap-2 plugin-box',
'flex flex-row justify-center border rounded border-slate-300 p-2 gap-2 plugin-box',
'cursor-pointer hover:bg-slate-100 hover:border-slate-400 active:bg-slate-200',
)}
onClick={onClick}
>
{!!error && <ErrorModal onClose={() => showError('')} message={error} />}
<img className="w-12 h-12" src={config.icon || DefaultPluginIcon} />
<div className="flex flex-col w-full items-start">
<div className="font-bold flex flex-row h-6 items-center justify-between w-full">
{config.title}
<Icon
fa="fa-solid fa-xmark"
className="flex flex-row items-center justify-center cursor-pointer text-red-500 bg-red-200 rounded-full plugin-box__remove-icon"
onClick={onRemove}
/>
{!remove ? (
<div className="flex flex-row w-full gap-2">
<img className="w-12 h-12" src={config.icon || DefaultPluginIcon} />
<div className="flex flex-col w-full items-start">
<div className="font-bold flex flex-row h-6 items-center justify-between w-full">
{config.title}
<div className="flex flex-row items-center justify-center">
<Icon
fa="fa-solid fa-circle-info"
className="flex flex-row items-center justify-center cursor-pointer plugin-box__remove-icon"
onClick={onPluginInfo}
/>
<Icon
fa="fa-solid fa-xmark"
className="flex flex-row items-center justify-center cursor-pointer text-red-500 bg-red-200 rounded-full plugin-box__remove-icon"
onClick={onConfirmRemove}
/>
</div>
</div>
<div>{config.description}</div>
</div>
</div>
<div>{config.description}</div>
</div>
</button>
) : (
<RemovePlugin
onRemove={onRemove}
showRemove={showRemove}
config={config}
/>
)}
{pluginInfo && (
<PluginInfoModal
pluginContent={config}
onClose={() => showPluginInfo(false)}
>
<PluginInfoModalHeader>
<div className="flex flex-row items-end justify-start gap-2">
<Icon
className="text-slate-500 hover:text-slate-700 cursor-pointer"
size={1}
fa="fa-solid fa-caret-left"
onClick={() => showPluginInfo(false)}
/>
</div>
</PluginInfoModalHeader>
<PluginInfoModalContent className="flex flex-col items-center cursor-default">
<img
className="w-12 h-12 mb-2"
src={config.icon || DefaultPluginIcon}
alt="Plugin Icon"
/>
<span className="text-3xl text-blue-600 font-semibold">
{config.title}
</span>
<div className="text-slate-500 text-lg">{config.description}</div>
</PluginInfoModalContent>
</PluginInfoModal>
)}
</div>
);
}
function RemovePlugin(props: {
onRemove: MouseEventHandler;
showRemove: (show: boolean) => void;
config: PluginConfig;
}): ReactElement {
const { onRemove, showRemove, config } = props;
const onCancel: MouseEventHandler = useCallback((e) => {
e.stopPropagation();
showRemove(false);
}, []);
return (
<div className="flex flex-col items-center w-full gap-1">
<div className="font-bold text-red-700">
{`Are you sure you want to remove "${config.title}" plugin?`}
</div>
<div className="mb-1">Warning: this cannot be undone.</div>
<div className="flex flex-row w-full gap-1">
<button className="flex-grow button p-1" onClick={onCancel}>
Cancel
</button>
<button
className="flex-grow font-bold bg-red-500 hover:bg-red-600 text-white rounded p-1"
onClick={onRemove}
>
Remove
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import React, { useCallback } from 'react';
import c from 'classnames';
export function InputBody(props: {
body: string;
setBody: (body: string) => void;
}) {
return (
<textarea
className="textarea h-[90%] w-full resize-none"
value={props.body}
onChange={(e) => props.setBody(e.target.value)}
/>
);
}
export function FormBodyTable(props: {
formBody: [string, string, boolean?][];
setFormBody: (formBody: [string, string, boolean?][]) => void;
}) {
const toggleKV = useCallback(
(index: number) => {
const newFormBody = [...props.formBody];
newFormBody[index][2] = !newFormBody[index][2];
props.setFormBody(newFormBody);
},
[props.formBody],
);
const setKV = useCallback(
(index: number, key: string, value: string) => {
const newFormBody = [...props.formBody];
newFormBody[index] = [key, value];
props.setFormBody(newFormBody);
if (index === props.formBody.length - 1 && (key || value)) {
props.setFormBody([...newFormBody, ['', '', true]]);
}
},
[props.formBody],
);
const last = props.formBody.length - 1;
return (
<table className="border border-slate-300 border-collapse table-fixed w-full">
<tbody>
{props.formBody.map(([key, value, silent], i) => (
<tr
key={i}
className={c('border-b border-slate-200', {
'opacity-30': !!silent,
})}
>
<td className="w-8 text-center pt-2">
{last !== i && (
<input
type="checkbox"
onChange={() => toggleKV(i)}
checked={!silent}
/>
)}
</td>
<td className="border border-slate-300 font-bold align-top break-all w-fit">
<input
className="input py-1 px-2 w-full"
type="text"
value={key}
placeholder="Key"
onChange={(e) => {
setKV(i, e.target.value, value);
}}
/>
</td>
<td className="border border-slate-300 break-all align-top">
<input
className="input py-1 px-2 w-full"
type="text"
value={value}
placeholder="Value"
onChange={(e) => {
setKV(i, key, e.target.value);
}}
/>
</td>
</tr>
))}
</tbody>
</table>
);
}
export function formatForRequest(
input: string | [string, string, boolean?][],
type: string,
): string {
try {
let pairs: [string, string][] = [];
if (typeof input === 'string') {
const lines = input.split('\n').filter((line) => line.trim() !== '');
pairs = lines.map((line) => {
const [key, value] = line.split('=').map((part) => part.trim());
return [key, value];
});
} else {
pairs = input
.filter(([, , silent]) => silent !== true)
.map(([key, value]) => [key, value]);
}
if (type === 'text/plain') {
return JSON.stringify(input as string);
}
if (type === 'application/json') {
const jsonObject = JSON.parse(input as string);
return JSON.stringify(jsonObject);
}
if (type === 'application/x-www-form-urlencoded') {
const searchParams = new URLSearchParams();
pairs.forEach(([key, value]) => {
searchParams.append(key, value);
});
return searchParams.toString();
}
return pairs.map(([key, value]) => `${key}=${value}`).join('&');
} catch (e) {
console.error('Error formatting for request:', e);
return '';
}
}
export async function parseResponse(contentType: string, res: Response) {
const parsedResponseData = {
json: '',
text: '',
img: '',
headers: Array.from(res.headers.entries()),
};
if (contentType?.includes('application/json')) {
parsedResponseData.json = await res.json();
} else if (contentType?.includes('text')) {
parsedResponseData.text = await res.text();
} else if (contentType?.includes('image')) {
const blob = await res.blob();
parsedResponseData.img = URL.createObjectURL(blob);
} else {
parsedResponseData.text = await res.text();
}
return parsedResponseData;
}

View File

@@ -26,7 +26,6 @@ import {
getMaxSent,
} from '../../utils/storage';
import { MAX_RECV, MAX_SENT } from '../../utils/constants';
import { urlify } from '../../utils/misc';
type Props = {
requestId: string;

View File

@@ -1,13 +1,21 @@
import React, { ReactElement, useCallback, useState } from 'react';
import React, {
ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { BackgroundActiontype, RequestLog } from '../../entries/Background/rpc';
import { useNavigate } from 'react-router';
import Fuse from 'fuse.js';
import Icon from '../Icon';
import { useDispatch } from 'react-redux';
import { setRequests } from '../../reducers/requests';
import classNames from 'classnames';
type Props = {
requests: RequestLog[];
shouldFix?: boolean;
};
export default function RequestTable(props: Props): ReactElement {
@@ -47,7 +55,14 @@ export default function RequestTable(props: Props): ReactElement {
return (
<div className="flex flex-col flex-nowrap flex-grow">
<div className="flex flex-row flex-nowrap bg-slate-300 py-1 px-2 gap-2">
<div
className={classNames(
'flex flex-row flex-nowrap bg-slate-300 py-1 px-2 gap-2',
{
'fixed top-[4.5rem] w-full shadow': props.shouldFix,
},
)}
>
<input
className="input w-full"
type="text"
@@ -61,7 +76,7 @@ export default function RequestTable(props: Props): ReactElement {
onClick={reset}
/>
</div>
<div className="flex-grow overflow-y-auto h-0">
<div className="flex-grow">
<table className="border border-slate-300 border-collapse table-fixed w-full">
<thead className="bg-slate-200">
<tr>

View File

@@ -1,63 +1,15 @@
import classNames from 'classnames';
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
export default function ResponseDetail(props: {
response: Response | null;
responseData: {
json: any | null;
text: string | null;
img: string | null;
headers: [string, string][] | null;
} | null;
className?: string;
}): ReactElement {
const [json, setJSON] = useState<any | null>(null);
const [text, setText] = useState<string | null>(null);
const [img, setImg] = useState<string | null>(null);
const [formData, setFormData] = useState<URLSearchParams | null>(null);
useEffect(() => {
const resp = props.response;
if (!resp) return;
const contentType =
resp.headers.get('content-type') || resp.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
resp
.json()
.then((json) => {
if (json) {
setJSON(json);
}
})
.catch();
} else if (contentType?.includes('text')) {
resp
.text()
.then((_text) => {
if (_text) {
setText(_text);
}
})
.catch();
} else if (contentType?.includes('image')) {
resp
.blob()
.then((blob) => {
if (blob) {
setImg(URL.createObjectURL(blob));
}
})
.catch();
} else {
resp
.blob()
.then((blob) => blob.text())
.then((_text) => {
if (_text) {
setText(_text);
}
})
.catch();
}
}, [props.response]);
return (
<div
className={classNames(
@@ -66,7 +18,7 @@ export default function ResponseDetail(props: {
)}
>
<table className="border border-slate-300 border-collapse table-fixed w-full">
{!!json && (
{!!props.responseData?.json && (
<>
<thead className="bg-slate-200">
<tr>
@@ -80,13 +32,13 @@ export default function ResponseDetail(props: {
<textarea
rows={16}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={JSON.stringify(json, null, 2)}
value={JSON.stringify(props.responseData.json, null, 2)}
></textarea>
</td>
</tr>
</>
)}
{!!text && (
{!!props.responseData?.text && (
<>
<thead className="bg-slate-200">
<tr>
@@ -100,13 +52,13 @@ export default function ResponseDetail(props: {
<textarea
rows={16}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={text}
value={props.responseData.text}
></textarea>
</td>
</tr>
</>
)}
{!!img && (
{!!props.responseData?.img && (
<>
<thead className="bg-slate-200">
<tr>
@@ -117,12 +69,12 @@ export default function ResponseDetail(props: {
</thead>
<tr>
<td className="bg-slate-100" colSpan={2}>
<img src={img} />
<img src={props.responseData.img} />
</td>
</tr>
</>
)}
{!!props.response?.headers && (
{!!props.responseData?.headers && (
<>
<thead className="bg-slate-200">
<tr>
@@ -132,20 +84,18 @@ export default function ResponseDetail(props: {
</tr>
</thead>
<tbody>
{Array.from(props.response.headers.entries()).map(
([name, value]) => {
return (
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{value}
</td>
</tr>
);
},
)}
{props.responseData?.headers.map(([name, value]) => {
return (
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{value}
</td>
</tr>
);
})}
</tbody>
</>
)}

View File

@@ -4,14 +4,6 @@ let RequestsLogs: {
[tabId: string]: NodeCache;
} = {};
let HeadersStore: {
[hostname: string]: NodeCache;
} = {};
let CookieStore: {
[hostname: string]: NodeCache;
} = {};
export const deleteCacheByTabId = (tabId: number) => {
delete RequestsLogs[tabId];
};
@@ -27,50 +19,10 @@ export const getCacheByTabId = (tabId: number): NodeCache => {
return RequestsLogs[tabId];
};
export const deleteHeadersByHost = (hostname: string) => {
delete HeadersStore[hostname];
};
export const getHeaderStoreByHost = (hostname: string): NodeCache => {
HeadersStore[hostname] =
HeadersStore[hostname] ||
new NodeCache({
stdTTL: 60 * 5, // default 5m TTL
maxKeys: 1000000,
});
return HeadersStore[hostname];
};
export const deleteCookiesByHost = (hostname: string) => {
delete CookieStore[hostname];
};
export const getCookieStoreByHost = (hostname: string): NodeCache => {
CookieStore[hostname] =
CookieStore[hostname] ||
new NodeCache({
stdTTL: 60 * 5, // default 5m TTL
maxKeys: 1000000,
});
return CookieStore[hostname];
};
export const clearRequestCache = () => {
RequestsLogs = {};
};
export const clearHeaderCache = () => {
HeadersStore = {};
};
export const clearCookieCache = () => {
CookieStore = {};
};
export const clearCache = () => {
clearRequestCache();
clearHeaderCache();
clearCookieCache();
};

View File

@@ -1,9 +1,10 @@
import { Level } from 'level';
import type { RequestHistory } from './rpc';
import { PluginConfig, sha256 } from '../../utils/misc';
import { PluginConfig, PluginMetadata, sha256 } from '../../utils/misc';
import mutex from './mutex';
const charwise = require('charwise');
const db = new Level('./ext-db', {
export const db = new Level('./ext-db', {
valueEncoding: 'json',
});
const historyDb = db.sublevel<string, RequestHistory>('history', {
@@ -15,6 +16,24 @@ const pluginDb = db.sublevel<string, string>('plugin', {
const pluginConfigDb = db.sublevel<string, PluginConfig>('pluginConfig', {
valueEncoding: 'json',
});
const pluginMetadataDb = db.sublevel<string, PluginMetadata>('pluginMetadata', {
valueEncoding: 'json',
});
const connectionDb = db.sublevel<string, boolean>('connections', {
valueEncoding: 'json',
});
const cookiesDb = db.sublevel<string, boolean>('cookies', {
valueEncoding: 'json',
});
const headersDb = db.sublevel<string, boolean>('headers', {
valueEncoding: 'json',
});
const appDb = db.sublevel<string, any>('app', {
valueEncoding: 'json',
});
enum AppDatabaseKey {
DefaultPluginsInstalled = 'DefaultPluginsInstalled',
}
export async function addNotaryRequest(
now = Date.now(),
@@ -127,14 +146,12 @@ export async function getNotaryRequests(): Promise<RequestHistory[]> {
export async function getNotaryRequest(
id: string,
): Promise<RequestHistory | null> {
return historyDb.get(id);
return historyDb.get(id).catch(() => null);
}
export async function getPluginHashes(): Promise<string[]> {
const retVal: string[] = [];
for await (const [key] of pluginDb.iterator()) {
// pluginDb.del(key);
// pluginConfigDb.del(key);
retVal.push(key);
}
return retVal;
@@ -205,6 +222,59 @@ export async function removePluginConfig(
return existing;
}
export async function getPlugins(): Promise<
(PluginConfig & { hash: string; metadata: PluginMetadata })[]
> {
const hashes = await getPluginHashes();
const ret: (PluginConfig & { hash: string; metadata: PluginMetadata })[] = [];
for (const hash of hashes) {
const config = await getPluginConfigByHash(hash);
const metadata = await getPluginMetadataByHash(hash);
if (config) {
ret.push({
...config,
hash,
metadata: metadata || {
filePath: '',
origin: '',
},
});
}
}
return ret;
}
export async function getPluginMetadataByHash(
hash: string,
): Promise<PluginMetadata | null> {
try {
const metadata = await pluginMetadataDb.get(hash);
return metadata;
} catch (e) {
return null;
}
}
export async function addPluginMetadata(
hash: string,
metadata: PluginMetadata,
): Promise<PluginMetadata | null> {
await pluginMetadataDb.put(hash, metadata);
return metadata;
}
export async function removePluginMetadata(
hash: string,
): Promise<PluginMetadata | null> {
const existing = await pluginMetadataDb.get(hash);
if (!existing) return null;
await pluginMetadataDb.del(hash);
return existing;
}
export async function setNotaryRequestCid(
id: string,
cid: string,
@@ -222,3 +292,105 @@ export async function setNotaryRequestCid(
return newReq;
}
export async function setConnection(origin: string) {
if (await getConnection(origin)) return null;
await connectionDb.put(origin, true);
return true;
}
export async function setCookies(host: string, name: string, value: string) {
return mutex.runExclusive(async () => {
await cookiesDb.sublevel(host).put(name, value);
return true;
});
}
export async function clearCookies(host: string) {
return mutex.runExclusive(async () => {
await cookiesDb.sublevel(host).clear();
return true;
});
}
export async function getCookies(host: string, name: string) {
try {
const existing = await cookiesDb.sublevel(host).get(name);
return existing;
} catch (e) {
return null;
}
}
export async function getCookiesByHost(host: string) {
const ret: { [key: string]: string } = {};
for await (const [key, value] of cookiesDb.sublevel(host).iterator()) {
ret[key] = value;
}
return ret;
}
export async function deleteConnection(origin: string) {
return mutex.runExclusive(async () => {
if (await getConnection(origin)) {
await connectionDb.del(origin);
}
});
}
export async function getConnection(origin: string) {
try {
const existing = await connectionDb.get(origin);
return existing;
} catch (e) {
return null;
}
}
export async function setHeaders(host: string, name: string, value?: string) {
if (!value) return null;
return mutex.runExclusive(async () => {
await headersDb.sublevel(host).put(name, value);
return true;
});
}
export async function clearHeaders(host: string) {
return mutex.runExclusive(async () => {
await headersDb.sublevel(host).clear();
return true;
});
}
export async function getHeaders(host: string, name: string) {
try {
const existing = await headersDb.sublevel(host).get(name);
return existing;
} catch (e) {
return null;
}
}
export async function getHeadersByHost(host: string) {
const ret: { [key: string]: string } = {};
for await (const [key, value] of headersDb.sublevel(host).iterator()) {
ret[key] = value;
}
return ret;
}
async function getDefaultPluginsInstalled(): Promise<boolean> {
return appDb.get(AppDatabaseKey.DefaultPluginsInstalled).catch(() => false);
}
export async function setDefaultPluginsInstalled(installed = false) {
return mutex.runExclusive(async () => {
await appDb.put(AppDatabaseKey.DefaultPluginsInstalled, installed);
});
}
export async function getAppState() {
return {
defaultPluginsInstalled: await getDefaultPluginsInstalled(),
};
}

View File

@@ -1,13 +1,10 @@
import {
getCacheByTabId,
getCookieStoreByHost,
getHeaderStoreByHost,
} from './cache';
import { getCacheByTabId } from './cache';
import { BackgroundActiontype, RequestLog } from './rpc';
import mutex from './mutex';
import browser from 'webextension-polyfill';
import { addRequest } from '../../reducers/requests';
import { urlify } from '../../utils/misc';
import { setCookies, setHeaders } from './db';
export const onSendHeaders = (
details: browser.WebRequest.OnSendHeadersDetailsType,
@@ -21,20 +18,17 @@ export const onSendHeaders = (
const { hostname } = urlify(details.url) || {};
if (hostname && details.requestHeaders) {
const headerStore = getHeaderStoreByHost(hostname);
details.requestHeaders.forEach((header) => {
const { name, value } = header;
if (/^cookie$/i.test(name) && value) {
const cookieStore = getCookieStoreByHost(hostname);
value
.split(';')
.map((v) => v.split('='))
.forEach((cookie) => {
cookieStore.set(cookie[0].trim(), cookie[1]);
setCookies(hostname, cookie[0].trim(), cookie[1]);
});
} else {
headerStore.set(name, value);
setHeaders(hostname, name, value);
}
});
}

View File

@@ -1,6 +1,8 @@
import { onBeforeRequest, onResponseStarted, onSendHeaders } from './handlers';
import { deleteCacheByTabId } from './cache';
import browser from 'webextension-polyfill';
import { getAppState, setDefaultPluginsInstalled } from './db';
import { installPlugin } from './plugins/utils';
(async () => {
browser.webRequest.onSendHeaders.addListener(
@@ -31,6 +33,19 @@ import browser from 'webextension-polyfill';
deleteCacheByTabId(tabId);
});
const { defaultPluginsInstalled } = await getAppState();
if (!defaultPluginsInstalled) {
try {
const twitterProfileUrl = browser.runtime.getURL('twitter_profile.wasm');
const discordDmUrl = browser.runtime.getURL('discord_dm.wasm');
await installPlugin(twitterProfileUrl);
await installPlugin(discordDmUrl);
} finally {
await setDefaultPluginsInstalled(true);
}
}
const { initRPC } = await import('./rpc');
await createOffscreenDocument();
initRPC();

View File

@@ -0,0 +1,29 @@
import { addPlugin, addPluginConfig, addPluginMetadata } from '../db';
import { getPluginConfig } from '../../../utils/misc';
export async function installPlugin(
urlOrBuffer: ArrayBuffer | string,
origin = '',
filePath = '',
metadata: {[key: string]: string} = {},
) {
let arrayBuffer;
if (typeof urlOrBuffer === 'string') {
const resp = await fetch(urlOrBuffer);
arrayBuffer = await resp.arrayBuffer();
} else {
arrayBuffer = urlOrBuffer;
}
const config = await getPluginConfig(arrayBuffer);
const hex = Buffer.from(arrayBuffer).toString('hex');
const hash = await addPlugin(hex);
await addPluginConfig(hash!, config);
await addPluginMetadata(hash!, {
...metadata,
origin,
filePath,
});
return hash;
}

View File

@@ -1,11 +1,6 @@
import browser from 'webextension-polyfill';
import {
clearCache,
getCacheByTabId,
getCookieStoreByHost,
getHeaderStoreByHost,
} from './cache';
import { addRequestHistory } from '../../reducers/history';
import { clearCache, getCacheByTabId } from './cache';
import { addRequestHistory, setRequests } from '../../reducers/history';
import {
addNotaryRequest,
addNotaryRequestProofs,
@@ -22,14 +17,23 @@ import {
addPluginConfig,
getPluginConfigByHash,
removePluginConfig,
getConnection,
setConnection,
deleteConnection,
addPluginMetadata,
getPlugins,
getCookiesByHost,
getHeadersByHost,
getAppState,
setDefaultPluginsInstalled,
} from './db';
import { addOnePlugin, removeOnePlugin } from '../../reducers/plugins';
import {
devlog,
extractBodyFromResponse,
getPluginConfig,
hexToArrayBuffer,
makePlugin,
PluginConfig,
} from '../../utils/misc';
import {
getLoggingFilter,
@@ -38,6 +42,10 @@ import {
getNotaryApi,
getProxyApi,
} from '../../utils/storage';
import { deferredPromise } from '../../utils/promise';
import { minimatch } from 'minimatch';
import { OffscreenActionTypes } from '../Offscreen/types';
import { SidePanelActionTypes } from '../SidePanel/types';
const charwise = require('charwise');
@@ -64,6 +72,23 @@ export enum BackgroundActiontype {
get_plugin_hashes = 'get_plugin_hashes',
open_popup = 'open_popup',
change_route = 'change_route',
connect_request = 'connect_request',
connect_response = 'connect_response',
get_history_request = 'get_history_request',
get_history_response = 'get_history_response',
get_proof_request = 'get_proof_request',
get_proof_response = 'get_proof_response',
notarize_request = 'notarize_request',
notarize_response = 'notarize_response',
install_plugin_request = 'install_plugin_request',
install_plugin_response = 'install_plugin_response',
get_plugins_request = 'get_plugins_request',
get_plugins_response = 'get_plugins_response',
run_plugin_request = 'run_plugin_request',
run_plugin_response = 'run_plugin_response',
get_logging_level = 'get_logging_level',
get_app_state = 'get_app_state',
set_default_plugins_installed = 'set_default_plugins_installed',
}
export type BackgroundAction = {
@@ -94,7 +119,6 @@ export type RequestHistory = {
method: string;
headers: { [key: string]: string };
body?: string;
maxTranscriptSize: number;
maxSentData: number;
maxRecvData: number;
notaryUrl: string;
@@ -110,11 +134,14 @@ export type RequestHistory = {
secretHeaders?: string[];
secretResps?: string[];
cid?: string;
metadata?: {
[k: string]: string;
};
};
export const initRPC = () => {
browser.runtime.onMessage.addListener(
async (request, sender, sendResponse) => {
(request, sender, sendResponse): any => {
switch (request.type) {
case BackgroundActiontype.get_requests:
return handleGetRequests(request, sendResponse);
@@ -126,8 +153,7 @@ export const initRPC = () => {
case BackgroundActiontype.finish_prove_request:
return handleFinishProveRequest(request, sendResponse);
case BackgroundActiontype.delete_prove_request:
await removeNotaryRequest(request.data);
return sendResponse();
return removeNotaryRequest(request.data);
case BackgroundActiontype.retry_prove_request:
return handleRetryProveReqest(request, sendResponse);
case BackgroundActiontype.prove_request_start:
@@ -152,6 +178,29 @@ export const initRPC = () => {
return handleExecPluginProver(request);
case BackgroundActiontype.open_popup:
return handleOpenPopup(request);
case BackgroundActiontype.connect_request:
return handleConnect(request);
case BackgroundActiontype.get_history_request:
return handleGetHistory(request);
case BackgroundActiontype.get_proof_request:
return handleGetProof(request);
case BackgroundActiontype.notarize_request:
return handleNotarizeRequest(request);
case BackgroundActiontype.install_plugin_request:
return handleInstallPluginRequest(request);
case BackgroundActiontype.get_plugins_request:
return handleGetPluginsRequest(request);
case BackgroundActiontype.run_plugin_request:
return handleRunPluginCSRequest(request);
case BackgroundActiontype.get_logging_level:
getLoggingFilter().then(sendResponse);
return true;
case BackgroundActiontype.get_app_state:
getAppState().then(sendResponse);
return true;
case BackgroundActiontype.set_default_plugins_installed:
setDefaultPluginsInstalled(request.data).then(sendResponse);
return true;
default:
break;
}
@@ -162,28 +211,30 @@ export const initRPC = () => {
function handleGetRequests(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
): boolean {
const cache = getCacheByTabId(request.data);
const keys = cache.keys() || [];
const data = keys.map((key) => cache.get(key));
return data;
sendResponse(data);
return true;
}
async function handleGetProveRequests(
function handleGetProveRequests(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const reqs = await getNotaryRequests();
for (const req of reqs) {
): boolean {
getNotaryRequests().then(async (reqs) => {
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addRequestHistory(req),
action: setRequests(reqs),
});
}
return sendResponse();
sendResponse(reqs);
});
return true;
}
async function handleFinishProveRequest(
@@ -259,7 +310,6 @@ async function handleRetryProveReqest(
...req,
notaryUrl,
websocketProxyUrl,
loggingFilter: await getLoggingFilter(),
},
});
@@ -275,7 +325,6 @@ async function handleProveRequestStart(
method,
headers,
body,
maxTranscriptSize,
maxSentData,
maxRecvData,
notaryUrl,
@@ -291,7 +340,6 @@ async function handleProveRequestStart(
body,
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
secretHeaders,
@@ -308,6 +356,67 @@ async function handleProveRequestStart(
action: addRequestHistory(await getNotaryRequest(id)),
});
browser.runtime.sendMessage({
type: BackgroundActiontype.process_prove_request,
data: {
id,
url,
method,
headers,
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
secretHeaders,
secretResps,
},
});
return sendResponse();
}
async function runPluginProver(request: BackgroundAction, now = Date.now()) {
const {
url,
method,
headers,
body,
secretHeaders,
secretResps,
notaryUrl: _notaryUrl,
websocketProxyUrl: _websocketProxyUrl,
maxSentData: _maxSentData,
maxRecvData: _maxRecvData,
} = request.data;
const notaryUrl = _notaryUrl || (await getNotaryApi());
const websocketProxyUrl = _websocketProxyUrl || (await getProxyApi());
const maxSentData = _maxSentData || (await getMaxSent());
const maxRecvData = _maxRecvData || (await getMaxRecv());
const { id } = await addNotaryRequest(now, {
url,
method,
headers,
body,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
secretHeaders,
secretResps,
});
await setNotaryRequestStatus(id, 'pending');
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addRequestHistory(await getNotaryRequest(id)),
});
await browser.runtime.sendMessage({
type: BackgroundActiontype.process_prove_request,
data: {
@@ -316,78 +425,12 @@ async function handleProveRequestStart(
method,
headers,
body,
maxTranscriptSize,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
secretHeaders,
secretResps,
loggingFilter: await getLoggingFilter(),
},
});
return sendResponse();
}
async function runPluginProver(request: BackgroundAction, now = Date.now()) {
const {
url,
method,
headers,
notaryUrl: _notaryUrl,
websocketProxyUrl: _websocketProxyUrl,
maxSentData: _maxSentData,
maxRecvData: _maxRecvData,
} = request.data;
const resp = await fetch(url, {
method,
headers,
});
const body = await extractBodyFromResponse(resp);
const notaryUrl = _notaryUrl || (await getNotaryApi());
const websocketProxyUrl = _websocketProxyUrl || (await getProxyApi());
const maxSentData = _maxSentData || (await getMaxSent());
const maxRecvData = _maxRecvData || (await getMaxRecv());
const maxTranscriptSize = 16384;
const { id } = await addNotaryRequest(now, {
url,
method,
headers,
body,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
});
await setNotaryRequestStatus(id, 'pending');
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addRequestHistory(await getNotaryRequest(id)),
});
await browser.runtime.sendMessage({
type: BackgroundActiontype.process_prove_request,
data: {
id,
url,
method,
headers,
body,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
loggingFilter: await getLoggingFilter(),
},
});
}
@@ -402,27 +445,23 @@ export async function handleExecPluginProver(request: BackgroundAction) {
function handleGetCookiesByHostname(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const cache = getCookieStoreByHost(request.data);
const keys = cache.keys() || [];
const data = keys.reduce((acc: { [k: string]: string }, key) => {
acc[key] = cache.get(key) || '';
return acc;
}, {});
return data;
): boolean {
(async () => {
const store = await getCookiesByHost(request.data);
sendResponse(store);
})();
return true;
}
function handleGetHeadersByHostname(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const cache = getHeaderStoreByHost(request.data);
const keys = cache.keys() || [];
const data = keys.reduce((acc: { [k: string]: string }, key) => {
acc[key] = cache.get(key) || '';
return acc;
}, {});
return data;
): boolean {
(async () => {
const cache = await getHeadersByHost(request.data);
sendResponse(cache);
})();
return true;
}
async function handleAddPlugin(
@@ -504,43 +543,60 @@ async function handleGetPluginConfigByHash(
return config;
}
async function handleRunPlugin(
function handleRunPlugin(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const { hash, method, params } = request.data;
const hex = await getPluginByHash(hash);
const arrayBuffer = hexToArrayBuffer(hex!);
const config = await getPluginConfig(arrayBuffer);
const plugin = await makePlugin(arrayBuffer, config);
devlog(`plugin::${method}`, params);
const out = await plugin.call(method, params);
devlog(`plugin response: `, out.string());
return JSON.parse(out.string());
(async () => {
const { hash, method, params } = request.data;
const hex = await getPluginByHash(hash);
const arrayBuffer = hexToArrayBuffer(hex!);
const config = await getPluginConfig(arrayBuffer);
const plugin = await makePlugin(arrayBuffer, config);
devlog(`plugin::${method}`, params);
const out = await plugin.call(method, params);
devlog(`plugin response: `, out.string());
sendResponse(JSON.parse(out.string()));
})();
return true;
}
let cachePopup: browser.Windows.Window | null = null;
async function openPopup(route: string, left?: number, top?: number) {
const tab = await browser.tabs.create({
url: browser.runtime.getURL('popup.html') + '#' + route,
active: false,
});
const popup = await browser.windows.create({
tabId: tab.id,
type: 'popup',
focused: true,
width: 480,
height: 640,
left: Math.round(left || 0),
top: Math.round(top || 0),
});
return { popup, tab };
}
async function handleOpenPopup(request: BackgroundAction) {
if (cachePopup) {
browser.windows.update(cachePopup.id!, {
focused: true,
});
} else {
const tab = await browser.tabs.create({
browser.tabs.update(cachePopup.id!, {
url: browser.runtime.getURL('popup.html') + '#' + request.data.route,
active: false,
});
const popup = await browser.windows.create({
tabId: tab.id,
type: 'popup',
focused: true,
width: 480,
height: 640,
left: request.data.position.left,
top: request.data.position.top,
});
} else {
const { popup } = await openPopup(
request.data.route,
request.data.position.left,
request.data.position.top,
);
cachePopup = popup;
@@ -554,3 +610,476 @@ async function handleOpenPopup(request: BackgroundAction) {
browser.windows.onRemoved.addListener(onPopUpClose);
}
}
async function handleConnect(request: BackgroundAction) {
const connection = await getConnection(request.data.origin);
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!connection) {
const defer = deferredPromise();
const { popup, tab } = await openPopup(
`connection-approval?origin=${encodeURIComponent(request.data.origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
request.data.position.left,
request.data.position.top,
);
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.connect_response) {
defer.resolve(req.data);
if (req.data) {
await setConnection(request.data.origin);
} else {
await deleteConnection(request.data.origin);
}
browser.runtime.onMessage.removeListener(onMessage);
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
defer.resolve(false);
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
return true;
}
async function handleGetHistory(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const {
origin,
position,
method: filterMethod,
url: filterUrl,
metadata: filterMetadata,
} = request.data;
const { popup, tab } = await openPopup(
`get-history-approval?${filterMetadata ? `metadata=${JSON.stringify(filterMetadata)}&` : ''}method=${filterMethod}&url=${filterUrl}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.get_history_response) {
if (req.data) {
const response = await getNotaryRequests();
const result = response
.map(
({ id, method, url, notaryUrl, websocketProxyUrl, metadata }) => ({
id,
time: new Date(charwise.decode(id)),
method,
url,
notaryUrl,
websocketProxyUrl,
metadata,
}),
)
.filter(({ method, url, metadata }) => {
let matchedMetadata = true;
if (filterMetadata) {
matchedMetadata = Object.entries(
filterMetadata as { [k: string]: string },
).reduce((bool, [k, v]) => {
try {
return bool && minimatch(metadata![k], v);
} catch (e) {
return false;
}
}, matchedMetadata);
}
return (
minimatch(method, filterMethod, { nocase: true }) &&
minimatch(url, filterUrl) &&
matchedMetadata
);
});
defer.resolve(result);
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
async function handleGetProof(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const { origin, position, id } = request.data;
const response = await getNotaryRequest(id);
if (!response) {
defer.reject(new Error('proof id not found.'));
return defer.promise;
}
const { popup, tab } = await openPopup(
`get-proof-approval?id=${id}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.get_proof_response) {
if (req.data) {
defer.resolve(response?.proof || null);
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
async function handleNotarizeRequest(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const {
url,
method = 'GET',
headers,
body,
maxSentData = await getMaxSent(),
maxRecvData = await getMaxRecv(),
notaryUrl = await getNotaryApi(),
websocketProxyUrl = await getProxyApi(),
origin,
position,
metadata,
} = request.data;
const config = JSON.stringify({
url,
method,
headers,
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
metadata,
});
const { popup, tab } = await openPopup(
`notarize-approval?config=${encodeURIComponent(config)}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const now = Date.now();
const id = charwise.encode(now).toString('hex');
let isUserClose = true;
const onNotarizationResponse = async (req: any) => {
if (req.type !== OffscreenActionTypes.notarization_response) return;
if (req.data.id !== id) return;
if (req.data.error) defer.reject(req.data.error);
if (req.data.proof) defer.resolve(req.data.proof);
browser.runtime.onMessage.removeListener(onNotarizationResponse);
};
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.notarize_response) {
if (req.data) {
try {
const { secretHeaders, secretResps } = req.data;
await addNotaryRequest(now, req.data);
await setNotaryRequestStatus(id, 'pending');
browser.runtime.onMessage.addListener(onNotarizationResponse);
browser.runtime.sendMessage({
type: OffscreenActionTypes.notarization_request,
data: {
id,
url,
method,
headers,
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
secretHeaders,
secretResps,
},
});
} catch (e) {
defer.reject(e);
}
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
isUserClose = false;
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (isUserClose && windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
async function handleInstallPluginRequest(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const { origin, position, url, metadata } = request.data;
let arrayBuffer: ArrayBuffer, config: PluginConfig;
try {
const resp = await fetch(url);
arrayBuffer = await resp.arrayBuffer();
config = await getPluginConfig(arrayBuffer);
} catch (e) {
defer.reject(e);
return defer.promise;
}
const { popup, tab } = await openPopup(
`install-plugin-approval?${metadata ? `metadata=${JSON.stringify(metadata)}&` : ''}url=${url}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.install_plugin_response) {
if (req.data) {
try {
const hex = Buffer.from(arrayBuffer).toString('hex');
const hash = await addPlugin(hex);
await addPluginConfig(hash!, config);
await addPluginMetadata(hash!, {
...metadata,
origin,
filePath: url,
});
defer.resolve(hash);
} catch (e) {
defer.reject(e);
}
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
async function handleGetPluginsRequest(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const {
origin,
position,
origin: filterOrigin,
url: filterUrl,
metadata: filterMetadata,
} = request.data;
const { popup, tab } = await openPopup(
`get-plugins-approval?${filterMetadata ? `metadata=${JSON.stringify(filterMetadata)}&` : ''}&filterOrigin=${filterOrigin}&url=${filterUrl}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.get_plugins_response) {
if (req.data) {
const response = await getPlugins();
const result = response.filter(({ metadata }) => {
let matchedMetadata = true;
if (filterMetadata) {
matchedMetadata = Object.entries(
filterMetadata as { [k: string]: string },
).reduce((bool, [k, v]) => {
try {
return bool && minimatch(metadata![k], v);
} catch (e) {
return false;
}
}, matchedMetadata);
}
return (
minimatch(metadata.filePath, filterUrl) &&
minimatch(metadata.origin, filterOrigin || '**') &&
matchedMetadata
);
});
defer.resolve(result);
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}
async function handleRunPluginCSRequest(request: BackgroundAction) {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const defer = deferredPromise();
const { origin, position, hash } = request.data;
const plugin = await getPluginByHash(hash);
const config = await getPluginConfigByHash(hash);
let isUserClose = true;
if (!plugin || !config) {
defer.reject(new Error('plugin not found.'));
return defer.promise;
}
const { popup, tab } = await openPopup(
`run-plugin-approval?hash=${hash}&origin=${encodeURIComponent(origin)}&favIconUrl=${encodeURIComponent(currentTab?.favIconUrl || '')}`,
position.left,
position.top,
);
const onPluginRequest = async (req: any) => {
console.log(req);
if (req.type !== SidePanelActionTypes.execute_plugin_response) return;
if (req.data.hash !== hash) return;
if (req.data.error) defer.reject(req.data.error);
if (req.data.proof) defer.resolve(req.data.proof);
browser.runtime.onMessage.removeListener(onPluginRequest);
};
const onMessage = async (req: BackgroundAction) => {
if (req.type === BackgroundActiontype.run_plugin_response) {
if (req.data) {
browser.runtime.onMessage.addListener(onPluginRequest);
} else {
defer.reject(new Error('user rejected.'));
}
browser.runtime.onMessage.removeListener(onMessage);
isUserClose = false;
browser.tabs.remove(tab.id!);
}
};
const onPopUpClose = (windowId: number) => {
if (isUserClose && windowId === popup.id) {
defer.reject(new Error('user rejected.'));
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
browser.runtime.onMessage.addListener(onMessage);
browser.windows.onRemoved.addListener(onPopUpClose);
return defer.promise;
}

View File

@@ -0,0 +1,118 @@
import { ContentScriptTypes, RPCClient } from './rpc';
import { RequestHistory } from '../Background/rpc';
import { PluginConfig, PluginMetadata } from '../../utils/misc';
import { PresentationJSON } from '../../utils/types';
const client = new RPCClient();
class TLSN {
async getHistory(
method: string,
url: string,
metadata?: {
[key: string]: string;
},
): Promise<
(Pick<
RequestHistory,
'id' | 'method' | 'notaryUrl' | 'url' | 'websocketProxyUrl'
> & { time: Date })[]
> {
const resp = await client.call(ContentScriptTypes.get_history, {
method,
url,
metadata,
});
return resp || [];
}
async getProof(id: string): Promise<PresentationJSON | null> {
const resp = await client.call(ContentScriptTypes.get_proof, {
id,
});
return resp || null;
}
async notarize(
url: string,
requestOptions?: {
method?: string;
headers?: { [key: string]: string };
body?: string;
},
proofOptions?: {
notaryUrl?: string;
websocketProxyUrl?: string;
maxSentData?: number;
maxRecvData?: number;
metadata?: {
[k: string]: string;
};
},
): Promise<PresentationJSON> {
const resp = await client.call(ContentScriptTypes.notarize, {
url,
method: requestOptions?.method,
headers: requestOptions?.headers,
body: requestOptions?.body,
maxSentData: proofOptions?.maxSentData,
maxRecvData: proofOptions?.maxRecvData,
notaryUrl: proofOptions?.notaryUrl,
websocketProxyUrl: proofOptions?.websocketProxyUrl,
metadata: proofOptions?.metadata,
});
return resp;
}
async installPlugin(
url: string,
metadata?: { [k: string]: string },
): Promise<string> {
const resp = await client.call(ContentScriptTypes.install_plugin, {
url,
metadata,
});
return resp;
}
async getPlugins(
url: string,
origin?: string,
metadata?: {
[key: string]: string;
},
): Promise<(PluginConfig & { hash: string; metadata: PluginMetadata })[]> {
const resp = await client.call(ContentScriptTypes.get_plugins, {
url,
origin,
metadata,
});
return resp;
}
async runPlugin(hash: string) {
const resp = await client.call(ContentScriptTypes.run_plugin, {
hash,
});
return resp;
}
}
const connect = async () => {
const resp = await client.call(ContentScriptTypes.connect);
if (resp) {
return new TLSN();
}
};
// @ts-ignore
window.tlsn = {
connect,
};

View File

@@ -1,9 +1,215 @@
window.onerror = (error) => {
// console.log('error');
// console.log(error);
};
import browser from 'webextension-polyfill';
import { ContentScriptRequest, ContentScriptTypes, RPCServer } from './rpc';
import { BackgroundActiontype, RequestHistory } from '../Background/rpc';
import { urlify } from '../../utils/misc';
(async () => {
console.log('Content script works!');
console.log('Must reload extension for modifications to take effect.');
loadScript('content.bundle.js');
const server = new RPCServer();
server.on(ContentScriptTypes.connect, async () => {
const connected = await browser.runtime.sendMessage({
type: BackgroundActiontype.connect_request,
data: {
...getPopupData(),
},
});
if (!connected) throw new Error('user rejected.');
return connected;
});
server.on(
ContentScriptTypes.get_history,
async (
request: ContentScriptRequest<{
method: string;
url: string;
metadata?: { [k: string]: string };
}>,
) => {
const {
method: filterMethod,
url: filterUrl,
metadata,
} = request.params || {};
if (!filterMethod || !filterUrl)
throw new Error('params must include method and url.');
const response: RequestHistory[] = await browser.runtime.sendMessage({
type: BackgroundActiontype.get_history_request,
data: {
...getPopupData(),
method: filterMethod,
url: filterUrl,
metadata,
},
});
return response;
},
);
server.on(
ContentScriptTypes.get_proof,
async (request: ContentScriptRequest<{ id: string }>) => {
const { id } = request.params || {};
if (!id) throw new Error('params must include id.');
const proof = await browser.runtime.sendMessage({
type: BackgroundActiontype.get_proof_request,
data: {
...getPopupData(),
id,
},
});
return proof;
},
);
server.on(
ContentScriptTypes.notarize,
async (
request: ContentScriptRequest<{
url: string;
method?: string;
headers?: { [key: string]: string };
metadata?: { [key: string]: string };
body?: string;
notaryUrl?: string;
websocketProxyUrl?: string;
maxSentData?: number;
maxRecvData?: number;
}>,
) => {
const {
url,
method,
headers,
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
metadata,
} = request.params || {};
if (!url || !urlify(url)) throw new Error('invalid url.');
const proof = await browser.runtime.sendMessage({
type: BackgroundActiontype.notarize_request,
data: {
...getPopupData(),
url,
method,
headers,
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
metadata,
},
});
return proof;
},
);
server.on(
ContentScriptTypes.install_plugin,
async (
request: ContentScriptRequest<{
url: string;
metadata?: { [k: string]: string };
}>,
) => {
const { url, metadata } = request.params || {};
if (!url) throw new Error('params must include url.');
const response: RequestHistory[] = await browser.runtime.sendMessage({
type: BackgroundActiontype.install_plugin_request,
data: {
...getPopupData(),
url,
metadata,
},
});
return response;
},
);
server.on(
ContentScriptTypes.get_plugins,
async (
request: ContentScriptRequest<{
url: string;
origin?: string;
metadata?: { [k: string]: string };
}>,
) => {
const {
url: filterUrl,
origin: filterOrigin,
metadata,
} = request.params || {};
if (!filterUrl) throw new Error('params must include url.');
const response = await browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugins_request,
data: {
...getPopupData(),
url: filterUrl,
origin: filterOrigin,
metadata,
},
});
return response;
},
);
server.on(
ContentScriptTypes.run_plugin,
async (request: ContentScriptRequest<{ hash: string }>) => {
const { hash } = request.params || {};
if (!hash) throw new Error('params must include hash');
const response = await browser.runtime.sendMessage({
type: BackgroundActiontype.run_plugin_request,
data: {
...getPopupData(),
hash,
},
});
return response;
},
);
})();
function loadScript(filename: string) {
const url = browser.runtime.getURL(filename);
const script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', url);
document.body.appendChild(script);
}
function getPopupData() {
return {
origin: window.origin,
position: {
left: window.screen.width / 2 - 240,
top: window.screen.height / 2 - 300,
},
};
}

118
src/entries/Content/rpc.ts Normal file
View File

@@ -0,0 +1,118 @@
import { deferredPromise, PromiseResolvers } from '../../utils/promise';
export enum ContentScriptTypes {
connect = 'tlsn/cs/connect',
get_history = 'tlsn/cs/get_history',
get_proof = 'tlsn/cs/get_proof',
notarize = 'tlsn/cs/notarize',
install_plugin = 'tlsn/cs/install_plugin',
get_plugins = 'tlsn/cs/get_plugins',
run_plugin = 'tlsn/cs/run_plugin',
}
export type ContentScriptRequest<params> = {
tlsnrpc: string;
} & RPCRequest<ContentScriptTypes, params>;
export type ContentScriptResponse = {
tlsnrpc: string;
} & RPCResponse;
export type RPCRequest<method, params> = {
id: number;
method: method;
params?: params;
};
export type RPCResponse = {
id: number;
result?: never;
error?: never;
};
export class RPCServer {
#handlers: Map<
ContentScriptTypes,
(message: ContentScriptRequest<any>) => Promise<any>
> = new Map();
constructor() {
window.addEventListener(
'message',
async (event: MessageEvent<ContentScriptRequest<never>>) => {
const data = event.data;
if (data.tlsnrpc !== '1.0') return;
if (!data.method) return;
const handler = this.#handlers.get(data.method);
if (handler) {
try {
const result = await handler(data);
window.postMessage({
tlsnrpc: '1.0',
id: data.id,
result,
});
} catch (error) {
window.postMessage({
tlsnrpc: '1.0',
id: data.id,
error,
});
}
} else {
throw new Error(`unknown method - ${data.method}`);
}
},
);
}
on(
method: ContentScriptTypes,
handler: (message: ContentScriptRequest<any>) => Promise<any>,
) {
this.#handlers.set(method, handler);
}
}
export class RPCClient {
#requests: Map<number, PromiseResolvers> = new Map();
#id = 0;
get id() {
return this.#id++;
}
constructor() {
window.addEventListener(
'message',
(event: MessageEvent<ContentScriptResponse>) => {
const data = event.data;
if (data.tlsnrpc !== '1.0') return;
const promise = this.#requests.get(data.id);
if (promise) {
if (typeof data.result !== 'undefined') {
promise.resolve(data.result);
this.#requests.delete(data.id);
} else if (typeof data.error !== 'undefined') {
promise.reject(data.error);
this.#requests.delete(data.id);
}
}
},
);
}
async call(method: ContentScriptTypes, params?: any): Promise<never> {
const request = { tlsnrpc: '1.0', id: this.id, method, params };
const defer = deferredPromise();
this.#requests.set(request.id, defer);
window.postMessage(request, '*');
return defer.promise;
}
}

View File

@@ -1,85 +1,121 @@
import React, { useEffect } from 'react';
import { BackgroundActiontype } from '../Background/rpc';
import { prove, set_logging_filter, verify } from 'tlsn-js';
import * as Comlink from 'comlink';
import { OffscreenActionTypes } from './types';
import {
NotaryServer,
Prover as TProver,
Presentation as TPresentation,
Transcript,
} from 'tlsn-js';
import { verify } from 'tlsn-js-v5';
import { urlify } from '../../utils/misc';
import { BackgroundActiontype } from '../Background/rpc';
import browser from 'webextension-polyfill';
import { getLoggingFilter } from '../../utils/storage';
import { LOGGING_LEVEL_INFO } from '../../utils/constants';
import { PresentationJSON } from '../../utils/types';
import { PresentationJSON as PresentationJSONa7 } from 'tlsn-js/build/types';
import { Method } from 'tlsn-js/wasm/pkg';
const { init, Prover, Presentation }: any = Comlink.wrap(
new Worker(new URL('./worker.ts', import.meta.url)),
);
const Offscreen = () => {
useEffect(() => {
// @ts-ignore
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
switch (request.type) {
case BackgroundActiontype.process_prove_request: {
const {
url,
method,
headers,
body = '',
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
id,
secretHeaders,
secretResps,
loggingFilter = LOGGING_LEVEL_INFO,
} = request.data;
(async () => {
const loggingLevel = await browser.runtime.sendMessage({
type: BackgroundActiontype.get_logging_level,
});
await init({ loggingLevel });
// @ts-ignore
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
switch (request.type) {
case OffscreenActionTypes.notarization_request: {
const { id } = request.data;
(async () => {
try {
const token = urlify(url)?.hostname || '';
await set_logging_filter(loggingFilter);
const proof = await prove(url, {
method,
headers,
body,
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl: websocketProxyUrl + `?token=${token}`,
secretHeaders,
secretResps,
});
(async () => {
try {
const proof = await createProof(request.data);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
proof,
},
});
} catch (error) {
console.log('i caught an error');
console.error(error);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
error,
},
});
}
})();
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
proof,
},
});
break;
}
case BackgroundActiontype.verify_proof: {
(async () => {
const result = await verify(request.data);
sendResponse(result);
})();
browser.runtime.sendMessage({
type: OffscreenActionTypes.notarization_response,
data: {
id,
proof,
},
});
} catch (error) {
console.error(error);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
error,
},
});
return true;
}
case BackgroundActiontype.verify_prove_request: {
(async () => {
const result = await verify(request.data.proof);
browser.runtime.sendMessage({
type: OffscreenActionTypes.notarization_response,
data: {
id,
error,
},
});
}
})();
break;
}
case BackgroundActiontype.process_prove_request: {
const { id } = request.data;
(async () => {
try {
const proof = await createProof(request.data);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
proof: proof,
},
});
} catch (error) {
console.error(error);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
error,
},
});
}
})();
break;
}
case BackgroundActiontype.verify_proof: {
(async () => {
const result = await verifyProof(request.data);
sendResponse(result);
})();
return true;
}
case BackgroundActiontype.verify_prove_request: {
(async () => {
const proof: PresentationJSON = request.data.proof;
const result: { sent: string; recv: string } =
await verifyProof(proof);
if (result) {
chrome.runtime.sendMessage<any, string>({
type: BackgroundActiontype.finish_prove_request,
data: {
@@ -90,17 +126,178 @@ const Offscreen = () => {
},
},
});
}
})();
break;
})();
break;
}
default:
break;
}
default:
break;
}
});
});
})();
}, []);
return <div className="App" />;
};
export default Offscreen;
function subtractRanges(
ranges: { start: number; end: number },
negatives: { start: number; end: number }[],
): { start: number; end: number }[] {
const returnVal: { start: number; end: number }[] = [ranges];
negatives
.sort((a, b) => (a.start < b.start ? -1 : 1))
.forEach(({ start, end }) => {
const last = returnVal.pop()!;
if (start < last.start || end > last.end) {
console.error('invalid ranges');
return;
}
if (start === last.start && end === last.end) {
return;
}
if (start === last.start && end < last.end) {
returnVal.push({ start: end, end: last.end });
return;
}
if (start > last.start && end < last.end) {
returnVal.push({ start: last.start, end: start });
returnVal.push({ start: end, end: last.end });
return;
}
if (start > last.start && end === last.end) {
returnVal.push({ start: last.start, end: start });
return;
}
});
return returnVal;
}
async function createProof(options: {
url: string;
notaryUrl: string;
websocketProxyUrl: string;
method?: Method;
headers?: {
[name: string]: string;
};
body?: any;
maxSentData?: number;
maxRecvData?: number;
id: string;
secretHeaders: string[];
secretResps: string[];
}): Promise<PresentationJSONa7> {
const {
url,
method = 'GET',
headers = {},
body,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
id,
secretHeaders = [],
secretResps = [],
} = options;
const hostname = urlify(url)?.hostname || '';
const notary = NotaryServer.from(notaryUrl);
const prover: TProver = await new Prover({
id,
serverDns: hostname,
maxSentData,
maxRecvData,
});
await prover.setup(await notary.sessionUrl(maxSentData, maxRecvData));
await prover.sendRequest(websocketProxyUrl + `?token=${hostname}`, {
url,
method,
headers,
body,
});
const transcript = await prover.transcript();
const commit = {
sent: subtractRanges(
transcript.ranges.sent.all,
secretHeaders
.map((secret: string) => {
const index = transcript.sent.indexOf(secret);
return index > -1
? {
start: index,
end: index + secret.length,
}
: null;
})
.filter((data: any) => !!data) as { start: number; end: number }[],
),
recv: subtractRanges(
transcript.ranges.recv.all,
secretResps
.map((secret: string) => {
const index = transcript.recv.indexOf(secret);
return index > -1
? {
start: index,
end: index + secret.length,
}
: null;
})
.filter((data: any) => !!data) as { start: number; end: number }[],
),
};
const notarizationOutputs = await prover.notarize(commit);
const presentation = (await new Presentation({
attestationHex: notarizationOutputs.attestation,
secretsHex: notarizationOutputs.secrets,
notaryUrl: notarizationOutputs.notaryUrl,
websocketProxyUrl: notarizationOutputs.websocketProxyUrl,
reveal: commit,
})) as TPresentation;
const presentationJSON = await presentation.json();
return presentationJSON;
}
async function verifyProof(
proof: PresentationJSON,
): Promise<{ sent: string; recv: string }> {
let result: { sent: string; recv: string };
switch (proof.version) {
case undefined: {
result = await verify(proof);
break;
}
case '0.1.0-alpha.7': {
const presentation: TPresentation = await new Presentation(proof.data);
const verifierOutput = await presentation.verify();
const transcript = new Transcript({
sent: verifierOutput.transcript.sent,
recv: verifierOutput.transcript.recv,
});
result = {
sent: transcript.sent(),
recv: transcript.recv(),
};
break;
}
}
return result;
}

View File

@@ -0,0 +1,4 @@
export enum OffscreenActionTypes {
notarization_request = 'offscreen/notarization_request',
notarization_response = 'offscreen/notarization_response',
}

View File

@@ -0,0 +1,8 @@
import * as Comlink from 'comlink';
import init, { Prover, Presentation } from 'tlsn-js';
Comlink.expose({
init,
Prover,
Presentation,
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import Options from '../../pages/Options';
import './index.css';
import './index.scss';
const container = document.getElementById('app-container');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { Navigate, Route, Routes, useNavigate } from 'react-router';
import { useDispatch } from 'react-redux';
import {
@@ -20,11 +20,24 @@ import History from '../../pages/History';
import ProofUploader from '../../pages/ProofUploader';
import browser from 'webextension-polyfill';
import store from '../../utils/store';
import PluginUploadInfo from '../../components/PluginInfo';
import ConnectionDetailsModal from '../../components/ConnectionDetailsModal';
import { ConnectionApproval } from '../../pages/ConnectionApproval';
import { GetHistoryApproval } from '../../pages/GetHistoryApproval';
import { GetProofApproval } from '../../pages/GetProofApproval';
import { NotarizeApproval } from '../../pages/NotarizeApproval';
import { InstallPluginApproval } from '../../pages/InstallPluginApproval';
import { GetPluginsApproval } from '../../pages/GetPluginsApproval';
import { RunPluginApproval } from '../../pages/RunPluginApproval';
import Icon from '../../components/Icon';
import classNames from 'classnames';
import { getConnection } from '../Background/db';
import { useIsConnected, setConnection } from '../../reducers/requests';
import { MenuIcon } from '../../components/Menu';
import Plugins from '../../pages/Plugins';
const Popup = () => {
const dispatch = useDispatch();
const activeTab = useActiveTab();
const url = useActiveTabUrl();
const navigate = useNavigate();
useEffect(() => {
@@ -73,7 +86,7 @@ const Popup = () => {
}, []);
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="flex flex-col w-full h-full overflow-hidden lg:w-[600px] lg:h-[800px] lg:border lg:m-auto lg:mt-40 lg:bg-white lg:shadow">
<div className="flex flex-nowrap flex-shrink-0 flex-row items-center relative gap-2 h-9 p-2 cursor-default justify-center bg-slate-300 w-full">
<img
className="absolute left-2 h-5 cursor-pointer"
@@ -81,15 +94,9 @@ const Popup = () => {
alt="logo"
onClick={() => navigate('/')}
/>
<div className="absolute right-2 flex flex-nowrap flex-row items-center gap-1 justify-center w-fit">
{!!activeTab?.favIconUrl && (
<img
src={activeTab?.favIconUrl}
className="h-5 rounded-full"
alt="logo"
/>
)}
<div className="text-xs">{url?.hostname}</div>
<div className="flex flex-row flex-grow items-center justify-end gap-4">
<AppConnectionLogo />
<MenuIcon />
</div>
</div>
<Routes>
@@ -97,11 +104,23 @@ const Popup = () => {
<Route path="/notary/:requestId" element={<Notarize />} />
<Route path="/verify/:requestId/*" element={<ProofViewer />} />
<Route path="/verify" element={<ProofUploader />} />
<Route path="/history" element={<History />} />
<Route path="/requests" element={<Requests />} />
<Route path="/history" element={<Home tab="history" />} />
<Route path="/requests" element={<Home tab="network" />} />
<Route path="/custom/*" element={<RequestBuilder />} />
<Route path="/options" element={<Options />} />
<Route path="/plugins" element={<Plugins />} />
<Route path="/home" element={<Home />} />
<Route path="/plugininfo" element={<PluginUploadInfo />} />
<Route path="/connection-approval" element={<ConnectionApproval />} />
<Route path="/get-history-approval" element={<GetHistoryApproval />} />
<Route path="/get-proof-approval" element={<GetProofApproval />} />
<Route path="/notarize-approval" element={<NotarizeApproval />} />
<Route path="/get-plugins-approval" element={<GetPluginsApproval />} />
<Route path="/run-plugin-approval" element={<RunPluginApproval />} />
<Route
path="/install-plugin-approval"
element={<InstallPluginApproval />}
/>
<Route path="*" element={<Navigate to="/home" />} />
</Routes>
</div>
@@ -109,3 +128,58 @@ const Popup = () => {
};
export default Popup;
function AppConnectionLogo() {
const dispatch = useDispatch();
const activeTab = useActiveTab();
const url = useActiveTabUrl();
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
const connected = useIsConnected();
useEffect(() => {
(async () => {
if (url) {
const isConnected: boolean | null = await getConnection(url?.origin);
dispatch(setConnection(!!isConnected));
}
})();
}, [url]);
return (
<div
className="flex flex-nowrap flex-row items-center gap-1 justify-center w-fit cursor-pointer"
onClick={() => setShowConnectionDetails(true)}
>
<div className="flex flex-row relative bg-black border-[1px] border-black rounded-full">
{!!activeTab?.favIconUrl ? (
<img
src={activeTab?.favIconUrl}
className="h-5 rounded-full"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
className="bg-white text-slate-400 rounded-full"
size={1.25}
/>
)}
<div
className={classNames(
'absolute right-[-2px] bottom-[-2px] rounded-full h-[10px] w-[10px] border-[2px]',
{
'bg-green-500': connected,
'bg-slate-500': !connected,
},
)}
/>
</div>
{showConnectionDetails && (
<ConnectionDetailsModal
showConnectionDetails={showConnectionDetails}
setShowConnectionDetails={setShowConnectionDetails}
/>
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Popup</title>
<title>TLSN Extension</title>
</head>
<body>

View File

@@ -30,6 +30,10 @@ code {
width: 100vw;
height: 100vh;
overflow: hidden;
@media (min-width: 1024px) {
@apply bg-slate-400;
}
}
.button {

View File

@@ -5,7 +5,6 @@ import Popup from './Popup';
import './index.scss';
import { Provider } from 'react-redux';
import store from '../../utils/store';
import { BackgroundActiontype } from '../Background/rpc';
const container = document.getElementById('app-container');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript

View File

@@ -2,7 +2,13 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import './sidePanel.scss';
import browser from 'webextension-polyfill';
import { fetchPluginConfigByHash, runPlugin } from '../../utils/rpc';
import { PluginConfig, StepConfig } from '../../utils/misc';
import {
getPluginConfig,
hexToArrayBuffer,
makePlugin,
PluginConfig,
StepConfig,
} from '../../utils/misc';
import { PluginList } from '../../components/PluginList';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
import logo from '../../assets/img/icon-128.png';
@@ -10,6 +16,10 @@ import classNames from 'classnames';
import Icon from '../../components/Icon';
import { useRequestHistory } from '../../reducers/history';
import { BackgroundActiontype } from '../Background/rpc';
import { getPluginByHash, getPluginConfigByHash } from '../Background/db';
import type { Plugin } from '@extism/extism';
import { OffscreenActionTypes } from '../Offscreen/types';
import { SidePanelActionTypes } from './types';
export default function SidePanel(): ReactElement {
const [config, setConfig] = useState<PluginConfig | null>(null);
@@ -19,7 +29,7 @@ export default function SidePanel(): ReactElement {
(async function () {
const result = await browser.storage.local.get('plugin_hash');
const { plugin_hash } = result;
const config = await fetchPluginConfigByHash(plugin_hash);
const config = await getPluginConfigByHash(plugin_hash);
setHash(plugin_hash);
setConfig(config);
// await browser.storage.local.set({ plugin_hash: '' });
@@ -50,16 +60,41 @@ function PluginBody(props: {
const { hash } = props;
const { title, description, icon, steps } = props.config;
const [responses, setResponses] = useState<any[]>([]);
const [notarizationId, setNotarizationId] = useState('');
const notaryRequest = useRequestHistory(notarizationId);
const setResponse = useCallback(
(response: any, i: number) => {
const result = responses.concat();
result[i] = response;
setResponses(result);
if (i === steps!.length - 1 && !!response) {
setNotarizationId(response);
}
},
[responses],
[hash, responses],
);
useEffect(() => {
if (notaryRequest?.status === 'success') {
browser.runtime.sendMessage({
type: SidePanelActionTypes.execute_plugin_response,
data: {
hash,
proof: notaryRequest.proof,
},
});
} else if (notaryRequest?.status === 'error') {
browser.runtime.sendMessage({
type: SidePanelActionTypes.execute_plugin_response,
data: {
hash,
error: notaryRequest.error,
},
});
}
}, [hash, notaryRequest?.status]);
return (
<div className="flex flex-col p-4">
<div className="flex flex-row items-center gap-4">
@@ -102,10 +137,10 @@ function StepContent(
description,
cta,
action,
hash,
setResponse,
lastResponse,
prover,
hash,
} = props;
const [completed, setCompleted] = useState(false);
const [pending, setPending] = useState(false);
@@ -113,26 +148,37 @@ function StepContent(
const [notarizationId, setNotarizationId] = useState('');
const notaryRequest = useRequestHistory(notarizationId);
const getPlugin = useCallback(async () => {
const hex = await getPluginByHash(hash);
const config = await getPluginConfigByHash(hash);
const arrayBuffer = hexToArrayBuffer(hex!);
return makePlugin(arrayBuffer, config!);
}, [hash]);
const processStep = useCallback(async () => {
const plugin = await getPlugin();
if (!plugin) return;
if (index > 0 && !lastResponse) return;
setPending(true);
setError('');
try {
setError('');
const val = await runPlugin(hash, action, JSON.stringify(lastResponse));
const out = await plugin.call(action, JSON.stringify(lastResponse));
const val = JSON.parse(out.string());
if (val && prover) {
setNotarizationId(val);
} else {
setCompleted(!!val);
setResponse(val, index);
}
setResponse(val, index);
} catch (e: any) {
console.error(e);
setError(e?.message || 'Unkonwn error');
} finally {
setPending(false);
}
}, [hash, action, index, lastResponse, prover]);
}, [action, index, lastResponse, prover, getPlugin]);
const onClick = useCallback(() => {
if (
@@ -189,7 +235,7 @@ function StepContent(
)}
onClick={viewProofInPopup}
>
<span className="text-sm">View Proof</span>
<span className="text-sm">View</span>
</button>
);
} else if (notaryRequest?.status === 'pending' || pending || notarizationId) {

View File

@@ -0,0 +1,4 @@
export enum SidePanelActionTypes {
execute_plugin_request = 'sidePanel/execute_plugin_request',
execute_plugin_response = 'sidePanel/execute_plugin_response',
}

View File

@@ -3,8 +3,8 @@
"name": "TLSN Extension",
"description": "A chrome extension for TLSN",
"options_page": "options.html",
"background": { "service_worker": "background.bundle.js",
"persistent": true
"background": {
"service_worker": "background.bundle.js"
},
"action": {
"default_popup": "popup.html",
@@ -28,12 +28,13 @@
],
"web_accessible_resources": [
{
"resources": ["content.styles.css", "icon-128.png", "icon-34.png"],
"matches": []
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "discord_dm.wasm", "twitter_profile.wasm"],
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
}
],
"host_permissions": ["<all_urls>"],
"permissions": [
"contextMenus".
"offscreen",
"storage",
"webRequest",

View File

@@ -0,0 +1,44 @@
import React, { ReactElement, ReactNode } from 'react';
import logo from '../../assets/img/icon-128.png';
export function BaseApproval({
onSecondaryClick,
onPrimaryClick,
header,
children,
secondaryCTAText = 'Cancel',
primaryCTAText = 'Accept',
}: {
header: ReactNode;
children: ReactNode;
onSecondaryClick: () => void;
onPrimaryClick: () => void;
secondaryCTAText?: string;
primaryCTAText?: string;
}): ReactElement {
return (
<div className="absolute flex flex-col items-center w-screen h-screen bg-white gap-2 cursor-default">
<div className="w-full p-2 border-b border-gray-200 text-gray-500">
<div className="flex flex-row items-end justify-start gap-2">
<img className="h-5" src={logo} alt="logo" />
<span className="font-semibold">{header}</span>
</div>
</div>
<div className="flex flex-col flex-grow gap-2 overflow-y-auto w-full">
{children}
</div>
<div className="flex flex-row w-full gap-2 justify-end border-t p-4">
{!!onSecondaryClick && !!secondaryCTAText && (
<button className="button" onClick={onSecondaryClick}>
{secondaryCTAText}
</button>
)}
{!!onPrimaryClick && !!primaryCTAText && (
<button className="button button--primary" onClick={onPrimaryClick}>
{primaryCTAText}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React, { ReactElement, useCallback } from 'react';
import Icon from '../../components/Icon';
import logo from '../../assets/img/icon-128.png';
import { useSearchParams } from 'react-router-dom';
import { urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
export function ConnectionApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const hostname = urlify(origin || '')?.hostname;
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.connect_response,
data: false,
});
}, []);
const onAccept = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.connect_response,
data: true,
});
}, []);
return (
<BaseApproval
header={`Connecting to ${hostname}`}
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 border border-slate-200 bg-slate-200 rounded-full"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-sm font-semibold">{hostname}</div>
</div>
<div className="text-lg font-bold text-center">Connect to this site?</div>
<div className="text-sm px-8 text-center text-slate-500 flex-grow">
Do you trust this site? By granting this permission, you're allowing
this site to view your installed plugins, suggest requests to notarize,
suggest plugins to install, ask you to share proofs metadata{' '}
<i>(method, url, notary url, and proxy url)</i>, and ask to view a
specific proof.
</div>
</BaseApproval>
);
}

View File

@@ -0,0 +1,139 @@
import React, { ReactElement, useCallback, useEffect } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import { safeParseJSON, urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { minimatch } from 'minimatch';
import { useAllProofHistory } from '../../reducers/history';
import classNames from 'classnames';
export function GetHistoryApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const method = params.get('method');
const url = params.get('url');
const rawMetadata = params.get('metadata');
const metadata = safeParseJSON(rawMetadata);
const hostname = urlify(origin || '')?.hostname;
const proofs = useAllProofHistory();
useEffect(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_prove_requests,
});
}, []);
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_history_response,
data: false,
});
}, []);
const onAccept = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_history_response,
data: true,
});
}, []);
const result = proofs.filter((proof) => {
let matchedMetadata = true;
if (metadata) {
matchedMetadata = Object.entries(
metadata as { [k: string]: string },
).reduce((bool, [k, v]) => {
try {
return bool && minimatch(proof.metadata![k], v);
} catch (e) {
return false;
}
}, matchedMetadata);
}
return (
minimatch(proof.method, method!, { nocase: true }) &&
minimatch(proof.url, url!) &&
matchedMetadata
);
});
return (
<BaseApproval
header="Requesting Proof History"
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
Do you want to share proof history with{' '}
<b className="text-blue-500">{hostname}</b>?
</div>
</div>
<div className="flex flex-col items-center gap-4 text-sm px-8 text-center flex-grow">
<div className="text-slate-500">
All proofs matching the following patterns with be shared:
</div>
<table className="border border-collapse table-auto rounded text-xs w-full">
<tbody>
<tr>
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
Method
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono text-left">
{method?.toUpperCase()}
</td>
</tr>
<tr className="">
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
URL
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono break-all text-left">
{url}
</td>
</tr>
{rawMetadata && (
<tr className="">
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
Metadata
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono break-all text-left">
{rawMetadata}
</td>
</tr>
)}
</tbody>
</table>
<div
className={classNames('border rounded font-semibold px-2 py-1', {
'text-green-500 bg-green-200 border-green-300': result.length,
'text-slate-500 bg-slate-200 border-slate-300': !result.length,
})}
>
{result.length} results found
</div>
</div>
<div className="text-xs px-8 pb-2 text-center text-slate-500">
Only certain metadata will be shared with the app, such as <i>id</i>,{' '}
<i>method</i>, <i>url</i>, <i>notary</i>, <i>proxy</i>, and{' '}
<i>timestamp</i>.
</div>
</BaseApproval>
);
}

View File

@@ -0,0 +1,138 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import { safeParseJSON, urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { getPlugins } from '../../entries/Background/db';
import { minimatch } from 'minimatch';
import classNames from 'classnames';
export function GetPluginsApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const url = params.get('url');
const filterOrigin = params.get('filterOrigin');
const rawMetadata = params.get('metadata');
const filterMetadata = safeParseJSON(rawMetadata);
const hostname = urlify(origin || '')?.hostname;
const [result, setResult] = useState<any[]>([]);
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugins_response,
data: false,
});
}, []);
const onAccept = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugins_response,
data: true,
});
}, []);
useEffect(() => {
(async () => {
const response = await getPlugins();
const res = response.filter(({ metadata }) => {
let matchedMetadata = true;
if (filterMetadata) {
matchedMetadata = Object.entries(
filterMetadata as { [k: string]: string },
).reduce((bool, [k, v]) => {
try {
return bool && minimatch(metadata![k], v);
} catch (e) {
return false;
}
}, matchedMetadata);
}
return (
minimatch(metadata.filePath, url || '**') &&
minimatch(metadata.origin, filterOrigin || '**') &&
matchedMetadata
);
});
setResult(res);
})();
}, [url, filterMetadata]);
return (
<BaseApproval
header="Requesting Plugins"
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
Do you want to share installed plugins with{' '}
<b className="text-blue-500">{hostname}</b>?
</div>
</div>
<div className="flex flex-col items-center gap-4 text-sm px-8 text-center flex-grow">
<div className="text-slate-500">
All plugins matching the following patterns with be shared:
</div>
<table className="border border-collapse table-auto rounded text-xs w-full">
<tbody>
<tr className="">
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
URL
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono break-all text-left">
{url}
</td>
</tr>
<tr className="">
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
Origin
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono break-all text-left">
{filterOrigin}
</td>
</tr>
{rawMetadata && (
<tr className="">
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top w-16 text-left">
Metadata
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-black font-mono break-all text-left">
{rawMetadata}
</td>
</tr>
)}
</tbody>
</table>
<div
className={classNames('border rounded font-semibold px-2 py-1', {
'text-green-500 bg-green-200 border-green-300': result.length,
'text-slate-500 bg-slate-200 border-slate-300': !result.length,
})}
>
{result.length} results found
</div>
</div>
<div className="text-xs px-8 pb-2 text-center text-slate-500">
Only certain metadata will be shared with the app, such as <i>id</i>,{' '}
<i>method</i>, <i>url</i>, <i>notary</i>, <i>proxy</i>, and{' '}
<i>timestamp</i>.
</div>
</BaseApproval>
);
}

View File

@@ -0,0 +1,68 @@
import React, { ReactElement, useCallback, useEffect } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import { urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { OneRequestHistory } from '../History';
export function GetProofApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const id = params.get('id');
const hostname = urlify(origin || '')?.hostname;
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_proof_response,
data: false,
});
}, []);
const onAccept = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.get_proof_response,
data: true,
});
}, []);
return (
<BaseApproval
header="Requesting Proof History"
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
Do you want to share proof data with{' '}
<b className="text-blue-500">{hostname}</b>?
</div>
</div>
<div className="flex flex-col items-center gap-4 text-sm px-8 text-center flex-grow">
<div className="text-slate-500">
The following proof will be shared:
</div>
<OneRequestHistory
className="w-full !cursor-default hover:bg-white text-xs"
requestId={id!}
hideActions={['share', 'delete', 'retry']}
/>
</div>
</BaseApproval>
);
}

View File

@@ -7,13 +7,7 @@ import {
deleteRequestHistory,
} from '../../reducers/history';
import Icon from '../../components/Icon';
import {
get,
NOTARY_API_LS_KEY,
PROXY_API_LS_KEY,
getNotaryApi,
getProxyApi,
} from '../../utils/storage';
import { getNotaryApi, getProxyApi } from '../../utils/storage';
import { urlify, download, upload } from '../../utils/misc';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import Modal, { ModalContent } from '../../components/Modal/Modal';
@@ -24,20 +18,28 @@ import {
getNotaryRequest,
setNotaryRequestCid,
} from '../../entries/Background/db';
const charwise = require('charwise');
export default function History(): ReactElement {
const history = useHistoryOrder();
return (
<div className="flex flex-col flex-nowrap overflow-y-auto">
{history.map((id) => {
return <OneRequestHistory key={id} requestId={id} />;
})}
{history
.map((id) => {
return <OneRequestHistory key={id} requestId={id} />;
})
.reverse()}
</div>
);
}
function OneRequestHistory(props: { requestId: string }): ReactElement {
export function OneRequestHistory(props: {
requestId: string;
className?: string;
hideActions?: string[];
}): ReactElement {
const { hideActions = [] } = props;
const dispatch = useDispatch();
const request = useRequestHistory(props.requestId);
const [showingError, showError] = useState(false);
@@ -115,7 +117,12 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
}, [props.requestId, request, cid]);
return (
<div className="flex flex-row flex-nowrap border rounded-md p-2 gap-1 hover:bg-slate-50 cursor-pointer">
<div
className={classNames(
'flex flex-row flex-nowrap border rounded-md p-2 gap-1 hover:bg-slate-50 cursor-pointer',
props.className,
)}
>
<ShareConfirmationModal />
<ErrorModal />
<div className="flex flex-col flex-nowrap flex-grow flex-shrink w-0">
@@ -127,6 +134,12 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
{requestUrl?.pathname}
</div>
</div>
<div className="flex flex-row">
<div className="font-bold text-slate-400">Time:</div>
<div className="ml-2 text-slate-800">
{new Date(charwise.decode(props.requestId, 'hex')).toISOString()}
</div>
</div>
<div className="flex flex-row">
<div className="font-bold text-slate-400">Host:</div>
<div className="ml-2 text-slate-800">{requestUrl?.host}</div>
@@ -136,7 +149,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
<div className="ml-2 text-slate-800">{request?.notaryUrl}</div>
</div>
<div className="flex flex-row">
<div className="font-bold text-slate-400">TLS Proxy API: </div>
<div className="font-bold text-slate-400">TLS Proxy API:</div>
<div className="ml-2 text-slate-800">
{request?.websocketProxyUrl}
</div>
@@ -149,7 +162,8 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
className="bg-slate-600 text-slate-200 hover:bg-slate-500 hover:text-slate-100"
onClick={onView}
fa="fa-solid fa-receipt"
ctaText="View Proof"
ctaText="View"
hidden={hideActions.includes('view')}
/>
<ActionButton
className="bg-slate-100 text-slate-300 hover:bg-slate-200 hover:text-slate-500"
@@ -158,35 +172,42 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
}
fa="fa-solid fa-download"
ctaText="Download"
hidden={hideActions.includes('download')}
/>
<ActionButton
className="flex flex-row flex-grow-0 gap-2 self-end items-center justify-end px-2 py-1 bg-slate-100 text-slate-300 hover:bg-slate-200 hover:text-slate-500 hover:font-bold"
onClick={() => setShowingShareConfirmation(true)}
fa="fa-solid fa-upload"
ctaText="Share"
hidden={hideActions.includes('share')}
/>
</>
)}
{status === 'error' && !!request?.error && <ErrorButton />}
{(!status || status === 'error') && <RetryButton />}
{status === 'error' && !!request?.error && (
<ErrorButton hidden={hideActions.includes('error')} />
)}
{(!status || status === 'error') && (
<RetryButton hidden={hideActions.includes('retry')} />
)}
{status === 'pending' && (
<button className="flex flex-row flex-grow-0 gap-2 self-end items-center justify-end px-2 py-1 bg-slate-100 text-slate-300 font-bold">
<Icon className="animate-spin" fa="fa-solid fa-spinner" size={1} />
<span className="text-xs font-bold">Pending</span>
</button>
)}
<button
<ActionButton
className="flex flex-row flex-grow-0 gap-2 self-end items-center justify-end px-2 py-1 bg-slate-100 text-slate-300 hover:bg-red-100 hover:text-red-500 hover:font-bold"
onClick={onDelete}
>
<Icon className="" fa="fa-solid fa-trash" size={1} />
<span className="text-xs font-bold">Delete</span>
</button>
fa="fa-solid fa-trash"
ctaText="Delete"
hidden={hideActions.includes('delete')}
/>
</div>
</div>
);
function RetryButton(): ReactElement {
function RetryButton(p: { hidden?: boolean }): ReactElement {
if (p.hidden) return <></>;
return (
<button
className="flex flex-row flex-grow-0 gap-2 self-end items-center justify-end px-2 py-1 bg-slate-100 text-slate-300 hover:bg-slate-200 hover:text-slate-500 hover:font-bold"
@@ -198,7 +219,8 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
);
}
function ErrorButton(): ReactElement {
function ErrorButton(p: { hidden?: boolean }): ReactElement {
if (p.hidden) return <></>;
return (
<button
className="flex flex-row flex-grow-0 gap-2 self-end items-center justify-end px-2 py-1 bg-red-100 text-red-300 hover:bg-red-200 hover:text-red-500 hover:font-bold"
@@ -211,6 +233,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
}
function ErrorModal(): ReactElement {
const msg = typeof request?.error === 'string' && request?.error;
return !showingError ? (
<></>
) : (
@@ -219,7 +242,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
onClose={closeAllModal}
>
<ModalContent className="flex justify-center items-center text-slate-500">
{request?.error || 'Something went wrong :('}
{msg || 'Something went wrong :('}
</ModalContent>
<button
className="m-0 w-24 bg-red-100 text-red-300 hover:bg-red-200 hover:text-red-500"
@@ -309,7 +332,10 @@ function ActionButton(props: {
fa: string;
ctaText: string;
className?: string;
hidden?: boolean;
}): ReactElement {
if (props.hidden) return <></>;
return (
<button
className={classNames(

View File

@@ -0,0 +1,5 @@
#home {
&::-webkit-scrollbar {
display: none;
}
}

View File

@@ -1,102 +1,285 @@
import React, {
ChangeEvent,
MouseEventHandler,
ReactElement,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import Icon from '../../components/Icon';
import classNames from 'classnames';
import { useNavigate } from 'react-router';
import { useRequests } from '../../reducers/requests';
import { makePlugin, getPluginConfig } from '../../utils/misc';
import { addPlugin } from '../../utils/rpc';
import { PluginList } from '../../components/PluginList';
import { ErrorModal } from '../../components/ErrorModal';
import History from '../History';
import './index.scss';
import Requests from '../Requests';
import PluginUploadInfo from '../../components/PluginInfo';
import {
useOnPluginClick,
usePluginConfig,
usePluginHashes,
} from '../../reducers/plugins';
import { PluginConfig } from '../../utils/misc';
import { getPluginConfigByHash } from '../../entries/Background/db';
import { fetchPluginHashes } from '../../utils/rpc';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
export default function Home(): ReactElement {
const requests = useRequests();
const navigate = useNavigate();
export default function Home(props: {
tab?: 'history' | 'network';
}): ReactElement {
const [error, showError] = useState('');
const [tab, setTab] = useState<'history' | 'network'>(props.tab || 'history');
const scrollableContent = useRef<HTMLDivElement | null>(null);
const [shouldFix, setFix] = useState(false);
const [actionPanelElement, setActionPanelElement] =
useState<HTMLDivElement | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const onAddPlugin = useCallback(
async (evt: ChangeEvent<HTMLInputElement>) => {
if (!evt.target.files) return;
try {
const [file] = evt.target.files;
const arrayBuffer = await file.arrayBuffer();
const plugin = await makePlugin(arrayBuffer);
await getPluginConfig(plugin);
await addPlugin(Buffer.from(arrayBuffer).toString('hex'));
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
useEffect(() => {
fetchPluginHashes();
}, []);
useEffect(() => {
const element = scrollableContent.current;
if (!element) return;
if (!actionPanelElement) return;
let timer = Date.now();
const onScroll = () => {
const now = Date.now();
if (now - timer > 20) {
timer = now;
setScrollTop(element.scrollTop);
if (element.scrollTop >= actionPanelElement.clientHeight) {
setFix(true);
} else {
setFix(false);
}
}
},
[],
);
};
element.addEventListener('scroll', onScroll);
return () => {
element.removeEventListener('scroll', onScroll);
};
}, [scrollableContent, actionPanelElement]);
return (
<div className="flex flex-col gap-4 py-4 overflow-y-auto">
<div
id="home"
ref={scrollableContent}
className="flex flex-col flex-grow overflow-y-auto"
>
{error && <ErrorModal onClose={() => showError('')} message={error} />}
<div className="flex flex-col flex-nowrap justify-center gap-2 mx-4">
<NavButton fa="fa-solid fa-table" onClick={() => navigate('/requests')}>
<span>Requests</span>
<span>{`(${requests.length})`}</span>
</NavButton>
<NavButton fa="fa-solid fa-hammer" onClick={() => navigate('/custom')}>
Custom
</NavButton>
<NavButton
fa="fa-solid fa-certificate"
onClick={() => navigate('/verify')}
<ActionPanel
setActionPanelElement={setActionPanelElement}
scrollTop={scrollTop}
/>
<div
className={classNames('flex flex-row justify-center items-center', {
'fixed top-9 w-full bg-white shadow lg:w-[598px] lg:mt-40': shouldFix,
})}
>
<TabSelector
onClick={() => setTab('network')}
selected={tab === 'network'}
>
Network
</TabSelector>
<TabSelector
onClick={() => setTab('history')}
selected={tab === 'history'}
>
Verify
</NavButton>
<NavButton fa="fa-solid fa-list" onClick={() => navigate('/history')}>
History
</NavButton>
<NavButton className="relative" fa="fa-solid fa-plus">
<input
className="opacity-0 absolute top-0 right-0 h-full w-full"
type="file"
onChange={onAddPlugin}
/>
Add a plugin
</NavButton>
<NavButton fa="fa-solid fa-gear" onClick={() => navigate('/options')}>
Options
</NavButton>
</TabSelector>
</div>
<div className="flex-grow">
{tab === 'history' && <History />}
{tab === 'network' && <Requests shouldFix={shouldFix} />}
</div>
<PluginList className="mx-4" />
</div>
);
}
function ActionPanel({
setActionPanelElement,
scrollTop,
}: {
scrollTop: number;
setActionPanelElement: (el: HTMLDivElement) => void;
}) {
const pluginHashes = usePluginHashes();
const navigate = useNavigate();
const container = useRef<HTMLDivElement | null>(null);
const [isOverflow, setOverflow] = useState(false);
const [expanded, setExpand] = useState(false);
useEffect(() => {
const element = container.current;
if (!element) return;
setActionPanelElement(element);
const onCheckSize = () => {
if (element.scrollWidth > element.clientWidth) {
setOverflow(true);
} else {
setOverflow(false);
}
};
onCheckSize();
window.addEventListener('resize', onCheckSize);
return () => {
window.removeEventListener('resize', onCheckSize);
};
}, [container, pluginHashes]);
useEffect(() => {
const element = container.current;
if (!element) return;
if (scrollTop >= element.clientHeight) {
setExpand(false);
}
}, [container, scrollTop]);
return (
<div
ref={container}
className={classNames(
'flex flex-row justify-start items-center gap-4 p-4 border-b relative',
{
'flex-wrap': expanded,
'flex-nowrap': !expanded,
},
)}
>
<NavButton
fa="fa-solid fa-hammer"
onClick={() => navigate('/custom')}
title="Build a custom request"
>
Custom
</NavButton>
<NavButton
fa="fa-solid fa-certificate"
onClick={() => navigate('/verify')}
title="Visualize an attestation"
>
Verify
</NavButton>
{pluginHashes.map((hash) => (
<PluginIcon hash={hash} />
))}
<button
className={
'flex flex-row items-center justify-center self-start rounded relative border-2 border-dashed border-slate-300 hover:border-slate-400 text-slate-300 hover:text-slate-400 h-16 w-16 mx-1'
}
title="Install a plugin"
>
<PluginUploadInfo />
<Icon fa="fa-solid fa-plus" />
</button>
<button
className={classNames(
'absolute right-0 top-0 w-6 h-full bg-slate-100 hover:bg-slate-200 font-semibold',
'flex flex-row items-center justify-center gap-2 text-slate-500 hover:text-slate-700',
{
hidden: !isOverflow || expanded,
},
)}
onClick={() => setExpand(true)}
>
<Icon fa="fa-solid fa-caret-down" size={0.875} />
</button>
</div>
);
}
function PluginIcon({ hash }: { hash: string }) {
const config = usePluginConfig(hash);
const onPluginClick = useOnPluginClick(hash);
const onClick = useCallback(() => {
if (!config) return;
onPluginClick();
}, [onPluginClick, config]);
return (
<button
className={classNames(
'flex flex-col flex-nowrap items-center justify-center',
'text-white px-2 py-1 gap-1 opacity-90 hover:opacity-100',
)}
onClick={onClick}
>
<Icon
className="rounded-full flex flex-row items-center justify-center flex-grow-0 flex-shrink-0"
url={config?.icon || DefaultPluginIcon}
size={2}
/>
<span className="font-bold text-primary h-10 w-14 overflow-hidden text-ellipsis">
{config?.title}
</span>
</button>
);
}
function TabSelector(props: {
children: string;
className?: string;
selected?: boolean;
onClick: MouseEventHandler;
}): ReactElement {
return (
<button
onClick={props.onClick}
className={classNames(
'flex flex-grow items-center justify-center p-2 font-semibold hover:text-slate-700 border-b-2 ',
{
'font-semibold text-slate-400 border-white': !props.selected,
'font-bold text-primary border-primary': props.selected,
},
props.className,
)}
>
{props.children}
</button>
);
}
function NavButton(props: {
fa: string;
children?: ReactNode;
onClick?: MouseEventHandler;
className?: string;
title?: string;
disabled?: boolean;
}): ReactElement {
return (
<button
className={classNames(
'flex flex-row flex-nowrap items-center justify-center',
'text-white rounded px-2 py-1 gap-1',
{
'bg-primary/[.8] hover:bg-primary/[.7] active:bg-primary':
!props.disabled,
'bg-primary/[.5]': props.disabled,
},
'flex flex-col flex-nowrap items-center justify-center',
'text-white px-2 py-1 gap-1 opacity-90 hover:opacity-100',
props.className,
)}
onClick={props.onClick}
disabled={props.disabled}
title={props.title}
>
<Icon className="flex-grow-0 flex-shrink-0" fa={props.fa} size={1} />
<span className="flex-grow flex-shrink w-0 flex-grow font-bold">
<Icon
className="w-8 h-8 rounded-full bg-primary flex flex-row items-center justify-center flex-grow-0 flex-shrink-0"
fa={props.fa}
size={0.875}
/>
<span className="font-bold text-primary h-10 w-14 overflow-hidden text-ellipsis">
{props.children}
</span>
</button>

View File

@@ -0,0 +1,108 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import {
getPluginConfig,
makePlugin,
type PluginConfig,
urlify,
} from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { PluginPermissions } from '../../components/PluginInfo';
export function InstallPluginApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const url = params.get('url');
const rawMetadata = params.get('metadata');
const hostname = urlify(origin || '')?.hostname;
const [error, showError] = useState('');
const [pluginBuffer, setPluginBuffer] = useState<ArrayBuffer | any>(null);
const [pluginContent, setPluginContent] = useState<PluginConfig | null>(null);
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.install_plugin_response,
data: false,
});
}, []);
const onAccept = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.install_plugin_response,
data: true,
});
}, []);
useEffect(() => {
(async () => {
try {
const resp = await fetch(url!);
const arrayBuffer = await resp.arrayBuffer();
const plugin = await makePlugin(arrayBuffer);
setPluginContent(await getPluginConfig(plugin));
setPluginBuffer(arrayBuffer);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
}
})();
}, [url]);
return (
<BaseApproval
header={`Installing Plugin`}
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
<b className="text-blue-500">{hostname}</b> wants to install a plugin:
</div>
</div>
{!pluginContent && (
<div className="flex flex-col items-center flex-grow gap-4 border border-slate-300 p-8 mx-8 rounded bg-slate-100">
<Icon
className="animate-spin w-fit text-slate-500"
fa="fa-solid fa-spinner"
size={1}
/>
</div>
)}
{pluginContent && (
<div className="flex flex-col flex-grow gap-4 border border-slate-300 p-8 mx-8 rounded bg-slate-100">
<div className="flex flex-col items-center">
<img
className="w-12 h-12 mb-2"
src={pluginContent.icon}
alt="Plugin Icon"
/>
<span className="text-3xl text-blue-600 font-semibold">
{pluginContent.title}
</span>
<div className="text-slate-500 text-lg">
{pluginContent.description}
</div>
</div>
<PluginPermissions className="w-full" pluginContent={pluginContent} />
</div>
)}
</BaseApproval>
);
}

View File

@@ -1,21 +1,18 @@
import classNames from 'classnames';
import React, {
ReactNode,
ReactElement,
useState,
useCallback,
ReactEventHandler,
useEffect,
useRef,
useMemo,
} from 'react';
import { useLocation, useNavigate, useParams } from 'react-router';
import { useNavigate, useParams } from 'react-router';
import { notarizeRequest, useRequest } from '../../reducers/requests';
import Icon from '../../components/Icon';
import { urlify } from '../../utils/misc';
import {
get,
NOTARY_API_LS_KEY,
PROXY_API_LS_KEY,
getNotaryApi,
getProxyApi,
getMaxSent,
@@ -23,8 +20,6 @@ import {
} from '../../utils/storage';
import { useDispatch } from 'react-redux';
const maxTranscriptSize = 16384;
export default function Notarize(): ReactElement {
const params = useParams<{ requestId: string }>();
const req = useRequest(params.requestId);
@@ -62,7 +57,6 @@ export default function Notarize(): ReactElement {
body: req.requestBody,
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
secretHeaders,
@@ -125,7 +119,7 @@ export default function Notarize(): ReactElement {
);
}
function RevealHeaderStep(props: {
export function RevealHeaderStep(props: {
onNext: () => void;
onCancel: () => void;
setSecretHeaders: (secrets: string[]) => void;
@@ -134,6 +128,8 @@ function RevealHeaderStep(props: {
const req = useRequest(params.requestId);
const [revealed, setRevealed] = useState<{ [key: string]: boolean }>({});
const headers = req?.requestHeaders;
useEffect(() => {
if (!req) return;
@@ -161,21 +157,21 @@ function RevealHeaderStep(props: {
[revealed, req],
);
if (!req) return <></>;
if (!headers) return <></>;
return (
<div className="flex flex-col flex-nowrap flex-shrink flex-grow h-0">
<div className="border bg-primary/[0.9] text-white border-slate-300 py-1 px-2 font-semibold">
Step 1 of 2: Select which request headers you want to reveal
`Step 1 of 2: Select which request headers you want to reveal`
</div>
<div className="flex-grow flex-shrink h-0 overflow-y-auto">
<table className="border border-slate-300 border-collapse table-fixed">
<tbody className="bg-slate-200">
{req.requestHeaders?.map((h) => (
{headers.map((h) => (
<tr
key={h.name}
className={classNames('border-b border-slate-200 text-xs', {
'bg-slate-50': !!revealed[h.name],
'bg-slate-50': revealed[h.name],
})}
>
<td className="border border-slate-300 py-1 px-2 align-top">
@@ -183,14 +179,14 @@ function RevealHeaderStep(props: {
type="checkbox"
className="cursor-pointer"
onChange={(e) => changeHeaderKey(h.name, e.target.checked)}
checked={!!revealed[h.name]}
checked={revealed[h.name]}
/>
</td>
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{h.name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{!!revealed[h.name]
{revealed[h.name]
? h.value
: Array(h.value?.length || 0)
.fill('*')
@@ -216,34 +212,146 @@ function RevealHeaderStep(props: {
);
}
function HideResponseStep(props: {
export function RevealHeaderTable(props: {
headers: { name: string; value: string }[];
className?: string;
onChange: (revealed: { [key: string]: boolean }) => void;
}) {
const { headers } = props;
const [revealed, setRevealed] = useState<{ [key: string]: boolean }>({});
const changeHeaderKey = useCallback(
(key: string, shouldReveal: boolean) => {
const result = {
...revealed,
[key]: shouldReveal,
};
setRevealed(result);
props.onChange(result);
},
[revealed],
);
return (
<table
className={classNames(
'border border-slate-300 border-collapse table-fixed',
props.className,
)}
>
<thead className="bg-slate-200">
<th className="border border-slate-300 py-1 px-2 align-middle w-8"></th>
<th className="border border-slate-300 py-1 px-2 align-middle">Name</th>
<th className="border border-slate-300 py-1 px-2 align-middle">
Value
</th>
</thead>
<tbody className="bg-slate-100">
{headers.map((h) => (
<tr
key={h.name}
className={classNames('border-b border-slate-200 text-xs', {
'bg-slate-50': revealed[h.name],
})}
>
<td className="border border-slate-300 py-1 px-2 align-top w-8">
<input
type="checkbox"
className="cursor-pointer"
onChange={(e) => changeHeaderKey(h.name, e.target.checked)}
checked={revealed[h.name]}
/>
</td>
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{h.name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{revealed[h.name]
? h.value
: Array(h.value?.length || 0)
.fill('*')
.join('')}
</td>
</tr>
))}
</tbody>
</table>
);
}
export function HideResponseStep(props: {
onNext: () => void;
onCancel: () => void;
setSecretResps: (secrets: string[]) => void;
}): ReactElement {
}): React.ReactElement {
const params = useParams<{ requestId: string }>();
const req = useRequest(params.requestId);
const [responseText, setResponseText] = useState('');
const [start, setStart] = useState(0);
const [end, setEnd] = useState(0);
const [redactedRanges, setRedactedRanges] = useState<
{ start: number; end: number }[]
>([]);
const [isRedactMode, setIsRedactMode] = useState(true);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const onSelectionChange: ReactEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
const ta = e.currentTarget;
if (ta.selectionEnd > ta.selectionStart) {
setStart(ta.selectionStart);
setEnd(ta.selectionEnd);
props.setSecretResps(
[
responseText.substring(0, ta.selectionStart),
responseText.substring(ta.selectionEnd, responseText.length),
].filter((d) => !!d),
);
const onSelectionChange: React.MouseEventHandler<HTMLTextAreaElement> =
useCallback(
(e) => {
const ta = e.currentTarget;
if (isRedactMode && ta.selectionEnd > ta.selectionStart) {
const newRange: { start: number; end: number } = {
start: ta.selectionStart,
end: ta.selectionEnd,
};
setRedactedRanges((prevRanges) => {
let updatedRanges = [...prevRanges, newRange].sort(
(a, b) => a.start - b.start,
);
updatedRanges = mergeRanges(updatedRanges);
const secretResps = updatedRanges
.map(({ start, end }) => responseText.substring(start, end))
.filter((d) => !!d);
props.setSecretResps(secretResps);
return updatedRanges;
});
} else if (!isRedactMode) {
const clickPosition = ta.selectionStart;
setRedactedRanges((prevRanges) => {
const updatedRanges = prevRanges.filter(
({ start, end }) => clickPosition < start || clickPosition > end,
);
const secretResps = updatedRanges
.map(({ start, end }) => responseText.substring(start, end))
.filter((d) => !!d);
props.setSecretResps(secretResps);
return updatedRanges;
});
}
},
[responseText, props, isRedactMode],
);
const mergeRanges = (
ranges: { start: number; end: number }[],
): { start: number; end: number }[] => {
if (ranges.length === 0) return [];
const mergedRanges: { start: number; end: number }[] = [ranges[0]];
for (let i = 1; i < ranges.length; i++) {
const lastRange = mergedRanges[mergedRanges.length - 1];
if (ranges[i].start <= lastRange.end) {
lastRange.end = Math.max(lastRange.end, ranges[i].end);
} else {
mergedRanges.push(ranges[i]);
}
},
[responseText],
);
}
return mergedRanges;
};
useEffect(() => {
if (!req) return;
@@ -279,36 +387,46 @@ function HideResponseStep(props: {
if (current) {
current.focus();
current.setSelectionRange(start, end);
}
}, [taRef, start, end]);
}, [taRef]);
if (!req) return <></>;
let shieldedText = '';
const shieldedText = responseText.split('');
redactedRanges.forEach(({ start, end }) => {
for (let i = start; i < end; i++) {
shieldedText[i] = '*';
}
});
if (end > start) {
shieldedText = Array(start)
.fill('*')
.join('')
.concat(responseText.substring(start, end))
.concat(
Array(responseText.length - end)
.fill('*')
.join(''),
);
}
return (
<div className="flex flex-col flex-nowrap flex-shrink flex-grow h-0">
<div className="border bg-primary/[0.9] text-white border-slate-300 py-1 px-2 font-semibold">
Step 2 of 2: Highlight text to show only selected text from response
Step 2 of 2:{' '}
{isRedactMode
? 'Highlight text to redact selected portions'
: 'Click redacted text to unredact'}
</div>
<div className="flex flex-row justify-end p-0.5 gap-2 border-t">
<button
className={`bg-${isRedactMode ? 'red-500' : 'green-500'} text-white font-bold hover:bg-${isRedactMode ? 'red-400' : 'green-400'} px-2 py-0.5 active:bg-${isRedactMode ? 'red-600' : 'green-600'}`}
onClick={() => setIsRedactMode(!isRedactMode)}
>
{isRedactMode ? 'Unredact Text' : 'Redact Text'}
</button>
<button
className="bg-gray-500 text-white font-bold hover:bg-gray-400 px-2 py-0.5 active:bg-gray-600"
onClick={() => setRedactedRanges([])}
>
Unredact All
</button>
</div>
<div className="flex flex-col flex-grow flex-shrink h-0 overflow-y-auto p-2">
<textarea
ref={taRef}
className="flex-grow textarea bg-slate-100 font-mono"
value={shieldedText || responseText}
onSelect={onSelectionChange}
value={shieldedText.join('')}
onMouseUp={onSelectionChange}
/>
</div>
<div className="flex flex-row justify-end p-2 gap-2 border-t">
@@ -326,6 +444,112 @@ function HideResponseStep(props: {
);
}
export function RedactBodyTextarea(props: {
className?: string;
onChange: (secretResponse: string[]) => void;
request: {
url: string;
method?: string;
headers?: { [name: string]: string };
formData?: { [k: string]: string[] };
body?: string;
};
}) {
const { className, onChange, request } = props;
const [loading, setLoading] = useState(false);
const [responseText, setResponseText] = useState('');
const [start, setStart] = useState(0);
const [end, setEnd] = useState(0);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const onSelectionChange: ReactEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
const ta = e.currentTarget;
if (ta.selectionEnd > ta.selectionStart) {
setStart(ta.selectionStart);
setEnd(ta.selectionEnd);
onChange(
[
responseText.substring(0, ta.selectionStart),
responseText.substring(ta.selectionEnd, responseText.length),
].filter((d) => !!d),
);
}
},
[responseText],
);
useEffect(() => {
const options = {
method: request.method,
headers: request.headers,
body: request.body,
};
if (request?.formData) {
const formData = new URLSearchParams();
Object.entries(request.formData).forEach(([key, values]) => {
values.forEach((v) => formData.append(key, v));
});
options.body = formData.toString();
}
setLoading(true);
replay(request.url, options).then((resp) => {
setResponseText(resp);
setLoading(false);
});
}, [request]);
useEffect(() => {
const current = taRef.current;
if (current) {
current.focus();
current.setSelectionRange(start, end);
}
}, [taRef, start, end]);
let shieldedText = '';
if (end > start) {
shieldedText = Array(start)
.fill('*')
.join('')
.concat(responseText.substring(start, end))
.concat(
Array(responseText.length - end)
.fill('*')
.join(''),
);
}
if (loading) {
return (
<div className="flex flex-col items-center !pt-4 flex-grow textarea bg-slate-100">
<Icon
className="animate-spin w-fit text-slate-500"
fa="fa-solid fa-spinner"
size={1}
/>
</div>
);
}
return (
<textarea
ref={taRef}
className={classNames(
'flex-grow textarea bg-slate-100 font-mono',
className,
)}
value={shieldedText || responseText}
onSelect={onSelectionChange}
/>
);
}
const replay = async (url: string, options: any) => {
const resp = await fetch(url, options);
const contentType =

View File

@@ -0,0 +1,178 @@
import React, { ReactElement, useCallback, useState } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import { urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { RedactBodyTextarea, RevealHeaderTable } from '../Notarize';
export function NotarizeApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const config = JSON.parse(params.get('config')!);
const hostname = urlify(origin || '')?.hostname;
const [step, setStep] = useState<'overview' | 'headers' | 'response'>(
'overview',
);
const [revealed, setRevealed] = useState<{ [key: string]: boolean }>({});
const [secretResps, setSecretResps] = useState<string[]>([]);
const headerList = Object.entries(config.headers || {}).map(
([name, value]) => ({
name,
value: String(value),
}),
);
const onCancel = useCallback(() => {
if (step === 'headers') return setStep('overview');
if (step === 'response') return setStep('headers');
browser.runtime.sendMessage({
type: BackgroundActiontype.notarize_response,
data: false,
});
}, [step]);
const onAccept = useCallback(() => {
if (step === 'overview') return setStep('headers');
if (step === 'headers') return setStep('response');
const secretHeaders = headerList
.map((h) => {
if (!revealed[h.name]) {
return `${h.name.toLowerCase()}: ${h.value || ''}` || '';
}
return '';
})
.filter((d) => !!d);
browser.runtime.sendMessage({
type: BackgroundActiontype.notarize_response,
data: {
...config,
secretHeaders,
secretResps,
},
});
}, [revealed, step, secretResps, config]);
let body, headerText, primaryCta, secondaryCta;
switch (step) {
case 'overview':
headerText = 'Notarizing Request';
primaryCta = 'Next';
secondaryCta = 'Cancel';
body = (
<>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
<b className="text-blue-500">{hostname}</b> wants to notarize the
following request:
</div>
</div>
<div className="flex flex-col items-center gap-4 text-sm px-8 text-center flex-grow break-all">
<table className="border border-collapse table-auto rounded text-xs w-full">
<tbody>
<TableRow label="Method" value={config.method?.toUpperCase()} />
<TableRow label="Request URL" value={config.url} />
<TableRow label="Notary URL" value={config.notaryUrl} />
<TableRow label="Proxy URL" value={config.websocketProxyUrl} />
<TableRow label="Max Sent" value={config.maxSentData} />
<TableRow label="Max Recv" value={config.maxRecvData} />
{config.metadata && (
<TableRow
label="Metadata"
value={JSON.stringify(config.metadata)}
/>
)}
</tbody>
</table>
</div>
<div className="text-xs px-8 pb-2 text-center text-slate-500">
You will be able to review and redact headers and response body.
</div>
</>
);
break;
case 'headers':
headerText = 'Step 1 of 2: Select headers to reveal';
primaryCta = 'Next';
secondaryCta = 'Back';
body = (
<div className="px-2 flex flex-col">
<RevealHeaderTable
className="w-full"
onChange={setRevealed}
headers={headerList}
/>
</div>
);
break;
case 'response':
headerText = 'Step 2 of 2: Highlight response to keep';
primaryCta = 'Notarize';
secondaryCta = 'Back';
body = (
<div className="px-2 flex flex-col flex-grow">
<RedactBodyTextarea
className="w-full "
onChange={setSecretResps}
request={{
url: config.url,
method: config.method,
headers: config.headers,
body: config.body,
formData: config.formData,
}}
/>
</div>
);
break;
}
return (
<BaseApproval
header={headerText}
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
primaryCTAText={primaryCta}
secondaryCTAText={secondaryCta}
>
{body}
</BaseApproval>
);
}
function TableRow({ label, value }: { label: string; value: string }) {
return (
<tr>
<td className="px-2 py-1 border border-slate-300 bg-slate-100 text-slate-500 align-top text-left w-24">
{label}
</td>
<td className="px-2 py-1 border border-slate-300 font-semibold text-slate-800 text-left">
<input
className="outline-0 flex-grow cursor-default w-full"
type="text"
value={value}
/>
</td>
</tr>
);
}

View File

@@ -24,20 +24,18 @@ import {
NOTARY_PROXY,
MAX_RECV,
MAX_SENT,
LOGGING_LEVEL_INFO,
LOGGING_LEVEL_NONE,
LOGGING_LEVEL_DEBUG,
LOGGING_LEVEL_TRACE,
} from '../../utils/constants';
import Modal, { ModalContent } from '../../components/Modal/Modal';
import browser from 'webextension-polyfill';
import { LoggingLevel } from 'tlsn-js';
import { version } from '../../../package.json';
export default function Options(): ReactElement {
const [notary, setNotary] = useState(NOTARY_API);
const [proxy, setProxy] = useState(NOTARY_PROXY);
const [maxSent, setMaxSent] = useState(MAX_SENT);
const [maxReceived, setMaxReceived] = useState(MAX_RECV);
const [loggingLevel, setLoggingLevel] = useState(LOGGING_LEVEL_INFO);
const [loggingLevel, setLoggingLevel] = useState<LoggingLevel>('Info');
const [dirty, setDirty] = useState(false);
const [shouldReload, setShouldReload] = useState(false);
@@ -50,7 +48,7 @@ export default function Options(): ReactElement {
setProxy((await getProxyApi()) || NOTARY_PROXY);
setMaxReceived((await getMaxRecv()) || MAX_RECV);
setMaxSent((await getMaxSent()) || MAX_SENT);
setLoggingLevel((await getLoggingFilter()) || LOGGING_LEVEL_INFO);
setLoggingLevel((await getLoggingFilter()) || 'Info');
})();
}, [advanced]);
@@ -82,6 +80,10 @@ export default function Options(): ReactElement {
setAdvanced(!advanced);
}, [advanced]);
const openInTab = useCallback((url: string) => {
browser.tabs.create({ url });
}, []);
return (
<div className="flex flex-col flex-nowrap flex-grow">
{showReloadModal && (
@@ -154,6 +156,22 @@ export default function Options(): ReactElement {
Save
</button>
</div>
<div className="flex flex-col w-full items-end gap-2 p-2">
<button
className="button"
onClick={() =>
openInTab('https://github.com/tlsnotary/tlsn-extension/issues/new')
}
>
File an issue
</button>
<button
className="button"
onClick={() => openInTab('https://discord.gg/9XwESXtcN7')}
>
Join our Discord
</button>
</div>
</div>
);
}
@@ -170,7 +188,7 @@ function InputField(props: {
return (
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">{label}</div>
<div className="font-semibold cursor-default">{label}</div>
<input
type={type}
className="input border"
@@ -194,6 +212,10 @@ function NormalOptions(props: {
return (
<div>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2 cursor-default">
<div className="font-semibold">Version</div>
<div className="input border bg-slate-100">{version}</div>
</div>
<InputField
label="Notary API"
placeholder="https://api.tlsnotary.org"
@@ -214,9 +236,9 @@ function NormalOptions(props: {
setDirty(true);
}}
/>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2 cursor-default">
<div className="font-semibold">Explorer URL</div>
<div className="input border">{EXPLORER_API}</div>
<div className="input border bg-slate-100">{EXPLORER_API}</div>
</div>
</div>
);
@@ -225,12 +247,12 @@ function NormalOptions(props: {
function AdvancedOptions(props: {
maxSent: number;
maxReceived: number;
loggingLevel: string;
loggingLevel: LoggingLevel;
setShouldReload: (reload: boolean) => void;
setMaxSent: (value: number) => void;
setMaxReceived: (value: number) => void;
setDirty: (value: boolean) => void;
setLoggingLevel: (level: string) => void;
setLoggingLevel: (level: LoggingLevel) => void;
}) {
const {
maxSent,
@@ -270,16 +292,17 @@ function AdvancedOptions(props: {
<select
className="select !bg-white border !px-2 !py-1"
onChange={(e) => {
setLoggingLevel(e.target.value);
setLoggingLevel(e.target.value as LoggingLevel);
setDirty(true);
setShouldReload(true);
}}
value={loggingLevel}
>
<option value={LOGGING_LEVEL_NONE}>None</option>
<option value={LOGGING_LEVEL_INFO}>Info</option>
<option value={LOGGING_LEVEL_DEBUG}>Debug</option>
<option value={LOGGING_LEVEL_TRACE}>Trace</option>
<option value="Error">Error</option>
<option value="Warn">Warn</option>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Trace">Trace</option>
</select>
</div>
<div className="flex flex-row flex-nowrap justify-end gap-2 p-2"></div>

View File

@@ -0,0 +1,10 @@
import React, { ReactElement } from "react";
import { PluginList } from "../../components/PluginList";
export default function Plugins(): ReactElement {
return (
<div className="flex flex-col flex-nowrap flex-grow">
<PluginList className="p-2 overflow-y-auto" />
</div>
)
}

View File

@@ -13,6 +13,7 @@ export default function ProofUploader(): ReactElement {
recv: string;
sent: string;
} | null>(null);
const [uploading, setUploading] = useState(false);
const onFileUpload: ChangeEventHandler<HTMLInputElement> = useCallback(
async (e) => {
@@ -25,16 +26,21 @@ export default function ProofUploader(): ReactElement {
const result = event.target?.result;
if (result) {
const proof = JSON.parse(result as string);
const res = await chrome.runtime.sendMessage<
any,
{ recv: string; sent: string }
>({
type: BackgroundActiontype.verify_proof,
data: proof,
});
setProof(res);
const res = await chrome.runtime
.sendMessage<any, { recv: string; sent: string }>({
type: BackgroundActiontype.verify_proof,
data: proof,
})
.catch(() => null);
if (proof) {
setUploading(false);
setProof(res);
}
}
});
setUploading(true);
reader.readAsText(file);
}
},
@@ -53,16 +59,23 @@ export default function ProofUploader(): ReactElement {
className="absolute w-full h-full top-0 left-0 opacity-0 z-10"
onChange={onFileUpload}
accept=".json"
disabled={uploading}
/>
<Icon className="mb-4" fa="fa-solid fa-upload" size={2} />
<div className="text-lg">Drop your proof here to continue</div>
<div className="text-sm">or</div>
<button
className="button !bg-primary/[.8] !hover:bg-primary/[.7] !active:bg-primary !text-white cursor-pointer"
onClick={() => null}
>
Browse Files
</button>
{uploading ? (
<Icon className="animate-spin" fa="fa-solid fa-spinner" size={2} />
) : (
<>
<Icon className="mb-4" fa="fa-solid fa-upload" size={2} />
<div className="text-lg">Drop your proof here to continue</div>
<div className="text-sm">or</div>
<button
className="button !bg-primary/[.8] !hover:bg-primary/[.7] !active:bg-primary !text-white cursor-pointer"
onClick={() => null}
>
Browse Files
</button>
</>
)}
</div>
</div>
);

View File

@@ -57,12 +57,14 @@ export default function ProofViewer(props?: {
<textarea
className="w-full resize-none bg-slate-100 text-slate-800 border p-2 text-[10px] break-all h-full outline-none font-mono"
value={props?.sent || request?.verification?.sent}
readOnly
></textarea>
)}
{tab === 'recv' && (
<textarea
className="w-full resize-none bg-slate-100 text-slate-800 border p-2 text-[10px] break-all h-full outline-none font-mono"
value={props?.recv || request?.verification?.recv}
readOnly
></textarea>
)}
</div>

View File

@@ -11,6 +11,20 @@ import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import NavigateWithParams from '../../components/NavigateWithParams';
import ResponseDetail from '../../components/ResponseDetail';
import { urlify } from '../../utils/misc';
import { notarizeRequest } from '../../reducers/requests';
import {
getMaxRecv,
getMaxSent,
getNotaryApi,
getProxyApi,
} from '../../utils/storage';
import { useDispatch } from 'react-redux';
import {
formatForRequest,
InputBody,
FormBodyTable,
parseResponse,
} from '../../components/RequestBuilder';
enum TabType {
Params = 'Params',
@@ -25,25 +39,33 @@ export default function RequestBuilder(props?: {
headers?: [string, string, boolean?][];
body?: string;
method?: string;
response?: Response;
}): ReactElement {
const loc = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const subpath = props?.subpath || '/custom';
const [_url, setUrl] = useState(props?.url || '');
const [params, setParams] = useState<[string, string, boolean?][]>(
props?.params || [],
);
const [headers, setHeaders] = useState<[string, string, boolean?][]>(
props?.headers || [],
);
const [body, setBody] = useState<string | undefined>(props?.body);
const [formBody, setFormBody] = useState<[string, string, boolean?][]>([
['', '', true],
]);
const [method, setMethod] = useState<string>(props?.method || 'GET');
const [response, setResponse] = useState<Response | null>(
props?.response || null,
const [type, setType] = useState<string>('text/plain');
const [headers, setHeaders] = useState<[string, string, boolean?][]>(
props?.headers || [['Content-Type', type, true]],
);
const [responseData, setResponseData] = useState<{
json: any | null;
text: string | null;
img: string | null;
headers: [string, string][] | null;
} | null>(null);
const url = urlify(_url);
const href = !url
@@ -57,6 +79,26 @@ export default function RequestBuilder(props?: {
setParams(Array.from(url?.searchParams || []));
}, [_url]);
useEffect(() => {
updateContentType(type);
}, [type, method]);
const updateContentType = useCallback(
(type: string) => {
const updateHeaders = headers.filter(
([key]) => key.toLowerCase() !== 'content-type',
);
if (method === 'GET' || method === 'HEAD') {
updateHeaders.push(['Content-Type', type, true]);
} else {
updateHeaders.push(['Content-Type', type, false]);
}
setHeaders(updateHeaders);
},
[method, type, headers],
);
const toggleParam = useCallback(
(i: number) => {
params[i][2] = !params[i][2];
@@ -91,7 +133,7 @@ export default function RequestBuilder(props?: {
const sendRequest = useCallback(async () => {
if (!href) return;
setResponseData(null);
// eslint-disable-next-line no-undef
const opts: RequestInit = {
method,
@@ -102,9 +144,13 @@ export default function RequestBuilder(props?: {
return map;
}, {}),
};
if (body) opts.body = body;
if (method !== 'GET' && method !== 'HEAD') {
if (type === 'application/x-www-form-urlencoded') {
opts.body = formatForRequest(formBody, type);
} else {
opts.body = formatForRequest(body!, type);
}
}
const cookie = headers.find(([key]) => key === 'Cookie');
if (cookie) {
@@ -114,15 +160,64 @@ export default function RequestBuilder(props?: {
const res = await fetch(href, opts);
setResponse(res);
const contentType =
res.headers.get('content-type') || res.headers.get('Content-Type');
setResponseData(await parseResponse(contentType!, res));
navigate(subpath + '/response');
}, [href, method, headers, body]);
}, [href, method, headers, body, type]);
const onNotarize = useCallback(async () => {
const maxSentData = await getMaxSent();
const maxRecvData = await getMaxRecv();
const notaryUrl = await getNotaryApi();
const websocketProxyUrl = await getProxyApi();
dispatch(
notarizeRequest(
//@ts-ignore
{
url: href || '',
method,
headers: headers.reduce((map: { [key: string]: string }, [k, v]) => {
if (k !== 'Cookie') {
map[k] = v;
}
return map;
}, {}),
body: body ? formatForRequest(body, type) : undefined,
maxSentData,
maxRecvData,
secretHeaders: [],
secretResps: [],
notaryUrl,
websocketProxyUrl,
},
),
);
navigate('/history');
}, [href, method, headers, body, type]);
const onMethod = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value === 'GET' || value === 'HEAD') {
setType('');
setMethod(value);
} else {
setMethod(value);
}
},
[method, type],
);
return (
<div className="flex flex-col w-full py-2 gap-2 flex-grow">
<div className="flex flex-row px-2">
<select className="select" onChange={(e) => setMethod(e.target.value)}>
<select className="select" onChange={(e) => onMethod(e)}>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
@@ -134,8 +229,14 @@ export default function RequestBuilder(props?: {
<input
className="input border flex-grow"
type="text"
value={url ? href : _url}
value={_url}
onChange={(e) => setUrl(e.target.value)}
onBlur={() => {
const formattedUrl = urlify(_url);
if (formattedUrl) {
setUrl(formattedUrl.href);
}
}}
/>
<button className="button" disabled={!url} onClick={sendRequest}>
Send
@@ -161,13 +262,19 @@ export default function RequestBuilder(props?: {
>
Body
</TabLabel>
{response && (
<TabLabel
onClick={() => navigate(subpath + '/response')}
active={loc.pathname.includes('response')}
>
Response
</TabLabel>
{responseData && (
<div className="flex flex-row justify-between w-full">
<TabLabel
onClick={() => navigate(subpath + '/response')}
active={loc.pathname.includes('response')}
>
Response
</TabLabel>
<button className="button" onClick={onNotarize}>
Notarize
</button>
</div>
)}
</div>
</div>
@@ -197,16 +304,38 @@ export default function RequestBuilder(props?: {
<Route
path="body"
element={
<textarea
className="textarea h-full w-full resize-none"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="h-full">
<select
className={c('select', {
'w-[80px]':
type === 'application/json' ||
type === 'text/plain' ||
type === '',
'w-[200px]': type === 'application/x-www-form-urlencoded',
})}
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="text/plain">Text</option>
<option value="application/json">JSON</option>
<option value="application/x-www-form-urlencoded">
x-www-form-urlencoded
</option>
</select>
{type === 'application/x-www-form-urlencoded' ? (
<FormBodyTable
formBody={formBody}
setFormBody={setFormBody}
/>
) : (
<InputBody body={body!} setBody={setBody} />
)}
</div>
}
/>
<Route
path="response"
element={<ResponseDetail response={response} />}
element={<ResponseDetail responseData={responseData} />}
/>
<Route path="/" element={<NavigateWithParams to="/params" />} />
</Routes>

View File

@@ -2,11 +2,11 @@ import React, { ReactElement } from 'react';
import RequestTable from '../../components/RequestTable';
import { useRequests } from '../../reducers/requests';
export default function Requests(): ReactElement {
export default function Requests(props: { shouldFix?: boolean }): ReactElement {
const requests = useRequests();
return (
<>
<RequestTable requests={requests} />
<RequestTable shouldFix={props.shouldFix} requests={requests} />
</>
);
}

View File

@@ -0,0 +1,119 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import Icon from '../../components/Icon';
import { useSearchParams } from 'react-router-dom';
import { type PluginConfig, PluginMetadata, urlify } from '../../utils/misc';
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../../entries/Background/rpc';
import { BaseApproval } from '../BaseApproval';
import { PluginPermissions } from '../../components/PluginInfo';
import {
getPluginConfigByHash,
getPluginMetadataByHash,
} from '../../entries/Background/db';
import { runPlugin } from '../../utils/rpc';
export function RunPluginApproval(): ReactElement {
const [params] = useSearchParams();
const origin = params.get('origin');
const favIconUrl = params.get('favIconUrl');
const hash = params.get('hash');
const hostname = urlify(origin || '')?.hostname;
const [error, showError] = useState('');
const [metadata, setPluginMetadata] = useState<PluginMetadata | null>(null);
const [pluginContent, setPluginContent] = useState<PluginConfig | null>(null);
const onCancel = useCallback(() => {
browser.runtime.sendMessage({
type: BackgroundActiontype.run_plugin_response,
data: false,
});
}, []);
const onAccept = useCallback(async () => {
if (!hash) return;
try {
const tab = await browser.tabs.create({
active: true,
});
await browser.storage.local.set({ plugin_hash: hash });
// @ts-ignore
if (chrome.sidePanel) await chrome.sidePanel.open({ tabId: tab.id });
browser.runtime.sendMessage({
type: BackgroundActiontype.run_plugin_response,
data: true,
});
} catch (e: any) {
showError(e.message);
}
}, [hash]);
useEffect(() => {
(async () => {
if (!hash) return;
try {
const config = await getPluginConfigByHash(hash);
const metadata = await getPluginMetadataByHash(hash);
setPluginContent(config);
setPluginMetadata(metadata);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
}
})();
}, [hash]);
return (
<BaseApproval
header={`Execute Plugin`}
onSecondaryClick={onCancel}
onPrimaryClick={onAccept}
>
<div className="flex flex-col items-center gap-2 py-8">
{!!favIconUrl ? (
<img
src={favIconUrl}
className="h-16 w-16 rounded-full border border-slate-200 bg-slate-200"
alt="logo"
/>
) : (
<Icon
fa="fa-solid fa-globe"
size={4}
className="h-16 w-16 rounded-full border border-slate-200 text-blue-500"
/>
)}
<div className="text-2xl text-center px-8">
<b className="text-blue-500">{hostname}</b> wants to execute a plugin:
</div>
</div>
{!pluginContent && (
<div className="flex flex-col items-center flex-grow gap-4 border border-slate-300 p-8 mx-8 rounded bg-slate-100">
<Icon
className="animate-spin w-fit text-slate-500"
fa="fa-solid fa-spinner"
size={1}
/>
</div>
)}
{pluginContent && (
<div className="flex flex-col gap-4 border border-slate-300 p-4 mx-8 rounded bg-slate-100">
<div className="flex flex-col items-center">
<img
className="w-12 h-12 mb-2"
src={pluginContent.icon}
alt="Plugin Icon"
/>
<span className="text-2xl text-blue-600 font-semibold">
{pluginContent.title}
</span>
<div className="text-slate-500 text-base">
{pluginContent.description}
</div>
</div>
</div>
)}
</BaseApproval>
);
}

View File

@@ -8,6 +8,7 @@ import deepEqual from 'fast-deep-equal';
enum ActionType {
'/history/addRequest' = '/history/addRequest',
'/history/setRequests' = '/history/setRequests',
'/history/deleteRequest' = '/history/deleteRequest',
}
@@ -37,6 +38,13 @@ export const addRequestHistory = (request?: RequestHistory | null) => {
};
};
export const setRequests = (requests: RequestHistory[]) => {
return {
type: ActionType['/history/setRequests'],
payload: requests,
};
};
export const deleteRequestHistory = (id: string) => {
chrome.runtime.sendMessage<any, string>({
type: BackgroundActiontype.delete_prove_request,
@@ -72,6 +80,18 @@ export default function history(
order: newOrder,
};
}
case ActionType['/history/setRequests']: {
const payload: RequestHistory[] = action.payload;
return {
...state,
map: payload.reduce((map: { [id: string]: RequestHistory }, req) => {
map[req.id] = req;
return map;
}, {}),
order: payload.map(({ id }) => id),
};
}
case ActionType['/history/deleteRequest']: {
const reqId: string = action.payload;
const newMap = { ...state.map };
@@ -94,6 +114,12 @@ export const useHistoryOrder = (): string[] => {
}, deepEqual);
};
export const useAllProofHistory = (): RequestHistory[] => {
return useSelector((state: AppRootState) => {
return state.history.order.map((id) => state.history.map[id]);
}, deepEqual);
};
export const useRequestHistory = (id?: string): RequestHistory | undefined => {
return useSelector((state: AppRootState) => {
if (!id) return undefined;

View File

@@ -1,6 +1,11 @@
import { useSelector } from 'react-redux';
import { AppRootState } from './index';
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useState } from 'react';
import { getPluginConfigByHash } from '../entries/Background/db';
import { PluginConfig } from '../utils/misc';
import { runPlugin } from '../utils/rpc';
import browser from 'webextension-polyfill';
enum ActionType {
'/plugin/addPlugin' = '/plugin/addPlugin',
@@ -52,3 +57,31 @@ export const usePluginHashes = (): string[] => {
return state.plugins.order;
}, deepEqual);
};
export const usePluginConfig = (hash: string) => {
const [config, setConfig] = useState<PluginConfig | null>(null);
useEffect(() => {
(async function () {
setConfig(await getPluginConfigByHash(hash));
})();
}, [hash]);
return config;
};
export const useOnPluginClick = (hash: string) => {
return useCallback(async () => {
await runPlugin(hash, 'start');
const [tab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
await browser.storage.local.set({ plugin_hash: hash });
// @ts-ignore
if (chrome.sidePanel) await chrome.sidePanel.open({ tabId: tab.id });
window.close();
}, [hash]);
};

View File

@@ -18,6 +18,7 @@ enum ActionType {
'/requests/setRequests' = '/requests/setRequests',
'/requests/addRequest' = '/requests/addRequest',
'/requests/setActiveTab' = '/requests/setActiveTab',
'/requests/isConnected' = '/requests/isConnected',
}
type Action<payload> = {
@@ -32,11 +33,22 @@ type State = {
[requestId: string]: RequestLog;
};
activeTab: chrome.tabs.Tab | null;
isConnected: boolean;
};
const initialState: State = {
map: {},
activeTab: null,
isConnected: false,
};
export const setConnection = (isConnected: boolean): Action<boolean> => ({
type: ActionType['/requests/isConnected'],
payload: isConnected,
});
export const isConnected = (isConnected: boolean) => async () => {
return isConnected;
};
export const setRequests = (requests: RequestLog[]): Action<RequestLog[]> => ({
@@ -57,7 +69,6 @@ export const notarizeRequest = (options: RequestHistory) => async () => {
method: options.method,
headers: options.headers,
body: options.body,
maxTranscriptSize: options.maxTranscriptSize,
maxSentData,
maxRecvData,
secretHeaders: options.secretHeaders,
@@ -113,6 +124,11 @@ export default function requests(
[action.payload.requestId]: action.payload,
},
};
case ActionType['/requests/isConnected']:
return {
...state,
isConnected: action.payload,
};
default:
return state;
}
@@ -142,3 +158,7 @@ export const useActiveTabUrl = (): URL | null => {
return activeTab?.url ? new URL(activeTab.url) : null;
}, deepEqual);
};
export const useIsConnected = (): boolean => {
return useSelector((state: AppRootState) => state.requests.isConnected);
};

View File

@@ -1,9 +1,5 @@
export const EXPLORER_API = 'https://explorer.tlsnotary.org';
export const NOTARY_API = 'https://notary.pse.dev/v0.1.0-alpha.5';
export const NOTARY_API = 'https://notary.pse.dev/v0.1.0-alpha.7';
export const NOTARY_PROXY = 'wss://notary.pse.dev/proxy';
export const MAX_RECV = 16384;
export const MAX_SENT = 4096;
export const LOGGING_LEVEL_NONE = ' ';
export const LOGGING_LEVEL_INFO = 'info,tlsn_extension_rs=info';
export const LOGGING_LEVEL_DEBUG = 'debug,tlsn_extension_rs=debug';
export const LOGGING_LEVEL_TRACE = 'trace,tlsn_extension_rs=trace';

View File

@@ -4,14 +4,16 @@ import {
RequestLog,
} from '../entries/Background/rpc';
import { EXPLORER_API } from './constants';
import createPlugin, { CallContext, Plugin } from '@extism/extism';
import createPlugin, {
CallContext,
ExtismPluginOptions,
Plugin,
} from '@extism/extism';
import browser from 'webextension-polyfill';
import NodeCache from 'node-cache';
import {
getCookieStoreByHost,
getHeaderStoreByHost,
} from '../entries/Background/cache';
import { getNotaryApi, getProxyApi } from './storage';
import { minimatch } from 'minimatch';
import { getCookiesByHost, getHeadersByHost } from '../entries/Background/db';
const charwise = require('charwise');
@@ -151,7 +153,7 @@ export const makePlugin = async (
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const injectedConfig = {
tabUrl: tab?.url,
tabUrl: tab?.url || 'x://x',
tabId: tab?.id,
};
@@ -175,7 +177,8 @@ export const makePlugin = async (
if (
!approvedRequests.find(
({ method, url }) => method === params.method && url === params.url,
({ method, url }) =>
method === params.method && minimatch(params.url, url),
)
) {
throw new Error(`Unapproved request - ${params.method}: ${params.url}`);
@@ -195,13 +198,37 @@ export const makePlugin = async (
throw new Error(`Unapproved proxy: ${params.websocketProxyUrl}`);
}
handleExecPluginProver({
type: BackgroundActiontype.execute_plugin_prover,
data: {
...params,
now,
},
});
(async () => {
const {
url,
method,
headers,
getSecretResponse,
body: reqBody,
} = params;
let secretResps;
const resp = await fetch(url, {
method,
headers,
body: reqBody,
});
const body = await extractBodyFromResponse(resp);
if (getSecretResponse) {
const out = await plugin.call(getSecretResponse, body);
secretResps = JSON.parse(out.string());
}
handleExecPluginProver({
type: BackgroundActiontype.execute_plugin_prover,
data: {
...params,
body: reqBody,
secretResps,
now,
},
});
})();
return context.store(`${id}`);
},
@@ -226,8 +253,8 @@ export const makePlugin = async (
if (config?.cookies) {
const cookies: { [hostname: string]: { [key: string]: string } } = {};
for (const host of config.cookies) {
const cache = getCookieStoreByHost(host);
cookies[host] = cacheToMap(cache);
const cache = await getCookiesByHost(host);
cookies[host] = cache;
}
// @ts-ignore
injectedConfig.cookies = JSON.stringify(cookies);
@@ -236,45 +263,52 @@ export const makePlugin = async (
if (config?.headers) {
const headers: { [hostname: string]: { [key: string]: string } } = {};
for (const host of config.headers) {
const cache = getHeaderStoreByHost(host);
headers[host] = cacheToMap(cache);
const cache = await getHeadersByHost(host);
headers[host] = cache;
}
// @ts-ignore
injectedConfig.headers = JSON.stringify(headers);
}
const pluginConfig = {
const pluginConfig: ExtismPluginOptions = {
useWasi: true,
config: injectedConfig,
// allowedHosts: approvedRequests.map((r) => urlify(r.url)?.origin),
functions: {
'extism:host/user': funcs,
},
};
const plugin = await createPlugin(module, pluginConfig);
return plugin;
};
export type StepConfig = {
title: string;
description?: string;
cta: string;
action: string;
prover?: boolean;
title: string; // Text for the step's title
description?: string; // Text for the step's description (optional)
cta: string; // Text for the step's call-to-action button
action: string; // The function name that this step will execute
prover?: boolean; // Boolean indicating if this step outputs a notarization (optional)
};
export type PluginConfig = {
title: string;
description: string;
icon?: string;
steps?: StepConfig[];
hostFunctions?: string[];
cookies?: string[];
headers?: string[];
requests: { method: string; url: string }[];
notaryUrls?: string[];
proxyUrls?: string[];
title: string; // The name of the plugin
description: string; // A description of the plugin's purpose
icon?: string; // A base64-encoded image string representing the plugin's icon (optional)
steps?: StepConfig[]; // An array describing the UI steps and behavior (see Step UI below) (optional)
hostFunctions?: string[]; // Host functions that the plugin will have access to
cookies?: string[]; // Cookies the plugin will have access to, cached by the extension from specified hosts (optional)
headers?: string[]; // Headers the plugin will have access to, cached by the extension from specified hosts (optional)
requests: { method: string; url: string }[]; // List of requests that the plugin is allowed to make
notaryUrls?: string[]; // List of notary services that the plugin is allowed to use (optional)
proxyUrls?: string[]; // List of websocket proxies that the plugin is allowed to use (optional)
};
export type PluginMetadata = {
origin: string;
filePath: string;
} & { [k: string]: string };
export const getPluginConfig = async (
data: Plugin | ArrayBuffer,
): Promise<PluginConfig> => {
@@ -348,3 +382,11 @@ export const cacheToMap = (cache: NodeCache) => {
return acc;
}, {});
};
export function safeParseJSON(data?: string | null) {
try {
return JSON.parse(data!);
} catch (e) {
return null;
}
}

87
src/utils/plugins.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { PluginConfig } from './misc';
import React, { ReactElement, ReactNode } from 'react';
import Icon from '../components/Icon';
export const HostFunctionsDescriptions: {
[key: string]: (pluginContent: PluginConfig) => ReactElement;
} = {
redirect: () => {
return (
<PermissionDescription fa="fa-solid fa-diamond-turn-right">
<span>Redirect your current tab to any URL</span>
</PermissionDescription>
);
},
notarize: ({ notaryUrls, proxyUrls }) => {
const notaries = ['default notary'].concat(notaryUrls || []);
const proxies = ['default proxy'].concat(proxyUrls || []);
return (
<>
<PermissionDescription fa="fa-solid fa-route">
<span className="cursor-default">
<span className="mr-1">Proxy notarization requests thru</span>
<MultipleParts parts={proxies} />
</span>
</PermissionDescription>
<PermissionDescription fa="fa-solid fa-stamp">
<span className="cursor-default">
<span className="mr-1">Submit notarization requests to</span>
<MultipleParts parts={notaries} />
</span>
</PermissionDescription>
</>
);
},
};
export function PermissionDescription({
fa,
children,
}: {
fa: string;
children?: ReactNode;
}): ReactElement {
return (
<div className="flex flex-row gap-4 items-start cursor-default">
<Icon className="" size={1.6125} fa={fa} />
<div className="text-sm mt-[0.125rem]">{children}</div>
</div>
);
}
export function MultipleParts({ parts }: { parts: string[] }): ReactElement {
const content = [];
if (parts.length > 1) {
for (let i = 0; i < parts.length; i++) {
content.push(
<span key={i} className="text-blue-600">
{parts[i]}
</span>,
);
if (parts.length - i === 2) {
content.push(
<span key={i + 'separator'} className="inline-block mx-1">
and
</span>,
);
} else if (parts.length - i > 1) {
content.push(
<span key={i + 'separator'} className="inline-block mr-1">
,
</span>,
);
}
}
} else {
content.push(
<span key={0} className="text-blue-600">
{parts[0]}
</span>,
);
}
return <>{content}</>;
}

17
src/utils/promise.ts Normal file
View File

@@ -0,0 +1,17 @@
export const deferredPromise = (): {
promise: Promise<never>;
resolve: (data?: any) => void;
reject: (reason?: any) => void;
} => {
let resolve: (data?: any) => void, reject: (reason?: any) => void;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
// @ts-ignore
return { promise, resolve, reject };
};
export type PromiseResolvers = ReturnType<typeof deferredPromise>;

View File

@@ -2,20 +2,6 @@ import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../entries/Background/rpc';
import { PluginConfig } from './misc';
export async function getCookiesByHost(hostname: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.get_cookies_by_hostname,
data: hostname,
});
}
export async function getHeadersByHost(hostname: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.get_headers_by_hostname,
data: hostname,
});
}
export async function addPlugin(hex: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.add_plugin,

View File

@@ -1,10 +1,11 @@
import { LOGGING_LEVEL_INFO } from './constants';
import { LoggingLevel } from 'tlsn-js';
import { MAX_RECV, MAX_SENT, NOTARY_API, NOTARY_PROXY } from './constants';
export const NOTARY_API_LS_KEY = 'notary-api';
export const PROXY_API_LS_KEY = 'proxy-api';
export const MAX_SENT_LS_KEY = 'max-sent';
export const MAX_RECEIVED_LS_KEY = 'max-received';
export const LOGGING_FILTER_KEY = 'logging-filter';
export const LOGGING_FILTER_KEY = 'logging-filter-2';
export async function set(key: string, value: string) {
return chrome.storage.sync.set({ [key]: value });
@@ -14,25 +15,25 @@ export async function get(key: string, defaultValue?: string) {
return chrome.storage.sync
.get(key)
.then((json: any) => json[key] || defaultValue)
.catch(() => '');
.catch(() => defaultValue);
}
export async function getMaxSent() {
return parseInt(await get(MAX_SENT_LS_KEY, '4096'));
return parseInt(await get(MAX_SENT_LS_KEY, MAX_SENT.toString()));
}
export async function getMaxRecv() {
return parseInt(await get(MAX_RECEIVED_LS_KEY, '16384'));
return parseInt(await get(MAX_RECEIVED_LS_KEY, MAX_RECV.toString()));
}
export async function getNotaryApi() {
return await get(NOTARY_API_LS_KEY, 'https://notary.pse.dev/v0.1.0-alpha.5');
return await get(NOTARY_API_LS_KEY, NOTARY_API);
}
export async function getProxyApi() {
return await get(PROXY_API_LS_KEY, 'wss://notary.pse.dev/proxy');
return await get(PROXY_API_LS_KEY, NOTARY_PROXY);
}
export async function getLoggingFilter() {
return await get(LOGGING_FILTER_KEY, LOGGING_LEVEL_INFO);
export async function getLoggingFilter(): Promise<LoggingLevel> {
return await get(LOGGING_FILTER_KEY, 'Info');
}

10
src/utils/types.ts Normal file
View File

@@ -0,0 +1,10 @@
import { PresentationJSON as PresentationJSONa7 } from 'tlsn-js/build/types';
export type PresentationJSON = PresentationJSONa5 | PresentationJSONa7;
export type PresentationJSONa5 = {
version?: undefined;
session: any;
substrings: any;
notaryUrl: string;
};

View File

@@ -40,7 +40,8 @@ var options = {
mode: process.env.NODE_ENV || "development",
ignoreWarnings: [
/Circular dependency between chunks with runtime/,
/ResizeObserver loop completed with undelivered notifications/
/ResizeObserver loop completed with undelivered notifications/,
/Should not import the named export/,
],
entry: {
@@ -48,6 +49,7 @@ var options = {
popup: path.join(__dirname, "src", "entries", "Popup", "index.tsx"),
background: path.join(__dirname, "src", "entries", "Background", "index.ts"),
contentScript: path.join(__dirname, "src", "entries", "Content", "index.ts"),
content: path.join(__dirname, "src", "entries", "Content", "content.ts"),
offscreen: path.join(__dirname, "src", "entries", "Offscreen", "index.tsx"),
sidePanel: path.join(__dirname, "src", "entries", "SidePanel", "index.tsx"),
},
@@ -81,6 +83,9 @@ var options = {
loader: "sass-loader",
options: {
sourceMap: true,
sassOptions: {
silenceDeprecations: ["legacy-js-api"],
}
},
},
],
@@ -198,31 +203,21 @@ var options = {
}),
new CopyWebpackPlugin({
patterns: [
// {
// from: "node_modules/tlsn-js/build/7.js",
// to: path.join(__dirname, "build"),
// force: true,
// },
// {
// from: "node_modules/tlsn-js/build/250.js",
// to: path.join(__dirname, "build"),
// force: true,
// },
// {
// from: "node_modules/tlsn-js/build/278.js",
// to: path.join(__dirname, "build"),
// force: true,
// },
// {
// from: "node_modules/tlsn-js/build/349.js",
// to: path.join(__dirname, "build"),
// force: true,
// },
{
from: "node_modules/tlsn-js/build",
to: path.join(__dirname, "build"),
force: true,
},
{
from: "src/assets/plugins/discord_dm.wasm",
to: path.join(__dirname, "build"),
force: true,
},
{
from: "src/assets/plugins/twitter_profile.wasm",
to: path.join(__dirname, "build"),
force: true,
},
],
}),
new HtmlWebpackPlugin({

7413
yarn.lock

File diff suppressed because it is too large Load Diff