Compare commits

..

2 Commits

Author SHA1 Message Date
tsukino
e099f0b4e9 feat: add plugin permission control system
- Add PermissionManager for runtime host permission management
- Update ConfirmPopup to request host permissions during plugin approval
- Add Options page Permissions tab to view and revoke granted permissions
- Track granted origins per managed window for cleanup on close
- Update manifest to use optional_host_permissions with <all_urls>
- Add extractOrigins() to collect API hosts and page URLs from plugin config
- Implement permission cleanup when window closes or plugin execution fails
2026-01-06 23:08:14 +08:00
tsukino
9c0b12da2c docs: update CLAUDE.md with permission system and new entry points
- Document Options page and ConfirmPopup entry points
- Add ConfirmationManager class documentation
- Add Permission Validation System section
- Document PluginConfig and RequestPermission interfaces
- Update manifest permissions (contextMenus, options_page)
- Update webpack entry points list
- Add Spotify plugin to demo files (PR #210)
- Document array field access fix (PR #212)
- Add new message types documentation
- Update Known Issues section
2026-01-06 19:30:43 +08:00
74 changed files with 5542 additions and 5526 deletions

View File

@@ -20,26 +20,9 @@ env:
should_publish: ${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '.')) || github.ref == 'refs/heads/staging' }}
jobs:
test_verifier:
name: test verifier server
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- name: Run verifier tests
working-directory: packages/verifier
run: cargo test
build_and_publish_demo_verifier_server:
name: build and publish demo verifier server image
runs-on: ubuntu-latest
needs: test_verifier
permissions:
contents: read
packages: write
@@ -69,8 +52,6 @@ jobs:
push: ${{ env.should_publish == 'true' }}
tags: ${{ steps.meta-prover-server.outputs.tags }}
labels: ${{ steps.meta-prover-server.outputs.labels }}
build-args: |
GIT_HASH=${{ github.sha }}
build_and_publish_demo_frontend:
name: build and publish demo frontend image
@@ -105,6 +86,5 @@ jobs:
tags: ${{ steps.meta-verifier-webapp.outputs.tags }}
labels: ${{ steps.meta-verifier-webapp.outputs.labels }}
build-args: |
VITE_VERIFIER_HOST=demo.tlsnotary.org
VITE_SSL=true
GIT_HASH=${{ github.sha }}
VERIFIER_HOST=demo-staging.tlsnotary.org
SSL=true

207
CLAUDE.md
View File

@@ -78,7 +78,7 @@ cd packages/extension && npm run dev
## Extension Architecture Overview
### Extension Entry Points
The extension has 5 main entry points defined in `webpack.config.js`:
The extension has 7 main entry points defined in `webpack.config.js`:
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
Core responsibilities:
@@ -170,6 +170,25 @@ Isolated React component for background processing:
- **Lifecycle**: Created dynamically by background script, reused if exists
- Entry point: `offscreen.html`
#### 7. **Options Page** (`src/entries/Options/index.tsx`)
Extension settings page for user configuration:
- **Log Level Control**: Configure logging verbosity (DEBUG, INFO, WARN, ERROR)
- **IndexedDB Storage**: Settings persisted via `logLevelStorage.ts` utility
- **Live Updates**: Changes take effect immediately without restart
- **Styling**: Custom SCSS with radio button group design
- Access: Right-click extension icon → Options, or `chrome://extensions` → Details → Extension options
- Entry point: `options.html`
#### 8. **ConfirmPopup** (`src/entries/ConfirmPopup/index.tsx`)
Permission confirmation dialog for plugin execution:
- **Plugin Info Display**: Shows plugin name, description, version, author
- **Permission Review**: Lists allowed network requests and URLs
- **User Controls**: Allow/Deny buttons with keyboard shortcuts (Enter/Esc)
- **Security**: Validates plugin config before execution
- **Timeout**: 60-second auto-deny if no response
- Opened by: `ConfirmationManager` when executing untrusted plugins
- Entry point: `confirmPopup.html`
### Key Classes
#### **WindowManager** (`src/background/WindowManager.ts`)
@@ -228,6 +247,64 @@ const proof = await prove(
);
```
#### **ConfirmationManager** (`src/background/ConfirmationManager.ts`)
Manages plugin execution confirmation dialogs:
- **Permission Flow**: Opens confirmation popup before plugin execution
- **User Approval**: Displays plugin info and permissions for user review
- **Timeout Handling**: 60-second timeout auto-denies if no response
- **Window Tracking**: Tracks confirmation popup windows by request ID
- **Singleton Pattern**: Exported as `confirmationManager` instance
Key methods:
- `requestConfirmation(config, requestId)`: Opens confirmation popup, returns Promise<boolean>
- `handleConfirmationResponse(requestId, allowed)`: Processes user's allow/deny decision
- `hasPendingConfirmation()`: Check if a confirmation is in progress
#### **PermissionManager** (`src/background/PermissionManager.ts`)
Manages runtime host permission requests and revocation:
- **Runtime Permissions**: Requests host permissions from browser at plugin execution time
- **Permission Extraction**: Extracts origin patterns from plugin's `config.requests`
- **Concurrent Execution Tracking**: Tracks which permissions are in use by active plugins
- **Automatic Revocation**: Removes permissions when no longer needed
- **Singleton Pattern**: Exported as `permissionManager` instance
Key methods:
- `extractOrigins(requests)`: Extract origin patterns from RequestPermission array
- `extractPermissionPatterns(requests)`: Extract patterns with host and pathname for display
- `formatForDisplay(requests)`: Format as `host + pathname` strings for UI
- `requestPermissions(origins)`: Request permissions from browser, tracks usage
- `removePermissions(origins)`: Revoke permissions if no longer in use
- `hasPermissions(origins)`: Check if permissions are already granted
**Plugin Execution Flow with Permissions:**
1. User clicks "Allow" in ConfirmPopup
2. PermissionManager extracts origins from `config.requests`
3. Browser shows native permission prompt
4. If granted, plugin executes in offscreen document
5. After execution (success or error), permissions are revoked
### Permission Validation System
The extension implements a permission control system (PR #211) that validates plugin operations:
#### **Permission Validator** (`src/offscreen/permissionValidator.ts`)
Validates plugin API calls against declared permissions:
- `validateProvePermission()`: Checks if prove() call matches declared `requests` permissions
- `validateOpenWindowPermission()`: Checks if openWindow() call matches declared `urls` permissions
- `deriveProxyUrl()`: Derives default proxy URL from verifier URL
- `matchesPathnamePattern()`: URLPattern-based pathname matching with wildcards
**Validation Flow:**
1. Plugin declares permissions in `config.requests` and `config.urls`
2. Before `prove()` or `openWindow()` executes, validator checks permissions
3. If no matching permission found, throws descriptive error
4. Error includes list of declared permissions for debugging
**URLPattern Syntax:**
- Exact paths: `/1.1/users/show.json`
- Single-segment wildcard: `/api/*/data` (matches `/api/v1/data`)
- Multi-segment wildcard: `/api/**` (matches `/api/v1/users/123`)
### State Management
Redux store located in `src/reducers/index.tsx`:
- **App State Interface**: `{ message: string, count: number }`
@@ -282,6 +359,17 @@ Content Script: Renders plugin UI from DOM JSON
- Content script validates origin (`event.origin === window.location.origin`)
- URL validation using `validateUrl()` utility before window creation
- Request interception limited to managed windows only
- Plugin permission validation before prove() and openWindow() calls
**Additional Message Types:**
- `PLUGIN_CONFIRM_RESPONSE` → User response (allow/deny) from confirmation popup
- `PLUGIN_UI_CLICK` → Button click event from plugin UI in content script
- `EXTRACT_CONFIG` → Request to extract plugin config from code
- `EXEC_CODE_OFFSCREEN` → Execute plugin code in offscreen context
- `RENDER_PLUGIN_UI` → Render plugin UI from DOM JSON in content script
- `OFFSCREEN_LOG` → Log forwarding from offscreen to page context
- `REQUEST_HOST_PERMISSIONS` → Request browser host permissions for origins
- `REMOVE_HOST_PERMISSIONS` → Revoke browser host permissions for origins
### TLSN Overlay Feature
@@ -298,7 +386,7 @@ The overlay is a full-screen modal showing intercepted requests:
### Build Configuration
**Webpack 5 Setup** (`webpack.config.js`):
- **Entry Points**: popup, background, contentScript, content, offscreen
- **Entry Points**: popup, background, contentScript, content, offscreen, devConsole, options, confirmPopup
- **Output**: `build/` directory with `[name].bundle.js` pattern
- **Loaders**:
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
@@ -310,7 +398,7 @@ The overlay is a full-screen modal showing intercepted requests:
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
- `CleanWebpackPlugin` - Cleans build directory
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
- `HtmlWebpackPlugin` - Generates popup.html, offscreen.html, devConsole.html, options.html, confirmPopup.html
- `TerserPlugin` - Code minification (production only)
- **Dev Server** (`utils/webserver.js`):
- Port: 3000 (configurable via `PORT` env var)
@@ -332,10 +420,19 @@ Defined in `src/manifest.json`:
- `activeTab` - Access active tab information
- `tabs` - Tab management (create, query, update)
- `windows` - Window management (create, track, remove)
- `host_permissions: ["<all_urls>"]` - Access all URLs for request interception
- `contextMenus` - Create context menu items (Developer Console access)
- `optional_host_permissions: ["https://*/*", "http://*/*"]` - Runtime-requested host permissions
- `content_scripts` - Inject into all HTTP/HTTPS pages
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
- `web_accessible_resources` - Make content.bundle.js, CSS, icons, and WASM files accessible to pages
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
- `options_page` - Extension settings page (`options.html`)
**Runtime Host Permissions:**
The extension uses `optional_host_permissions` instead of blanket `host_permissions` for improved privacy:
- No host permissions granted by default
- Permissions are requested at runtime based on plugin's `config.requests`
- Permissions are revoked immediately after plugin execution completes
- Uses browser's native permission prompt for user consent
### TypeScript Configuration
@@ -407,6 +504,47 @@ Defined in `src/manifest.json`:
## Plugin SDK Package (`packages/plugin-sdk`)
### Plugin Configuration
Plugins must export a `config` object with the following structure:
```typescript
interface PluginConfig {
name: string; // Display name
description: string; // What the plugin does
version?: string; // Optional version string
author?: string; // Optional author name
requests?: RequestPermission[]; // Allowed HTTP requests for prove()
urls?: string[]; // Allowed URLs for openWindow()
}
interface RequestPermission {
method: string; // HTTP method (GET, POST, etc.)
host: string; // Target hostname
pathname: string; // URL path pattern (supports wildcards)
verifierUrl: string; // Verifier server URL
proxyUrl?: string; // Optional proxy URL (derived from verifierUrl if omitted)
}
```
**Example config with permissions:**
```javascript
const config = {
name: 'Twitter Plugin',
description: 'Generate TLS proofs for Twitter profile data',
version: '1.0.0',
author: 'TLSN Team',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.tlsnotary.org'
}
],
urls: ['https://x.com/*']
};
```
### Host Class API
The SDK provides a `Host` class for sandboxed plugin execution with capability injection:
@@ -422,6 +560,9 @@ const host = new Host({
// Execute plugin code
await host.executePlugin(pluginCode, { eventEmitter });
// Extract plugin config without executing
const config = await host.getPluginConfig(pluginCode);
```
**Capabilities injected into plugin environment:**
@@ -471,6 +612,7 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Handle chunked transfer encoding
- Extract header ranges with case-insensitive names
- Extract JSON field ranges (top-level only)
- Array field access returns range for entire array (PR #212)
- Regex-based body pattern matching
- Track byte offsets for TLSNotary selective disclosure
@@ -486,6 +628,20 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Reactive rendering: `main()` function called whenever hook state changes
- Force re-render: `main(true)` can be called to force UI re-render even if state hasn't changed (used on content script initialization)
### Config Extraction
The SDK provides utilities to extract plugin config without full execution:
```typescript
import { extractConfig } from '@tlsn/plugin-sdk';
// Fast extraction using regex (name/description only)
const basicConfig = await extractConfig(pluginCode);
// Full extraction using QuickJS sandbox (includes permissions)
const host = new Host({ /* ... */ });
const fullConfig = await host.getPluginConfig(pluginCode);
```
### Build Configuration
- **Vite**: Builds isomorphic package for Node.js and browser
- **TypeScript**: Strict mode with full type declarations
@@ -586,37 +742,48 @@ logger.setLevel(LogLevel.WARN);
[HH:MM:SS] [LEVEL] message
```
**Log Level Storage** (extension only):
The extension persists log level in IndexedDB via `src/utils/logLevelStorage.ts`:
- `getStoredLogLevel()`: Retrieve saved log level (defaults to WARN)
- `setStoredLogLevel(level)`: Save log level to IndexedDB
- Uses `idb-keyval` for simple key-value storage
## Demo Package (`packages/demo`)
Docker-based demo environment for testing plugins:
**Files:**
- `src/plugins/*.plugin.ts` - Plugin source files (TypeScript)
- `public/plugins/*.js` - Built plugin files (generated by `build-plugins.js`)
- `twitter.js`, `swissbank.js`, `spotify.js` - Example plugin files (PR #210 added Spotify)
- `docker-compose.yml` - Docker services configuration
- `nginx.conf` - Reverse proxy configuration
- `start.sh` - Setup script with URL templating
**Docker Services:**
1. `verifier` - TLSNotary verifier server (port 7047)
2. `demo-static` - nginx serving static plugin files
3. `nginx` - Reverse proxy (port 80)
**Environment Variables (via `.env` files or Docker build args):**
- `VITE_VERIFIER_HOST` - Verifier server host (default: `localhost:7047`)
- `VITE_SSL` - Use https/wss protocols (default: `false`)
**Environment Variables:**
- `VERIFIER_HOST` - Verifier server host (default: `localhost:7047`)
- `SSL` - Use https/wss protocols (default: `false`)
**Usage:**
```bash
# Local development with npm
npm run demo
# Local development
./start.sh
# Docker (detached mode)
npm run docker:up
# Production with SSL
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./start.sh
# Docker with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Docker detached mode
./start.sh -d
```
The `start.sh` script:
1. Processes plugin files, replacing `verifierUrl` and `proxyUrl` placeholders
2. Copies processed files to `generated/` directory
3. Starts Docker Compose services
## Important Implementation Notes
### Plugin API Changes
@@ -654,12 +821,8 @@ Parser tests (`packages/plugin-sdk/src/parser.test.ts`) use redacted sensitive d
### Known Issues
⚠️ **Legacy Code Warning**: `src/entries/utils.ts` contains imports from non-existent files:
- `Background/rpc.ts` (removed in refactor)
- `SidePanel/types.ts` (removed in refactor)
- Functions: `pushToRedux()`, `openSidePanel()`, `waitForEvent()`
- **Status**: Dead code, not used by current entry points
- **Action**: Remove this file or refactor if functionality needed
- **Nested JSON field access**: Parser does not yet support paths like `"user.profile.name"`
- **Duplicate CLAUDE.md**: `packages/extension/CLAUDE.md` contains outdated documentation; use root CLAUDE.md instead
## Websockify Integration

View File

@@ -58,7 +58,8 @@ tlsn-extension/
│ │
│ ├── demo/ # Demo server with Docker setup
│ │ ├── *.js # Example plugin files
│ │ ── docker-compose.yml # Docker services configuration
│ │ ── docker-compose.yml # Docker services configuration
│ │ └── start.sh # Setup script with configurable URLs
│ │
│ ├── tutorial/ # Tutorial examples
│ │ └── *.js # Tutorial plugin files
@@ -115,9 +116,10 @@ Rust-based HTTP/WebSocket server for TLSNotary verification:
#### 5. **demo** - Demo Server
Docker-based demo environment with:
- Pre-configured example plugins (Twitter, SwissBank)
- React + Vite frontend with environment-based configuration
- Docker Compose setup with verifier and nginx
- Configurable verifier URLs via `.env` files or Docker build args
- Configurable verifier URLs via environment variables
- Plugin file generator (`generate.sh`) with SSL support
- Docker startup script (`start.sh`)
#### 6. **tlsn-wasm-pkg** - TLSN WebAssembly Package
Pre-built WebAssembly binaries for TLSNotary functionality in the browser.
@@ -493,19 +495,25 @@ npm run demo
### Environment Variables
The demo uses `.env` files for configuration:
- `.env` - Local development defaults (`localhost:7047`)
- `.env.production` - Production settings (`verifier.tlsnotary.org`, SSL enabled)
For Docker deployments, override via environment variables:
Configure the demo for different environments:
```bash
# Local development (default)
npm run docker:up
cd packages/demo
./generate.sh && ./start.sh
# Production with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Production with SSL
cd packages/demo
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh
./start.sh
# Docker detached mode
./generate.sh && ./start.sh -d
```
The demo uses two scripts:
- **`generate.sh`** - Generates plugin files with configured verifier URLs (use environment variables here)
- **`start.sh`** - Starts Docker Compose services (assumes `generated/` directory exists)
### Tutorial
```bash

2635
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tlsn-monorepo",
"version": "0.1.0-alpha.14",
"version": "0.1.0-alpha.13",
"private": true,
"description": "TLSN Extension monorepo with plugin SDK",
"license": "MIT",
@@ -22,11 +22,11 @@
"test": "npm run test --workspaces --if-present",
"serve:test": "npm run serve:test --workspace=extension",
"clean": "rm -rf packages/*/node_modules packages/*/dist packages/*/build node_modules",
"build:wasm": "sh packages/tlsn-wasm/build.sh v0.1.0-alpha.14 --no-logging",
"demo": "npm run dev --workspace=@tlsnotary/demo",
"build:wasm": "sh packages/tlsn-wasm/build.sh v0.1.0-alpha.13 --no-logging",
"demo": "serve -l 8080 packages/demo",
"tutorial": "serve -l 8080 packages/tutorial",
"docker:up": "cd packages/demo && docker compose up --build -d",
"docker:down": "cd packages/demo && docker compose down"
"docker:up": "cd packages/demo && ./start.sh -d",
"docker:down": "cd packages/demo && docker-compose down"
},
"devDependencies": {
"typescript": "^5.5.4",

View File

@@ -33,11 +33,6 @@
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"typescript": "^5.0.0",
"vitest": "^4.0.16"
},
"dependencies": {
"happy-dom": "20.0.11",
"vite": "7.3.0",
"webpack-dev-server": "5.2.2"
"vitest": "^1.0.0"
}
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Logger, LogLevel, DEFAULT_LOG_LEVEL } from './index';
describe('Logger', () => {
@@ -11,10 +11,6 @@ describe('Logger', () => {
logger.init(DEFAULT_LOG_LEVEL);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('LogLevel', () => {
it('should have correct hierarchy values', () => {
expect(LogLevel.DEBUG).toBe(0);

View File

@@ -1,3 +0,0 @@
# Verifier Configuration
VITE_VERIFIER_HOST=localhost:7047
VITE_SSL=false

View File

@@ -1,3 +0,0 @@
# Production environment variables
VITE_VERIFIER_HOST=verifier.tlsnotary.org
VITE_SSL=true

View File

@@ -1,4 +1,2 @@
*.wasm
dist/
public/plugins/
generated/

View File

@@ -1,52 +0,0 @@
# Adding New Plugins
Adding new plugins to the demo is straightforward. Just update the `plugins.ts` file:
## Example: Adding a GitHub Plugin
```typescript
// packages/demo/src/plugins.ts
export const plugins: Record<string, Plugin> = {
// ... existing plugins ...
github: {
name: 'GitHub Profile',
description: 'Prove your GitHub contributions and profile information',
logo: '🐙', // or use emoji: '💻', '⚡', etc.
file: '/github.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
};
```
## Plugin Properties
| Property | Type | Description |
| ------------- | -------- | ------------------------------------------------------- |
| `name` | string | Display name shown in the card header |
| `description` | string | Brief description of what the plugin proves |
| `logo` | string | Emoji or character to display as the plugin icon |
| `file` | string | Path to the plugin JavaScript file |
| `parseResult` | function | Function to extract the result from the plugin response |
## Tips
- **Logo**: Use emojis for visual appeal (🔒, 🎮, 📧, 💰, etc.)
- **Description**: Keep it concise (1-2 lines) explaining what data is proven
- **File**: Place the plugin JS file in `/packages/demo/` directory
- **Name**: Use short, recognizable names
## Card Display
The plugin will automatically render as a card with:
- Large logo at the top
- Plugin name as heading
- Description text below
- "Run Plugin" button at the bottom
- Hover effects and animations
- Running state with spinner
No additional UI code needed!

View File

@@ -1,27 +1,17 @@
# Build stage
FROM node:20-alpine AS builder
FROM rust:latest AS builder
# Accept build arguments with defaults
ARG VITE_VERIFIER_HOST=localhost:7047
ARG VITE_SSL=false
ARG GIT_HASH=local
ARG VERIFIER_HOST=localhost:7047
ARG SSL=false
WORKDIR /app
COPY index.html *.ico *.js *.sh /app/
# Copy package files and install dependencies
COPY package.json ./
RUN npm install
# Copy source files
COPY . .
# Build with environment variables
ENV VITE_VERIFIER_HOST=${VITE_VERIFIER_HOST}
ENV VITE_SSL=${VITE_SSL}
ENV GIT_HASH=${GIT_HASH}
RUN npm run build
# Pass build args as environment variables to generate.sh
RUN VERIFIER_HOST="${VERIFIER_HOST}" SSL="${SSL}" ./generate.sh
# Runtime stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/generated /usr/share/nginx/html

View File

@@ -57,44 +57,31 @@ You can use the websocketproxy hosted by the TLSNotary team, or run your own pro
## 4. Launch the demo
### Development with React
This demo is built with React + TypeScript + Vite. To run it locally:
```bash
cd packages/demo
npm install
npm run dev
```
The demo will open at `http://localhost:3000` in your browser with the TLSNotary extension.
### Docker Setup
Run the demo with `npm run demo` from the repository root, or run it with docker using `npm run docker:up`.
#### Manual Docker Setup
### Manual Setup
If you want to run Docker manually:
If you want to run the scripts manually:
```bash
cd packages/demo
docker compose up --build
./generate.sh && ./start.sh
```
#### Environment Variables
The demo uses two scripts:
- **`generate.sh`** - Generates plugin files with configured verifier URLs
- **`start.sh`** - Starts Docker Compose services
The demo uses `.env` files for configuration:
- `.env` - Local development defaults (`localhost:7047`)
- `.env.production` - Production settings (`verifier.tlsnotary.org`, SSL enabled)
### Environment Variables
For Docker deployments, override via environment variables:
Configure for different environments:
```bash
# Local development (default)
docker compose up --build
./generate.sh && ./start.sh
# Production with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Production with SSL
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh
./start.sh
```
You can now open the demo by opening http://localhost:8080 in your browser with the TLSNotary extension

View File

@@ -1,46 +0,0 @@
import { build } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const plugins = ['twitter', 'swissbank', 'spotify', 'duolingo'];
// Build URLs from environment variables (matching config.ts pattern)
const VERIFIER_HOST = process.env.VITE_VERIFIER_HOST || 'localhost:7047';
const SSL = process.env.VITE_SSL === 'true';
const VERIFIER_URL = `${SSL ? 'https' : 'http'}://${VERIFIER_HOST}`;
const PROXY_URL = `${SSL ? 'wss' : 'ws'}://${VERIFIER_HOST}/proxy?token=`;
// Build each plugin separately as plain ES module
for (const plugin of plugins) {
await build({
configFile: false,
publicDir: false, // Don't copy public assets into plugin output
build: {
lib: {
entry: path.resolve(__dirname, `plugins/${plugin}.plugin.ts`),
formats: ['es'],
fileName: () => `${plugin}.js`,
},
outDir: 'public/plugins',
emptyOutDir: false,
sourcemap: false,
minify: false,
rollupOptions: {
output: {
exports: 'default',
},
},
},
define: {
VITE_VERIFIER_URL: JSON.stringify(VERIFIER_URL),
VITE_PROXY_URL: JSON.stringify(PROXY_URL),
},
});
console.log(`✓ Built ${plugin}.js`);
}
console.log('✓ All plugins built successfully');

View File

@@ -9,16 +9,14 @@ services:
- "7047:7047"
environment:
- RUST_LOG=info
- GIT_HASH=${GIT_HASH:-dev}
restart: unless-stopped
demo-static:
build:
context: .
args:
VITE_VERIFIER_HOST: ${VITE_VERIFIER_HOST:-localhost:7047}
VITE_SSL: ${VITE_SSL:-false}
GIT_HASH: ${GIT_HASH:-dev}
VERIFIER_HOST: ${VERIFIER_HOST:-localhost:7047}
SSL: ${SSL:-false}
restart: unless-stopped
nginx:

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

105
packages/demo/generate.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
#
# Demo Plugin File Generator
#
# This script generates plugin files with configurable verifier URLs.
# Used both locally and in CI/CD pipelines.
#
# Environment Variables:
# VERIFIER_HOST - Verifier server host (default: localhost:7047)
# SSL - Use https/wss if true (default: false)
#
# Usage:
# ./generate.sh # Local development
# VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh # Production
set -e
cd "$(dirname "$0")"
# Configuration with defaults
VERIFIER_HOST="${VERIFIER_HOST:-localhost:7047}"
SSL="${SSL:-false}"
# Determine protocol based on SSL setting
if [ "$SSL" = "true" ]; then
HTTP_PROTOCOL="https"
WS_PROTOCOL="wss"
else
HTTP_PROTOCOL="http"
WS_PROTOCOL="ws"
fi
VERIFIER_URL="${HTTP_PROTOCOL}://${VERIFIER_HOST}"
PROXY_URL_BASE="${WS_PROTOCOL}://${VERIFIER_HOST}/proxy?token="
echo "========================================"
echo "TLSNotary Demo Plugin Generator"
echo "========================================"
echo "Verifier Host: $VERIFIER_HOST"
echo "SSL Enabled: $SSL"
echo "Verifier URL: $VERIFIER_URL"
echo "Proxy URL: ${PROXY_URL_BASE}<host>"
echo "========================================"
# Create generated directory for processed files
mkdir -p generated
# Function to process a plugin file
process_plugin() {
local input_file="$1"
local output_file="generated/$(basename "$input_file")"
echo "Processing: $input_file -> $output_file"
# Replace verifierUrl and proxyUrl patterns
sed -E \
-e "s|verifierUrl: '[^']*'|verifierUrl: '${VERIFIER_URL}'|g" \
-e "s|verifierUrl: \"[^\"]*\"|verifierUrl: \"${VERIFIER_URL}\"|g" \
-e "s|proxyUrl: 'ws://[^/]+/proxy\?token=([^']+)'|proxyUrl: '${PROXY_URL_BASE}\1'|g" \
-e "s|proxyUrl: 'wss://[^/]+/proxy\?token=([^']+)'|proxyUrl: '${PROXY_URL_BASE}\1'|g" \
-e "s|proxyUrl: \"ws://[^/]+/proxy\?token=([^\"]+)\"|proxyUrl: \"${PROXY_URL_BASE}\1\"|g" \
-e "s|proxyUrl: \"wss://[^/]+/proxy\?token=([^\"]+)\"|proxyUrl: \"${PROXY_URL_BASE}\1\"|g" \
"$input_file" > "$output_file"
}
# Function to process index.html
process_index_html() {
local input_file="$1"
local output_file="generated/$(basename "$input_file")"
echo "Processing: $input_file -> $output_file"
# Replace hardcoded health check URL with configured verifier URL
sed -E \
-e "s|http://localhost:7047/health|${VERIFIER_URL}/health|g" \
"$input_file" > "$output_file"
}
# Process index.html
echo ""
echo "Processing index.html..."
process_index_html "index.html"
# Copy other static files
echo ""
echo "Copying other static files..."
cp favicon.ico generated/ 2>/dev/null || true
# Process plugin files
echo ""
echo "Processing plugin files..."
for plugin_file in *.js; do
if [ -f "$plugin_file" ]; then
process_plugin "$plugin_file"
fi
done
echo ""
echo "Generated files:"
ls -la generated/
echo ""
echo "========================================"
echo "Generation complete!"
echo "========================================"

View File

@@ -1,16 +1,510 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TLSNotary Plugin Demo</title>
<title>TLSNotary Plugin test page</title>
<style>
.result {
background: #e8f5e8;
border: 2px solid #28a745;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
font-size: 18px;
display: inline-block;
}
.debug {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 20px 0;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.plugin-buttons {
margin: 20px 0;
}
.plugin-buttons button {
margin-right: 10px;
padding: 10px 20px;
font-size: 16px;
}
.check-item {
margin: 10px 0;
padding: 10px;
border-radius: 4px;
border: 1px solid #ddd;
}
.check-item.checking {
background: #f0f8ff;
border-color: #007bff;
}
.check-item.success {
background: #f0f8f0;
border-color: #28a745;
}
.check-item.error {
background: #fff0f0;
border-color: #dc3545;
}
.status {
font-weight: bold;
margin-left: 10px;
}
.status.checking {
color: #007bff;
}
.status.success {
color: #28a745;
}
.status.error {
color: #dc3545;
}
.warning-box {
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
}
.warning-box h3 {
margin-top: 0;
color: #856404;
}
.console-section {
margin: 20px 0;
border: 1px solid #dee2e6;
border-radius: 8px;
background: #1e1e1e;
overflow: hidden;
}
.console-header {
background: #2d2d2d;
color: #fff;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #3d3d3d;
}
.console-title {
font-weight: 600;
font-size: 14px;
}
.console-output {
max-height: 300px;
overflow-y: auto;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #d4d4d4;
}
.console-entry {
margin: 4px 0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.console-entry.info {
color: #4fc3f7;
}
.console-entry.success {
color: #4caf50;
}
.console-entry.error {
color: #f44336;
}
.console-entry.warning {
color: #ff9800;
}
.console-timestamp {
color: #888;
margin-right: 8px;
}
.console-message {
color: inherit;
}
.btn-console {
background: #007bff;
color: white;
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-console:hover {
background: #0056b3;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<h1>TLSNotary Plugin Demo</h1>
<p>
This page demonstrates TLSNotary plugins. Choose a plugin to test below.
</p>
<!-- Browser compatibility warning -->
<div id="browser-warning" class="warning-box" style="display: none;">
<h3>⚠️ Browser Compatibility</h3>
<p><strong>Unsupported Browser Detected</strong></p>
<p>TLSNotary extension requires a Chrome-based browser (Chrome, Edge, Brave, etc.).</p>
<p>Please switch to a supported browser to continue.</p>
</div>
<!-- System checks -->
<div>
<strong>System Checks:</strong>
<div id="check-browser" class="check-item checking">
🌐 Browser: <span class="status checking">Checking...</span>
</div>
<div id="check-extension" class="check-item checking">
🔌 Extension: <span class="status checking">Checking...</span>
</div>
<div id="check-verifier" class="check-item checking">
✅ Verifier: <span class="status checking">Checking...</span>
<div id="verifier-instructions" style="display: none; margin-top: 10px; font-size: 14px;">
<p>Start the verifier server:</p>
<code>cd packages/verifier; cargo run --release</code>
<button onclick="checkVerifier()" style="margin-left: 10px; padding: 5px 10px;">Check Again</button>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<strong>Steps:</strong>
<ol>
<li>Click one of the plugin "Run" buttons below.</li>
<li>The plugin will open a new browser window with the target website.</li>
<li>Log in to the website if you are not already logged in.</li>
<li>A TLSNotary overlay will appear in the bottom right corner.</li>
<li>Click the <strong>Prove</strong> button in the overlay to start the proving process.</li>
<li>After successful proving, you can close the browser window and the results will appear on this page.</li>
</ol>
</div>
<div class="plugin-buttons" id="buttonContainer"></div>
<!-- Console Section -->
<div class="console-section">
<div class="console-header">
<div class="console-title">Console Output</div>
<div style="display: flex; gap: 10px;">
<button class="btn-console" onclick="openExtensionLogs()" style="background: #6c757d;">View Extension
Logs</button>
<button class="btn-console" onclick="clearConsole()">Clear</button>
</div>
</div>
<div class="console-output" id="consoleOutput">
<div class="console-entry info">
<span class="console-timestamp">[INFO]</span>
<span class="console-message">💡 TLSNotary proving logs will appear here in real-time. You can also view them in
the extension console by clicking "View Extension Logs" above.</span>
</div>
</div>
</div>
<script>
console.log('Testing TLSNotary plugins...');
let allChecksPass = false;
// Console functionality
function addConsoleEntry(message, type = 'info') {
const consoleOutput = document.getElementById('consoleOutput');
const timestamp = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `console-entry ${type}`;
const timestampSpan = document.createElement('span');
timestampSpan.className = 'console-timestamp';
timestampSpan.textContent = `[${timestamp}]`;
const messageSpan = document.createElement('span');
messageSpan.className = 'console-message';
messageSpan.textContent = message;
entry.appendChild(timestampSpan);
entry.appendChild(messageSpan);
consoleOutput.appendChild(entry);
// Auto-scroll to bottom
consoleOutput.scrollTop = consoleOutput.scrollHeight;
}
function clearConsole() {
const consoleOutput = document.getElementById('consoleOutput');
consoleOutput.innerHTML = '';
addConsoleEntry('Console cleared', 'info');
// Re-add the tip
const tipEntry = document.createElement('div');
tipEntry.className = 'console-entry info';
tipEntry.innerHTML = '<span class="console-timestamp">[INFO]</span><span class="console-message">💡 TLSNotary proving logs will appear here in real-time.</span>';
consoleOutput.insertBefore(tipEntry, consoleOutput.firstChild);
}
function openExtensionLogs() {
// Open extensions page
window.open('chrome://extensions/', '_blank');
addConsoleEntry('Opening chrome://extensions/ - Find TLSNotary extension → click "service worker" → find "offscreen.html" → click "inspect"', 'info');
}
// Listen for logs from offscreen document
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'TLSN_OFFSCREEN_LOG') {
addConsoleEntry(event.data.message, event.data.level);
}
});
// Initialize console with welcome message
window.addEventListener('load', () => {
addConsoleEntry('TLSNotary Plugin Demo initialized', 'success');
});
// Check browser compatibility
function checkBrowserCompatibility() {
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isEdge = /Edg/.test(navigator.userAgent);
const isBrave = navigator.brave && typeof navigator.brave.isBrave === 'function';
const isChromium = /Chromium/.test(navigator.userAgent);
const isChromeBasedBrowser = isChrome || isEdge || isBrave || isChromium;
const checkDiv = document.getElementById('check-browser');
const warningDiv = document.getElementById('browser-warning');
const statusSpan = checkDiv.querySelector('.status');
if (isChromeBasedBrowser) {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Chrome-based browser detected';
return true;
} else {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.textContent = '❌ Unsupported browser';
warningDiv.style.display = 'block';
return false;
}
}
// Check extension
async function checkExtension() {
const checkDiv = document.getElementById('check-extension');
const statusSpan = checkDiv.querySelector('.status');
// Wait a bit for tlsn to load if page just loaded
await new Promise(resolve => setTimeout(resolve, 1000));
if (typeof window.tlsn !== 'undefined') {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Extension installed';
return true;
} else {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.innerHTML = '❌ Extension not found - <a href="chrome://extensions/" target="_blank">Install extension</a>';
return false;
}
}
// Check verifier server
async function checkVerifier() {
const checkDiv = document.getElementById('check-verifier');
const statusSpan = checkDiv.querySelector('.status');
const instructions = document.getElementById('verifier-instructions');
statusSpan.textContent = 'Checking...';
statusSpan.className = 'status checking';
checkDiv.className = 'check-item checking';
try {
const response = await fetch('http://localhost:7047/health');
if (response.ok && await response.text() === 'ok') {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Verifier running';
instructions.style.display = 'none';
return true;
} else {
throw new Error('Unexpected response');
}
} catch (error) {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.textContent = '❌ Verifier not running';
instructions.style.display = 'block';
return false;
}
}
// Run all checks
async function runAllChecks() {
const browserOk = checkBrowserCompatibility();
if (!browserOk) {
allChecksPass = false;
return;
}
const extensionOk = await checkExtension();
const verifierOk = await checkVerifier();
allChecksPass = extensionOk && verifierOk;
updateButtonState();
}
// Update button state based on checks
function updateButtonState() {
const container = document.getElementById('buttonContainer');
const buttons = container.querySelectorAll('button');
buttons.forEach(button => {
button.disabled = !allChecksPass;
if (!allChecksPass) {
button.title = 'Please complete all system checks first';
} else {
button.title = '';
}
});
}
const plugins = {
twitter: {
name: 'Twitter profile Plugin',
file: 'twitter.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
},
swissbank: {
name: 'Swiss Bank Plugin',
file: 'swissbank.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
},
spotify: {
name: 'Spotify Plugin',
file: 'spotify.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
}
};
async function runPlugin(pluginKey) {
const plugin = plugins[pluginKey];
const button = document.getElementById(`${pluginKey}Button`);
try {
addConsoleEntry(`🎬 Starting ${plugin.name} plugin...`, 'info');
console.log(`Running ${plugin.name} plugin...`);
button.disabled = true;
button.textContent = 'Running...';
const startTime = performance.now();
const pluginCode = await fetch(plugin.file).then(r => r.text());
addConsoleEntry('🔧 Executing plugin code...', 'info');
const result = await window.tlsn.execCode(pluginCode);
const executionTime = (performance.now() - startTime).toFixed(2);
const json = JSON.parse(result);
// Create result div
const resultDiv = document.createElement('div');
resultDiv.className = 'result';
resultDiv.innerHTML = plugin.parseResult(json);
document.body.appendChild(resultDiv);
// Create header
const header = document.createElement('h3');
header.textContent = `${plugin.name} Results:`;
document.body.appendChild(header);
// Create debug div
const debugDiv = document.createElement('div');
debugDiv.className = 'debug';
debugDiv.textContent = JSON.stringify(json.results, null, 2);
document.body.appendChild(debugDiv);
addConsoleEntry(`${plugin.name} completed successfully in ${executionTime}ms`, 'success');
// Remove the button after successful execution
button.remove();
} catch (err) {
console.error(err);
// Create error div
const errorDiv = document.createElement('pre');
errorDiv.style.color = 'red';
errorDiv.textContent = err.message;
document.body.appendChild(errorDiv);
button.textContent = `Run ${plugin.name}`;
button.disabled = false;
}
}
window.addEventListener('tlsn_loaded', () => {
console.log('TLSNotary client loaded, showing plugin buttons...');
const container = document.getElementById('buttonContainer');
Object.entries(plugins).forEach(([key, plugin]) => {
const button = document.createElement('button');
button.id = `${key}Button`;
button.textContent = `Run ${plugin.name}`;
button.onclick = () => runPlugin(key);
container.appendChild(button);
});
// Update button states after creating them
updateButtonState();
});
// Run checks on page load
window.addEventListener('load', () => {
setTimeout(() => {
runAllChecks();
}, 500);
});
</script>
</body>
</html>

View File

@@ -39,12 +39,6 @@ server {
proxy_set_header X-Real-IP $remote_addr;
}
location /info {
proxy_pass http://verifier:7047;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Default: proxy to static demo server
location / {
proxy_pass http://demo-static:80;

View File

@@ -1,26 +0,0 @@
{
"name": "@tlsnotary/demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run build:plugins && vite",
"build": "npm run build:plugins && vite build",
"build:plugins": "node build-plugins.js",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"happy-dom": "20.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webpack-dev-server": "5.2.2"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^7.3.0"
}
}

View File

@@ -1,272 +0,0 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// Environment variables injected at build time
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
const config = {
name: 'Swiss Bank Prover',
description: 'This plugin will prove your Swiss Bank account balance.',
requests: [
{
method: 'GET',
host: 'swissbank.tlsnotary.org',
pathname: '/balances',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://swissbank.tlsnotary.org/*',
],
};
const host = 'swissbank.tlsnotary.org';
const ui_path = '/account';
const path = '/balances';
const url = `https://${host}${path}`;
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
// Use cached cookie from state
const cachedCookie = useState('cookie', null);
if (!cachedCookie) {
setState('isRequestPending', false);
return;
}
const headers = {
'cookie': cachedCookie,
Host: host,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: url,
method: 'GET',
headers: headers,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + 'swissbank.tlsnotary.org',
maxRecvData: 460,
maxSentData: 180,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: { type: 'json', path: 'account_id' },
},
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: { type: 'json', path: 'accounts.CHF' },
},
],
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
function main() {
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
const cachedCookie = useState('cookie', null);
// Only search for cookie if not already cached
if (!cachedCookie) {
const [header] = useHeaders((headers: any[]) =>
headers.filter(h => h.url.includes(`https://${host}`))
);
if (header) {
const cookie = header.requestHeaders.find((h: any) => h.name === 'Cookie')?.value;
if (cookie) {
setState('cookie', cookie);
console.log('Cookie found');
}
}
}
useEffect(() => {
openWindow(`https://${host}${ui_path}`);
}, []);
if (isMinimized) {
return div(
{
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
},
['🔐']
);
}
return div(
{
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
},
[
div(
{
style: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
},
},
[
div(
{
style: {
fontWeight: '600',
fontSize: '16px',
},
},
['Swiss Bank Prover']
),
button(
{
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
},
['']
),
]
),
div(
{
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
},
},
[
div(
{
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: cachedCookie ? '#d4edda' : '#f8d7da',
color: cachedCookie ? '#155724' : '#721c24',
border: `1px solid ${cachedCookie ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
},
[cachedCookie ? '✓ Cookie detected' : '⚠ No Cookie detected']
),
cachedCookie
? button(
{
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
},
[isRequestPending ? 'Generating Proof...' : 'Generate Proof']
)
: div(
{
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
},
},
['Please login to continue']
),
]
),
]
);
}
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,319 +0,0 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// Environment variables injected at build time
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
// =============================================================================
// PLUGIN CONFIGURATION
// =============================================================================
/**
* The config object defines plugin metadata displayed to users.
* This information appears in the plugin selection UI.
*/
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/account/settings.json',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://x.com/*',
],
};
// =============================================================================
// PROOF GENERATION CALLBACK
// =============================================================================
/**
* This function is triggered when the user clicks the "Prove" button.
* It extracts authentication headers from intercepted requests and generates
* a TLSNotary proof using the unified prove() API.
*/
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
// Use cached values from state
const cachedCookie = useState('cookie', null);
const cachedCsrfToken = useState('x-csrf-token', null);
const cachedTransactionId = useState('x-client-transaction-id', null);
const cachedAuthorization = useState('authorization', null);
if (!cachedCookie || !cachedCsrfToken || !cachedAuthorization) {
setState('isRequestPending', false);
return;
}
const headers = {
'cookie': cachedCookie,
'x-csrf-token': cachedCsrfToken,
...(cachedTransactionId ? { 'x-client-transaction-id': cachedTransactionId } : {}),
Host: 'api.x.com',
authorization: cachedAuthorization,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: 'https://api.x.com/1.1/account/settings.json',
method: 'GET',
headers: headers as unknown as Record<string, string>,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + 'api.x.com',
maxRecvData: 4000,
maxSentData: 2000,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{
type: 'RECV',
part: 'HEADERS',
action: 'REVEAL',
params: { key: 'date' },
},
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: {
type: 'json',
path: 'screen_name',
},
},
],
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
// =============================================================================
// MAIN UI FUNCTION
// =============================================================================
function main() {
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
const cachedCookie = useState('cookie', null);
const cachedCsrfToken = useState('x-csrf-token', null);
const cachedTransactionId = useState('x-client-transaction-id', null);
const cachedAuthorization = useState('authorization', null);
// Only search for header values if not already cached
if (!cachedCookie || !cachedCsrfToken || !cachedAuthorization) {
const [header] = useHeaders((headers: any[]) =>
headers.filter(h => h.url.includes('https://api.x.com/1.1/account/settings.json'))
);
if (header) {
const cookie = header.requestHeaders.find((h: any) => h.name === 'Cookie')?.value;
const csrfToken = header.requestHeaders.find((h: any) => h.name === 'x-csrf-token')?.value;
const transactionId = header.requestHeaders.find((h: any) => h.name === 'x-client-transaction-id')?.value;
const authorization = header.requestHeaders.find((h: any) => h.name === 'authorization')?.value;
if (cookie && !cachedCookie) {
setState('cookie', cookie);
console.log('Cookie found');
}
if (csrfToken && !cachedCsrfToken) {
setState('x-csrf-token', csrfToken);
console.log('CSRF token found');
}
if (transactionId && !cachedTransactionId) {
setState('x-client-transaction-id', transactionId);
console.log('Transaction ID found');
}
if (authorization && !cachedAuthorization) {
setState('authorization', authorization);
console.log('Authorization found');
}
}
}
const header = cachedCookie && cachedCsrfToken && cachedAuthorization;
useEffect(() => {
openWindow('https://x.com');
}, []);
if (isMinimized) {
return div(
{
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
},
['🔐']
);
}
return div(
{
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
},
[
div(
{
style: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
},
},
[
div(
{
style: {
fontWeight: '600',
fontSize: '16px',
},
},
['X Profile Prover']
),
button(
{
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
},
['']
),
]
),
div(
{
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
},
},
[
div(
{
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
},
[header ? '✓ Profile detected' : '⚠ No profile detected']
),
header
? button(
{
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? '0.5' : '1',
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
},
[isRequestPending ? 'Generating Proof...' : 'Generate Proof']
)
: div(
{
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
},
},
['Please login to x.com to continue']
),
]
),
]
);
}
// =============================================================================
// PLUGIN EXPORTS
// =============================================================================
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,2 +0,0 @@
User-agent: *
Allow: /

View File

@@ -1,30 +1,12 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
const config = {
name: 'Spotify Top Artist',
description: 'This plugin will prove your top artist on Spotify.',
};
const api = 'api.spotify.com';
const ui = 'https://developer.spotify.com/';
const top_artist_path = '/v1/me/top/artists?time_range=medium_term&limit=1';
const config = {
name: 'Spotify Top Artist',
description: 'This plugin will prove your top artist on Spotify.',
requests: [
{
method: 'GET',
host: 'api.spotify.com',
pathname: '/v1/me/top/artists',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://developer.spotify.com/*',
],
};
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
@@ -33,30 +15,29 @@ async function onClick() {
setState('isRequestPending', true);
// Use cached authorization token from state
const authToken = useState('authToken', null);
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes(`https://${api}`));
});
if (!authToken) {
setState('isRequestPending', false);
return;
}
// console.log('Intercepted Spotify API request header:', header);
const headers = {
authorization: authToken,
authorization: header.requestHeaders.find(header => header.name === 'Authorization')?.value,
Host: api,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
// -------------------------------------------------------------------------
{
url: `https://${api}${top_artist_path}`,
method: 'GET',
headers: headers,
url: `https://${api}${top_artist_path}`, // Target API endpoint
method: 'GET', // HTTP method
headers: headers, // Authentication headers
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + api,
verifierUrl: 'http://localhost:7047',
proxyUrl: 'ws://localhost:7047/proxy?token=api.spotify.com',
maxRecvData: 2400,
maxSentData: 600,
handlers: [
@@ -67,6 +48,7 @@ async function onClick() {
},
{
type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'items[0].name', },
// type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'items[0].external_urls.spotify', },
},
]
}
@@ -83,23 +65,11 @@ function minimizeUI() {
}
function main() {
const [header] = useHeaders(headers => headers.filter(h => h.url.includes(`https://${api}`)));
// const [header] = useHeaders(headers => { return headers.filter(headers => headers.url.includes('https://api.spotify.com')) });
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
const authToken = useState('authToken', null);
// Only search for auth token if not already cached
if (!authToken) {
const token = useHeaders(h => h.filter(x => x.url.startsWith(`https://${api}`)))
.flatMap(h => h.requestHeaders)
.find((h: { name: string; value?: string }) => h.name === 'Authorization')
?.value;
if (token) {
setState('authToken', token);
console.log('Auth Token found:', token);
}
}
useEffect(() => {
openWindow(ui);
@@ -189,16 +159,18 @@ function main() {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: authToken ? '#d4edda' : '#f8d7da',
color: authToken ? '#155724' : '#721c24',
border: `1px solid ${authToken ? '#c3e6cb' : '#f5c6cb'}`,
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
}, [
authToken ? '✓ Api token detected' : '⚠ No API token detected'
header ? '✓ Api token detected' : '⚠ No API token detected'
]),
authToken ? (
// Conditional UI based on whether we have intercepted the headers
header ? (
// Show prove button when not pending
button({
style: {
width: '100%',
@@ -209,14 +181,16 @@ function main() {
color: 'white',
fontWeight: '600',
fontSize: '15px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? '0.5' : '1',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
}, [isRequestPending ? 'Generating Proof...' : 'Generate Proof'])
) : (
// Show login message
div({
style: {
textAlign: 'center',

File diff suppressed because it is too large Load Diff

View File

@@ -1,299 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { SystemChecks } from './components/SystemChecks';
import { ConsoleOutput } from './components/Console';
import { PluginButtons } from './components/PluginButtons';
import { StatusBar } from './components/StatusBar';
import { CollapsibleSection } from './components/CollapsibleSection';
import { HowItWorks } from './components/HowItWorks';
import { WhyPlugins } from './components/WhyPlugins';
import { BuildYourOwn } from './components/BuildYourOwn';
import { plugins } from './plugins';
import { checkBrowserCompatibility, checkExtension, checkVerifier, formatTimestamp } from './utils';
import { ConsoleEntry, CheckStatus, PluginResult as PluginResultType } from './types';
import './App.css';
interface PluginResultData {
resultHtml: string;
debugJson: string;
}
export function App() {
const [consoleEntries, setConsoleEntries] = useState<ConsoleEntry[]>([
{
timestamp: formatTimestamp(),
message:
'💡 TLSNotary proving logs will appear here in real-time. You can also view them in the extension console by clicking "View Extension Logs" above.',
type: 'info',
},
]);
const [browserCheck, setBrowserCheck] = useState<{ status: CheckStatus; message: string }>({
status: 'checking',
message: 'Checking...',
});
const [extensionCheck, setExtensionCheck] = useState<{ status: CheckStatus; message: string }>({
status: 'checking',
message: 'Checking...',
});
const [verifierCheck, setVerifierCheck] = useState<{
status: CheckStatus;
message: string;
showInstructions: boolean;
}>({
status: 'checking',
message: 'Checking...',
showInstructions: false,
});
const [showBrowserWarning, setShowBrowserWarning] = useState(false);
const [allChecksPass, setAllChecksPass] = useState(false);
const [runningPlugins, setRunningPlugins] = useState<Set<string>>(new Set());
const [pluginResults, setPluginResults] = useState<Record<string, PluginResultData>>({});
const [consoleExpanded, setConsoleExpanded] = useState(false);
const addConsoleEntry = useCallback((message: string, type: ConsoleEntry['type'] = 'info') => {
setConsoleEntries((prev) => [
...prev,
{
timestamp: formatTimestamp(),
message,
type,
},
]);
}, []);
const handleClearConsole = useCallback(() => {
setConsoleEntries([
{
timestamp: formatTimestamp(),
message: 'Console cleared',
type: 'info',
},
{
timestamp: formatTimestamp(),
message: '💡 TLSNotary proving logs will appear here in real-time.',
type: 'info',
},
]);
}, []);
const handleOpenExtensionLogs = useCallback(() => {
window.open('chrome://extensions/', '_blank');
addConsoleEntry(
'Opening chrome://extensions/ - Find TLSNotary extension → click "service worker" → find "offscreen.html" → click "inspect"',
'info'
);
}, [addConsoleEntry]);
const runAllChecks = useCallback(async () => {
// Browser check
const browserOk = checkBrowserCompatibility();
if (browserOk) {
setBrowserCheck({ status: 'success', message: '✅ Chrome-based browser detected' });
setShowBrowserWarning(false);
} else {
setBrowserCheck({ status: 'error', message: '❌ Unsupported browser' });
setShowBrowserWarning(true);
setAllChecksPass(false);
return;
}
// Extension check
const extensionOk = await checkExtension();
if (extensionOk) {
setExtensionCheck({ status: 'success', message: '✅ Extension installed' });
} else {
setExtensionCheck({ status: 'error', message: '❌ Extension not found' });
}
// Verifier check
const verifierOk = await checkVerifier();
if (verifierOk) {
setVerifierCheck({ status: 'success', message: '✅ Verifier running', showInstructions: false });
} else {
setVerifierCheck({ status: 'error', message: '❌ Verifier not running', showInstructions: true });
}
setAllChecksPass(extensionOk && verifierOk);
}, []);
const handleRecheck = useCallback(async () => {
// Recheck extension
setExtensionCheck({ status: 'checking', message: 'Checking...' });
const extensionOk = await checkExtension();
if (extensionOk) {
setExtensionCheck({ status: 'success', message: '✅ Extension installed' });
} else {
setExtensionCheck({ status: 'error', message: '❌ Extension not found' });
}
// Recheck verifier
setVerifierCheck({ status: 'checking', message: 'Checking...', showInstructions: false });
const verifierOk = await checkVerifier();
if (verifierOk) {
setVerifierCheck({ status: 'success', message: '✅ Verifier running', showInstructions: false });
} else {
setVerifierCheck({ status: 'error', message: '❌ Verifier not running', showInstructions: true });
}
setAllChecksPass(extensionOk && verifierOk);
}, []);
const handleRunPlugin = useCallback(
async (pluginKey: string) => {
const plugin = plugins[pluginKey];
if (!plugin) return;
setRunningPlugins((prev) => new Set(prev).add(pluginKey));
setConsoleExpanded(true);
try {
const startTime = performance.now();
const pluginCode = await fetch(plugin.file).then((r) => r.text());
addConsoleEntry('🔧 Executing plugin code...', 'info');
const result = await window.tlsn!.execCode(pluginCode);
const executionTime = (performance.now() - startTime).toFixed(2);
const json: PluginResultType = JSON.parse(result);
setPluginResults((prev) => ({
...prev,
[pluginKey]: {
resultHtml: plugin.parseResult(json),
debugJson: JSON.stringify(json.results, null, 2),
},
}));
addConsoleEntry(`${plugin.name} completed successfully in ${executionTime}ms`, 'success');
} catch (err) {
console.error(err);
addConsoleEntry(`❌ Error: ${err instanceof Error ? err.message : String(err)}`, 'error');
} finally {
setRunningPlugins((prev) => {
const newSet = new Set(prev);
newSet.delete(pluginKey);
return newSet;
});
}
},
[addConsoleEntry]
);
// Listen for tlsn_loaded event
useEffect(() => {
const handleTlsnLoaded = () => {
console.log('TLSNotary client loaded');
addConsoleEntry('TLSNotary client loaded', 'success');
};
window.addEventListener('tlsn_loaded', handleTlsnLoaded);
return () => window.removeEventListener('tlsn_loaded', handleTlsnLoaded);
}, [addConsoleEntry]);
// Listen for offscreen logs
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'TLSN_OFFSCREEN_LOG') {
addConsoleEntry(event.data.message, event.data.level);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [addConsoleEntry]);
// Run checks on mount
useEffect(() => {
addConsoleEntry('TLSNotary Plugin Demo initialized', 'success');
setTimeout(() => {
runAllChecks();
}, 500);
}, [runAllChecks, addConsoleEntry]);
return (
<div className="app-container">
<div className="hero-section">
<h1 className="hero-title">TLSNotary Plugin Demo</h1>
<p className="hero-subtitle">
zkTLS in action secure, private data verification from any website
</p>
</div>
<HowItWorks />
<StatusBar
browserOk={browserCheck.status === 'success'}
extensionOk={extensionCheck.status === 'success'}
verifierOk={verifierCheck.status === 'success'}
onRecheck={handleRecheck}
detailsContent={
<div className="checks-section">
<div className="checks-title">System Status Details</div>
<SystemChecks
checks={{
browser: browserCheck,
extension: extensionCheck,
verifier: verifierCheck,
}}
onRecheck={handleRecheck}
showBrowserWarning={showBrowserWarning}
/>
</div>
}
/>
<div className="content-card">
<h2 className="section-title">Try It: Demo Plugins</h2>
<p className="section-subtitle">
Run a plugin to see TLSNotary in action. Click "View Source" to see how each plugin works.
</p>
{!allChecksPass && (
<div className="alert-box">
<span className="alert-icon"></span>
<span>Complete system setup above to run plugins</span>
</div>
)}
<PluginButtons
plugins={plugins}
runningPlugins={runningPlugins}
pluginResults={pluginResults}
allChecksPass={allChecksPass}
onRunPlugin={handleRunPlugin}
/>
</div>
<WhyPlugins />
<BuildYourOwn />
<CollapsibleSection title="Console Output" expanded={consoleExpanded}>
<ConsoleOutput
entries={consoleEntries}
onClear={handleClearConsole}
onOpenExtensionLogs={handleOpenExtensionLogs}
/>
</CollapsibleSection>
<footer className="app-footer">
<a
href="https://github.com/tlsnotary/tlsn-extension/tree/main/packages/demo"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
View source on GitHub
</a>
<span className="footer-version">{__GIT_COMMIT_HASH__}</span>
</footer>
</div>
);
}
declare const __GIT_COMMIT_HASH__: string;

View File

@@ -1,62 +0,0 @@
export function BuildYourOwn() {
return (
<div className="build-your-own">
<div className="cta-content">
<h2 className="cta-title">Ready to Build Your Own Plugin?</h2>
<p className="cta-description">
Create custom plugins to prove data from any website.
Our SDK and documentation will help you get started in minutes.
</p>
<div className="cta-buttons">
<a
href="https://tlsnotary.org/docs/extension/plugins"
target="_blank"
rel="noopener noreferrer"
className="cta-btn cta-btn-primary"
>
📚 Read the Docs
</a>
<a
href="https://github.com/tlsnotary/tlsn-extension/tree/main/packages/demo/src/plugins"
target="_blank"
rel="noopener noreferrer"
className="cta-btn cta-btn-secondary"
>
💻 View Plugin Sources
</a>
</div>
<div className="cta-resources">
<h4 className="cta-resources-title">Resources</h4>
<ul className="cta-resources-list">
<li>
<a href="https://github.com/tlsnotary/tlsn-extension" target="_blank" rel="noopener noreferrer">
GitHub Repository
<span className="resource-desc"> Extension source code and examples</span>
</a>
</li>
<li>
<a href="https://tlsnotary.org/docs/extension/plugins" target="_blank" rel="noopener noreferrer">
TLSNotary Plugin Documentation
<span className="resource-desc"> Complete protocol and API reference</span>
</a>
</li>
<li>
<a href="https://tlsnotary.org" target="_blank" rel="noopener noreferrer">
TLSNotary
<span className="resource-desc"> TLSNotary landing page</span>
</a>
</li>
<li>
<a href="https://discord.com/invite/9XwESXtcN7" target="_blank" rel="noopener noreferrer">
Discord Community
<span className="resource-desc"> Get help and share your plugins</span>
</a>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { useState, useEffect } from 'react';
interface CollapsibleSectionProps {
title: string;
defaultExpanded?: boolean;
expanded?: boolean;
children: React.ReactNode;
}
export function CollapsibleSection({ title, defaultExpanded = false, expanded, children }: CollapsibleSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
useEffect(() => {
if (expanded !== undefined) {
setIsExpanded(expanded);
}
}, [expanded]);
return (
<div className="collapsible-section">
<button className="collapsible-header" onClick={() => setIsExpanded(!isExpanded)}>
<span className="collapsible-icon">{isExpanded ? '▼' : '▶'}</span>
<span className="collapsible-title">{title}</span>
</button>
{isExpanded && <div className="collapsible-content">{children}</div>}
</div>
);
}

View File

@@ -1,45 +0,0 @@
interface ConsoleEntryProps {
timestamp: string;
message: string;
type: 'info' | 'success' | 'error' | 'warning';
}
export function ConsoleEntry({ timestamp, message, type }: ConsoleEntryProps) {
return (
<div className={`console-entry ${type}`}>
<span className="console-timestamp">[{timestamp}]</span>
<span className="console-message">{message}</span>
</div>
);
}
interface ConsoleOutputProps {
entries: ConsoleEntryProps[];
onClear: () => void;
onOpenExtensionLogs: () => void;
}
export function ConsoleOutput({ entries, onClear, onOpenExtensionLogs }: ConsoleOutputProps) {
return (
<div className="console-section">
<div className="console-header">
<div className="console-title">Console Output</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-console" onClick={onOpenExtensionLogs} style={{ background: '#6c757d' }}>
View Extension Logs
</button>
<button className="btn-console" onClick={onClear}>
Clear
</button>
</div>
</div>
<div className="console-output" id="consoleOutput">
{entries.map((entry, index) => (
<ConsoleEntry key={index} {...entry} />
))}
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
export function HowItWorks() {
return (
<div className="how-it-works">
<h2 className="how-it-works-title">How It Works</h2>
<p className="how-it-works-subtitle">
Experience cryptographic proof generation in three simple steps
</p>
<div className="steps-container">
<div className="step">
<div className="step-number">1</div>
<div className="step-icon">🔌</div>
<h3 className="step-title">Run a Plugin</h3>
<p className="step-description">
Select a plugin and click "Run". A new browser window opens to the target website.
</p>
</div>
<div className="step-arrow"></div>
<div className="step">
<div className="step-number">2</div>
<div className="step-icon">🔐</div>
<h3 className="step-title">Create Proof</h3>
<p className="step-description">
Log in if needed, then click "Prove". TLSNotary creates a cryptographic proof of your data.
</p>
</div>
<div className="step-arrow"></div>
<div className="step">
<div className="step-number">3</div>
<div className="step-icon"></div>
<h3 className="step-title">Verify Result</h3>
<p className="step-description">
The proof is verified by the server. Only the data you chose to reveal is shared.
</p>
</div>
</div>
<div className="how-it-works-note">
<span className="note-icon">💡</span>
<span>
<strong>Your data stays private:</strong> Plugins run inside the TLSNotary extension's secure sandbox.
Data flows through your browser never through third-party servers.
</span>
</div>
</div>
);
}

View File

@@ -1,111 +0,0 @@
import { useState } from 'react';
import { Plugin } from '../types';
interface PluginResultData {
resultHtml: string;
debugJson: string;
}
interface PluginButtonsProps {
plugins: Record<string, Plugin>;
runningPlugins: Set<string>;
pluginResults: Record<string, PluginResultData>;
allChecksPass: boolean;
onRunPlugin: (pluginKey: string) => void;
}
export function PluginButtons({
plugins,
runningPlugins,
pluginResults,
allChecksPass,
onRunPlugin,
}: PluginButtonsProps) {
const [expandedRawData, setExpandedRawData] = useState<Set<string>>(new Set());
const toggleRawData = (key: string) => {
setExpandedRawData((prev) => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
return (
<div className="plugin-grid">
{Object.entries(plugins).map(([key, plugin]) => {
const isRunning = runningPlugins.has(key);
const result = pluginResults[key];
const hasResult = !!result;
return (
<div key={key} className={`plugin-card ${hasResult ? 'plugin-card--completed' : ''}`}>
<div className="plugin-header">
<div className="plugin-logo">{plugin.logo}</div>
<div className="plugin-info">
<h3 className="plugin-name">
{plugin.name}
{hasResult && <span className="plugin-badge"> Verified</span>}
</h3>
<p className="plugin-description">{plugin.description}</p>
</div>
</div>
<div className="plugin-actions">
<button
className="plugin-run-btn"
disabled={!allChecksPass || isRunning}
onClick={() => onRunPlugin(key)}
title={!allChecksPass ? 'Please complete all system checks first' : ''}
>
{isRunning ? (
<>
<span className="spinner"></span> Running...
</>
) : hasResult ? (
'↻ Run Again'
) : (
'▶ Run Plugin'
)}
</button>
<a
href={plugin.file}
target="_blank"
rel="noopener noreferrer"
className="plugin-source-btn"
>
<span>📄 View Source</span>
</a>
</div>
{hasResult && (
<div className="plugin-result">
<div className="plugin-result-header">
<span className="plugin-result-title">Result</span>
</div>
<div
className="plugin-result-content"
dangerouslySetInnerHTML={{ __html: result.resultHtml }}
/>
<button
className="plugin-raw-toggle"
onClick={() => toggleRawData(key)}
>
{expandedRawData.has(key) ? '▼ Hide Raw Data' : '▶ Show Raw Data'}
</button>
{expandedRawData.has(key) && (
<pre className="plugin-raw-data">{result.debugJson}</pre>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,98 +0,0 @@
import { useState } from 'react';
interface StatusBarProps {
browserOk: boolean;
extensionOk: boolean;
verifierOk: boolean;
onRecheck: () => void;
detailsContent?: React.ReactNode;
}
export function StatusBar({
browserOk,
extensionOk,
verifierOk,
onRecheck,
detailsContent,
}: StatusBarProps) {
const [showDetails, setShowDetails] = useState(false);
const allOk = browserOk && extensionOk && verifierOk;
const someIssues = !allOk;
return (
<div className={`status-bar ${allOk ? 'status-ready' : 'status-issues'}`}>
<div className="status-bar-content">
<div className="status-indicator">
{allOk ? (
<>
<span className="status-icon"></span>
<span className="status-text">System Ready</span>
</>
) : (
<>
<span className="status-icon"></span>
<span className="status-text">Setup Required</span>
</>
)}
</div>
<div className="status-items">
<div className={`status-badge ${browserOk ? 'ok' : 'error'}`}>
Browser: {browserOk ? '✓' : '✗'}
</div>
<div className={`status-badge ${extensionOk ? 'ok' : 'error'}`}>
Extension: {extensionOk ? '✓' : '✗'}
</div>
<div className={`status-badge ${verifierOk ? 'ok' : 'error'}`}>
Verifier: {verifierOk ? '✓' : '✗'}
</div>
</div>
<div className="status-actions">
{!verifierOk && (
<button className="btn-recheck" onClick={onRecheck}>
Recheck
</button>
)}
<button
className={`btn-details ${showDetails ? 'expanded' : ''}`}
onClick={() => setShowDetails(!showDetails)}
>
<span className="btn-details-icon">{showDetails ? '▼' : '▶'}</span>
<span>Details</span>
</button>
</div>
</div>
{
someIssues && (
<div className="status-help">
{!browserOk && <div>Please use a Chrome-based browser (Chrome, Edge, Brave)</div>}
{!extensionOk && (
<div>
TLSNotary extension not detected.{' '}
<a href="https://chromewebstore.google.com/detail/tlsnotary/gnoglgpcamodhflknhmafmjdahcejcgg?authuser=2&hl=en" target="_blank" rel="noopener noreferrer">
Install extension
</a>
{' '}then <strong>refresh this page</strong>.
</div>
)}
{!verifierOk && (
<div>
Verifier server not running. Start it with: <code>cd packages/verifier; cargo run --release</code>
</div>
)}
</div>
)
}
{
showDetails && detailsContent && (
<div className="status-details-content">
{detailsContent}
</div>
)
}
</div >
);
}

View File

@@ -1,85 +0,0 @@
import { CheckStatus } from '../types';
interface CheckItemProps {
id: string;
icon: string;
label: string;
status: CheckStatus;
message: string;
showInstructions?: boolean;
onRecheck?: () => void;
}
export function CheckItem({ icon, label, status, message, showInstructions, onRecheck }: CheckItemProps) {
return (
<div className={`check-item ${status}`}>
{icon} {label}: <span className={`status ${status}`}>{message}</span>
{showInstructions && (
<div style={{ marginTop: '10px', fontSize: '14px' }}>
<p>Start the verifier server:</p>
<code>cd packages/verifier; cargo run --release</code>
{onRecheck && (
<button onClick={onRecheck} style={{ marginLeft: '10px', padding: '5px 10px' }}>
Check Again
</button>
)}
</div>
)}
</div>
);
}
interface SystemChecksProps {
checks: {
browser: { status: CheckStatus; message: string };
extension: { status: CheckStatus; message: string };
verifier: { status: CheckStatus; message: string; showInstructions: boolean };
};
onRecheck: () => void;
showBrowserWarning: boolean;
}
export function SystemChecks({ checks, onRecheck, showBrowserWarning }: SystemChecksProps) {
return (
<>
{showBrowserWarning && (
<div className="warning-box">
<h3> Browser Compatibility</h3>
<p>
<strong>Unsupported Browser Detected</strong>
</p>
<p>TLSNotary extension requires a Chrome-based browser (Chrome, Edge, Brave, etc.).</p>
<p>Please switch to a supported browser to continue.</p>
</div>
)}
<div>
<strong>System Checks:</strong>
<CheckItem
id="check-browser"
icon="🌐"
label="Browser"
status={checks.browser.status}
message={checks.browser.message}
/>
<CheckItem
id="check-extension"
icon="🔌"
label="Extension"
status={checks.extension.status}
message={checks.extension.message}
/>
<CheckItem
id="check-verifier"
icon="✅"
label="Verifier"
status={checks.verifier.status}
message={checks.verifier.message}
showInstructions={checks.verifier.showInstructions}
onRecheck={onRecheck}
/>
</div>
</>
);
}

View File

@@ -1,39 +0,0 @@
export function WhyPlugins() {
return (
<div className="why-plugins">
<h2 className="why-plugins-title">Why Plugins?</h2>
<p className="why-plugins-subtitle">
TLSNotary plugins provide a secure, flexible way to prove and verify web data
</p>
<div className="benefits-grid">
<div className="benefit-card">
<div className="benefit-icon">🔒</div>
<h3 className="benefit-title">Secure by Design</h3>
<p className="benefit-description">
Plugins run inside the TLSNotary extension's sandboxed environment.
Your credentials and sensitive data never leave your browser.
</p>
</div>
<div className="benefit-card">
<div className="benefit-icon">👤</div>
<h3 className="benefit-title">User-Controlled</h3>
<p className="benefit-description">
Data flows through the user's browser not third-party servers.
You choose exactly what data to reveal in each proof.
</p>
</div>
<div className="benefit-card">
<div className="benefit-icon"></div>
<h3 className="benefit-title">Easy to Build</h3>
<p className="benefit-description">
Write plugins in JavaScript with a simple API.
Intercept requests, create proofs, and build custom UIs with minimal code.
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
// Environment configuration helper
// Reads from Vite's import.meta.env (populated from .env files)
const VERIFIER_HOST = (import.meta as any).env.VITE_VERIFIER_HOST || 'localhost:7047';
const SSL = (import.meta as any).env.VITE_SSL === 'true';
export const config = {
verifierUrl: `${SSL ? 'https' : 'http'}://${VERIFIER_HOST}`,
getProxyUrl: (host: string) => `${SSL ? 'wss' : 'ws'}://${VERIFIER_HOST}/proxy?token=${host}`,
};

View File

@@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -1,40 +0,0 @@
import { Plugin } from './types';
export const plugins: Record<string, Plugin> = {
twitter: {
name: 'Twitter Profile',
description: 'Prove your Twitter profile information with cryptographic verification',
logo: '𝕏',
file: '/plugins/twitter.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
swissbank: {
name: 'Swiss Bank',
description: 'Verify your Swiss bank account balance securely and privately. (Login: admin / admin)',
logo: '🏦',
file: '/plugins/swissbank.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
spotify: {
name: 'Spotify',
description: 'Prove your Spotify listening history and music preferences',
logo: '🎵',
file: '/plugins/spotify.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
duolingo: {
name: 'Duolingo',
description: 'Prove your Duolingo language learning progress and achievements',
logo: '🦉',
file: '/plugins/duolingo.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
};

View File

@@ -1,45 +0,0 @@
export interface Plugin {
name: string;
description: string;
logo: string;
file: string;
parseResult: (json: PluginResult) => string;
}
export interface PluginResult {
results: Array<{
value: string;
}>;
}
export interface ConsoleEntry {
timestamp: string;
message: string;
type: 'info' | 'success' | 'error' | 'warning';
}
export type CheckStatus = 'checking' | 'success' | 'error';
export interface SystemCheck {
id: string;
label: string;
status: CheckStatus;
message: string;
showInstructions?: boolean;
}
declare global {
interface Window {
tlsn?: {
execCode: (code: string) => Promise<string>;
};
}
interface Navigator {
brave?: {
isBrave: () => Promise<boolean>;
};
}
}
export { };

View File

@@ -1,32 +0,0 @@
import { config } from './config';
export function checkBrowserCompatibility(): boolean {
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isEdge = /Edg/.test(navigator.userAgent);
const isBrave = navigator.brave && typeof navigator.brave.isBrave === 'function';
const isChromium = /Chromium/.test(navigator.userAgent);
return isChrome || isEdge || isBrave || isChromium;
}
export async function checkExtension(): Promise<boolean> {
// Wait a bit for tlsn to load if page just loaded
await new Promise((resolve) => setTimeout(resolve, 1000));
return typeof window.tlsn !== 'undefined';
}
export async function checkVerifier(): Promise<boolean> {
try {
const response = await fetch(`${config.verifierUrl}/health`);
if (response.ok && (await response.text()) === 'ok') {
return true;
}
return false;
} catch {
return false;
}
}
export function formatTimestamp(): string {
return new Date().toLocaleTimeString();
}

31
packages/demo/start.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
#
# Demo Server Startup Script
#
# This script starts the verifier server and demo file server via Docker.
# Note: Run generate.sh first to create plugin files in the generated/ directory.
#
# Usage:
# ./generate.sh && ./start.sh # Generate and start
# ./start.sh # Start only (assumes generated/ exists)
# ./start.sh -d # Start in detached mode
set -e
cd "$(dirname "$0")"
# Check if generated directory exists
if [ ! -d "generated" ]; then
echo "ERROR: generated/ directory not found!"
echo "Please run ./generate.sh first to create plugin files."
exit 1
fi
echo "========================================"
echo "TLSNotary Demo Server"
echo "========================================"
echo "Starting Docker services..."
echo "========================================"
# Start docker compose
docker compose up --build "$@"

View File

@@ -1,69 +1,77 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
const api = 'www.duolingo.com';
const ui = 'https://www.duolingo.com/';
const config = {
name: 'Duolingo Plugin',
description: 'This plugin will prove your email and current streak on Duolingo.',
name: 'Swiss Bank Prover',
description: 'This plugin will prove your Swiss Bank account balance.',
requests: [
{
method: 'GET',
host: 'www.duolingo.com',
pathname: '/2023-05-23/users/*',
verifierUrl: VERIFIER_URL,
host: 'swissbank.tlsnotary.org',
pathname: '/balances',
verifierUrl: 'http://localhost:7047',
},
],
urls: [
'https://www.duolingo.com/*',
'https://swissbank.tlsnotary.org/*',
],
};
const host = 'swissbank.tlsnotary.org';
const ui_path = '/account';
const path = '/balances';
const url = `https://${host}${path}`;
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
// Use cached values from state
const authorization = useState('authorization', null);
const user_id = useState('user_id', null);
if (!authorization || !user_id) {
setState('isRequestPending', false);
return;
}
const [header] = useHeaders(headers => {
console.log('Intercepted headers:', headers);
return headers.filter(header => header.url.includes(`https://${host}`));
});
const headers = {
authorization: authorization,
Host: api,
'cookie': header.requestHeaders.find(header => header.name === 'Cookie')?.value,
Host: host,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: `https://${api}/2023-05-23/users/${user_id}?fields=longestStreak,username`,
url: url,
method: 'GET',
headers: headers,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + api,
maxRecvData: 2400,
maxSentData: 1200,
// Verifier URL: The notary server that verifies the TLS connection
verifierUrl: 'http://localhost:7047',
proxyUrl: 'ws://localhost:7047/proxy?token=swissbank.tlsnotary.org',
// proxyUrl: 'ws://localhost:55688',
maxRecvData: 460, // Maximum bytes to receive from server (response size limit)
maxSentData: 180,// Maximum bytes to send to server (request size limit)
// -----------------------------------------------------------------------
// HANDLERS
// -----------------------------------------------------------------------
// These handlers specify which parts of the TLS transcript to reveal
// in the proof. Unrevealed data is redacted for privacy.
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL', },
{ type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'longestStreak', }, },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL', },
{ type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'account_id' }, },
{ type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'accounts.CHF' }, },
// { type: 'RECV', part: 'ALL', action: 'REVEAL', params: { type: 'regex', regex: '"CHF"\s*:\s*"[^"]+"' }, },
// { type: 'RECV', part: 'ALL', action: 'REVEAL', params: { type: 'regex', regex: '"CHF"\s*:' }, },
// { type: 'RECV', part: 'ALL', action: 'REVEAL', params: { type: 'regex', regex: '"275_000_000"' }, },
]
}
);
// Step 4: Complete plugin execution and return the proof result
// done() signals that the plugin has finished and passes the result back
done(JSON.stringify(resp));
}
@@ -74,39 +82,23 @@ function expandUI() {
function minimizeUI() {
setState('isMinimized', true);
}
function main() {
const [header] = useHeaders(
headers => headers
.filter(header => header.url.includes(`https://${host}${ui_path}`))
);
const hasNecessaryHeader = header?.requestHeaders.some(h => h.name === 'Cookie');
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
const authorization = useState('authorization', null);
const user_id = useState('user_id', null);
// Only search for auth values if not already cached
if (!authorization || !user_id) {
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes(`https://${api}/2023-05-23/users`));
});
const authValue = header?.requestHeaders.find(h => h.name === 'Authorization')?.value;
const traceId = header?.requestHeaders.find(h => h.name === 'X-Amzn-Trace-Id')?.value;
const userIdValue = traceId?.split('=')[1];
if (authValue && !authorization) {
setState('authorization', authValue);
console.log('Authorization found:', authValue);
}
if (userIdValue && !user_id) {
setState('user_id', userIdValue);
console.log('User ID found:', userIdValue);
}
}
const header_has_necessary_values = authorization && user_id;
// Run once on plugin load
useEffect(() => {
openWindow(ui);
openWindow(`https://${host}${ui_path}`);
}, []);
// If minimized, show floating action button
if (isMinimized) {
return div({
style: {
@@ -116,7 +108,7 @@ function main() {
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#58CC02',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
@@ -128,9 +120,11 @@ function main() {
color: 'white',
},
onclick: 'expandUI',
}, ['🦉']);
}, ['🔐']);
}
// Render the plugin UI overlay
// This creates a fixed-position widget in the bottom-right corner
return div({
style: {
position: 'fixed',
@@ -146,9 +140,10 @@ function main() {
overflow: 'hidden',
},
}, [
// Header with minimize button
div({
style: {
background: 'linear-gradient(135deg, #58CC02 0%, #4CAF00 100%)',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
@@ -161,7 +156,7 @@ function main() {
fontWeight: '600',
fontSize: '16px',
}
}, ['Duolingo Streak']),
}, ['Swiss Bank Prover']),
button({
style: {
background: 'transparent',
@@ -180,45 +175,51 @@ function main() {
}, [''])
]),
// Content area
div({
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
}
}, [
// Status indicator showing whether cookie is detected
div({
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header_has_necessary_values ? '#d4edda' : '#f8d7da',
color: header_has_necessary_values ? '#155724' : '#721c24',
border: `1px solid ${header_has_necessary_values ? '#c3e6cb' : '#f5c6cb'}`,
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
}, [
header_has_necessary_values ? '✓ Api token detected' : '⚠ No API token detected'
hasNecessaryHeader ? '✓ Cookie detected' : '⚠ No Cookie detected'
]),
header_has_necessary_values ? (
// Conditional UI based on whether we have intercepted the headers
hasNecessaryHeader ? (
// Show prove button when not pending
button({
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #58CC02 0%, #4CAF00 100%)',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
cursor: isRequestPending ? 'not-allowed' : 'pointer',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
}, [isRequestPending ? 'Generating Proof...' : 'Generate Proof'])
) : (
// Show login message
div({
style: {
textAlign: 'center',
@@ -228,7 +229,7 @@ function main() {
borderRadius: '6px',
border: '1px solid #ffeaa7',
}
}, ['Please login to Duolingo to continue'])
}, ['Please login to continue'])
)
])
]);

View File

@@ -1,33 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -1,15 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"isolatedModules": false
},
"include": [
"src/plugins/**/*.ts",
"src/plugins/plugin-globals.d.ts"
],
"exclude": []
}

361
packages/demo/twitter.js Normal file
View File

@@ -0,0 +1,361 @@
// =============================================================================
// PLUGIN CONFIGURATION
// =============================================================================
/**
* The config object defines plugin metadata displayed to users.
* This information appears in the plugin selection UI.
*/
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/account/settings.json',
verifierUrl: 'http://localhost:7047',
},
],
urls: [
'https://x.com/*',
],
};
// =============================================================================
// PROOF GENERATION CALLBACK
// =============================================================================
/**
* This function is triggered when the user clicks the "Prove" button.
* It extracts authentication headers from intercepted requests and generates
* a TLSNotary proof using the unified prove() API.
*
* Flow:
* 1. Get the intercepted X.com API request headers
* 2. Extract authentication headers (Cookie, CSRF token, OAuth token, etc.)
* 3. Call prove() with the request configuration and reveal handlers
* 4. prove() internally:
* - Creates a prover connection to the verifier
* - Sends the HTTP request through the TLS prover
* - Captures the TLS transcript (sent/received bytes)
* - Parses the transcript with byte-level range tracking
* - Applies selective reveal handlers to show only specified data
* - Generates and returns the cryptographic proof
* 5. Return the proof result to the caller via done()
*/
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
// Step 1: Get the intercepted header from the X.com API request
// useHeaders() provides access to all intercepted HTTP request headers
// We filter for the specific X.com API endpoint we want to prove
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'));
});
// Step 2: Extract authentication headers from the intercepted request
// These headers are required to authenticate with the X.com API
const headers = {
// Cookie: Session authentication token
'cookie': header.requestHeaders.find(header => header.name === 'Cookie')?.value,
// X-CSRF-Token: Cross-Site Request Forgery protection token
'x-csrf-token': header.requestHeaders.find(header => header.name === 'x-csrf-token')?.value,
// X-Client-Transaction-ID: Request tracking identifier
'x-client-transaction-id': header.requestHeaders.find(header => header.name === 'x-client-transaction-id')?.value,
// Host: Target server hostname
Host: 'api.x.com',
// Authorization: OAuth bearer token for API authentication
authorization: header.requestHeaders.find(header => header.name === 'authorization')?.value,
// Accept-Encoding: Must be 'identity' for TLSNotary (no compression)
// TLSNotary requires uncompressed data to verify byte-for-byte
'Accept-Encoding': 'identity',
// Connection: Use 'close' to complete the connection after one request
Connection: 'close',
};
// Step 3: Generate TLS proof using the unified prove() API
// This single function handles the entire proof generation pipeline
const resp = await prove(
// -------------------------------------------------------------------------
// REQUEST OPTIONS
// -------------------------------------------------------------------------
// Defines the HTTP request to be proven
{
url: 'https://api.x.com/1.1/account/settings.json', // Target API endpoint
method: 'GET', // HTTP method
headers: headers, // Authentication headers
},
// -------------------------------------------------------------------------
// PROVER OPTIONS
// -------------------------------------------------------------------------
// Configures the TLS proof generation process
{
// Verifier URL: The notary server that verifies the TLS connection
// Must be running locally or accessible at this address
verifierUrl: 'http://localhost:7047',
// Proxy URL: WebSocket proxy that relays TLS data to the target server
// The token parameter specifies which server to connect to
proxyUrl: 'ws://localhost:7047/proxy?token=api.x.com',
// Maximum bytes to receive from server (response size limit)
maxRecvData: 4000,
// Maximum bytes to send to server (request size limit)
maxSentData: 2000,
// -----------------------------------------------------------------------
// HANDLERS
// -----------------------------------------------------------------------
// These handlers specify which parts of the TLS transcript to reveal
// in the proof. Unrevealed data is redacted for privacy.
handlers: [
// Reveal the request start line (GET /path HTTP/1.1)
// This proves the HTTP method and path were sent
{
type: 'SENT', // Direction: data sent to server
part: 'START_LINE', // Part: HTTP request line
action: 'REVEAL', // Action: include as plaintext in proof
},
// Reveal the response start line (HTTP/1.1 200 OK)
// This proves the server responded with status code 200
{
type: 'RECV', // Direction: data received from server
part: 'START_LINE', // Part: HTTP response line
action: 'REVEAL', // Action: include as plaintext in proof
},
// Reveal the 'date' header from the response
// This proves when the server generated the response
{
type: 'RECV', // Direction: data received from server
part: 'HEADERS', // Part: HTTP headers
action: 'REVEAL', // Action: include as plaintext in proof
params: {
key: 'date', // Specific header to reveal
},
},
// Reveal the 'screen_name' field from the JSON response body
// This proves the X.com username without revealing other profile data
{
type: 'RECV', // Direction: data received from server
part: 'BODY', // Part: HTTP response body
action: 'REVEAL', // Action: include as plaintext in proof
params: {
type: 'json', // Body format: JSON
path: 'screen_name', // JSON field to reveal (top-level only)
},
},
]
}
);
// Step 4: Complete plugin execution and return the proof result
// done() signals that the plugin has finished and passes the result back
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
// =============================================================================
// MAIN UI FUNCTION
// =============================================================================
/**
* The main() function is called reactively whenever plugin state changes.
* It returns a DOM structure that is rendered as the plugin UI.
*
* React-like Hooks Used:
* - useHeaders(): Subscribes to intercepted HTTP request headers
* - useEffect(): Runs side effects when dependencies change
*
* UI Flow:
* 1. Check if X.com API request headers have been intercepted
* 2. If not intercepted yet: Show "Please login" message
* 3. If intercepted: Show "Profile detected" with a "Prove" button
* 4. On first render: Open X.com in a new window to trigger login
*/
function main() {
// Subscribe to intercepted headers for the X.com API endpoint
// This will reactively update whenever new headers matching the filter arrive
const [header] = useHeaders(headers => headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json')));
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
// Run once on plugin load: Open X.com in a new window
// The empty dependency array [] means this runs only once
// The opened window's requests will be intercepted by the plugin
useEffect(() => {
openWindow('https://x.com');
}, []);
// If minimized, show floating action button
if (isMinimized) {
return div({
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
}, ['🔐']);
}
// Render the plugin UI overlay
// This creates a fixed-position widget in the bottom-right corner
return div({
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
}, [
// Header with minimize button
div({
style: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
}
}, [
div({
style: {
fontWeight: '600',
fontSize: '16px',
}
}, ['X Profile Prover']),
button({
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
}, [''])
]),
// Content area
div({
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
}
}, [
// Status indicator showing whether profile is detected
div({
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
}, [
header ? '✓ Profile detected' : '⚠ No profile detected'
]),
// Conditional UI based on whether we have intercepted the headers
header ? (
// Show prove button when not pending
button({
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
}, [isRequestPending ? 'Generating Proof...' : 'Generate Proof'])
) : (
// Show login message
div({
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
}
}, ['Please login to x.com to continue'])
)
])
]);
}
// =============================================================================
// PLUGIN EXPORTS
// =============================================================================
/**
* All plugins must export an object with these properties:
* - main: The reactive UI rendering function
* - onClick: Click handler callback for buttons
* - config: Plugin metadata
*/
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,23 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Get git commit hash from GIT_HASH env var (set by CI/Docker) or fallback to 'local'
const gitHash = process.env.GIT_HASH || 'local';
export default defineConfig({
define: {
__GIT_COMMIT_HASH__: JSON.stringify(gitHash),
},
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: true,
},
server: {
port: 3000,
open: true,
headers: {
'Cache-Control': 'no-store',
},
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "extension",
"version": "0.1.0.1400",
"version": "0.1.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -20,13 +20,13 @@
"serve:test": "python3 -m http.server 8081 --directory ./tests/integration"
},
"dependencies": {
"@tlsn/common": "*",
"@tlsn/plugin-sdk": "*",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.6",
"@fortawesome/fontawesome-free": "^6.4.2",
"@tlsn/common": "*",
"@tlsn/plugin-sdk": "*",
"@uiw/react-codemirror": "^4.25.2",
"assert": "^2.1.0",
"buffer": "^6.0.3",
@@ -47,6 +47,7 @@
"redux-thunk": "^2.4.2",
"stream-browserify": "^3.0.0",
"tailwindcss": "^3.3.3",
"tlsn-js": "^0.1.0-alpha.12.0",
"tlsn-wasm": "./lib/tlsn-wasm-pkg/",
"util": "^0.12.5"
},
@@ -83,7 +84,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"happy-dom": "^20.0.11",
"happy-dom": "^19.0.1",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"null-loader": "^4.0.1",
@@ -106,7 +107,7 @@
"webextension-polyfill": "^0.10.0",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^5.2.2",
"webpack-dev-server": "^4.11.1",
"webpack-ext-reloader": "^1.1.12",
"zip-webpack-plugin": "^4.0.1"
}

View File

@@ -2,9 +2,14 @@ import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { PluginConfig } from '@tlsn/plugin-sdk/src/types';
interface ConfirmationResult {
allowed: boolean;
grantedOrigins: string[];
}
interface PendingConfirmation {
requestId: string;
resolve: (allowed: boolean) => void;
resolve: (result: ConfirmationResult) => void;
reject: (error: Error) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
@@ -34,15 +39,16 @@ export class ConfirmationManager {
/**
* Request confirmation from the user for plugin execution.
* Opens a popup window displaying plugin details and waits for user response.
* The popup also handles requesting host permissions from the browser.
*
* @param config - Plugin configuration (can be null for unknown plugins)
* @param requestId - Unique ID to correlate the confirmation request
* @returns Promise that resolves to true (allowed) or false (denied)
* @returns Promise that resolves to ConfirmationResult with allowed status and granted origins
*/
async requestConfirmation(
config: PluginConfig | null,
requestId: string,
): Promise<boolean> {
): Promise<ConfirmationResult> {
// Check if there's already a pending confirmation
if (this.pendingConfirmations.size > 0) {
logger.warn(
@@ -54,7 +60,7 @@ export class ConfirmationManager {
// Build URL with plugin info as query params
const popupUrl = this.buildPopupUrl(config, requestId);
return new Promise<boolean>(async (resolve, reject) => {
return new Promise<ConfirmationResult>(async (resolve, reject) => {
try {
// Create the confirmation popup window
const window = await browser.windows.create({
@@ -77,7 +83,7 @@ export class ConfirmationManager {
if (pending) {
logger.debug('[ConfirmationManager] Confirmation timed out');
this.cleanup(requestId);
resolve(false); // Treat timeout as denial
resolve({ allowed: false, grantedOrigins: [] }); // Treat timeout as denial
}
}, this.CONFIRMATION_TIMEOUT_MS);
@@ -109,8 +115,13 @@ export class ConfirmationManager {
*
* @param requestId - The request ID to match
* @param allowed - Whether the user allowed execution
* @param grantedOrigins - Origins that were granted by the browser (from popup's permission request)
*/
handleConfirmationResponse(requestId: string, allowed: boolean): void {
handleConfirmationResponse(
requestId: string,
allowed: boolean,
grantedOrigins: string[] = [],
): void {
const pending = this.pendingConfirmations.get(requestId);
if (!pending) {
logger.warn(
@@ -120,11 +131,11 @@ export class ConfirmationManager {
}
logger.debug(
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}`,
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}, origins: ${grantedOrigins.length}`,
);
// Resolve the promise
pending.resolve(allowed);
// Resolve the promise with the result
pending.resolve({ allowed, grantedOrigins });
// Close popup window if still open
if (pending.windowId) {
@@ -154,7 +165,7 @@ export class ConfirmationManager {
logger.debug(
`[ConfirmationManager] Treating window close as denial for request: ${requestId}`,
);
pending.resolve(false); // Treat close as denial
pending.resolve({ allowed: false, grantedOrigins: [] }); // Treat close as denial
this.cleanup(requestId);
break;
}

View File

@@ -0,0 +1,306 @@
import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { RequestPermission } from '@tlsn/plugin-sdk/src/types';
/**
* Represents a permission pattern with metadata for display.
*/
interface PermissionPattern {
/** Browser permission pattern (e.g., "https://api.x.com/*") */
origin: string;
/** Original host from config */
host: string;
/** Original pathname pattern from config */
pathname: string;
}
/**
* Manages runtime host permissions for plugin execution.
*
* The extension uses optional_host_permissions instead of blanket host_permissions.
* Permissions are requested before plugin execution and revoked after completion.
*/
export class PermissionManager {
/** Track permissions currently in use by active plugin executions */
private activePermissions: Map<string, number> = new Map();
/**
* Extract permission patterns from plugin's request permissions.
* Uses req.host and req.pathname to build patterns.
*
* Note: Browser permissions API only supports origin-level permissions,
* but we track pathname for display and internal validation.
*
* @example
* // Input: { host: "api.x.com", pathname: "/1.1/users/*" }
* // Output: { origin: "https://api.x.com/*", host: "api.x.com", pathname: "/1.1/users/*" }
*/
extractPermissionPatterns(
requests: RequestPermission[],
): PermissionPattern[] {
const patterns: PermissionPattern[] = [];
const seenOrigins = new Set<string>();
for (const req of requests) {
// Build origin pattern from req.host
const origin = `https://${req.host}/*`;
if (!seenOrigins.has(origin)) {
seenOrigins.add(origin);
patterns.push({
origin,
host: req.host,
pathname: req.pathname,
});
}
// Also add the verifier URL host if different
try {
const verifierUrl = new URL(req.verifierUrl);
const verifierOrigin = `${verifierUrl.protocol}//${verifierUrl.host}/*`;
if (!seenOrigins.has(verifierOrigin)) {
seenOrigins.add(verifierOrigin);
patterns.push({
origin: verifierOrigin,
host: verifierUrl.host,
pathname: '/*', // Verifier needs full access
});
}
} catch {
// Invalid verifier URL, skip
}
}
return patterns;
}
/**
* Extract just the origin patterns for browser.permissions API.
* Uses req.host to build origin-level patterns.
*/
extractOrigins(requests: RequestPermission[]): string[] {
return this.extractPermissionPatterns(requests).map((p) => p.origin);
}
/**
* Format permissions for display in UI.
* Shows both host and pathname for user clarity.
*/
formatForDisplay(requests: RequestPermission[]): string[] {
return requests.map((req) => `${req.host}${req.pathname}`);
}
/**
* Request permissions for the given host patterns.
* Tracks active permissions to handle concurrent plugin executions.
*
* @returns true if all permissions granted, false otherwise
*/
async requestPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
// Check if already have permissions
const alreadyGranted = await this.hasPermissions(origins);
if (alreadyGranted) {
logger.debug(
'[PermissionManager] Permissions already granted for:',
origins,
);
// Track that we're using these permissions
this.trackPermissionUsage(origins, 1);
return true;
}
// Request new permissions
const granted = await browser.permissions.request({ origins });
logger.info(
`[PermissionManager] Permissions ${granted ? 'granted' : 'denied'} for:`,
origins,
);
if (granted) {
this.trackPermissionUsage(origins, 1);
}
return granted;
} catch (error) {
logger.error('[PermissionManager] Failed to request permissions:', error);
return false;
}
}
/**
* Check if an origin is removable (not a manifest-defined wildcard pattern).
* Manifest patterns like "http://*\/*" and "https://*\/*" cannot be removed.
*/
private isRemovableOrigin(origin: string): boolean {
// Manifest-defined patterns that cannot be removed
const manifestPatterns = ['http://*/*', 'https://*/*', '<all_urls>'];
if (manifestPatterns.includes(origin)) {
return false;
}
// Check if host contains wildcards (not removable)
try {
const url = new URL(origin.replace('/*', '/'));
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Remove permissions for the given host patterns.
* Only removes if no other plugin execution is using them.
* Filters out non-removable (manifest-defined) patterns.
*/
async removePermissions(origins: string[]): Promise<boolean> {
logger.info('[PermissionManager] removePermissions called with:', origins);
if (origins.length === 0) {
logger.info('[PermissionManager] No origins to remove (empty array)');
return true;
}
// Decrement usage count
this.trackPermissionUsage(origins, -1);
// Only remove permissions that are no longer in use AND are removable
logger.info('[PermissionManager] Filtering origins for removal...');
const originsToRemove = origins.filter((origin) => {
const count = this.activePermissions.get(origin) || 0;
const notInUse = count <= 0;
const removable = this.isRemovableOrigin(origin);
logger.info(
`[PermissionManager] Origin "${origin}": count=${count}, notInUse=${notInUse}, removable=${removable}`,
);
if (!removable) {
logger.debug(
`[PermissionManager] Skipping non-removable origin: ${origin}`,
);
}
return notInUse && removable;
});
logger.info(
`[PermissionManager] After filtering: ${originsToRemove.length} origins to remove:`,
originsToRemove,
);
if (originsToRemove.length === 0) {
logger.info(
'[PermissionManager] No removable permissions to remove from:',
origins,
);
return true;
}
try {
// Verify which permissions actually exist before removal
const existingPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Current permissions before removal:',
existingPermissions.origins,
);
// Filter to only origins that actually exist
const existingOrigins = new Set(existingPermissions.origins || []);
const originsActuallyExist = originsToRemove.filter((o) =>
existingOrigins.has(o),
);
if (originsActuallyExist.length === 0) {
logger.info(
'[PermissionManager] None of the origins to remove actually exist, skipping',
);
return true;
}
logger.info(
'[PermissionManager] Calling browser.permissions.remove() for:',
originsActuallyExist,
);
const removed = await browser.permissions.remove({
origins: originsActuallyExist,
});
logger.info(
`[PermissionManager] browser.permissions.remove() returned: ${removed}`,
);
// Log permissions after removal
const afterPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Permissions after removal:',
afterPermissions.origins,
);
return removed;
} catch (error) {
// Handle "required permissions" error gracefully
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
`[PermissionManager] browser.permissions.remove() threw error: ${errorMessage}`,
);
if (errorMessage.includes('required permissions')) {
logger.warn(
'[PermissionManager] Some permissions are required and cannot be removed:',
originsToRemove,
);
return true; // Don't treat as failure
}
logger.error('[PermissionManager] Failed to remove permissions:', error);
return false;
}
}
/**
* Check if permissions are already granted for the given origins.
*/
async hasPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
return await browser.permissions.contains({ origins });
} catch (error) {
logger.error('[PermissionManager] Failed to check permissions:', error);
return false;
}
}
/**
* Track permission usage for concurrent plugin executions.
* @param origins - Origins to track
* @param delta - +1 for acquire, -1 for release
*/
private trackPermissionUsage(origins: string[], delta: number): void {
for (const origin of origins) {
const current = this.activePermissions.get(origin) || 0;
const newCount = current + delta;
if (newCount <= 0) {
this.activePermissions.delete(origin);
} else {
this.activePermissions.set(origin, newCount);
}
}
}
/**
* Get the number of active usages for an origin.
* Useful for debugging and testing.
*/
getActiveUsageCount(origin: string): number {
return this.activePermissions.get(origin) || 0;
}
}
// Export singleton instance
export const permissionManager = new PermissionManager();

View File

@@ -116,6 +116,7 @@ export class WindowManager implements IWindowManager {
overlayVisible: false,
pluginUIVisible: false,
showOverlayWhenReady: config.showOverlay !== false, // Default: true
grantedOrigins: [],
};
this.windows.set(config.id, managedWindow);
@@ -363,6 +364,38 @@ export class WindowManager implements IWindowManager {
return window?.headers || [];
}
/**
* Set granted origins for a window (for cleanup on close)
*
* @param windowId - Chrome window ID
* @param origins - Array of origin patterns that were granted
*/
setGrantedOrigins(windowId: number, origins: string[]): void {
const window = this.windows.get(windowId);
if (!window) {
logger.error(
`[WindowManager] Cannot set granted origins for non-existent window: ${windowId}`,
);
return;
}
window.grantedOrigins = origins;
logger.debug(
`[WindowManager] Set granted origins for window ${windowId}:`,
origins,
);
}
/**
* Get granted origins for a window
*
* @param windowId - Chrome window ID
* @returns Array of granted origin patterns
*/
getGrantedOrigins(windowId: number): string[] {
const window = this.windows.get(windowId);
return window?.grantedOrigins || [];
}
async showPluginUI(
windowId: number,
json: any,

View File

@@ -1,6 +1,7 @@
import browser from 'webextension-polyfill';
import { WindowManager } from '../../background/WindowManager';
import { confirmationManager } from '../../background/ConfirmationManager';
import { permissionManager } from '../../background/PermissionManager';
import type { PluginConfig } from '@tlsn/plugin-sdk/src/types';
import type {
InterceptedRequest,
@@ -21,6 +22,187 @@ getStoredLogLevel().then((level) => {
// Initialize WindowManager for multi-window support
const windowManager = new WindowManager();
// Temporary storage for granted origins pending window creation
// When a plugin is executed, we store the granted origins here
// Then when the plugin opens a window, we associate these origins with that window
let pendingGrantedOrigins: string[] = [];
// =============================================================================
// DYNAMIC WEBREQUEST LISTENER MANAGEMENT
// =============================================================================
// Track active dynamic listeners by origin pattern
// We need to store handler references to remove them later
type ListenerHandlers = {
onBeforeRequest: (
details: browser.WebRequest.OnBeforeRequestDetailsType,
) => void;
onBeforeSendHeaders: (
details: browser.WebRequest.OnBeforeSendHeadersDetailsType,
) => void;
};
const dynamicListeners = new Map<string, ListenerHandlers>();
/**
* Handler for onBeforeRequest - intercepts request body
*/
function createOnBeforeRequestHandler() {
return (details: browser.WebRequest.OnBeforeRequestDetailsType) => {
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
requestBody: details.requestBody,
};
logger.debug(`[webRequest] Intercepted request: ${details.url}`);
windowManager.addRequest(managedWindow.id, request);
}
};
}
/**
* Handler for onBeforeSendHeaders - intercepts request headers
*/
function createOnBeforeSendHeadersHandler() {
return (details: browser.WebRequest.OnBeforeSendHeadersDetailsType) => {
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const header: InterceptedRequestHeader = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: details.timeStamp,
type: details.type,
requestHeaders: details.requestHeaders || [],
tabId: details.tabId,
};
logger.debug(`[webRequest] Intercepted headers for: ${details.url}`);
windowManager.addHeader(managedWindow.id, header);
}
};
}
/**
* Register dynamic webRequest listeners for specific origin patterns.
* Must be called AFTER permissions are granted for those origins.
*/
function registerDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] registerDynamicListeners called with:`, origins);
for (const origin of origins) {
// Skip if already registered
if (dynamicListeners.has(origin)) {
logger.debug(`[webRequest] Listener already registered for: ${origin}`);
continue;
}
logger.info(`[webRequest] Registering listener for: "${origin}"`);
const onBeforeRequestHandler = createOnBeforeRequestHandler();
const onBeforeSendHeadersHandler = createOnBeforeSendHeadersHandler();
try {
browser.webRequest.onBeforeRequest.addListener(
onBeforeRequestHandler,
{ urls: [origin] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
onBeforeSendHeadersHandler,
{ urls: [origin] },
['requestHeaders', 'extraHeaders'],
);
dynamicListeners.set(origin, {
onBeforeRequest: onBeforeRequestHandler,
onBeforeSendHeaders: onBeforeSendHeadersHandler,
});
logger.info(
`[webRequest] Successfully registered listener for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to register listener for ${origin}:`,
error,
);
}
}
}
/**
* Unregister dynamic webRequest listeners for specific origin patterns.
* Should be called when permissions are revoked.
*/
function unregisterDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] unregisterDynamicListeners called with:`, origins);
logger.info(
`[webRequest] Current dynamicListeners Map keys:`,
Array.from(dynamicListeners.keys()),
);
for (const origin of origins) {
logger.info(`[webRequest] Looking for listener with key: "${origin}"`);
const handlers = dynamicListeners.get(origin);
if (!handlers) {
logger.warn(
`[webRequest] No listener found for: "${origin}" - available keys: ${Array.from(dynamicListeners.keys()).join(', ')}`,
);
continue;
}
logger.info(`[webRequest] Found handlers for: ${origin}, removing...`);
try {
browser.webRequest.onBeforeRequest.removeListener(
handlers.onBeforeRequest,
);
logger.info(
`[webRequest] Removed onBeforeRequest listener for: ${origin}`,
);
browser.webRequest.onBeforeSendHeaders.removeListener(
handlers.onBeforeSendHeaders,
);
logger.info(
`[webRequest] Removed onBeforeSendHeaders listener for: ${origin}`,
);
dynamicListeners.delete(origin);
logger.info(
`[webRequest] Successfully unregistered all listeners for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to unregister listener for ${origin}:`,
error,
);
}
}
logger.info(
`[webRequest] After unregister, remaining keys:`,
Array.from(dynamicListeners.keys()),
);
}
/**
* Unregister all dynamic webRequest listeners.
*/
function unregisterAllDynamicListeners(): void {
const origins = Array.from(dynamicListeners.keys());
unregisterDynamicListeners(origins);
}
// Create context menu for Developer Console - only for extension icon
browser.contextMenus.create({
id: 'developer-console',
@@ -43,88 +225,88 @@ browser.runtime.onInstalled.addListener((details) => {
logger.info('Extension installed/updated:', details.reason);
});
// Set up webRequest listener to intercept all requests
browser.webRequest.onBeforeRequest.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
// NOTE: Static webRequest listeners removed - using dynamic listeners instead
// Dynamic listeners are registered when plugin permissions are granted
// and unregistered when permissions are revoked
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
requestBody: details.requestBody,
};
// if (details.requestBody) {
// console.log(details.requestBody);
// }
// Add request to window's request history
windowManager.addRequest(managedWindow.id, request);
}
},
{ urls: ['<all_urls>'] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const header: InterceptedRequestHeader = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: details.timeStamp,
type: details.type,
requestHeaders: details.requestHeaders || [],
tabId: details.tabId,
};
// Add request to window's request history
windowManager.addHeader(managedWindow.id, header);
}
},
{ urls: ['<all_urls>'] },
['requestHeaders', 'extraHeaders'],
);
// Listen for window removal
// Listen for window removal - clean up permissions and listeners
browser.windows.onRemoved.addListener(async (windowId) => {
const managedWindow = windowManager.getWindow(windowId);
if (managedWindow) {
logger.debug(
`Managed window closed: ${managedWindow.uuid} (ID: ${windowId})`,
);
// Get granted origins before closing the window
const grantedOrigins = managedWindow.grantedOrigins || [];
logger.info(
`[Permission Cleanup] Window ${windowId} grantedOrigins:`,
grantedOrigins,
);
logger.info(
`[Permission Cleanup] Current dynamicListeners keys:`,
Array.from(dynamicListeners.keys()),
);
// Clean up permissions and listeners for this window
if (grantedOrigins.length > 0) {
logger.info(
`[Permission Cleanup] Window ${windowId} closed, cleaning up ${grantedOrigins.length} origins:`,
grantedOrigins,
);
// Step 1: Unregister dynamic webRequest listeners FIRST
logger.info(
'[Permission Cleanup] Step 1: Unregistering dynamic listeners...',
);
unregisterDynamicListeners(grantedOrigins);
logger.info(
'[Permission Cleanup] Step 1 complete. Remaining dynamicListeners:',
Array.from(dynamicListeners.keys()),
);
// Step 2: Revoke host permissions AFTER listeners are removed
logger.info('[Permission Cleanup] Step 2: Revoking host permissions...');
try {
const removed =
await permissionManager.removePermissions(grantedOrigins);
logger.info(
`[Permission Cleanup] Step 2 complete. Permissions removed: ${removed}`,
);
} catch (error) {
logger.error(
`[Permission Cleanup] Step 2 FAILED for window ${windowId}:`,
error,
);
}
} else {
logger.info(
`[Permission Cleanup] Window ${windowId} has no granted origins to clean up`,
);
}
await windowManager.closeWindow(windowId);
}
});
// Listen for tab updates to show overlay when tab is ready (Task 3.4)
// Listen for tab updates to show overlay when tab is ready
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
// Only act when tab becomes complete
if (changeInfo.status !== 'complete') {
return;
}
// Check if this tab belongs to a managed window
// Check if this tab belongs to a managed window for overlay handling
const managedWindow = windowManager.getWindowByTabId(tabId);
if (!managedWindow) {
return;
}
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
if (managedWindow) {
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
}
}
});
@@ -162,6 +344,7 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
confirmationManager.handleConfirmationResponse(
request.requestId,
request.allowed,
request.grantedOrigins || [],
);
return true;
}
@@ -182,12 +365,12 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
// Continue with null config - user will see "Unknown Plugin" warning
}
// Step 2: Request user confirmation
// Step 2: Request user confirmation (popup also handles permission request)
const confirmRequestId = `confirm_${Date.now()}_${Math.random()}`;
let userAllowed: boolean;
let confirmResult: { allowed: boolean; grantedOrigins: string[] };
try {
userAllowed = await confirmationManager.requestConfirmation(
confirmResult = await confirmationManager.requestConfirmation(
pluginConfig,
confirmRequestId,
);
@@ -204,8 +387,8 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
}
// Step 3: If user denied, return rejection error
if (!userAllowed) {
logger.info('User rejected plugin execution');
if (!confirmResult.allowed) {
logger.info('User rejected plugin execution or denied permissions');
sendResponse({
success: false,
error: 'User rejected plugin execution',
@@ -213,13 +396,36 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return;
}
// Step 4: User allowed - proceed with execution
logger.info('User allowed plugin execution, proceeding...');
// Step 4: User allowed and permissions granted - proceed with execution
logger.info(
'User allowed plugin execution, granted origins:',
confirmResult.grantedOrigins,
);
// Ensure offscreen document exists
// Step 4.1: Register dynamic webRequest listeners for granted origins
// This must happen AFTER permissions are granted
if (confirmResult.grantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Registering dynamic webRequest listeners for:',
confirmResult.grantedOrigins,
);
registerDynamicListeners(confirmResult.grantedOrigins);
// Store granted origins for association with the window that will be opened
// The OPEN_WINDOW handler will associate these with the new window
pendingGrantedOrigins = confirmResult.grantedOrigins;
logger.info(
'[EXEC_CODE] Stored pendingGrantedOrigins:',
pendingGrantedOrigins,
);
}
// Step 4.2: Execute plugin
// Note: Cleanup happens when the window is closed (windows.onRemoved listener)
// NOT in a finally block here, because EXEC_CODE_OFFSCREEN returns immediately
// when the plugin starts, not when it finishes
await createOffscreenDocument();
// Forward to offscreen document
const response = await chrome.runtime.sendMessage({
type: 'EXEC_CODE_OFFSCREEN',
code: request.code,
@@ -229,6 +435,18 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
sendResponse(response);
} catch (error) {
logger.error('Error executing code:', error);
// Clean up listeners and pending origins if execution failed before window opened
if (pendingGrantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Error occurred - cleaning up pending origins:',
pendingGrantedOrigins,
);
unregisterDynamicListeners(pendingGrantedOrigins);
await permissionManager.removePermissions(pendingGrantedOrigins);
pendingGrantedOrigins = [];
}
sendResponse({
success: false,
error:
@@ -240,6 +458,55 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return true; // Keep message channel open for async response
}
// Handle permission requests from offscreen/content scripts
if (request.type === 'REQUEST_HOST_PERMISSIONS') {
logger.debug('REQUEST_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const granted = await permissionManager.requestPermissions(
request.origins,
);
sendResponse({ success: true, granted });
} catch (error) {
logger.error('Failed to request permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission request failed',
});
}
})();
return true;
}
if (request.type === 'REMOVE_HOST_PERMISSIONS') {
logger.debug('REMOVE_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const removed = await permissionManager.removePermissions(
request.origins,
);
sendResponse({ success: true, removed });
} catch (error) {
logger.error('Failed to remove permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission removal failed',
});
}
})();
return true;
}
// Handle CLOSE_WINDOW requests
if (request.type === 'CLOSE_WINDOW') {
logger.debug('CLOSE_WINDOW request received:', request.windowId);
@@ -334,6 +601,30 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
logger.debug(`Window registered: ${managedWindow.uuid}`);
// Associate pending granted origins with this window
// These will be cleaned up when the window is closed
logger.info(
`[OPEN_WINDOW] pendingGrantedOrigins at association time:`,
pendingGrantedOrigins,
);
if (pendingGrantedOrigins.length > 0) {
logger.info(
`[OPEN_WINDOW] Associating ${pendingGrantedOrigins.length} origins with window ${windowId}:`,
pendingGrantedOrigins,
);
windowManager.setGrantedOrigins(windowId, pendingGrantedOrigins);
logger.info(
`[OPEN_WINDOW] Successfully associated origins. Window ${windowId} now has grantedOrigins:`,
windowManager.getGrantedOrigins(windowId),
);
// Clear pending origins now that they're associated
pendingGrantedOrigins = [];
} else {
logger.warn(
`[OPEN_WINDOW] No pending origins to associate with window ${windowId}`,
);
}
// Send success response
sendResponse({
type: 'WINDOW_OPENED',

View File

@@ -24,6 +24,48 @@ interface PluginInfo {
urls?: string[];
}
/**
* Extract origin patterns for browser.permissions API and webRequest interception.
*
* Includes:
* - requests[].host: API hosts for webRequest interception (e.g., api.x.com)
* - urls[]: Page URLs for webRequest interception (e.g., x.com/*)
*
* Does NOT include:
* - verifierUrl: Extension connects to verifier directly, doesn't need host permission
*
* NOTE: Page URL permissions may become "required" if they match content_scripts.matches,
* but API host permissions (like api.x.com) should remain revocable.
*
* @param requests - Request permissions from plugin config (for API endpoints)
* @param urls - Page URL patterns from plugin config
*/
function extractOrigins(
requests: RequestPermission[],
urls?: string[],
): string[] {
const origins = new Set<string>();
// Add target API hosts from requests
for (const req of requests) {
origins.add(`https://${req.host}/*`);
}
// Add page URLs for webRequest interception
if (urls) {
for (const urlPattern of urls) {
if (
urlPattern.startsWith('https://') ||
urlPattern.startsWith('http://')
) {
origins.add(urlPattern);
}
}
}
return Array.from(origins);
}
const ConfirmPopup: React.FC = () => {
const [pluginInfo, setPluginInfo] = useState<PluginInfo | null>(null);
const [requestId, setRequestId] = useState<string>('');
@@ -109,16 +151,64 @@ const ConfirmPopup: React.FC = () => {
if (!requestId) return;
try {
let grantedOrigins: string[] = [];
// Request host permissions if plugin has requests or urls defined
// This MUST be done in the popup context (user gesture) for the browser to show the prompt
const hasRequests =
pluginInfo?.requests && pluginInfo.requests.length > 0;
const hasUrls = pluginInfo?.urls && pluginInfo.urls.length > 0;
if (hasRequests || hasUrls) {
const origins = extractOrigins(
pluginInfo?.requests || [],
pluginInfo?.urls,
);
logger.info('Requesting permissions for origins:', origins);
try {
const granted = await browser.permissions.request({ origins });
if (!granted) {
logger.warn('User denied host permissions');
// Send denial response
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Host permissions denied',
});
window.close();
return;
}
grantedOrigins = origins;
logger.info('Host permissions granted:', grantedOrigins);
} catch (permError) {
logger.error('Failed to request permissions:', permError);
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Permission request failed',
});
window.close();
return;
}
}
// Send approval with granted origins
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: true,
grantedOrigins,
});
window.close();
} catch (err) {
logger.error('Failed to send allow response:', err);
}
}, [requestId]);
}, [requestId, pluginInfo]);
const handleDeny = useCallback(async () => {
if (!requestId) return;

View File

@@ -36,10 +36,23 @@ body {
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 1s linear infinite;
&--small {
width: 24px;
height: 24px;
border-width: 2px;
}
&--tiny {
width: 14px;
height: 14px;
border-width: 2px;
display: inline-block;
}
}
&__header {
margin-bottom: 32px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
@@ -53,6 +66,36 @@ body {
}
}
// Tabs
&__tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
&__tab {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #a0a0a0;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: #e8e8e8;
}
&--active {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.4);
color: #4a9eff;
}
}
&__content {
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
@@ -186,6 +229,211 @@ body {
font-family: 'Monaco', 'Menlo', monospace;
}
// Permissions styles
&__permissions-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 24px;
color: #a0a0a0;
}
&__permissions-empty {
text-align: center;
padding: 40px 24px;
color: #a0a0a0;
p {
margin-top: 8px;
}
}
&__permissions-empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
&__permissions-empty-hint {
font-size: 13px;
color: #666;
margin-top: 8px;
}
&__permissions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
&__permissions-count {
font-size: 14px;
color: #a0a0a0;
}
&__delete-all-btn {
padding: 6px 12px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__permissions-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
&__permission-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
&__permission-origin {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #e8e8e8;
word-break: break-all;
}
&__permission-delete {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 12px;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Confirmation dialog
&__confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
&__confirm-dialog {
background: linear-gradient(135deg, #1e293b 0%, #1a1a2e 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
animation: slideUp 0.2s ease;
h3 {
font-size: 18px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
}
p {
font-size: 14px;
color: #e8e8e8;
margin-bottom: 8px;
}
}
&__confirm-warning {
font-size: 13px !important;
color: #a0a0a0 !important;
margin-top: 12px !important;
}
&__confirm-actions {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
&__confirm-cancel {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #e8e8e8;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
&__confirm-delete {
padding: 10px 20px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
}
}
&__footer {
margin-top: 24px;
text-align: center;
@@ -202,3 +450,23 @@ body {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import browser from 'webextension-polyfill';
import { LogLevel, logLevelToName, logger } from '@tlsn/common';
import {
getStoredLogLevel,
@@ -7,6 +8,11 @@ import {
} from '../../utils/logLevelStorage';
import './index.scss';
// Initialize logger
logger.init(LogLevel.DEBUG);
type TabId = 'logging' | 'permissions';
interface LogLevelOption {
level: LogLevel;
name: string;
@@ -37,19 +43,25 @@ const LOG_LEVEL_OPTIONS: LogLevelOption[] = [
];
const Options: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('logging');
const [currentLevel, setCurrentLevel] = useState<LogLevel>(LogLevel.WARN);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
// Permissions state
const [hostPermissions, setHostPermissions] = useState<string[]>([]);
const [permissionsLoading, setPermissionsLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
// Load current log level on mount
useEffect(() => {
const loadLevel = async () => {
try {
const level = await getStoredLogLevel();
setCurrentLevel(level);
// Initialize the logger with the stored level
logger.init(level);
logger.setLevel(level);
} catch (error) {
logger.error('Failed to load log level:', error);
} finally {
@@ -60,6 +72,65 @@ const Options: React.FC = () => {
loadLevel();
}, []);
// Load permissions when permissions tab is active
useEffect(() => {
if (activeTab === 'permissions') {
loadPermissions();
}
}, [activeTab]);
// Manifest-defined patterns that cannot be removed via permissions API
// Note: Since we removed declarative content_scripts from manifest.json,
// we no longer have required host permissions. Only web_accessible_resources
// uses <all_urls>, but that doesn't create host permissions.
// We still filter out wildcard patterns as a safety measure.
const MANIFEST_PATTERNS = new Set([
'http://*/*',
'https://*/*',
'<all_urls>',
]);
/**
* Check if an origin is removable (runtime-granted optional permission)
* A permission is removable if:
* 1. It's not a manifest-defined pattern (http://*\/* or https://*\/*)
* 2. It's a specific host pattern (e.g., https://api.x.com/*)
*/
const isRemovableOrigin = (origin: string): boolean => {
// Not removable if it's a manifest pattern
if (MANIFEST_PATTERNS.has(origin)) {
return false;
}
// Only consider specific host patterns as removable
// These are patterns like "https://api.x.com/*" (not wildcards)
try {
const url = new URL(origin.replace('/*', '/'));
// If host contains wildcards, it's not a specific host
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
};
const loadPermissions = async () => {
setPermissionsLoading(true);
try {
const permissions = await browser.permissions.getAll();
logger.debug('All permissions:', permissions.origins);
// Filter to only show removable host permissions
const origins = (permissions.origins || []).filter(isRemovableOrigin);
logger.debug('Removable permissions:', origins);
setHostPermissions(origins);
} catch (error) {
logger.error('Failed to load permissions:', error);
} finally {
setPermissionsLoading(false);
}
};
const handleLevelChange = useCallback(async (level: LogLevel) => {
setSaving(true);
setSaveSuccess(false);
@@ -67,11 +138,9 @@ const Options: React.FC = () => {
try {
await setStoredLogLevel(level);
setCurrentLevel(level);
// Update the logger immediately
logger.setLevel(level);
setSaveSuccess(true);
// Clear success message after 2 seconds
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save log level:', error);
@@ -80,6 +149,121 @@ const Options: React.FC = () => {
}
}, []);
const handleDeletePermission = useCallback(async (origin: string) => {
logger.info('[Options] handleDeletePermission called for:', origin);
logger.info(
'[Options] isRemovableOrigin check:',
isRemovableOrigin(origin),
);
// Double-check that this origin is actually removable
if (!isRemovableOrigin(origin)) {
logger.warn('[Options] Origin is not removable, skipping:', origin);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
setConfirmDelete(null);
return;
}
setDeleting(origin);
try {
// Get current permissions to verify the origin exists
const currentPerms = await browser.permissions.getAll();
logger.info('[Options] Current permissions:', currentPerms.origins);
if (!currentPerms.origins?.includes(origin)) {
logger.info(
'[Options] Origin not in current permissions, removing from UI',
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
return;
}
logger.info('[Options] Calling browser.permissions.remove for:', origin);
const removed = await browser.permissions.remove({ origins: [origin] });
if (removed) {
setHostPermissions((prev) => prev.filter((o) => o !== origin));
logger.info('[Options] Permission removed:', origin);
} else {
logger.warn('[Options] Failed to remove permission:', origin);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing permission:', errorMessage);
// If Chrome says it's a required permission, remove from UI anyway
if (errorMessage.includes('required permissions')) {
logger.warn(
'[Options] Chrome considers this a required permission, removing from UI:',
origin,
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, []);
const handleDeleteAllPermissions = useCallback(async () => {
if (hostPermissions.length === 0) return;
logger.info(
'[Options] handleDeleteAllPermissions called for:',
hostPermissions,
);
setDeleting('all');
try {
// Filter to only actually removable permissions
const removableOrigins = hostPermissions.filter(isRemovableOrigin);
logger.info('[Options] Filtered removable origins:', removableOrigins);
if (removableOrigins.length === 0) {
logger.info('[Options] No removable permissions');
setHostPermissions([]);
return;
}
// Get current permissions to verify
const currentPerms = await browser.permissions.getAll();
const existingOrigins = new Set(currentPerms.origins || []);
const originsToRemove = removableOrigins.filter((o) =>
existingOrigins.has(o),
);
logger.info('[Options] Origins that actually exist:', originsToRemove);
if (originsToRemove.length === 0) {
logger.info('[Options] None of the origins exist in permissions');
setHostPermissions([]);
return;
}
const removed = await browser.permissions.remove({
origins: originsToRemove,
});
if (removed) {
setHostPermissions([]);
logger.info('[Options] All permissions removed');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing all permissions:', errorMessage);
// If it's a "required permissions" error, just clear the UI
if (errorMessage.includes('required permissions')) {
logger.warn('[Options] Some permissions are required, clearing UI');
// Reload to show actual state
loadPermissions();
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, [hostPermissions]);
if (loading) {
return (
<div className="options options--loading">
@@ -95,54 +279,190 @@ const Options: React.FC = () => {
<h1>TLSN Extension Settings</h1>
</header>
{/* Tabs */}
<nav className="options__tabs">
<button
className={`options__tab ${activeTab === 'logging' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('logging')}
>
Logging
</button>
<button
className={`options__tab ${activeTab === 'permissions' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('permissions')}
>
Permissions
</button>
</nav>
<main className="options__content">
<section className="options__section">
<h2>Logging</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
{/* Logging Tab */}
{activeTab === 'logging' && (
<section className="options__section">
<h2>Log Level</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
</span>
</span>
</span>
</label>
))}
</div>
</label>
))}
</div>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
)}
{/* Permissions Tab */}
{activeTab === 'permissions' && (
<section className="options__section">
<h2>Host Permissions</h2>
<p className="options__section-description">
These are the hosts the extension currently has access to. You can
revoke access by clicking the trash icon.
</p>
{permissionsLoading ? (
<div className="options__permissions-loading">
<div className="options__spinner options__spinner--small"></div>
<span>Loading permissions...</span>
</div>
) : hostPermissions.length === 0 ? (
<div className="options__permissions-empty">
<span className="options__permissions-empty-icon">🔒</span>
<p>No host permissions granted</p>
<p className="options__permissions-empty-hint">
Permissions are requested when you run plugins that need
network access.
</p>
</div>
) : (
<>
<div className="options__permissions-header">
<span className="options__permissions-count">
{hostPermissions.length} host
{hostPermissions.length !== 1 ? 's' : ''}
</span>
{hostPermissions.length > 1 && (
<button
className="options__delete-all-btn"
onClick={() => setConfirmDelete('all')}
disabled={deleting !== null}
>
Remove All
</button>
)}
</div>
<ul className="options__permissions-list">
{hostPermissions.map((origin) => (
<li key={origin} className="options__permission-item">
<span className="options__permission-origin">
{origin}
</span>
<button
className="options__permission-delete"
onClick={() => setConfirmDelete(origin)}
disabled={deleting !== null}
title="Remove permission"
>
{deleting === origin ? (
<span className="options__spinner options__spinner--tiny"></span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
</li>
))}
</ul>
</>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
{/* Confirmation Dialog */}
{confirmDelete && (
<div className="options__confirm-overlay">
<div className="options__confirm-dialog">
<h3>Confirm Removal</h3>
<p>
{confirmDelete === 'all'
? `Remove all ${hostPermissions.length} host permissions?`
: `Remove permission for "${confirmDelete}"?`}
</p>
<p className="options__confirm-warning">
Plugins will need to request this permission again to access
these hosts.
</p>
<div className="options__confirm-actions">
<button
className="options__confirm-cancel"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
<button
className="options__confirm-delete"
onClick={() =>
confirmDelete === 'all'
? handleDeleteAllPermissions()
: handleDeletePermission(confirmDelete)
}
>
Remove
</button>
</div>
</div>
</div>
)}
</section>
)}
</main>
<footer className="options__footer">

View File

@@ -1,6 +1,6 @@
{
"manifest_version": 3,
"version": "0.1.0.1400",
"version": "0.1.0.13",
"name": "TLSNotary",
"description": "A Chrome extension for TLSNotary",
"options_page": "options.html",
@@ -15,41 +15,22 @@
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*",
"<all_urls>"
],
"js": [
"contentScript.bundle.js"
],
"css": [
"content.styles.css"
]
"matches": ["<all_urls>"],
"js": ["contentScript.bundle.js"],
"css": ["content.styles.css"]
}
],
"web_accessible_resources": [
{
"resources": [
"content.styles.css",
"icon-128.png",
"icon-34.png",
"content.bundle.js",
"*.wasm"
],
"matches": [
"http://*/*",
"https://*/*",
"<all_urls>"
]
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "*.wasm"],
"matches": ["<all_urls>"]
}
],
"host_permissions": [
"<all_urls>"
],
"optional_host_permissions": ["<all_urls>"],
"permissions": [
"offscreen",
"webRequest",
"storage",
"activeTab",
"tabs",
"windows",

View File

@@ -1,6 +1,6 @@
import Host, { Parser } from '@tlsn/plugin-sdk/src';
import { ProveManager } from './ProveManager';
import type { Method } from '../../../tlsn-wasm-pkg/tlsn_wasm';
import { Method } from 'tlsn-js';
import { DomJson, Handler, PluginConfig } from '@tlsn/plugin-sdk/src/types';
import { processHandlers } from './rangeExtractor';
import { logger } from '@tlsn/common';

View File

@@ -117,6 +117,9 @@ export interface ManagedWindow {
/** Whether to show overlay when tab becomes ready (complete status) */
showOverlayWhenReady: boolean;
/** Origins granted for this window's plugin session (for cleanup on close) */
grantedOrigins: string[];
}
/**

View File

@@ -0,0 +1,317 @@
/**
* Tests for PermissionManager
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PermissionManager } from '../../src/background/PermissionManager';
import type { RequestPermission } from '@tlsn/plugin-sdk/src/types';
// Mock webextension-polyfill
vi.mock('webextension-polyfill', () => ({
default: {
permissions: {
request: vi.fn(),
remove: vi.fn(),
contains: vi.fn(),
},
},
}));
// Mock logger
vi.mock('@tlsn/common', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import browser from 'webextension-polyfill';
describe('PermissionManager', () => {
let manager: PermissionManager;
beforeEach(() => {
manager = new PermissionManager();
vi.clearAllMocks();
});
describe('extractPermissionPatterns', () => {
it('should extract origin pattern from host', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[0]).toEqual({
origin: 'https://api.x.com/*',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
});
expect(patterns[1]).toEqual({
origin: 'https://verifier.example.com/*',
host: 'verifier.example.com',
pathname: '/*',
});
});
it('should deduplicate origins', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.x.com',
pathname: '/1.1/statuses/update.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have 2 unique origins (api.x.com + verifier.example.com)
expect(patterns).toHaveLength(2);
});
it('should handle http verifier URL', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'http://localhost:7047',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[1].origin).toBe('http://localhost:7047/*');
});
it('should handle invalid verifier URL gracefully', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'not-a-valid-url',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have the host origin, skip invalid verifier
expect(patterns).toHaveLength(1);
expect(patterns[0].origin).toBe('https://api.x.com/*');
});
});
describe('extractOrigins', () => {
it('should return just origin strings', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'https://verifier.example.com',
},
];
const origins = manager.extractOrigins(requests);
expect(origins).toEqual([
'https://api.x.com/*',
'https://verifier.example.com/*',
]);
});
});
describe('formatForDisplay', () => {
it('should combine host and pathname', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.twitter.com',
pathname: '/graphql/*',
verifierUrl: 'https://verifier.example.com',
},
];
const display = manager.formatForDisplay(requests);
expect(display).toEqual([
'api.x.com/1.1/users/show.json',
'api.twitter.com/graphql/*',
]);
});
});
describe('requestPermissions', () => {
it('should return true for empty origins', async () => {
const granted = await manager.requestPermissions([]);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should request permissions and return result', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return true if permissions already granted', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should return false on denial', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(false);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockRejectedValue(
new Error('Permission error'),
);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should track permission usage', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
});
});
describe('removePermissions', () => {
it('should return true for empty origins', async () => {
const removed = await manager.removePermissions([]);
expect(removed).toBe(true);
expect(browser.permissions.remove).not.toHaveBeenCalled();
});
it('should remove permissions when no longer in use', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// First request permissions
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Then remove
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(true);
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should not remove permissions still in use by other executions', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// Request permissions twice (simulating two concurrent executions)
await manager.requestPermissions(['https://api.x.com/*']);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(2);
// Remove once
await manager.removePermissions(['https://api.x.com/*']);
// Should NOT call browser.permissions.remove because still in use
expect(browser.permissions.remove).not.toHaveBeenCalled();
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Remove second time
await manager.removePermissions(['https://api.x.com/*']);
// Now it should call browser.permissions.remove
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.remove).mockRejectedValue(
new Error('Remove error'),
);
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(false);
});
});
describe('hasPermissions', () => {
it('should return true for empty origins', async () => {
const has = await manager.hasPermissions([]);
expect(has).toBe(true);
});
it('should check permissions with browser API', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(true);
expect(browser.permissions.contains).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockRejectedValue(
new Error('Check error'),
);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(false);
});
});
});

View File

@@ -27,7 +27,7 @@ var compiler = webpack(config);
var server = new WebpackDevServer(
{
server: 'http',
https: false,
hot: true,
liveReload: false,
client: {

View File

@@ -42,7 +42,6 @@
],
"devDependencies": {
"@types/node": "^20.19.18",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitest/browser": "^3.2.4",
@@ -52,7 +51,7 @@
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"happy-dom": "^20.0.11",
"happy-dom": "^19.0.2",
"path-browserify": "^1.0.1",
"playwright": "^1.55.1",
"prettier": "^3.6.2",
@@ -68,7 +67,6 @@
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.31.0",
"@sebastianwessel/quickjs": "^3.0.0",
"@tlsn/common": "*",
"quickjs-emscripten": "^0.31.0",
"uuid": "^13.0.0"
"quickjs-emscripten": "^0.31.0"
}
}

View File

@@ -1,97 +0,0 @@
/**
* Global type declarations for TLSNotary plugin runtime environment
*
* These functions are injected at runtime by the plugin sandbox.
* Import this file in your plugin to get TypeScript support:
*
* /// <reference types="@tlsn/plugin-sdk/globals" />
*/
import type {
InterceptedRequest,
InterceptedRequestHeader,
Handler,
DomOptions,
DomJson,
} from './types';
declare global {
/**
* Create a div element
*/
function div(options?: DomOptions, children?: (DomJson | string)[]): DomJson;
function div(children?: (DomJson | string)[]): DomJson;
/**
* Create a button element
*/
function button(options?: DomOptions, children?: (DomJson | string)[]): DomJson;
function button(children?: (DomJson | string)[]): DomJson;
/**
* Get or initialize state value (React-like useState)
*/
function useState<T>(key: string, initialValue: T): T;
/**
* Update state value
*/
function setState<T>(key: string, value: T): void;
/**
* Run side effect when dependencies change (React-like useEffect)
*/
function useEffect(effect: () => void, deps: any[]): void;
/**
* Subscribe to intercepted HTTP headers
*/
function useHeaders(
filter: (headers: InterceptedRequestHeader[]) => InterceptedRequestHeader[],
): [InterceptedRequestHeader | undefined];
/**
* Subscribe to intercepted HTTP requests
*/
function useRequests(
filter: (requests: InterceptedRequest[]) => InterceptedRequest[],
): [InterceptedRequest | undefined];
/**
* Open a new browser window for user interaction
*/
function openWindow(
url: string,
options?: {
width?: number;
height?: number;
showOverlay?: boolean;
},
): Promise<void>;
/**
* Generate a TLS proof for an HTTP request
*/
function prove(
requestOptions: {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
},
proverOptions: {
verifierUrl: string;
proxyUrl: string;
maxRecvData?: number;
maxSentData?: number;
handlers: Handler[];
},
): Promise<any>;
/**
* Complete plugin execution and return result
*/
function done(result?: any): void;
}
export {};

View File

@@ -2,7 +2,7 @@
"name": "tlsn-wasm",
"type": "module",
"description": "A core WebAssembly package for TLSNotary.",
"version": "0.1.0-alpha.14",
"version": "0.1.0-alpha.13",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",

View File

@@ -1,8 +1,19 @@
/* tslint:disable */
/* eslint-disable */
export type LoggingLevel = "Trace" | "Debug" | "Info" | "Warn" | "Error";
export type SpanEvent = "New" | "Close" | "Active";
/**
* Initializes the module.
*/
export function initialize(logging_config: LoggingConfig | null | undefined, thread_count: number): Promise<void>;
/**
* Starts the thread spawner on a dedicated worker thread.
*/
export function startSpawner(): Promise<any>;
export function web_spawn_start_worker(worker: number): void;
export function web_spawn_recover_spawner(spawner: number): Spawner;
export interface CrateLogFilter {
level: LoggingLevel;
name: string;
}
export interface LoggingConfig {
level: LoggingLevel | undefined;
@@ -10,43 +21,15 @@ export interface LoggingConfig {
span_events: SpanEvent[] | undefined;
}
export interface CrateLogFilter {
level: LoggingLevel;
name: string;
}
export type SpanEvent = "New" | "Close" | "Active";
export type Body = JsonValue;
export type LoggingLevel = "Trace" | "Debug" | "Info" | "Warn" | "Error";
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export type NetworkSetting = "Bandwidth" | "Latency";
export interface HttpRequest {
uri: string;
method: Method;
headers: Map<string, number[]>;
body: Body | undefined;
}
export interface HttpResponse {
status: number;
headers: [string, number[]][];
}
export type TlsVersion = "V1_2" | "V1_3";
export interface TranscriptLength {
sent: number;
recv: number;
}
export interface ConnectionInfo {
time: number;
version: TlsVersion;
transcript_length: TranscriptLength;
}
export interface Transcript {
sent: number[];
recv: number[];
export interface Commit {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
}
export interface PartialTranscript {
@@ -56,16 +39,12 @@ export interface PartialTranscript {
recv_authed: { start: number; end: number }[];
}
export interface Commit {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
export interface HttpResponse {
status: number;
headers: [string, number[]][];
}
export interface Reveal {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
server_identity: boolean;
}
export type Body = JsonValue;
export interface VerifierOutput {
server_name: string | undefined;
@@ -73,7 +52,38 @@ export interface VerifierOutput {
transcript: PartialTranscript | undefined;
}
export type NetworkSetting = "Bandwidth" | "Latency";
export interface ConnectionInfo {
time: number;
version: TlsVersion;
transcript_length: TranscriptLength;
}
export interface TranscriptLength {
sent: number;
recv: number;
}
export type TlsVersion = "V1_2" | "V1_3";
export interface HttpRequest {
uri: string;
method: Method;
headers: Map<string, number[]>;
body: Body | undefined;
}
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export interface Reveal {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
server_identity: boolean;
}
export interface Transcript {
sent: number[];
recv: number[];
}
export interface ProverConfig {
server_name: string;
@@ -94,14 +104,18 @@ export interface VerifierConfig {
max_recv_records_online: number | undefined;
}
export class Prover {
free(): void;
[Symbol.dispose](): void;
/**
* Returns the transcript.
*/
transcript(): Transcript;
/**
* Send the HTTP request to the server.
*/
send_request(ws_proxy_url: string, request: HttpRequest): Promise<HttpResponse>;
constructor(config: ProverConfig);
/**
* Set up the prover.
*
@@ -113,13 +127,10 @@ export class Prover {
* Reveals data to the verifier and finalizes the protocol.
*/
reveal(reveal: Reveal): Promise<void>;
/**
* Returns the transcript.
*/
transcript(): Transcript;
constructor(config: ProverConfig);
}
/**
* Global spawner which spawns closures into web workers.
*/
export class Spawner {
private constructor();
free(): void;
@@ -130,10 +141,10 @@ export class Spawner {
run(url: string): Promise<void>;
intoRaw(): number;
}
export class Verifier {
free(): void;
[Symbol.dispose](): void;
constructor(config: VerifierConfig);
/**
* Verifies the connection and finalizes the protocol.
*/
@@ -142,29 +153,13 @@ export class Verifier {
* Connect to the prover.
*/
connect(prover_url: string): Promise<void>;
constructor(config: VerifierConfig);
}
export class WorkerData {
private constructor();
free(): void;
[Symbol.dispose](): void;
}
/**
* Initializes the module.
*/
export function initialize(logging_config: LoggingConfig | null | undefined, thread_count: number): Promise<void>;
/**
* Starts the thread spawner on a dedicated worker thread.
*/
export function startSpawner(): Promise<any>;
export function web_spawn_recover_spawner(spawner: number): Spawner;
export function web_spawn_start_worker(worker: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
@@ -187,12 +182,12 @@ export interface InitOutput {
readonly web_spawn_recover_spawner: (a: number) => number;
readonly web_spawn_start_worker: (a: number) => void;
readonly ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent____Output_______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent_____: (a: number, b: number, c: any) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any) => void;
readonly wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__wasm_bindgen_d93ce3c58293cca3___JsValue____Output_______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue__wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h1221e6fae8f79e66: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__closure__destroy__h77926bfd4964395c: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__ha226a7154e96c3a6: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h667d3f209ba8d8c8: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0a1439cca01ee997: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__he1146594190fdf85: (a: number, b: number, c: any, d: any) => void;
readonly memory: WebAssembly.Memory;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
@@ -206,7 +201,6 @@ export interface InitOutput {
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,12 @@ export const startSpawner: () => any;
export const web_spawn_recover_spawner: (a: number) => number;
export const web_spawn_start_worker: (a: number) => void;
export const ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent____Output_______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent_____: (a: number, b: number, c: any) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any) => void;
export const wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__wasm_bindgen_d93ce3c58293cca3___JsValue____Output_______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue__wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h1221e6fae8f79e66: (a: number, b: number, c: any) => void;
export const wasm_bindgen__closure__destroy__h77926bfd4964395c: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__ha226a7154e96c3a6: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__h667d3f209ba8d8c8: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h0a1439cca01ee997: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__he1146594190fdf85: (a: number, b: number, c: any, d: any) => void;
export const memory: WebAssembly.Memory;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;

View File

@@ -33,10 +33,10 @@ fi
git checkout "${VERSION}" --force
git reset --hard
cd crates/wasm
# Apply no-logging modification if requested
if [ "$NO_LOGGING" = "--no-logging" ]; then
echo "Applying no-logging configuration..."
cd crates/wasm
# Add it to the wasm32 target section (after the section header)
sed -i.bak '/^\[target\.\x27cfg(target_arch = "wasm32")\x27\.dependencies\]$/a\
@@ -45,8 +45,11 @@ tracing = { workspace = true, features = ["release_max_level_off"] }' Cargo.toml
# Clean up backup file
rm Cargo.toml.bak
cd ../..
fi
cd crates/wasm
cargo update
./build.sh
cd ../../

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
# TLSNotary dependency
tlsn = { git = "https://github.com/tlsnotary/tlsn.git", tag = "v0.1.0-alpha.14", features = ["mozilla-certs"] }
tlsn = { git = "https://github.com/tlsnotary/tlsn.git", tag = "v0.1.0-alpha.13" }
# HTTP server framework
axum = { version = "0.7", features = ["ws"] }
@@ -46,7 +46,7 @@ eyre = "0.6"
tokio-util = { version = "0.7", features = ["compat"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
regex = "1.12.2"
rangeset = "0.4.0"
rangeset = "0.2.0"
[dev-dependencies]
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }

View File

@@ -34,10 +34,6 @@ FROM debian:bookworm-slim
WORKDIR /app
# Accept build argument for git hash and set as environment variable
ARG GIT_HASH=local
ENV GIT_HASH=${GIT_HASH}
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \

View File

@@ -13,7 +13,7 @@ A Rust-based HTTP server with WebSocket support for TLSNotary verification opera
## Dependencies
- **tlsn**: v0.1.0-alpha.14 from GitHub - TLSNotary verification library
- **tlsn**: v0.1.0-alpha.13 from GitHub - TLSNotary verification library
- **axum**: Modern web framework with WebSocket support
- **tokio**: Async runtime with full features
- **tokio-util**: Async utilities for stream compatibility

View File

@@ -12,7 +12,7 @@ use axum::{
Router,
};
use axum_websocket::{WebSocket, WebSocketUpgrade};
use rangeset::prelude::RangeSet;
use rangeset::RangeSet;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -59,7 +59,6 @@ async fn main() {
// Build router with routes
let app = Router::new()
.route("/health", get(health_handler))
.route("/info", get(info_handler))
.route("/session", get(session_ws_handler))
.route("/verifier", get(verifier_ws_handler))
.route("/proxy", get(proxy_ws_handler))
@@ -76,7 +75,6 @@ async fn main() {
info!("Server listening on http://{}", addr);
info!("Health endpoint: http://{}/health", addr);
info!("Info endpoint: http://{}/info", addr);
info!("Session WebSocket endpoint: ws://{}/session", addr);
info!(
"Verifier WebSocket endpoint: ws://{}/verifier?sessionId=<id>",
@@ -354,28 +352,6 @@ async fn health_handler() -> impl IntoResponse {
"ok"
}
/// Info response structure
#[derive(Debug, Serialize)]
struct InfoResponse {
/// Package version from Cargo.toml
version: &'static str,
/// Git commit hash (from GIT_HASH env var, set by CI)
git_hash: String,
/// TLSNotary library version
tlsn_version: &'static str,
}
/// Info endpoint handler - returns server information as JSON
pub(crate) async fn info_handler() -> impl IntoResponse {
let git_hash = std::env::var("GIT_HASH").unwrap_or_else(|_| "dev".to_string());
axum::Json(InfoResponse {
version: env!("CARGO_PKG_VERSION"),
git_hash,
tlsn_version: "0.1.0-alpha.14",
})
}
// WebSocket session handler for extension
pub(crate) async fn session_ws_handler(
ws: WebSocketUpgrade,
@@ -916,13 +892,13 @@ async fn run_verifier_task(
"[{}] Sent data length: {} bytes (authed: {} bytes)",
session_id,
sent_bytes.len(),
transcript.sent_authed().len(),
transcript.sent_authed().iter().sum::<usize>(),
);
info!(
"[{}] Received data length: {} bytes (authed: {} bytes)",
session_id,
recv_bytes.len(),
transcript.received_authed().len()
transcript.received_authed().iter().sum::<usize>()
);
// Wait for RevealConfig to be available (with polling and timeout)

View File

@@ -28,11 +28,9 @@ use tracing::info;
use ws_stream_tungstenite::WsStream;
use tlsn::{
config::{
prove::ProveConfig, prover::ProverConfig, tls::TlsClientConfig, tls_commit::TlsCommitConfig,
},
prover::Prover,
Session,
config::ProtocolConfig,
connection::ServerName,
prover::{ProveConfig, Prover, ProverConfig},
};
// ============================================================================
@@ -158,11 +156,11 @@ async fn webhook_handler(
// ============================================================================
async fn start_verifier_server(webhook_port: u16, verifier_port: u16) -> JoinHandle<()> {
// Create config with webhook for raw.githubusercontent.com
// Create config with webhook for swapi.dev
let config_yaml = format!(
r#"
webhooks:
"raw.githubusercontent.com":
"swapi.dev":
url: "http://127.0.0.1:{}"
headers: {{}}
"#,
@@ -178,9 +176,14 @@ webhooks:
let app = Router::new()
.route("/health", axum::routing::get(|| async { "ok" }))
.route("/info", axum::routing::get(crate::info_handler))
.route("/session", axum::routing::get(crate::session_ws_handler))
.route("/verifier", axum::routing::get(crate::verifier_ws_handler))
.route(
"/session",
axum::routing::get(crate::session_ws_handler),
)
.route(
"/verifier",
axum::routing::get(crate::verifier_ws_handler),
)
.route("/proxy", axum::routing::get(crate::proxy_ws_handler))
.layer(CorsLayer::permissive())
.with_state(app_state);
@@ -382,23 +385,15 @@ async fn connect_wss(
/// Helper function that performs MPC-TLS and HTTP request with a given proxy stream
async fn run_prover_with_stream<S>(
prover: Prover,
tls_commit_config: TlsCommitConfig,
tls_client_config: TlsClientConfig,
prover: tlsn::prover::Prover<tlsn::prover::state::Setup>,
proxy_stream: S,
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error + Send + Sync>>
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
// 5. Start the TLS commitment protocol
let prover = prover
.commit(tls_commit_config)
.await
.map_err(|e| format!("Commitment failed: {}", e))?;
// 6. Pass proxy connection into the prover for TLS
// 5. Pass proxy connection into the prover for TLS
let (mpc_tls_connection, prover_fut) = prover
.connect(tls_client_config, proxy_stream)
.connect(proxy_stream)
.await
.map_err(|e| format!("TLS connect failed: {}", e))?;
@@ -410,19 +405,18 @@ where
// Spawn the prover task
let prover_task = tokio::spawn(prover_fut);
// 7. HTTP handshake
let (mut request_sender, connection) =
hyper::client::conn::http1::handshake(mpc_tls_connection)
.await
.map_err(|e| format!("HTTP handshake failed: {}", e))?;
// 6. HTTP handshake
let (mut request_sender, connection) = hyper::client::conn::http1::handshake(mpc_tls_connection)
.await
.map_err(|e| format!("HTTP handshake failed: {}", e))?;
tokio::spawn(connection);
// 8. Send HTTP GET request
info!("[Prover] Sending GET /tlsnotary/tlsn/refs/heads/main/crates/server-fixture/server/src/data/1kb.json");
// 7. Send HTTP GET request
info!("[Prover] Sending GET /api/films/1/");
let request = Request::builder()
.uri("/tlsnotary/tlsn/refs/heads/main/crates/server-fixture/server/src/data/1kb.json")
.header("Host", "raw.githubusercontent.com")
.uri("/api/films/1/")
.header("Host", "swapi.dev")
.header("Accept", "application/json")
.header("Connection", "close")
.method("GET")
@@ -437,7 +431,7 @@ where
info!("[Prover] Response status: {}", response.status());
assert_eq!(response.status(), StatusCode::OK);
// 9. Wait for prover task to complete
// 8. Wait for prover task to complete
let mut prover = prover_task
.await
.map_err(|e| format!("Prover task panicked: {}", e))?
@@ -452,26 +446,24 @@ where
recv.len()
);
// 10. Build proof configuration (reveal everything including server identity)
let mut prove_config = ProveConfig::builder(prover.transcript());
prove_config.server_identity();
prove_config
// 9. Build reveal configuration (reveal everything)
let mut builder = ProveConfig::builder(prover.transcript());
builder.server_identity();
builder
.reveal_sent(&(0..sent.len()))
.map_err(|e| format!("reveal_sent failed: {}", e))?;
prove_config
builder
.reveal_recv(&(0..recv.len()))
.map_err(|e| format!("reveal_recv failed: {}", e))?;
let prove_config = prove_config
.build()
.map_err(|e| format!("build proof failed: {}", e))?;
// 11. Send proof to verifier
let config = builder.build().unwrap();
// 10. Send proof to verifier
info!("[Prover] Sending proof to verifier");
prover
.prove(&prove_config)
.prove(&config)
.await
.map_err(|e| format!("prove failed: {}", e))?;
prover
.close()
.await
@@ -499,134 +491,50 @@ async fn run_prover(
// WsStream implements tokio::io::AsyncRead/AsyncWrite when inner implements futures_io traits
let verifier_stream = WsStream::new(verifier_ws);
// 2. Create session with verifier stream
let session = Session::new(verifier_stream);
let (driver, mut handle) = session.split();
// Spawn the session driver in the background
let driver_task = tokio::spawn(driver);
// 3. Create TLS commit config for MPC protocol
use tlsn::config::tls_commit::{mpc::MpcTlsConfig, TlsCommitProtocolConfig};
let mpc_config = MpcTlsConfig::builder()
.max_sent_data(max_sent_data)
.max_recv_data(max_recv_data)
.build()
.map_err(|e| format!("Failed to build MPC TLS config: {}", e))?;
let tls_commit_config = TlsCommitConfig::builder()
.protocol(TlsCommitProtocolConfig::Mpc(mpc_config))
.build()
.map_err(|e| format!("Failed to build TLS commit config: {}", e))?;
// 4. Create prover config
// 2. Create prover config
let prover_config = ProverConfig::builder()
.server_name(ServerName::Dns("swapi.dev".try_into().unwrap()))
.protocol_config(
ProtocolConfig::builder()
.max_sent_data(max_sent_data)
.max_recv_data(max_recv_data)
.build()
.unwrap(),
)
.build()
.map_err(|e| format!("Failed to build prover config: {}", e))?;
.unwrap();
info!("[Prover] Setting up MPC-TLS with verifier");
// 5. Create prover via handle
let prover = handle
.new_prover(prover_config)
.map_err(|e| format!("Failed to create prover: {}", e))?;
// 6. Create TLS client config with server name and root certs
use tlsn::{connection::ServerName, webpki::RootCertStore};
let tls_client_config = TlsClientConfig::builder()
.server_name(ServerName::Dns(
"raw.githubusercontent.com".try_into().unwrap(),
))
.root_store(RootCertStore::mozilla())
.build()
.map_err(|e| format!("Failed to build TLS client config: {}", e))?;
// 3. Create prover and perform setup with verifier
// tlsn expects futures_io traits, so we don't need compat() - WsStream already provides them
let prover = Prover::new(prover_config)
.setup(verifier_stream)
.await
.map_err(|e| format!("Prover setup failed: {}", e))?;
info!("[Prover] Connecting to proxy at {}", proxy_url);
// 7. Connect to proxy WebSocket (ws:// or wss://) and run prover
let result = if proxy_url.starts_with("wss://") {
// 4. Connect to proxy WebSocket (ws:// or wss://)
if proxy_url.starts_with("wss://") {
let proxy_ws = connect_wss(&proxy_url).await?;
info!("[Prover] Connected to proxy (wss)");
let proxy_stream = WsStream::new(proxy_ws);
run_prover_with_stream(prover, tls_commit_config, tls_client_config, proxy_stream).await
run_prover_with_stream(prover, proxy_stream).await
} else {
let proxy_ws = connect_ws(&proxy_url).await?;
info!("[Prover] Connected to proxy (ws)");
let proxy_stream = WsStream::new(proxy_ws);
run_prover_with_stream(prover, tls_commit_config, tls_client_config, proxy_stream).await
};
// 8. Close the session handle
handle.close();
// 9. Wait for the driver to complete
driver_task
.await
.map_err(|e| format!("Driver task failed: {}", e))?
.map_err(|e| format!("Session driver error: {}", e))?;
result
run_prover_with_stream(prover, proxy_stream).await
}
}
// ============================================================================
// Integration Tests
// Integration Test
// ============================================================================
/// Test the /health endpoint
#[tokio::test]
async fn health() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.try_init();
let verifier_handle = start_verifier_server(WEBHOOK_PORT + 1, VERIFIER_PORT + 1).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{}/health", VERIFIER_PORT + 1))
.send()
.await
.expect("Failed to send request");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "ok");
verifier_handle.abort();
}
/// Test the /info endpoint returns expected JSON structure
#[tokio::test]
async fn info() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.try_init();
let verifier_handle = start_verifier_server(WEBHOOK_PORT + 2, VERIFIER_PORT + 2).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{}/info", VERIFIER_PORT + 2))
.send()
.await
.expect("Failed to send request");
assert_eq!(resp.status(), StatusCode::OK);
let info: Value = resp.json().await.expect("Failed to parse JSON");
// Verify required fields exist
info.get("version").expect("Missing version field");
info.get("git_hash").expect("Missing git_hash field");
info.get("tlsn_version")
.expect("Missing tlsn_version field");
verifier_handle.abort();
}
#[tokio::test]
async fn test_webhook_integration_with_github() {
async fn test_webhook_integration_with_swapi() {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
@@ -665,10 +573,7 @@ async fn test_webhook_integration_with_github() {
"ws://127.0.0.1:{}/verifier?sessionId={}",
VERIFIER_PORT, session_id
);
let proxy_url = format!(
"ws://127.0.0.1:{}/proxy?token=raw.githubusercontent.com",
VERIFIER_PORT
);
let proxy_url = format!("ws://127.0.0.1:{}/proxy?token=swapi.dev", VERIFIER_PORT);
let prover_handle = tokio::spawn(async move {
run_prover(verifier_ws_url, proxy_url, MAX_SENT_DATA, MAX_RECV_DATA).await
@@ -723,11 +628,11 @@ async fn test_webhook_integration_with_github() {
// 8. Verify results contain expected data
assert!(!results.is_empty(), "Should have handler results");
// Check that response contains expected JSON data
// Check that response contains Star Wars data
let recv_str = String::from_utf8_lossy(&recv_transcript);
assert!(
recv_str.contains("software engineer") || recv_str.contains("Anytown"),
"Response should contain expected JSON data: {}",
recv_str.contains("A New Hope") || recv_str.contains("Star Wars"),
"Response should contain Star Wars film data: {}",
&recv_str[..recv_str.len().min(500)]
);
@@ -746,8 +651,8 @@ async fn test_webhook_integration_with_github() {
// Verify webhook payload structure
assert_eq!(
payload["server_name"], "raw.githubusercontent.com",
"server_name should be raw.githubusercontent.com"
payload["server_name"], "swapi.dev",
"server_name should be swapi.dev"
);
assert!(payload["results"].is_array(), "results should be an array");
assert!(
@@ -786,8 +691,8 @@ async fn test_webhook_integration_with_github() {
// Verify transcript contains expected content
let webhook_recv = payload["transcript"]["recv"].as_str().unwrap();
assert!(
webhook_recv.contains("software engineer") || webhook_recv.contains("Anytown"),
"Webhook transcript should contain expected JSON data"
webhook_recv.contains("A New Hope") || webhook_recv.contains("title"),
"Webhook transcript should contain Star Wars film data"
);
info!("All assertions passed!");

View File

@@ -1,11 +1,9 @@
use eyre::eyre;
use tlsn::{
config::{tls_commit::TlsCommitProtocolConfig, verifier::VerifierConfig},
config::ProtocolConfigValidator,
connection::{DnsName, ServerName},
transcript::PartialTranscript,
verifier::VerifierOutput,
webpki::RootCertStore,
Session,
verifier::{Verifier, VerifierConfig, VerifierOutput, VerifyConfig},
};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_util::compat::TokioAsyncReadCompatExt;
@@ -25,103 +23,31 @@ pub async fn verifier<T: AsyncWrite + AsyncRead + Send + Unpin + 'static>(
max_sent_data, max_recv_data
);
// Create a session with the prover
let session = Session::new(socket.compat());
let (driver, mut handle) = session.split();
// Spawn the session driver to run in the background
let driver_task = tokio::spawn(driver);
// Create verifier config with Mozilla root certificates for TLS verification
let verifier_config = VerifierConfig::builder()
.root_store(RootCertStore::mozilla())
let config_validator = ProtocolConfigValidator::builder()
.max_sent_data(max_sent_data)
.max_recv_data(max_recv_data)
.build()
.map_err(|e| eyre!("Failed to build verifier config: {}", e))?;
.unwrap();
let verifier = handle
.new_verifier(verifier_config)
.map_err(|e| eyre!("Failed to create verifier: {}", e))?;
let verifier_config = VerifierConfig::builder()
.protocol_config_validator(config_validator)
.build()
.unwrap();
info!("Starting TLS commitment protocol");
info!("verifier_config: {:?}", verifier_config);
let verifier = Verifier::new(verifier_config);
// Run the commitment protocol
let verifier = verifier
.commit()
.await
.map_err(|e| eyre!("Commitment failed: {}", e))?;
info!("Starting verification");
// Check the proposed configuration
let request = verifier.request();
let TlsCommitProtocolConfig::Mpc(mpc_config) = request.protocol() else {
return Err(eyre!("Only MPC protocol is supported"));
};
// Validate the proposed configuration
if mpc_config.max_sent_data() > max_sent_data {
return Err(eyre!(
"Prover requested max_sent_data {} exceeds limit {}",
mpc_config.max_sent_data(),
max_sent_data
));
}
if mpc_config.max_recv_data() > max_recv_data {
return Err(eyre!(
"Prover requested max_recv_data {} exceeds limit {}",
mpc_config.max_recv_data(),
max_recv_data
));
}
info!(
"Accepting TLS commitment with max_sent={}, max_recv={}",
mpc_config.max_sent_data(),
mpc_config.max_recv_data()
);
// Accept and run the commitment protocol
let verifier = verifier
.accept()
.await
.map_err(|e| eyre!("Accept failed: {}", e))?
.run()
.await
.map_err(|e| eyre!("Run failed: {}", e))?;
info!("TLS connection complete, starting verification");
// Verify the proof
let verifier = verifier
.verify()
let VerifierOutput {
server_name,
transcript,
..
} = verifier
.verify(socket.compat(), &VerifyConfig::default())
.await
.map_err(|e| eyre!("Verification failed: {}", e))?;
let (
VerifierOutput {
server_name,
transcript,
..
},
verifier,
) = verifier
.accept()
.await
.map_err(|e| eyre!("Accept verification failed: {}", e))?;
// Close the verifier
verifier
.close()
.await
.map_err(|e| eyre!("Failed to close verifier: {}", e))?;
// Close the session handle
handle.close();
// Wait for the driver to complete
driver_task
.await
.map_err(|e| eyre!("Driver task failed: {}", e))?
.map_err(|e| eyre!("Session driver error: {}", e))?;
info!("verify() returned successfully - prover sent all data");
let server_name =