Compare commits

...

37 Commits

Author SHA1 Message Date
Codetrauma
bef61d95fb fix: content-type setting to empty string when changing from a method with a body to a GET 2024-08-23 12:54:31 -03:00
Codetrauma
c2ca376bda feat: adding default browser headers to custom request builder 2024-08-23 12:44:00 -03: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
tsukino
047eb673f3 feat: add logging options and reload extension on save (#61)
* feat: add logging options and reload extension on save

* chore: update lockfiles
2024-05-28 05:41:05 -04:00
Tanner
b8d2ba06d7 Store CID to prevent having to upload multiple times (#53)
* feature: store cid

* chore: renamed error state

* chore: fixing linter errors

* refactor: refactored cid store from chrome storage to db

* fix: typo OneRequestHistory

* fix: update lockfiles

---------

Co-authored-by: John Shaw <codetrauma@Johns-MacBook-Pro.local>
Co-authored-by: tsukino <0xtsukino@gmail.com>
2024-05-21 09:45:56 -04:00
Hendrik Eeckhaut
ca1ea2b34e fix: pass maxSentData and maxRecvData correctly (#60)
+ use user settings in Twitter plugin
2024-05-21 09:35:42 -04:00
tsukino
53ba6f69b8 feat: implement plugin system and add demo plugin for twitter profile (#59)
* wip: testing extism host function

* wip

* wip

* wip: adding ui for plugin

* feat: add cache for headers and cookies by host

* feat: add plugin stores

* feat: add a plugin and render plugin list

* feat: add plugin config db

* feat: add basic plugin steps ui

* feat: add completion status to steps ui

* fix: refactor twitter profile plugin

* fix: refactor steps execution

* fix: testing plugin

* Added README to plugins folder

* feat: fix twitter profile plugin

* improved README: document how to run the twitter plugin example

* remove ddos

* WIP: Reddit plugin

* fix: steps circular reference

* feat: open popup

* Reddit plugin (result too big)

* feat: add view proof

* chore: add plugins to eslint ignore

* fix: twitter plugin

* fix: remove logs

* feat: add permission for approved request, notary, and proxy url in plugin

---------

Co-authored-by: Hendrik Eeckhaut <hendrik@eeckhaut.org>
2024-05-17 06:04:47 -04:00
Tanner
217824f2bf feat: Advanced Options to set MAX_SENT and MAX_RECV (#58)
* feature: adding advanced options for setting max_sent and max_recv

* refactor: changed MAX_RECV and MAX_SENT to be numbers instead

* feature: setting default values for max_sent and max_recv

* feat: added Advanced options tab to RequestDetails

* refactor: remove props from advanced route - useEffect refactored for advanced options

* chore: linting

* refactor: moved advanced options to drop down

* refactor: added helper functions to get stored values - refactored useEffect in Options

* refactor: refactored useEffect in RequestDetails advanced options

* chore: linting

* chore: linting

* refactor: applying new get functions where applicable

* chore: linting

* feat: adding maxSentData and maxRecv data to prover

* chore: linting

---------

Co-authored-by: John Shaw <codetrauma@Johns-MacBook-Pro.local>
2024-05-17 04:48:05 -04:00
Hendrik Eeckhaut
869fa5eeaa docs: Add license information (#57) 2024-05-07 11:05:51 -04:00
Tanner
92f9d65c23 Merge pull request #54 from tlsnotary/notary-api
Notary Server Url Constant
2024-04-09 10:35:08 -07:00
Tanner Shaw
c481ee6bbf chore: fixing linter issues 2024-04-09 10:33:24 -07:00
Tanner
810e7bf415 Merge branch 'main' into notary-api 2024-04-09 10:32:31 -07:00
Tanner Shaw
8348756f0a chore: bumping tlsn-js version 2024-04-09 09:50:50 -07:00
Tanner Shaw
71cef56356 chore: updating notary api url constant to alpha.5 2024-04-08 12:03:23 -07:00
Tanner Shaw
25689017b0 refactor: removing version from notary proxy 2024-04-03 13:25:51 -07:00
Tanner Shaw
c68e2e1548 chore: fixing linter issues 2024-04-03 10:45:12 -07:00
Tanner Shaw
8bb76ad969 feature: added notary proxy to constants 2024-04-03 10:43:05 -07:00
Tanner Shaw
d55279501e refactor: just changing to .5 now, can push when released 2024-04-02 12:15:52 -07:00
Tanner Shaw
2331074c9c feature: adding constant for versioning for the notary, will change to .5 when released 2024-04-02 11:42:29 -07:00
69 changed files with 9167 additions and 11740 deletions

View File

@@ -31,6 +31,7 @@
"wasm",
"tlsn",
"util",
"plugins",
"webpack.config.js"
]
}

View File

@@ -1,9 +1,27 @@
![MIT licensed][mit-badge]
![Apache licensed][apache-badge]
[![Build Status][actions-badge]][actions-url]
[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
[apache-badge]: https://img.shields.io/github/license/saltstack/salt
[actions-badge]: https://github.com/tlsnotary/tlsn-extension/actions/workflows/build.yaml/badge.svg
[actions-url]: https://github.com/tlsnotary/tlsn-extension/actions?query=workflow%3Abuild+branch%3Amain++
<img src="src/assets/img/icon-128.png" width="64"/>
# Chrome Extension (MV3) for TLSNotary
### ⚠️ Notice
- When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/dev/notary-server), please ensure that the server's version is the same as the version of this extension
> [!IMPORTANT]
> ⚠️ When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/dev/notary-server), please ensure that the server's version is the same as the version of this extension
## License
This repository is licensed under either of
- [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)
- [MIT license](http://opensource.org/licenses/MIT)
at your option.
## Installing and Running
@@ -24,7 +42,7 @@
```
$ 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

4947
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.6",
"license": "MIT",
"repository": {
"type": "git",
@@ -16,6 +16,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@extism/extism": "^1.0.2",
"@fortawesome/fontawesome-free": "^6.4.2",
"async-mutex": "^0.4.0",
"buffer": "^6.0.3",
@@ -26,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",
@@ -36,7 +38,8 @@
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"tailwindcss": "^3.3.3",
"tlsn-js": "0.1.0-alpha.5.2"
"tlsn-js": "0.1.0-alpha.6.2",
"tlsn-jsV5.3": "npm:tlsn-js@0.1.0-alpha.5.3"
},
"devDependencies": {
"@babel/core": "^7.20.12",

1
plugins/README.md Normal file
View File

@@ -0,0 +1 @@
You can find example plugins at https://github.com/tlsnotary/tlsn-plugin-boilerplate/tree/main/examples

2499
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

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,26 @@
import React, { ReactElement } from 'react';
import Modal, { ModalContent } from '../Modal/Modal';
export function ErrorModal(props: {
onClose: () => void;
message: string;
}): ReactElement {
const { onClose, message } = props;
return (
<Modal
className="flex flex-col gap-4 items-center text-base cursor-default justify-center !w-auto mx-4 my-[50%] min-h-24 p-4 border border-red-500 !bg-red-100"
onClose={onClose}
>
<ModalContent className="flex justify-center items-center text-red-500">
{message || 'Something went wrong :('}
</ModalContent>
<button
className="m-0 w-24 bg-red-200 text-red-400 hover:bg-red-200 hover:text-red-500"
onClick={onClose}
>
OK
</button>
</Modal>
);
}

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,207 @@
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';
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} 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}
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

@@ -0,0 +1,45 @@
.plugin-box {
&__remove-icon {
opacity: 0;
height: 0;
width: 0;
padding: 0;
overflow: hidden;
transition: 200ms opacity;
}
&:hover {
.plugin-box__remove-icon {
height: 1.25rem;
width: 1.25rem;
padding: .5rem;
opacity: .5;
&:hover {
opacity: 1;
}
}
}
}
.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

@@ -0,0 +1,217 @@
import React, {
MouseEventHandler,
ReactElement,
useCallback,
useEffect,
useState,
} from 'react';
import {
fetchPluginHashes,
removePlugin,
fetchPluginConfigByHash,
runPlugin,
} from '../../utils/rpc';
import { usePluginHashes } from '../../reducers/plugins';
import { PluginConfig } from '../../utils/misc';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
import classNames from 'classnames';
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();
useEffect(() => {
fetchPluginHashes();
}, []);
return (
<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>
</div>
)}
{hashes.map((hash) => (
<Plugin key={hash} hash={hash} />
))}
</div>
);
}
export function Plugin(props: {
hash: string;
onClick?: () => void;
}): 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 || remove) return;
try {
await runPlugin(props.hash, 'start');
const [tab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
await browser.storage.local.set({ plugin_hash: props.hash });
// @ts-ignore
if (chrome.sidePanel) await chrome.sidePanel.open({ tabId: tab.id });
window.close();
} catch (e: any) {
showError(e.message);
}
}, [props.hash, config, remove]);
useEffect(() => {
(async function () {
setConfig(await getPluginConfigByHash(props.hash));
})();
}, [props.hash]);
const onRemove: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
removePlugin(props.hash);
showRemove(false);
},
[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 (
<div
className={classNames(
'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} />}
{!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>
) : (
<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}
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

@@ -17,7 +17,15 @@ import {
} from 'react-router';
import Icon from '../Icon';
import NavigateWithParams from '../NavigateWithParams';
import { get, NOTARY_API_LS_KEY, PROXY_API_LS_KEY } from '../../utils/storage';
import {
set,
get,
MAX_SENT_LS_KEY,
MAX_RECEIVED_LS_KEY,
getMaxRecv,
getMaxSent,
} from '../../utils/storage';
import { MAX_RECV, MAX_SENT } from '../../utils/constants';
import { urlify } from '../../utils/misc';
type Props = {
@@ -31,7 +39,6 @@ export default function RequestDetail(props: Props): ReactElement {
const notarize = useCallback(async () => {
if (!request) return;
console.log('/notary/' + props.requestId);
navigate('/notary/' + request.requestId);
}, [request, props.requestId]);
@@ -54,6 +61,9 @@ export default function RequestDetail(props: Props): ReactElement {
<RequestDetailsHeaderTab path="/response">
Response
</RequestDetailsHeaderTab>
<RequestDetailsHeaderTab path="/advanced">
Advanced
</RequestDetailsHeaderTab>
<button
className="absolute right-2 bg-primary/[0.9] text-white font-bold px-2 py-0.5 hover:bg-primary/[0.8] active:bg-primary"
onClick={notarize}
@@ -74,6 +84,7 @@ export default function RequestDetail(props: Props): ReactElement {
path="response"
element={<WebResponse requestId={props.requestId} />}
/>
<Route path="advanced" element={<AdvancedOptions />} />
<Route path="/" element={<NavigateWithParams to="/headers" />} />
</Routes>
</>
@@ -101,6 +112,62 @@ function RequestDetailsHeaderTab(props: {
);
}
function AdvancedOptions(): ReactElement {
const [maxSent, setMaxSent] = useState(MAX_SENT);
const [maxRecv, setMaxRecv] = useState(MAX_RECV);
const [dirty, setDirty] = useState(false);
useEffect(() => {
(async () => {
setMaxRecv((await getMaxRecv()) || MAX_RECV);
setMaxSent((await getMaxSent()) || MAX_SENT);
})();
}, []);
const onSave = useCallback(async () => {
await set(MAX_RECEIVED_LS_KEY, maxRecv.toString());
await set(MAX_SENT_LS_KEY, maxSent.toString());
setDirty(false);
}, [maxSent, maxRecv]);
return (
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Max Sent Data</div>
<input
type="number"
className="input border"
value={maxSent}
min={0}
onChange={(e) => {
setMaxSent(parseInt(e.target.value));
setDirty(true);
}}
/>
<div className="font-semibold">Max Received Data</div>
<input
type="number"
className="input border"
value={maxRecv}
min={0}
onChange={(e) => {
setMaxRecv(parseInt(e.target.value));
setDirty(true);
}}
/>
<div className="flex flex-row flex-nowrap justify-end gap-2 p-2">
<button
className="button !bg-primary/[0.9] hover:bg-primary/[0.8] active:bg-primary !text-white"
disabled={!dirty}
onClick={onSave}
>
Save
</button>
</div>
</div>
);
}
function RequestPayload(props: Props): ReactElement {
const data = useRequest(props.requestId);
const [url, setUrl] = useState<URL | null>();

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

@@ -19,6 +19,10 @@ export const getCacheByTabId = (tabId: number): NodeCache => {
return RequestsLogs[tabId];
};
export const clearCache = () => {
export const clearRequestCache = () => {
RequestsLogs = {};
};
export const clearCache = () => {
clearRequestCache();
};

View File

@@ -1,13 +1,33 @@
import { Level } from 'level';
import type { RequestHistory } from './rpc';
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', {
valueEncoding: 'json',
});
const pluginDb = db.sublevel<string, string>('plugin', {
valueEncoding: 'hex',
});
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',
});
export async function addNotaryRequest(
now = Date.now(),
@@ -120,5 +140,235 @@ 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()) {
retVal.push(key);
}
return retVal;
}
export async function getPluginByHash(hash: string): Promise<string | null> {
try {
const plugin = await pluginDb.get(hash);
return plugin;
} catch (e) {
return null;
}
}
export async function addPlugin(hex: string): Promise<string | null> {
const hash = await sha256(hex);
if (await getPluginByHash(hash)) {
return null;
}
await pluginDb.put(hash, hex);
return hash;
}
export async function removePlugin(hash: string): Promise<string | null> {
const existing = await pluginDb.get(hash);
if (!existing) return null;
await pluginDb.del(hash);
return hash;
}
export async function getPluginConfigByHash(
hash: string,
): Promise<PluginConfig | null> {
try {
const config = await pluginConfigDb.get(hash);
return config;
} catch (e) {
return null;
}
}
export async function addPluginConfig(
hash: string,
config: PluginConfig,
): Promise<PluginConfig | null> {
if (await getPluginConfigByHash(hash)) {
return null;
}
await pluginConfigDb.put(hash, config);
return config;
}
export async function removePluginConfig(
hash: string,
): Promise<PluginConfig | null> {
const existing = await pluginConfigDb.get(hash);
if (!existing) return null;
await pluginConfigDb.del(hash);
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,
): Promise<RequestHistory | null> {
const existing = await historyDb.get(id);
if (!existing) return null;
const newReq = {
...existing,
cid,
};
await historyDb.put(id, newReq);
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;
}

View File

@@ -3,6 +3,8 @@ 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,
@@ -13,6 +15,24 @@ export const onSendHeaders = (
if (method !== 'OPTIONS') {
const cache = getCacheByTabId(tabId);
const existing = cache.get<RequestLog>(requestId);
const { hostname } = urlify(details.url) || {};
if (hostname && details.requestHeaders) {
details.requestHeaders.forEach((header) => {
const { name, value } = header;
if (/^cookie$/i.test(name) && value) {
value
.split(';')
.map((v) => v.split('='))
.forEach((cookie) => {
setCookies(hostname, cookie[0].trim(), cookie[1]);
});
} else {
setHeaders(hostname, name, value);
}
});
}
cache.set(requestId, {
...existing,
method: details.method as 'GET' | 'POST',

View File

@@ -2,20 +2,56 @@ import browser from 'webextension-polyfill';
import { clearCache, getCacheByTabId } from './cache';
import { addRequestHistory } from '../../reducers/history';
import {
getNotaryRequests,
addNotaryRequest,
addNotaryRequestProofs,
getNotaryRequest,
setNotaryRequestStatus,
setNotaryRequestError,
setNotaryRequestVerification,
getNotaryRequests,
removeNotaryRequest,
setNotaryRequestError,
setNotaryRequestStatus,
setNotaryRequestVerification,
addPlugin,
getPluginHashes,
getPluginByHash,
removePlugin,
addPluginConfig,
getPluginConfigByHash,
removePluginConfig,
getConnection,
setConnection,
deleteConnection,
addPluginMetadata,
getPlugins,
getCookiesByHost,
getHeadersByHost,
} from './db';
import { addOnePlugin, removeOnePlugin } from '../../reducers/plugins';
import {
devlog,
getPluginConfig,
hexToArrayBuffer,
makePlugin,
PluginConfig,
} from '../../utils/misc';
import {
getLoggingFilter,
getMaxRecv,
getMaxSent,
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');
export enum BackgroundActiontype {
get_requests = 'get_requests',
clear_requests = 'clear_requests',
push_action = 'push_action',
execute_plugin_prover = 'execute_plugin_prover',
get_prove_requests = 'get_prove_requests',
prove_request_start = 'prove_request_start',
process_prove_request = 'process_prove_request',
@@ -24,6 +60,31 @@ export enum BackgroundActiontype {
verify_proof = 'verify_proof',
delete_prove_request = 'delete_prove_request',
retry_prove_request = 'retry_prove_request',
get_cookies_by_hostname = 'get_cookies_by_hostname',
get_headers_by_hostname = 'get_headers_by_hostname',
add_plugin = 'add_plugin',
remove_plugin = 'remove_plugin',
get_plugin_by_hash = 'get_plugin_by_hash',
get_plugin_config_by_hash = 'get_plugin_config_by_hash',
run_plugin = 'run_plugin',
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',
}
export type BackgroundAction = {
@@ -55,6 +116,8 @@ export type RequestHistory = {
headers: { [key: string]: string };
body?: string;
maxTranscriptSize: number;
maxSentData: number;
maxRecvData: number;
notaryUrl: string;
websocketProxyUrl: string;
status: '' | 'pending' | 'success' | 'error';
@@ -67,11 +130,15 @@ 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);
@@ -83,12 +150,48 @@ 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:
return handleProveRequestStart(request, sendResponse);
case BackgroundActiontype.get_cookies_by_hostname:
return handleGetCookiesByHostname(request, sendResponse);
case BackgroundActiontype.get_headers_by_hostname:
return handleGetHeadersByHostname(request, sendResponse);
case BackgroundActiontype.add_plugin:
return handleAddPlugin(request, sendResponse);
case BackgroundActiontype.remove_plugin:
return handleRemovePlugin(request, sendResponse);
case BackgroundActiontype.get_plugin_hashes:
return handleGetPluginHashes(request, sendResponse);
case BackgroundActiontype.get_plugin_by_hash:
return handleGetPluginByHash(request, sendResponse);
case BackgroundActiontype.get_plugin_config_by_hash:
return handleGetPluginConfigByHash(request, sendResponse);
case BackgroundActiontype.run_plugin:
return handleRunPlugin(request, sendResponse);
case BackgroundActiontype.execute_plugin_prover:
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;
default:
break;
}
@@ -99,28 +202,32 @@ 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) {
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addRequestHistory(req),
});
}
return sendResponse();
): boolean {
getNotaryRequests().then(async (reqs) => {
for (const req of reqs) {
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addRequestHistory(req),
});
}
sendResponse(reqs);
});
return true;
}
async function handleFinishProveRequest(
@@ -212,6 +319,8 @@ async function handleProveRequestStart(
headers,
body,
maxTranscriptSize,
maxSentData,
maxRecvData,
notaryUrl,
websocketProxyUrl,
secretHeaders,
@@ -223,6 +332,8 @@ async function handleProveRequestStart(
method,
headers,
body,
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
@@ -240,6 +351,70 @@ async function handleProveRequestStart(
action: addRequestHistory(await getNotaryRequest(id)),
});
browser.runtime.sendMessage({
type: BackgroundActiontype.process_prove_request,
data: {
id,
url,
method,
headers,
body,
maxTranscriptSize,
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 maxTranscriptSize = 16384;
const { id } = await addNotaryRequest(now, {
url,
method,
headers,
body,
maxTranscriptSize,
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: {
@@ -251,10 +426,662 @@ async function handleProveRequestStart(
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
secretHeaders,
secretResps,
},
});
}
export async function handleExecPluginProver(request: BackgroundAction) {
const now = request.data.now;
const id = charwise.encode(now).toString('hex');
runPluginProver(request, now);
return id;
}
function handleGetCookiesByHostname(
request: BackgroundAction,
sendResponse: (data?: any) => void,
): boolean {
(async () => {
const store = await getCookiesByHost(request.data);
sendResponse(store);
})();
return true;
}
function handleGetHeadersByHostname(
request: BackgroundAction,
sendResponse: (data?: any) => void,
): boolean {
(async () => {
const cache = await getHeadersByHost(request.data);
sendResponse(cache);
})();
return true;
}
async function handleAddPlugin(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
try {
const config = await getPluginConfig(hexToArrayBuffer(request.data));
if (config) {
const hash = await addPlugin(request.data);
if (hash) {
await addPluginConfig(hash, config);
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addOnePlugin(hash),
});
}
}
} finally {
return sendResponse();
}
}
async function handleRemovePlugin(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
await removePlugin(request.data);
await removePluginConfig(request.data);
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: removeOnePlugin(request.data),
});
return sendResponse();
}
async function handleGetPluginHashes(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const hashes = await getPluginHashes();
for (const hash of hashes) {
await browser.runtime.sendMessage({
type: BackgroundActiontype.push_action,
data: {
tabId: 'background',
},
action: addOnePlugin(hash),
});
}
return sendResponse();
}
async function handleGetPluginByHash(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const hash = request.data;
const hex = await getPluginByHash(hash);
return hex;
}
async function handleGetPluginConfigByHash(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const hash = request.data;
const config = await getPluginConfigByHash(hash);
return config;
}
function handleRunPlugin(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
(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,
});
browser.tabs.update(cachePopup.id!, {
url: browser.runtime.getURL('popup.html') + '#' + request.data.route,
});
} else {
const { popup } = await openPopup(
request.data.route,
request.data.position.left,
request.data.position.top,
);
cachePopup = popup;
const onPopUpClose = (windowId: number) => {
if (windowId === popup.id) {
cachePopup = null;
browser.windows.onRemoved.removeListener(onPopUpClose);
}
};
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(),
maxTranscriptSize,
notaryUrl = await getNotaryApi(),
websocketProxyUrl = await getProxyApi(),
origin,
position,
metadata,
} = request.data;
const config = JSON.stringify({
url,
method,
headers,
body,
maxSentData,
maxRecvData,
maxTranscriptSize,
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,
maxTranscriptSize,
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,120 @@
import { ContentScriptTypes, RPCClient } from './rpc';
import { RequestHistory } from '../Background/rpc';
import { PluginConfig, PluginMetadata } from '../../utils/misc';
import { Proof } 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<Proof | 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;
maxTranscriptSize?: number;
metadata?: {
[k: string]: string;
};
},
): Promise<Proof> {
const resp = await client.call(ContentScriptTypes.notarize, {
url,
method: requestOptions?.method,
headers: requestOptions?.headers,
body: requestOptions?.body,
maxSentData: proofOptions?.maxSentData,
maxRecvData: proofOptions?.maxRecvData,
maxTranscriptSize: proofOptions?.maxTranscriptSize,
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,218 @@
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;
maxTranscriptSize?: number;
}>,
) => {
const {
url,
method,
headers,
body,
maxSentData,
maxRecvData,
maxTranscriptSize,
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,
maxTranscriptSize,
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,77 +1,120 @@
import React, { useEffect } from 'react';
import { BackgroundActiontype } from '../Background/rpc';
import { prove, verify } from 'tlsn-js';
import * as Comlink from 'comlink';
import { OffscreenActionTypes } from './types';
import {
NotaryServer,
Prover as _Prover,
NotarizedSession as _NotarizedSession,
TlsProof as _TlsProof,
} from 'tlsn-js';
import { verify } from 'tlsn-jsV5.3';
import { urlify } from '../../utils/misc';
import { BackgroundActiontype } from '../Background/rpc';
import browser from 'webextension-polyfill';
import { Proof, ProofV1 } from '../../utils/types';
import { Method } from 'tlsn-js/wasm/pkg';
const { init, Prover, NotarizedSession, TlsProof }: 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 = '',
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
id,
secretHeaders,
secretResps,
} = 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 || '';
const proof = await prove(url, {
method,
headers,
body,
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: Proof = 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: {
@@ -82,17 +125,176 @@ 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<ProofV1> {
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: _Prover = 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 session: _NotarizedSession = await new NotarizedSession(
await prover.notarize(commit),
);
const proofHex = await session.proof(commit);
const proof: ProofV1 = {
version: '1.0',
meta: {
notaryUrl,
websocketProxyUrl,
},
data: proofHex,
};
return proof;
}
async function verifyProof(
proof: Proof,
): Promise<{ sent: string; recv: string }> {
let result: { sent: string; recv: string };
switch (proof.version) {
case undefined: {
result = await verify(proof);
break;
}
case '1.0': {
const tlsProof: _TlsProof = await new TlsProof(proof.data);
result = await tlsProof.verify({
typ: 'P256',
key: await NotaryServer.from(proof.meta.notaryUrl).publicKey(),
});
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,9 @@
import * as Comlink from 'comlink';
import init, { Prover, NotarizedSession, TlsProof } from 'tlsn-js';
Comlink.expose({
init,
Prover,
NotarizedSession,
TlsProof,
});

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 {
@@ -19,11 +19,23 @@ import ProofViewer from '../../pages/ProofViewer';
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';
const Popup = () => {
const dispatch = useDispatch();
const activeTab = useActiveTab();
const url = useActiveTabUrl();
const navigate = useNavigate();
useEffect(() => {
@@ -49,6 +61,28 @@ const Popup = () => {
})();
}, []);
useEffect(() => {
chrome.runtime.onMessage.addListener((request) => {
switch (request.type) {
case BackgroundActiontype.push_action: {
if (
request.data.tabId === store.getState().requests.activeTab?.id ||
request.data.tabId === 'background'
) {
store.dispatch(request.action);
}
break;
}
case BackgroundActiontype.change_route: {
if (request.data.tabId === 'background') {
navigate(request.route);
break;
}
}
}
});
}, []);
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<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">
@@ -58,16 +92,7 @@ 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>
<AppConnectionLogo />
</div>
<Routes>
<Route path="/requests/:requestId/*" element={<Request />} />
@@ -79,6 +104,17 @@ const Popup = () => {
<Route path="/custom/*" element={<RequestBuilder />} />
<Route path="/options" element={<Options />} />
<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>
@@ -86,3 +122,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="absolute right-2 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

@@ -1,30 +1,14 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
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
chrome.runtime.onMessage.addListener((request) => {
switch (request.type) {
case BackgroundActiontype.push_action: {
if (
request.data.tabId === store.getState().requests.activeTab?.id ||
request.data.tabId === 'background'
) {
store.dispatch(request.action);
}
break;
}
}
});
root.render(
<Provider store={store}>
<HashRouter>

View File

@@ -0,0 +1,281 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import './sidePanel.scss';
import browser from 'webextension-polyfill';
import { fetchPluginConfigByHash, runPlugin } from '../../utils/rpc';
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';
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);
const [hash, setHash] = useState('');
useEffect(() => {
(async function () {
const result = await browser.storage.local.get('plugin_hash');
const { plugin_hash } = result;
const config = await getPluginConfigByHash(plugin_hash);
setHash(plugin_hash);
setConfig(config);
// await browser.storage.local.set({ plugin_hash: '' });
})();
}, []);
return (
<div className="flex flex-col bg-slate-100 w-screen h-screen">
<div className="relative 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="h-5" src={logo} alt="logo" />
<button
className="button absolute right-2"
onClick={() => window.close()}
>
Close
</button>
</div>
{!config && <PluginList />}
{config && <PluginBody hash={hash} config={config} />}
</div>
);
}
function PluginBody(props: {
config: PluginConfig;
hash: string;
}): ReactElement {
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);
}
},
[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">
<img className="w-12 h-12 self-start" src={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 text-base">
{title}
</div>
<div className="text-slate-500 text-sm">{description}</div>
</div>
</div>
<div className="flex flex-col items-start gap-8 mt-8">
{steps?.map((step, i) => (
<StepContent
hash={hash}
index={i}
setResponse={setResponse}
lastResponse={i > 0 ? responses[i - 1] : undefined}
responses={responses}
{...step}
/>
))}
</div>
</div>
);
}
function StepContent(
props: StepConfig & {
hash: string;
index: number;
setResponse: (resp: any, i: number) => void;
responses: any[];
lastResponse?: any;
},
): ReactElement {
const {
index,
title,
description,
cta,
action,
setResponse,
lastResponse,
prover,
hash,
} = props;
const [completed, setCompleted] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
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 {
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);
} catch (e: any) {
console.error(e);
setError(e?.message || 'Unkonwn error');
} finally {
setPending(false);
}
}, [action, index, lastResponse, prover, getPlugin]);
const onClick = useCallback(() => {
if (
pending ||
completed ||
notaryRequest?.status === 'pending' ||
notaryRequest?.status === 'success'
)
return;
processStep();
}, [processStep, pending, completed, notaryRequest]);
const viewProofInPopup = useCallback(async () => {
if (!notaryRequest) return;
chrome.runtime.sendMessage<any, string>({
type: BackgroundActiontype.verify_prove_request,
data: notaryRequest,
});
await browser.runtime.sendMessage({
type: BackgroundActiontype.open_popup,
data: {
position: {
left: window.screen.width / 2 - 240,
top: window.screen.height / 2 - 300,
},
route: `/verify/${notaryRequest.id}`,
},
});
}, [notaryRequest, notarizationId]);
useEffect(() => {
processStep();
}, [processStep]);
let btnContent = null;
if (completed) {
btnContent = (
<button
className={classNames(
'button mt-2 w-fit flex flex-row flex-nowrap items-center gap-2',
'!bg-green-200 !text-black cursor-default border border-green-500 rounded',
)}
>
<Icon className="text-green-600" fa="fa-solid fa-check" />
<span className="text-sm">DONE</span>
</button>
);
} else if (notaryRequest?.status === 'success') {
btnContent = (
<button
className={classNames(
'button button--primary mt-2 w-fit flex flex-row flex-nowrap items-center gap-2',
)}
onClick={viewProofInPopup}
>
<span className="text-sm">View Proof</span>
</button>
);
} else if (notaryRequest?.status === 'pending' || pending || notarizationId) {
btnContent = (
<button className="button mt-2 w-fit flex flex-row flex-nowrap items-center gap-2 cursor-default">
<Icon className="animate-spin" fa="fa-solid fa-spinner" size={1} />
<span className="text-sm">{cta}</span>
</button>
);
} else {
btnContent = (
<button
className={classNames(
'button mt-2 w-fit flex flex-row flex-nowrap items-center gap-2',
)}
disabled={index > 0 && typeof lastResponse === 'undefined'}
onClick={onClick}
>
<span className="text-sm">{cta}</span>
</button>
);
}
return (
<div className="flex flex-row gap-4 text-base w-full">
<div className="text-slate-500 self-start">{index + 1}.</div>
<div className="flex flex-col flex-grow flex-shrink w-0">
<div
className={classNames('font-semibold', {
'line-through text-slate-500': completed,
})}
>
{title}
</div>
{!!description && (
<div className="text-slate-500 text-sm">{description}</div>
)}
{!!error && <div className="text-red-500 text-sm">{error}</div>}
{btnContent}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" width="480px">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Side Panel</title>
</head>
<body>
<div id="app-container"></div>
<div id="modal-root"></div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import SidePanel from './SidePanel';
import store from '../../utils/store';
import { Provider } from 'react-redux';
import { BackgroundActiontype } from '../Background/rpc';
const container = document.getElementById('app-container');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
chrome.runtime.onMessage.addListener((request) => {
switch (request.type) {
case BackgroundActiontype.push_action: {
if (
request.data.tabId === store.getState().requests.activeTab?.id ||
request.data.tabId === 'background'
) {
store.dispatch(request.action);
}
break;
}
}
});
root.render(
<Provider store={store}>
<SidePanel />
</Provider>,
);

View File

@@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/solid";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/regular";
@import "../Popup/index.scss";
html {
width: 100vw;
height: 100vh;
}
body {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
}
#app-container {
width: 100vw;
height: 100vh;
}

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,11 +3,16 @@
"name": "TLSN Extension",
"description": "A chrome extension for TLSN",
"options_page": "options.html",
"background": { "service_worker": "background.bundle.js" },
"background": {
"service_worker": "background.bundle.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": "icon-34.png"
},
"side_panel": {
"default_path": "sidePanel.html"
},
"icons": {
"128": "icon-128.png"
},
@@ -23,8 +28,8 @@
],
"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"],
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
}
],
"host_permissions": ["<all_urls>"],
@@ -32,6 +37,7 @@
"offscreen",
"storage",
"webRequest",
"activeTab"
"activeTab",
"sidePanel"
]
}

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

@@ -1,4 +1,4 @@
import React, { ReactElement, useState, useCallback } from 'react';
import React, { ReactElement, useState, useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import {
@@ -7,13 +7,18 @@ import {
deleteRequestHistory,
} from '../../reducers/history';
import Icon from '../../components/Icon';
import { get, NOTARY_API_LS_KEY, PROXY_API_LS_KEY } 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';
import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import { EXPLORER_API } from '../../utils/constants';
import {
getNotaryRequest,
setNotaryRequestCid,
} from '../../entries/Background/db';
const charwise = require('charwise');
export default function History(): ReactElement {
const history = useHistoryOrder();
@@ -27,22 +32,41 @@ export default function History(): ReactElement {
);
}
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);
const [uploadError, setUploadError] = useState('');
const [showingShareConfirmation, setShowingShareConfirmation] =
useState(false);
const [cid, setCid] = useState('');
const [cid, setCid] = useState<{ [key: string]: string }>({});
const [uploading, setUploading] = useState(false);
const navigate = useNavigate();
const { status } = request || {};
const requestUrl = urlify(request?.url || '');
useEffect(() => {
const fetchData = async () => {
try {
const request = await getNotaryRequest(props.requestId);
if (request && request.cid) {
setCid({ [props.requestId]: request.cid });
}
} catch (e) {
console.error('Error fetching data', e);
}
};
fetchData();
}, []);
const onRetry = useCallback(async () => {
const notaryUrl = await get(NOTARY_API_LS_KEY);
const websocketProxyUrl = await get(PROXY_API_LS_KEY);
const notaryUrl = await getNotaryApi();
const websocketProxyUrl = await getProxyApi();
chrome.runtime.sendMessage<any, string>({
type: BackgroundActiontype.retry_prove_request,
data: {
@@ -81,16 +105,22 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
`${request?.id}.json`,
JSON.stringify(request?.proof),
);
setCid(data);
setCid((prevCid) => ({ ...prevCid, [props.requestId]: data }));
await setNotaryRequestCid(props.requestId, data);
} catch (e: any) {
setUploadError(e.message);
} finally {
setUploading(false);
}
}, []);
}, [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">
@@ -102,6 +132,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>
@@ -111,7 +147,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>
@@ -125,6 +161,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
onClick={onView}
fa="fa-solid fa-receipt"
ctaText="View Proof"
hidden={hideActions.includes('view')}
/>
<ActionButton
className="bg-slate-100 text-slate-300 hover:bg-slate-200 hover:text-slate-500"
@@ -133,35 +170,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"
@@ -173,7 +217,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"
@@ -186,6 +231,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
}
function ErrorModal(): ReactElement {
const msg = typeof request?.error === 'string' && request?.error;
return !showingError ? (
<></>
) : (
@@ -194,7 +240,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"
@@ -215,7 +261,7 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
onClose={closeAllModal}
>
<ModalContent className="flex flex-col w-full gap-4 items-center text-base justify-center">
{!cid ? (
{!cid[props.requestId] ? (
<p className="text-slate-500 text-center">
{uploadError ||
'This will make your proof publicly accessible by anyone with the CID'}
@@ -224,13 +270,13 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
<input
className="input w-full bg-slate-100 border border-slate-200"
readOnly
value={`${EXPLORER_API}/ipfs/${cid}`}
value={`${EXPLORER_API}/ipfs/${cid[props.requestId]}`}
onFocus={(e) => e.target.select()}
/>
)}
</ModalContent>
<div className="flex flex-row gap-2 justify-center">
{!cid ? (
{!cid[props.requestId] ? (
<>
{!uploadError && (
<button
@@ -258,7 +304,9 @@ function OneRequestHistory(props: { requestId: string }): ReactElement {
) : (
<>
<button
onClick={() => copy(`${EXPLORER_API}/ipfs/${cid}`)}
onClick={() =>
copy(`${EXPLORER_API}/ipfs/${cid[props.requestId]}`)
}
className="m-0 w-24 bg-slate-600 text-slate-200 hover:bg-slate-500 hover:text-slate-100 font-bold"
>
Copy
@@ -282,7 +330,10 @@ function ActionButton(props: {
fa: string;
ctaText: string;
className?: string;
hidden?: boolean;
}): ReactElement {
if (props.hidden) return <></>;
return (
<button
className={classNames(

View File

@@ -2,44 +2,34 @@ import React, {
MouseEventHandler,
ReactElement,
ReactNode,
useCallback,
useState,
} from 'react';
import Icon from '../../components/Icon';
import classNames from 'classnames';
import { useNavigate } from 'react-router';
import {
notarizeRequest,
useActiveTabUrl,
useRequests,
} from '../../reducers/requests';
import { Link } from 'react-router-dom';
import bookmarks from '../../../utils/bookmark/bookmarks.json';
import { replayRequest, urlify } from '../../utils/misc';
import { useDispatch } from 'react-redux';
import { get, NOTARY_API_LS_KEY, PROXY_API_LS_KEY } from '../../utils/storage';
import { useRequests } from '../../reducers/requests';
import { PluginList } from '../../components/PluginList';
import PluginUploadInfo from '../../components/PluginInfo';
import { ErrorModal } from '../../components/ErrorModal';
export default function Home(): ReactElement {
const requests = useRequests();
const url = useActiveTabUrl();
const navigate = useNavigate();
const dispatch = useDispatch();
const [error, showError] = useState('');
return (
<div className="flex flex-col gap-4 py-4 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-magnifying-glass"
onClick={() => navigate('/custom')}
>
<NavButton fa="fa-solid fa-hammer" onClick={() => navigate('/custom')}>
Custom
</NavButton>
<NavButton
fa="fa-solid fa-magnifying-glass"
fa="fa-solid fa-certificate"
onClick={() => navigate('/verify')}
>
Verify
@@ -47,131 +37,15 @@ export default function Home(): ReactElement {
<NavButton fa="fa-solid fa-list" onClick={() => navigate('/history')}>
History
</NavButton>
<NavButton className="relative" fa="fa-solid fa-plus">
<PluginUploadInfo />
Add a plugin
</NavButton>
<NavButton fa="fa-solid fa-gear" onClick={() => navigate('/options')}>
Options
</NavButton>
</div>
{!bookmarks.length && (
<div className="flex flex-col flex-nowrap">
<div className="flex flex-col items-center justify-center text-slate-300 cursor-default select-none">
<div>No available notarization for {url?.hostname}</div>
<div>
Browse <Link to="/requests">Requests</Link>
</div>
</div>
</div>
)}
<div className="flex flex-col px-4 gap-4">
{bookmarks.map((bm, i) => {
try {
const reqs = requests.filter((req) => {
return req?.url?.includes(bm.url);
});
const bmHost = urlify(bm.targetUrl)?.host;
const isReady = !!reqs.length;
return (
<div
key={i}
className="flex flex-col flex-nowrap border rounded-md p-2 gap-1 hover:bg-slate-50 cursor-pointer"
>
<div className="flex flex-row items-center text-xs">
<div className="bg-slate-200 text-slate-400 px-1 py-0.5 rounded-sm">
{bm.method}
</div>
<div className="text-slate-400 px-2 py-1 rounded-md">
{bm.type}
</div>
</div>
<div className="font-bold">{bm.title}</div>
<div className="italic">{bm.description}</div>
{isReady && (
<button
className="button button--primary w-fit self-end mt-2"
onClick={async () => {
if (!isReady) return;
const req = reqs[0];
const res = await replayRequest(req);
const secretHeaders = req.requestHeaders
.map((h) => {
return (
`${h.name.toLowerCase()}: ${h.value || ''}` || ''
);
})
.filter((d) => !!d);
const selectedValue = res.match(
new RegExp(bm.responseSelector, 'g'),
);
if (selectedValue) {
const revealed = bm.valueTransform.replace(
'%s',
selectedValue[0],
);
const selectionStart = res.indexOf(revealed);
const selectionEnd =
selectionStart + revealed.length - 1;
const secretResps = [
res.substring(0, selectionStart),
res.substring(selectionEnd, res.length),
].filter((d) => !!d);
const hostname = urlify(req.url)?.hostname;
const notaryUrl = await get(NOTARY_API_LS_KEY);
const websocketProxyUrl = await get(PROXY_API_LS_KEY);
const headers: { [k: string]: string } =
req.requestHeaders.reduce(
(acc: any, h) => {
acc[h.name] = h.value;
return acc;
},
{ Host: hostname },
);
//TODO: for some reason, these needs to be override to work
headers['Accept-Encoding'] = 'identity';
headers['Connection'] = 'close';
dispatch(
// @ts-ignore
notarizeRequest({
url: req.url,
method: req.method,
headers: headers,
body: req.requestBody,
maxTranscriptSize: 16384,
notaryUrl,
websocketProxyUrl,
secretHeaders,
secretResps,
}),
);
navigate(`/history`);
}
}}
>
Notarize
</button>
)}
{!isReady && (
<button
className="button w-fit self-end mt-2"
onClick={() => chrome.tabs.update({ url: bm.targetUrl })}
>
{`Go to ${bmHost}`}
</button>
)}
</div>
);
} catch (e) {
return null;
}
})}
</div>
<PluginList className="mx-4" />
</div>
);
}

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,6 +1,5 @@
import classNames from 'classnames';
import React, {
ReactNode,
ReactElement,
useState,
useCallback,
@@ -8,11 +7,16 @@ import React, {
useEffect,
useRef,
} 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 } from '../../utils/storage';
import {
getNotaryApi,
getProxyApi,
getMaxSent,
getMaxRecv,
} from '../../utils/storage';
import { useDispatch } from 'react-redux';
const maxTranscriptSize = 16384;
@@ -29,9 +33,10 @@ export default function Notarize(): ReactElement {
const notarize = useCallback(async () => {
if (!req) return;
const hostname = urlify(req.url)?.hostname;
const notaryUrl = await get(NOTARY_API_LS_KEY);
const websocketProxyUrl = await get(PROXY_API_LS_KEY);
const notaryUrl = await getNotaryApi();
const websocketProxyUrl = await getProxyApi();
const maxSentData = await getMaxSent();
const maxRecvData = await getMaxRecv();
const headers: { [k: string]: string } = req.requestHeaders.reduce(
(acc: any, h) => {
acc[h.name] = h.value;
@@ -51,6 +56,8 @@ export default function Notarize(): ReactElement {
method: req.method,
headers,
body: req.requestBody,
maxSentData,
maxRecvData,
maxTranscriptSize,
notaryUrl,
websocketProxyUrl,
@@ -114,7 +121,7 @@ export default function Notarize(): ReactElement {
);
}
function RevealHeaderStep(props: {
export function RevealHeaderStep(props: {
onNext: () => void;
onCancel: () => void;
setSecretHeaders: (secrets: string[]) => void;
@@ -123,13 +130,14 @@ function RevealHeaderStep(props: {
const req = useRequest(params.requestId);
const [revealed, setRevealed] = useState<{ [key: string]: boolean }>({});
const headers = req?.requestHeaders;
useEffect(() => {
if (!req) return;
props.setSecretHeaders(
req.requestHeaders
.map((h) => {
console.log(h.name, !revealed[h.name]);
if (!revealed[h.name]) {
return `${h.name.toLowerCase()}: ${h.value || ''}` || '';
}
@@ -151,21 +159,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">
@@ -173,14 +181,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('*')
@@ -206,6 +214,73 @@ function RevealHeaderStep(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>
);
}
function HideResponseStep(props: {
onNext: () => void;
onCancel: () => void;
@@ -288,6 +363,7 @@ function HideResponseStep(props: {
.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">
@@ -316,6 +392,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

@@ -1,60 +1,148 @@
import React, { ReactElement, useState, useEffect, useCallback } from 'react';
import React, {
ReactElement,
useState,
useEffect,
useCallback,
MouseEvent,
} from 'react';
import {
set,
get,
NOTARY_API_LS_KEY,
PROXY_API_LS_KEY,
MAX_SENT_LS_KEY,
MAX_RECEIVED_LS_KEY,
getMaxSent,
getMaxRecv,
getNotaryApi,
getProxyApi,
getLoggingFilter,
LOGGING_FILTER_KEY,
} from '../../utils/storage';
import {
EXPLORER_API,
NOTARY_API,
NOTARY_PROXY,
MAX_RECV,
MAX_SENT,
} 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('https://notary.pse.dev/v0.1.0-alpha.5');
const [proxy, setProxy] = useState('wss://notary.pse.dev/proxy');
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<LoggingLevel>('Info');
const [dirty, setDirty] = useState(false);
const [shouldReload, setShouldReload] = useState(false);
const [advanced, setAdvanced] = useState(false);
const [showReloadModal, setShowReloadModal] = useState(false);
useEffect(() => {
(async () => {
setNotary(await get(NOTARY_API_LS_KEY));
setProxy(await get(PROXY_API_LS_KEY));
setNotary((await getNotaryApi()) || NOTARY_API);
setProxy((await getProxyApi()) || NOTARY_PROXY);
setMaxReceived((await getMaxRecv()) || MAX_RECV);
setMaxSent((await getMaxSent()) || MAX_SENT);
setLoggingLevel((await getLoggingFilter()) || 'Info');
})();
}, []);
}, [advanced]);
const onSave = useCallback(async () => {
await set(NOTARY_API_LS_KEY, notary);
await set(PROXY_API_LS_KEY, proxy);
setDirty(false);
}, [notary, proxy]);
const onSave = useCallback(
async (e: MouseEvent<HTMLButtonElement>, skipCheck = false) => {
if (!skipCheck && shouldReload) {
setShowReloadModal(true);
return;
}
await set(NOTARY_API_LS_KEY, notary);
await set(PROXY_API_LS_KEY, proxy);
await set(MAX_SENT_LS_KEY, maxSent.toString());
await set(MAX_RECEIVED_LS_KEY, maxReceived.toString());
await set(LOGGING_FILTER_KEY, loggingLevel);
setDirty(false);
},
[notary, proxy, maxSent, maxReceived, loggingLevel, shouldReload],
);
const onSaveAndReload = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
await onSave(e, true);
browser.runtime.reload();
},
[onSave],
);
const onAdvanced = useCallback(() => {
setAdvanced(!advanced);
}, [advanced]);
return (
<div className="flex flex-col flex-nowrap flex-grow">
<div className="flex flex-row flex-nowrap py-1 px-2 gap-2 font-bold text-base">
Settings
{showReloadModal && (
<Modal
className="flex flex-col items-center text-base cursor-default justify-center !w-auto mx-4 my-[50%] p-4 gap-4"
onClose={() => setShowReloadModal(false)}
>
<ModalContent className="flex flex-col w-full gap-4 items-center text-base justify-center">
Modifying your logging your will require your extension to reload.
Do you want to proceed?
</ModalContent>
<div className="flex flex-row justify-end items-center gap-2 w-full">
<button
className="button"
onClick={() => setShowReloadModal(false)}
>
No
</button>
<button
className="button button--primary"
onClick={onSaveAndReload}
>
Yes
</button>
</div>
</Modal>
)}
<div className="flex flex-row flex-nowrap justify-between items-between py-1 px-2 gap-2">
<p className="font-bold text-base">Settings</p>
</div>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Notary API</div>
<input
type="text"
className="input border"
placeholder="http://localhost:7047"
onChange={(e) => {
setNotary(e.target.value);
setDirty(true);
}}
value={notary}
<NormalOptions
notary={notary}
setNotary={setNotary}
proxy={proxy}
setProxy={setProxy}
setDirty={setDirty}
/>
<div className="justify-left px-2 pt-3 gap-2">
<button className="font-bold" onClick={onAdvanced}>
<i
className={
advanced
? 'fa-solid fa-caret-down pr-1'
: 'fa-solid fa-caret-right pr-1'
}
></i>
Advanced
</button>
</div>
{!advanced ? (
<></>
) : (
<AdvancedOptions
maxSent={maxSent}
setMaxSent={setMaxSent}
maxReceived={maxReceived}
setMaxReceived={setMaxReceived}
setDirty={setDirty}
loggingLevel={loggingLevel}
setLoggingLevel={setLoggingLevel}
setShouldReload={setShouldReload}
/>
</div>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Proxy API</div>
<input
type="text"
className="input border"
placeholder="ws://127.0.0.1:55688"
onChange={(e) => {
setProxy(e.target.value);
setDirty(true);
}}
value={proxy}
/>
</div>
)}
<div className="flex flex-row flex-nowrap justify-end gap-2 p-2">
<button
className="button !bg-primary/[0.9] hover:bg-primary/[0.8] active:bg-primary !text-white"
@@ -67,3 +155,137 @@ export default function Options(): ReactElement {
</div>
);
}
function InputField(props: {
label?: string;
placeholder?: string;
value?: string;
type?: string;
min?: number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const { label, placeholder, value, type, min, onChange } = props;
return (
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold cursor-default">{label}</div>
<input
type={type}
className="input border"
onChange={onChange}
value={value}
min={min}
placeholder={placeholder}
/>
</div>
);
}
function NormalOptions(props: {
notary: string;
setNotary: (value: string) => void;
proxy: string;
setProxy: (value: string) => void;
setDirty: (value: boolean) => void;
}) {
const { notary, setNotary, proxy, setProxy, setDirty } = 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"
value={notary}
type="text"
onChange={(e) => {
setNotary(e.target.value);
setDirty(true);
}}
/>
<InputField
label="Proxy API"
placeholder="https://proxy.tlsnotary.org"
value={proxy}
type="text"
onChange={(e) => {
setProxy(e.target.value);
setDirty(true);
}}
/>
<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 bg-slate-100">{EXPLORER_API}</div>
</div>
</div>
);
}
function AdvancedOptions(props: {
maxSent: number;
maxReceived: number;
loggingLevel: LoggingLevel;
setShouldReload: (reload: boolean) => void;
setMaxSent: (value: number) => void;
setMaxReceived: (value: number) => void;
setDirty: (value: boolean) => void;
setLoggingLevel: (level: LoggingLevel) => void;
}) {
const {
maxSent,
setMaxSent,
maxReceived,
setMaxReceived,
setDirty,
setLoggingLevel,
loggingLevel,
setShouldReload,
} = props;
return (
<div>
<InputField
label="Set Max Received Data"
value={maxReceived.toString()}
type="number"
min={0}
onChange={(e) => {
setMaxReceived(parseInt(e.target.value));
setDirty(true);
}}
/>
<InputField
label="Set Max Sent Data"
value={maxSent.toString()}
type="number"
min={0}
onChange={(e) => {
setMaxSent(parseInt(e.target.value));
setDirty(true);
}}
/>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Logging Level</div>
<select
className="select !bg-white border !px-2 !py-1"
onChange={(e) => {
setLoggingLevel(e.target.value as LoggingLevel);
setDirty(true);
setShouldReload(true);
}}
value={loggingLevel}
>
<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>
</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 '../../utils/requestbuilder';
enum TabType {
Params = 'Params',
@@ -25,25 +39,41 @@ 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],
['Accept', '*/*', false],
['Host', '', false],
['Connection', 'keep-alive', false],
['Accept-Encoding', 'gzip, deflate, br', false],
['Accept-Language', 'en-US,en;q=0.9', false],
['User-Agent', window.navigator.userAgent, false],
],
);
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 +87,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 +141,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 +152,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 +168,68 @@ 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;
}
if (k === 'Host') {
map[k] ? map[k] : (map[k] = url?.host || '');
}
return map;
}, {}),
body: body ? formatForRequest(body, type) : undefined,
maxSentData,
maxRecvData,
secretHeaders: [],
secretResps: [],
maxTranscriptSize: 0,
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(''); Leaving this here for now - I feel like I did this for a specific reason but I can't remember why
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 +241,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 +274,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 +316,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>
@@ -282,7 +423,7 @@ function HeaderTable(props: {
}): ReactElement {
const headers: [string, string, boolean?][] = [
...props.headers,
['', '', true],
['', ' ', true],
];
const last = props.headers.length;
@@ -320,7 +461,7 @@ function HeaderTable(props: {
<input
className="input py-1 px-2 w-full py-1 px-2"
type="text"
value={value}
value={value ? value : 'Calculated when request is sent'}
placeholder="Value"
onChange={(e) => {
props.setHeader(i, key, e.target.value);

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

@@ -94,6 +94,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,10 +1,12 @@
import { combineReducers } from 'redux';
import requests from './requests';
import history from './history';
import plugins from './plugins';
const rootReducer = combineReducers({
requests,
history,
plugins,
});
export type AppRootState = ReturnType<typeof rootReducer>;

54
src/reducers/plugins.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { useSelector } from 'react-redux';
import { AppRootState } from './index';
import deepEqual from 'fast-deep-equal';
enum ActionType {
'/plugin/addPlugin' = '/plugin/addPlugin',
'/plugin/removePlugin' = '/plugin/removePlugin',
}
type Action<payload> = {
type: ActionType;
payload?: payload;
error?: boolean;
meta?: any;
};
type State = {
order: string[];
};
const initState: State = {
order: [],
};
export const addOnePlugin = (hash: string): Action<string> => ({
type: ActionType['/plugin/addPlugin'],
payload: hash,
});
export const removeOnePlugin = (hash: string): Action<string> => ({
type: ActionType['/plugin/removePlugin'],
payload: hash,
});
export default function plugins(state = initState, action: Action<any>): State {
switch (action.type) {
case ActionType['/plugin/addPlugin']:
return {
order: [...new Set(state.order.concat(action.payload))],
};
case ActionType['/plugin/removePlugin']:
return {
order: state.order.filter((h) => h !== action.payload),
};
default:
return state;
}
}
export const usePluginHashes = (): string[] => {
return useSelector((state: AppRootState) => {
return state.plugins.order;
}, deepEqual);
};

View File

@@ -5,7 +5,12 @@ import {
import { useSelector } from 'react-redux';
import { AppRootState } from './index';
import deepEqual from 'fast-deep-equal';
import { get, NOTARY_API_LS_KEY, PROXY_API_LS_KEY } from '../utils/storage';
import {
getNotaryApi,
getProxyApi,
getMaxSent,
getMaxRecv,
} from '../utils/storage';
import { BackgroundActiontype } from '../entries/Background/rpc';
import browser from 'webextension-polyfill';
@@ -13,6 +18,7 @@ enum ActionType {
'/requests/setRequests' = '/requests/setRequests',
'/requests/addRequest' = '/requests/addRequest',
'/requests/setActiveTab' = '/requests/setActiveTab',
'/requests/isConnected' = '/requests/isConnected',
}
type Action<payload> = {
@@ -27,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[]> => ({
@@ -40,14 +57,10 @@ export const setRequests = (requests: RequestLog[]): Action<RequestLog[]> => ({
});
export const notarizeRequest = (options: RequestHistory) => async () => {
const notaryUrl = await get(
NOTARY_API_LS_KEY,
'https://notary.pse.dev/v0.1.0-alpha.5',
);
const websocketProxyUrl = await get(
PROXY_API_LS_KEY,
'wss://notary.pse.dev/proxy',
);
const notaryUrl = await getNotaryApi();
const websocketProxyUrl = await getProxyApi();
const maxSentData = await getMaxSent();
const maxRecvData = await getMaxRecv();
chrome.runtime.sendMessage<any, string>({
type: BackgroundActiontype.prove_request_start,
@@ -57,6 +70,8 @@ export const notarizeRequest = (options: RequestHistory) => async () => {
headers: options.headers,
body: options.body,
maxTranscriptSize: options.maxTranscriptSize,
maxSentData,
maxRecvData,
secretHeaders: options.secretHeaders,
secretResps: options.secretResps,
notaryUrl,
@@ -110,6 +125,11 @@ export default function requests(
[action.payload.requestId]: action.payload,
},
};
case ActionType['/requests/isConnected']:
return {
...state,
isConnected: action.payload,
};
default:
return state;
}
@@ -139,3 +159,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 +1,5 @@
export const EXPLORER_API = 'http://localhost:3000';
export const EXPLORER_API = 'https://explorer.tlsnotary.org';
export const NOTARY_API = 'https://notary.pse.dev/v0.1.0-alpha.5';
export const NOTARY_PROXY = 'wss://notary.pse.dev/proxy';
export const MAX_RECV = 16384;
export const MAX_SENT = 4096;

View File

@@ -1,5 +1,21 @@
import { RequestLog } from '../entries/Background/rpc';
import {
BackgroundActiontype,
handleExecPluginProver,
RequestLog,
} from '../entries/Background/rpc';
import { EXPLORER_API } from './constants';
import createPlugin, {
CallContext,
ExtismPluginOptions,
Plugin,
} from '@extism/extism';
import browser from 'webextension-polyfill';
import NodeCache from 'node-cache';
import { getNotaryApi, getProxyApi } from './storage';
import { minimatch } from 'minimatch';
import { getCookiesByHost, getHeadersByHost } from '../entries/Background/db';
const charwise = require('charwise');
export function urlify(
text: string,
@@ -44,7 +60,6 @@ export function download(filename: string, content: string) {
export async function upload(filename: string, content: string) {
const formData = new FormData();
formData.append(
'file',
new Blob([content], { type: 'application/json' }),
@@ -95,8 +110,14 @@ export async function replayRequest(req: RequestLog): Promise<string> {
// @ts-ignore
const resp = await fetch(req.url, options);
return extractBodyFromResponse(resp);
}
export const extractBodyFromResponse = async (
resp: Response,
): Promise<string> => {
const contentType =
resp?.headers.get('content-type') || resp?.headers.get('Content-Type');
resp.headers.get('content-type') || resp.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return resp.text();
@@ -107,4 +128,265 @@ export async function replayRequest(req: RequestLog): Promise<string> {
} else {
return resp.blob().then((blob) => blob.text());
}
};
export const sha256 = async (data: string) => {
const encoder = new TextEncoder().encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
return hashHex;
};
const VALID_HOST_FUNCS: { [name: string]: string } = {
redirect: 'redirect',
notarize: 'notarize',
};
export const makePlugin = async (
arrayBuffer: ArrayBuffer,
config?: PluginConfig,
) => {
const module = await WebAssembly.compile(arrayBuffer);
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const injectedConfig = {
tabUrl: tab?.url || 'x://x',
tabId: tab?.id,
};
const approvedRequests = config?.requests || [];
const approvedNotary = [await getNotaryApi()].concat(config?.notaryUrls);
const approvedProxy = [await getProxyApi()].concat(config?.proxyUrls);
const HostFunctions: {
[key: string]: (callContext: CallContext, ...args: any[]) => any;
} = {
redirect: function (context: CallContext, off: bigint) {
const r = context.read(off);
const url = r.text();
browser.tabs.update(tab.id, { url });
},
notarize: function (context: CallContext, off: bigint) {
const r = context.read(off);
const params = JSON.parse(r.text());
const now = Date.now();
const id = charwise.encode(now).toString('hex');
if (
!approvedRequests.find(
({ method, url }) =>
method === params.method && minimatch(params.url, url),
)
) {
throw new Error(`Unapproved request - ${params.method}: ${params.url}`);
}
if (
params.notaryUrl &&
!approvedNotary.find((n) => n === params.notaryUrl)
) {
throw new Error(`Unapproved notary: ${params.notaryUrl}`);
}
if (
params.websocketProxyUrl &&
!approvedProxy.find((w) => w === params.websocketProxyUrl)
) {
throw new Error(`Unapproved proxy: ${params.websocketProxyUrl}`);
}
(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}`);
},
};
const funcs: {
[key: string]: (callContext: CallContext, ...args: any[]) => any;
} = {};
for (const fn of Object.keys(VALID_HOST_FUNCS)) {
funcs[fn] = function (context: CallContext) {
throw new Error(`no permission for ${fn}`);
};
}
if (config?.hostFunctions) {
for (const fn of config.hostFunctions) {
funcs[fn] = HostFunctions[fn];
}
}
if (config?.cookies) {
const cookies: { [hostname: string]: { [key: string]: string } } = {};
for (const host of config.cookies) {
const cache = await getCookiesByHost(host);
cookies[host] = cache;
}
// @ts-ignore
injectedConfig.cookies = JSON.stringify(cookies);
}
if (config?.headers) {
const headers: { [hostname: string]: { [key: string]: string } } = {};
for (const host of config.headers) {
const cache = await getHeadersByHost(host);
headers[host] = cache;
}
// @ts-ignore
injectedConfig.headers = JSON.stringify(headers);
}
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;
};
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[];
};
export type PluginMetadata = {
origin: string;
filePath: string;
} & { [k: string]: string };
export const getPluginConfig = async (
data: Plugin | ArrayBuffer,
): Promise<PluginConfig> => {
const plugin = data instanceof ArrayBuffer ? await makePlugin(data) : data;
const out = await plugin.call('config');
const config: PluginConfig = JSON.parse(out.string());
assert(typeof config.title === 'string' && config.title.length);
assert(typeof config.description === 'string' && config.description.length);
assert(!config.icon || typeof config.icon === 'string');
for (const req of config.requests) {
assert(typeof req.method === 'string' && req.method);
assert(typeof req.url === 'string' && req.url);
}
if (config.hostFunctions) {
for (const func of config.hostFunctions) {
assert(typeof func === 'string' && !!VALID_HOST_FUNCS[func]);
}
}
if (config.notaryUrls) {
for (const notaryUrl of config.notaryUrls) {
assert(typeof notaryUrl === 'string' && notaryUrl);
}
}
if (config.proxyUrls) {
for (const proxyUrl of config.proxyUrls) {
assert(typeof proxyUrl === 'string' && proxyUrl);
}
}
if (config.cookies) {
for (const name of config.cookies) {
assert(typeof name === 'string' && name.length);
}
}
if (config.headers) {
for (const name of config.headers) {
assert(typeof name === 'string' && name.length);
}
}
if (config.steps) {
for (const step of config.steps) {
assert(typeof step.title === 'string' && step.title.length);
assert(!step.description || typeof step.description);
assert(typeof step.cta === 'string' && step.cta.length);
assert(typeof step.action === 'string' && step.action.length);
assert(!step.prover || typeof step.prover === 'boolean');
}
}
return config;
};
export const assert = (expr: any, msg = 'unknown error') => {
if (!expr) throw new Error(msg);
};
export const hexToArrayBuffer = (hex: string) =>
new Uint8Array(Buffer.from(hex, 'hex')).buffer;
export const cacheToMap = (cache: NodeCache) => {
const keys = cache.keys();
return keys.reduce((acc: { [k: string]: string }, key) => {
acc[key] = cache.get(key) || '';
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

@@ -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;
}

50
src/utils/rpc.ts Normal file
View File

@@ -0,0 +1,50 @@
import browser from 'webextension-polyfill';
import { BackgroundActiontype } from '../entries/Background/rpc';
import { PluginConfig } from './misc';
export async function addPlugin(hex: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.add_plugin,
data: hex,
});
}
export async function removePlugin(hash: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.remove_plugin,
data: hash,
});
}
export async function fetchPluginHashes() {
return browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugin_hashes,
});
}
export async function fetchPluginByHash(hash: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugin_by_hash,
data: hash,
});
}
export async function fetchPluginConfigByHash(
hash: string,
): Promise<PluginConfig | null> {
return browser.runtime.sendMessage({
type: BackgroundActiontype.get_plugin_config_by_hash,
data: hash,
});
}
export async function runPlugin(hash: string, method: string, params?: string) {
return browser.runtime.sendMessage({
type: BackgroundActiontype.run_plugin,
data: {
hash,
method,
params,
},
});
}

View File

@@ -1,6 +1,10 @@
import { LoggingLevel } from 'tlsn-js';
export const NOTARY_API_LS_KEY = 'notary-api';
export const PROXY_API_LS_KEY = 'proxy-api';
export const HISTORY_LS_KEY = 'history';
export const MAX_SENT_LS_KEY = 'max-sent';
export const MAX_RECEIVED_LS_KEY = 'max-received';
export const LOGGING_FILTER_KEY = 'logging-filter-2';
export async function set(key: string, value: string) {
return chrome.storage.sync.set({ [key]: value });
@@ -10,5 +14,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'));
}
export async function getMaxRecv() {
return parseInt(await get(MAX_RECEIVED_LS_KEY, '16384'));
}
export async function getNotaryApi() {
return await get(NOTARY_API_LS_KEY, 'https://notary.pse.dev/v0.1.0-alpha.6');
}
export async function getProxyApi() {
return await get(PROXY_API_LS_KEY, 'wss://notary.pse.dev/proxy');
}
export async function getLoggingFilter(): Promise<LoggingLevel> {
return await get(LOGGING_FILTER_KEY, 'Info');
}

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

@@ -0,0 +1,18 @@
export type Proof = ProofV0 | ProofV1;
export type ProofV0 = {
version?: undefined;
session: any;
substrings: any;
notaryUrl: string;
};
export type ProofV1 = {
version: '1.0';
data: string;
meta: {
notaryUrl: string;
websocketProxyUrl: string;
pluginUrl?: string;
};
};

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,

View File

@@ -1,22 +0,0 @@
[
{
"url": "https://api.twitter.com/1.1/account/settings.json",
"targetUrl": "https://www.twitter.com",
"method": "GET",
"type": "xmlhttprequest",
"title": "Twitter Profile",
"description": "Notarize ownership of a twitter profile. To start, go to your own profile",
"responseSelector": "(?<=\"screen_name\":)\"(.*?)\"",
"valueTransform": "\"screen_name\":%s"
},
{
"url": "https://gateway.reddit.com/desktopapi/v1/prefs",
"targetUrl": "https://www.reddit.com/settings",
"method": "GET",
"type": "xmlhttprequest",
"title": "Reddit Profile",
"description": "Notarize ownership of a reddit profile. To start, go to reddit.com/settings",
"responseSelector": "(?<=\"displayText\": )\"(.*?)\"",
"valueTransform": "\"displayText\": %s"
}
]

View File

@@ -1,34 +0,0 @@
import bookmarks from './bookmarks.json';
import { RequestLog } from '../../src/entries/Background/rpc';
type Bookmark = {
url: string;
title: string;
description: string;
method: string;
type: string;
};
export function findBookmarksByURL(url: URL | null): Bookmark[] {
if (!url) return [];
return bookmarks.filter((m) => {
const _url = new URL(m.url);
return url.host === _url.host;
});
}
export function filterByBookmarks(requests: RequestLog[]): Bookmark[] {
const hosts = requests
.map((r) => r.url && new URL(r.url).host)
.reduce((acc: { [host: string]: string }, host) => {
acc[host] = host;
return acc;
}, {});
return bookmarks.filter((bm) => {
if (hosts[new URL(bm.url).host]) {
return true;
}
});
}

View File

@@ -48,7 +48,9 @@ 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"),
},
// chromeExtensionBoilerplate: {
// notHotReload: ["background", "contentScript", "devtools"],
@@ -146,9 +148,9 @@ var options = {
new webpack.ProgressPlugin(),
// expose and write the allowed env vars on the compiled bundle
new webpack.EnvironmentPlugin(["NODE_ENV"]),
new ExtReloader({
manifest: path.resolve(__dirname, "src/manifest.json")
}),
// new ExtReloader({
// manifest: path.resolve(__dirname, "src/manifest.json")
// }),
new CopyWebpackPlugin({
patterns: [
{
@@ -242,6 +244,12 @@ var options = {
chunks: ["offscreen"],
cache: false,
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, "src", "entries", "SidePanel", "index.html"),
filename: "sidePanel.html",
chunks: ["sidePanel"],
cache: false,
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
}),

7500
yarn.lock

File diff suppressed because it is too large Load Diff