diff --git a/.github/actions/install-linux-native-deps/action.yml b/.github/actions/install-linux-native-deps/action.yml new file mode 100644 index 00000000..6bd59811 --- /dev/null +++ b/.github/actions/install-linux-native-deps/action.yml @@ -0,0 +1,13 @@ +name: Install Linux native dependencies +description: Install system libraries required to build Ledger USB dependencies on Linux + +runs: + using: composite + steps: + - name: Install native USB build dependencies + shell: bash + run: | + # Refresh the image's package index first; cached entries can point to + # package builds that Ubuntu mirrors have already rotated out. + sudo apt-get update + sudo apt-get install -y libudev-dev libusb-1.0-0-dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df6e6a6..29246f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,9 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Feature-branch supersession is useful, but main's long native network + # proof must be allowed to finish even if another merge lands meanwhile. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: @@ -131,6 +133,211 @@ jobs: FREEDOM_IPFS_NATIVE_SMOKE_LIVE: '0' FREEDOM_IPFS_NATIVE_SMOKE_ISSUE_102: '1' + # Load every native Myotis addon target that release packaging requires. + # The live network proof below remains one representative architecture per + # shipped OS; this fast matrix closes the packaging gap for Intel macOS and + # ARM64 Linux without multiplying the expensive cold-sync workload. + myotis-addon-load: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + addon: myotis-bin/mac-arm64/myotis-node.node + - os: macos-15-intel + target: darwin-x64 + addon: myotis-bin/mac-x64/myotis-node.node + - os: ubuntu-latest + target: linux-x64 + addon: myotis-bin/linux-x64/myotis-node.node + - os: ubuntu-24.04-arm + target: linux-arm64 + addon: myotis-bin/linux-arm64/myotis-node.node + - os: windows-latest + target: win32-x64 + addon: myotis-bin/win-x64/myotis-node.node + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Download this runner's Myotis addon + run: npm run myotis:download + env: + GITHUB_TOKEN: ${{ github.token }} + MYOTIS_DOWNLOAD_TARGET: ${{ matrix.target }} + + - name: Load addon and verify ABI + run: node -e "const addon=require('./'+process.env.MYOTIS_ADDON); const abi=addon.init(); if(abi!==22) throw new Error('Expected ABI 22, got '+abi); console.log(process.platform+'-'+process.arch+' ABI '+abi);" + env: + MYOTIS_ADDON: ${{ matrix.addon }} + + # Real Myotis release-addon + Freedom resolver E2E on every operating + # system we ship. One production-app launch cold-syncs over libp2p/devp2p, + # proves direct verified Ethereum + Gnosis reads, then exercises ENS plus + # WNS/GNS through Freedom's Myotis resolver tier. Avoid a raw-smoke launch + # followed by an immediate app restart: persisted peer backoff can leave the + # second client temporarily unable to reacquire a usable snap peer even + # though the first launch just served verified reads. + # + # Cold sync can take 30-40 minutes. Cache restore/save are split so a timed + # out first attempt still preserves its cleanly-stopped partial sync; a + # re-run continues from that snapshot instead of starting from zero. + myotis-native-e2e: + # A feature branch with an open PR otherwise launches this 30-40 minute + # live-network matrix twice (push + pull_request), doubling cost and exposure + # to transient peer selection. Keep PR/manual gates and post-merge coverage on main. + if: github.event_name != 'push' || github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + addon: myotis-bin/mac-arm64/myotis-node.node + - os: ubuntu-latest + target: linux-x64 + addon: myotis-bin/linux-x64/myotis-node.node + - os: windows-latest + target: win32-x64 + addon: myotis-bin/win-x64/myotis-node.node + runs-on: ${{ matrix.os }} + timeout-minutes: 190 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + uses: ./.github/actions/install-linux-native-deps + + - name: Install dependencies + run: npm ci + + - name: Install Playwright system dependencies + if: runner.os == 'Linux' + run: npx playwright install-deps + + - name: Download Myotis release addons + run: npm run myotis:download + env: + GITHUB_TOKEN: ${{ github.token }} + MYOTIS_DOWNLOAD_TARGET: ${{ matrix.target }} + + - name: Assert native-addon target + run: node -e "const actual=process.platform+'-'+process.arch; const expected=process.env.EXPECTED_TARGET; if(actual!==expected) throw new Error('expected '+expected+', got '+actual); console.log(actual);" + env: + EXPECTED_TARGET: ${{ matrix.target }} + + - name: Restore Myotis sync snapshot + id: myotis-cache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/myotis-data + key: myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}- + + - name: Download IPFS native addon + run: npm run ipfs:download + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Run Freedom Myotis resolver E2E + shell: bash + env: + MYOTIS_DATA_DIR: ${{ runner.temp }}/myotis-data + MYOTIS_NODE_PATH: ${{ github.workspace }}/${{ matrix.addon }} + MYOTIS_E2E_READY_TIMEOUT_MIN: '75' + FREEDOM_TEST_HIDE_WINDOW: '1' + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + xvfb-run -a npm run test:e2e:live -- test-e2e/live/myotis-ens.spec.js + else + npm run test:e2e:live -- test-e2e/live/myotis-ens.spec.js + fi + + - name: Save Myotis sync snapshot + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/myotis-data + key: myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Upload Playwright traces and screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: myotis-native-e2e-${{ matrix.target }}-traces + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 14 + + # Settings navigation, persistence, and chain policy are shipped control + # surfaces. Exercise their real renderer/webview integration so malformed + # sidebar markup, broken transitions, and Myotis policy regressions cannot + # pass unit-only CI. + e2e-settings: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Linux native USB build dependency + uses: ./.github/actions/install-linux-native-deps + + - name: Install dependencies + run: npm ci + + - name: Install Playwright system dependencies + run: npx playwright install-deps + + - name: Download adblock filter lists + run: npm run adblock:download + + - name: Run settings E2E + run: xvfb-run -a npm run test:e2e -- test-e2e/settings.spec.js test-e2e/settings-adblock.spec.js + + - name: Upload Playwright traces and screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-settings-traces + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 14 + # Onboarding wizard must create node identities while a real Swarm node (now # antd) is running. Drives the full password wizard end-to-end against a real # antd spawn across all three desktop platforms — the cross-platform proof @@ -158,12 +365,7 @@ jobs: - name: Install Linux native USB build dependency if: runner.os == 'Linux' - # node-hid builds against libudev (hidraw) and libusb; install both. - # `apt-get update` first — the image's pre-seeded index can name a - # build the mirrors already rotated out (install 404s). - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev libusb-1.0-0-dev + uses: ./.github/actions/install-linux-native-deps - name: Install dependencies run: npm ci @@ -233,12 +435,7 @@ jobs: - name: Install Linux native USB build dependency if: runner.os == 'Linux' - # node-hid builds against libudev (hidraw) and libusb; install both. - # `apt-get update` first — the image's pre-seeded index can name a - # build the mirrors already rotated out (install 404s). - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev libusb-1.0-0-dev + uses: ./.github/actions/install-linux-native-deps - name: Install dependencies run: npm ci @@ -292,12 +489,7 @@ jobs: - name: Install Linux native USB build dependency if: runner.os == 'Linux' - # node-hid builds against libudev (hidraw) and libusb; install both. - # `apt-get update` first — the image's pre-seeded index can name a - # build the mirrors already rotated out (install 404s). - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev libusb-1.0-0-dev + uses: ./.github/actions/install-linux-native-deps - name: Install dependencies run: npm ci @@ -349,12 +541,7 @@ jobs: - name: Install Linux native USB build dependency if: runner.os == 'Linux' - # node-hid builds against libudev (hidraw) and libusb; install both. - # `apt-get update` first — the image's pre-seeded index can name a - # build the mirrors already rotated out (install 404s). - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev libusb-1.0-0-dev + uses: ./.github/actions/install-linux-native-deps - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index 451b55aa..bc9ed064 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ tmp/ *.swp .env ant-bin/ +myotis-bin/ ant-data/ assets/adblock/ ipfs-bin/ @@ -23,6 +24,8 @@ dev-scripts/ helios-bin/ radicle-bin/ radicle-data/ +arti-bin/ +tor-data/ dev-app-update.yml CLAUDE.md .codex diff --git a/CHANGELOG.md b/CHANGELOG.md index b651f2bf..5e8b0b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to Freedom will be documented in this file. - Searchable list grouped by category; click a binding and press the new combination - Conflict warning with a one-click swap when a combination is already taken - Per-shortcut reset and a Restore defaults button; changes apply without a restart +- Private windows (`Cmd+Shift+N` / `Ctrl+Shift+N`, File > New Private Window): ephemeral browsing on a per-window in-memory session with dark, badged chrome + - No history, favicon-cache, or autocomplete writes; cookies and site data evaporate on close + - Downloads are allowed but flagged and drop out of the downloads list when the window closes (files stay on disk); permission prompts work but decisions are session-only + - Wallet and `window.ethereum` / `window.swarm` / `window.radicle` providers are unavailable in private windows (EIP-6963 silent); x402 payment interception is off + - Honest private start page: what's protected (local traces) and what isn't (network observers, sites you log into, your IP) - Find in page (`Cmd+F` / `Ctrl+F`, also under Edit in the menu): overlay bar over the page with a live match counter, `Enter` / `Shift+Enter` to cycle matches, `Esc` to close - Download manager covering every download source, including `bzz://` and `ipfs://` content: - Shelf card with live progress and cancel; Open / Show in Folder on completion diff --git a/README.md b/README.md index 84d2957e..e4ac242a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Platform](https://img.shields.io/badge/platform-macOS%20|%20Linux%20|%20Windows-lightgrey)](https://github.com/solardev-xyz/freedom-browser/releases) Freedom is a browser for the decentralized web, with Swarm, IPFS, Radicle, ENS, and Tezos Domains as first-class protocols. -It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to-peer network access without relying on centralized HTTP gateways. Radicle is available on macOS and Linux; the Windows build ships without Radicle until official Windows binaries are published upstream. +It ships with integrated Swarm, IPFS, Radicle, and experimental Myotis nodes, enabling direct peer-to-peer network access without relying on centralized HTTP gateways. Radicle is available on macOS and Linux; the Windows build ships without Radicle until official Windows binaries are published upstream. --- @@ -24,6 +24,7 @@ It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to npm run ant:download npm run ipfs:download npm run radicle:download + npm run myotis:download ``` 4. **Launch the app:** @@ -32,15 +33,15 @@ It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to npm start ``` -5. Swarm and IPFS nodes start automatically by default. To use `rad://`, first enable **Settings → Experimental → Enable Radicle integration (Beta)**. Enter a Swarm hash, IPFS CID, Radicle ID, `bzz://` URL, `ipfs://` URL, `rad://` URL, or `.eth`/`.box`/`.wei`/`.gwei`/`.tez` domain in the address bar. +5. Swarm and IPFS nodes start automatically by default. Myotis is an opt-in embedded Ethereum light client under **Settings → Automatic Startup**. To use `rad://`, first enable **Settings → Experimental → Enable Radicle integration (Beta)**. Enter a Swarm hash, IPFS CID, Radicle ID, `bzz://` URL, `ipfs://` URL, `rad://` URL, or `.eth`/`.box`/`.wei`/`.gwei`/`.tez` domain in the address bar. --- ## Architecture -Freedom Browser is an Electron application. Protocol logic lives in the main process; the renderer is a modular UI layer that talks to it over IPC (channels defined in `src/shared/ipc-channels.js`). The main process manages node lifecycles (`ant-manager.js`, `ipfs-manager.js`, `radicle-manager.js`), URL rewriting (`request-rewriter.js`), and persistent data (settings, bookmarks, history). A central `service-registry.js` tracks node endpoints, modes, and status, and broadcasts state to all windows — both node managers and the request rewriter read from it. +Freedom Browser is an Electron application. Protocol logic lives in the main process; the renderer is a modular UI layer that talks to it over IPC (channels defined in `src/shared/ipc-channels.js`). The main process manages node lifecycles (`ant-manager.js`, `ipfs-manager.js`, `myotis/myotis-manager.js`, `radicle-manager.js`, `tor-manager.js`), URL rewriting (`request-rewriter.js`), and persistent data (settings, bookmarks, history). A central `service-registry.js` tracks node endpoints, modes, and status, and broadcasts state to all windows — both node managers and the request rewriter read from it. -When a user enters a `bzz://`, `ipfs://`, `ipns://`, `rad://`, or ENS URL, the main process either dispatches to a custom protocol handler (`bzz`, `ipfs`, `ipns`) that proxies to the local node, or rewrites the URL to the active gateway URL via the registry (`rad`). `rad://` handling is gated by the Radicle integration setting. `bzz://` navigation is additionally gated by a cold-start probe (see next section). `ipfs://` / `ipns://` navigation goes straight to the native IPFS protocol handler, so no renderer warm-up probe is needed. +When a user enters a `bzz://`, `ipfs://`, `ipns://`, `rad://`, `.onion`, or ENS URL, the main process either dispatches to a custom protocol handler (`bzz`, `ipfs`, `ipns`) that proxies to the local node, rewrites the URL to the active gateway URL via the registry (`rad`), or routes `.onion` hosts through the active profile's Tor SOCKS5 endpoint. `rad://` handling is gated by the Radicle integration setting, and `.onion` routing is gated by the Tor integration setting. `bzz://` navigation is additionally gated by a cold-start probe (see next section). `ipfs://` / `ipns://` navigation goes straight to the native IPFS protocol handler, so no renderer warm-up probe is needed. --- @@ -176,29 +177,30 @@ Don't hardcode `http://localhost:8080` — Freedom no longer exposes a desktop I ## Features -### Triple Node Architecture +### Integrated Node Architecture -Freedom runs Swarm, IPFS, and Radicle nodes, giving you access to three major decentralized networks from a single interface. +Freedom runs Swarm, IPFS, Radicle, and Tor nodes, plus an experimental Myotis Ethereum light client, giving you access to decentralized and onion networks from a single interface. -| | Swarm | IPFS | Radicle | -| -------------------- | -------------- | ------------------------------------- | ------------------------------ | -| **Protocol** | `bzz://` | `ipfs://`, `ipns://` | `rad://` | -| **Node Software** | Ant (antd, bee-compatible) | freedom-ipfs native | radicle-node + radicle-httpd | -| **Hash Format** | 64 or 128-char hex (encrypted refs supported) | CIDv0 (`Qm...`) or CIDv1 (`bafy...`) | Repository ID (`z...`) | -| **Managed Gateway Port** | 11633+ | internal native handler | 18780+ | -| **Managed API Port** | 11633+ | internal native handler | 18780+ | -| **Managed P2P Port** | 12633+ | internal native handler | 18776+ | -| **Route Prefix** | `/bzz/{hash}/` | `/ipfs/{cid}/`, `/ipns/{name}/` | `/api/v1/repos/{rid}/` | -| **Data Directory** | `/ant-data/` | `/ipfs-data/freedom-ipfs/` | profile-scoped short Radicle home | -| **Binary Directory** | `ant-bin/` | `native/freedom-ipfs-node/` | `radicle-bin/` | +| | Swarm | IPFS | Myotis | Radicle | Tor (.onion) | +| -------------------- | -------------- | ------------------------------------- | ------------------------------ | ------------------------------ | ----------------------------- | +| **Protocol** | `bzz://` | `ipfs://`, `ipns://` | Verified Ethereum/Gnosis reads and transaction broadcast | `rad://` | `http(s)://*.onion` | +| **Node Software** | Ant (antd, bee-compatible) | freedom-ipfs native | Myotis native addon | radicle-node + radicle-httpd | Arti SOCKS5 proxy | +| **Hash Format** | 64 or 128-char hex (encrypted refs supported) | CIDv0 (`Qm...`) or CIDv1 (`bafy...`) | n/a | Repository ID (`z...`) | Onion service hostname | +| **Managed Gateway Port** | 11633+ | internal native handler | none; embedded native client | 18780+ | n/a | +| **Managed API Port** | 11633+ | internal native handler | none; embedded native client | 18780+ | n/a | +| **Managed P2P Port** | 12633+ | internal native handler | none; embedded native client | 18776+ | n/a | +| **Managed SOCKS Port** | n/a | n/a | n/a | n/a | 19150+ | +| **Route Prefix** | `/bzz/{hash}/` | `/ipfs/{cid}/`, `/ipns/{name}/` | n/a | `/api/v1/repos/{rid}/` | SOCKS5 for `.onion` hosts | +| **Data Directory** | `/ant-data/` | `/ipfs-data/freedom-ipfs/` | `/myotis/` (Ethereum) and `/myotis/gnosis/` | profile-scoped short Radicle home | `/tor-data/` | +| **Binary Directory** | `ant-bin/` | `native/freedom-ipfs-node/` | `myotis-bin/` | `radicle-bin/` | `arti-bin/` | ### Smart Node Connection Freedom manages nodes per browser profile: -1. **Independent Managed Nodes**: By default, each profile starts its own Ant, native IPFS, and Radicle data directories. Ant and Radicle use profile-specific non-default ports; IPFS uses the embedded native handler without loopback API or gateway ports. -2. **Explicit External Nodes**: Profiles can opt into external Swarm/Radicle endpoints in profile settings. External node identity and storage are shared outside that profile. IPFS always uses the embedded `freedom-ipfs` native node. -3. **Port Conflict Handling**: If a managed Ant or Radicle profile port is busy, Freedom picks a free profile port and persists the reassignment. +1. **Independent Managed Nodes**: By default, each profile starts its own Ant, native IPFS, Myotis, Radicle, and Arti data directories. Ant, Radicle, and Tor use profile-specific non-default ports; IPFS and Myotis are embedded native clients without loopback API or gateway ports. +2. **Explicit External Nodes**: Profiles can opt into external Swarm/Radicle endpoints or an external Tor SOCKS5 endpoint in profile settings. External node identity, storage, and circuit state are shared outside that profile. IPFS and Myotis always use their embedded native clients. +3. **Port Conflict Handling**: If a managed Ant, Radicle, or Tor profile port is busy, Freedom picks a free profile port and persists the reassignment. 4. **Visual Feedback**: The Nodes panel and profile settings show whether a node is managed, external/shared, or disabled. This means Freedom works seamlessly whether you: @@ -224,6 +226,16 @@ launching can use `open -n -a Freedom --args --profile=`. - **Native Transport**: Uses the embedded `freedom-ipfs` native addon instead of a loopback Kubo process. - **Live Diagnostics**: View native gateway stats and request progress while IPFS/IPNS pages load. +### Integrated Myotis Ethereum Light Client (Experimental) + +- **Per-profile clients**: Each profile has independent Ethereum and Gnosis runtimes and state, and never reuses a separately installed Myotis application. +- **Native transport**: Runs in-process through the Myotis native addon, with no HTTP API or managed port. +- **Independent controls**: Ethereum and Gnosis have separate autostart, runtime toggle, sync status, peer count, and finalized-block controls. +- **Chain data routing**: Wallet balances, transaction preparation, signed-transaction broadcast, and compatible dapp reads prefer Myotis, then Colibri verification, RPC quorum, and direct RPC according to each chain's settings. Unsupported methods transparently continue to the next source. +- **Gnosis verification**: Both Myotis and Colibri are available as verified Gnosis sources; Colibri keeps separate verifier state per chain. +- **Verified ENS reads**: Once synced, ENS content, address, and forward-verified reverse records are resolved against finalized Ethereum state before Freedom falls back to its configured verification method. ERC-3668/CCIP-Read records use their declared gateway only to retrieve the callback payload; Myotis verifies the callback against the same chain state. +- **WNS/GNS adapters**: Freedom executes the existing WNS and GNS NameNFT calls through Myotis's local EVM path. Myotis v0.1.7 serves generic contract calls from the beacon optimistic head without a finalized verdict, so the UI labels those answers unverified rather than overstating their trust. + ### Integrated Radicle Node (macOS & Linux) - **Two-Process Architecture**: Manages both `radicle-node` (P2P network) and `radicle-httpd` (HTTP API) as a coordinated pair. @@ -259,6 +271,8 @@ The address bar also provides **autocomplete suggestions** from browsing history - **Automatic Resolution**: `.eth`, `.box`, `.wei`, and `.gwei` domains resolve to their Swarm, IPFS, or IPNS content. `.eth` and `.box` use ENS; `.wei` uses Wei Name Service (WNS); `.gwei` uses Gwei Name Service (GNS). - **CCIP-Read Support**: `.box` domains resolve via offchain CCIP-Read (EIP-3668) through 3dns.xyz. +- **Ordered Resolution Policy**: Settings → Name Resolution presents Myotis, Colibri, RPC quorum, and direct RPC as one ordered list. Methods can be enabled, disabled, and reprioritized per profile. By default Freedom tries Myotis, then Colibri, then RPC quorum; direct RPC is disabled. +- **Verified-Answer Preference**: An unverified result can be held provisionally while later enabled methods try to produce a verified answer. If none succeeds, Freedom uses the provisional result and applies the configured warning behavior. - **Protocol Detection**: Automatically detects and routes to Swarm (`bzz://`), IPFS (`ipfs://`), or IPNS (`ipns://`) content. - **Transport-Aware Address Bar**: After resolution, the address bar shows the resolved transport with the name as the host — e.g. `vitalik.eth` resolves and displays as `ipfs://vitalik.eth`, a Swarm-backed `mysite.eth` displays as `bzz://mysite.eth`, a WNS-backed `alice.wei` displays as `ipfs://alice.wei`, and a GNS-backed `apoorv.gwei` displays as `ipfs://apoorv.gwei`. The legacy `ens://` form is still accepted as input (and stored bookmarks keep working) but is no longer the canonical display. - **Typed Scheme Is an Assertion**: Typing `bzz://name.eth`, `ipfs://name.eth`, `ipns://name.eth`, or the equivalent `.wei`/`.gwei` forms only resolves if the contenthash matches the typed transport. Mismatches surface as a "resolves to X, not Y" message rather than silently switching transports — same rule the `bzz://` protocol handler enforces for subresource fetches. Bare names and the legacy `ens://` form make no assertion and accept any supported transport. @@ -289,6 +303,7 @@ The address bar also provides **autocomplete suggestions** from browsing history - **Home**: Return to the welcome page. - **Keyboard Shortcuts** (defaults; remap them under Settings > Shortcuts — click a binding, press the new combination, changes apply immediately; `Cmd+Q`, the standard Cut/Copy/Paste/Select-All/Undo set, and `F12` stay reserved): - `Cmd+N` / `Ctrl+N`: New window + - `Cmd+Shift+N` / `Ctrl+Shift+N`: New private window - `Cmd+T` / `Ctrl+T`: New tab - `Cmd+W` / `Ctrl+W` / `Ctrl+F4`: Close tab - `Cmd+Shift+T` / `Ctrl+Shift+T`: Reopen last closed tab @@ -321,6 +336,15 @@ The address bar also provides **autocomplete suggestions** from browsing history - **Automatic Recording**: Pages are recorded as you browse. - **History Page**: View and search your browsing history at `freedom://history`. +### Private Windows + +- **Open**: `Cmd+Shift+N` / `Ctrl+Shift+N` (the default — remappable under Settings > Shortcuts, applies immediately) or File > New Private Window. Private windows have a dark, badged chrome so they're recognisable at a glance. +- **Ephemeral by construction**: Every private window runs its webviews on a unique in-memory session (`private-` partition, never written to disk). Cookies, logins, caches, and site data evaporate when the window closes. +- **No local traces**: Nothing browsed in a private window is written to history, the favicon cache, or address-bar autocomplete. Downloads still work, but their entries are kept in memory only — never written to the profile's download database, visible only inside the private window, and gone when it closes (saved files stay on disk). Site-permission decisions made in a private window last only as long as the window — never remembered, even if you tick "remember". +- **Wallet disabled**: Your identity and wallet are persistent by design, so they are unavailable in private windows — pages see no `window.ethereum` / `window.swarm` / `window.radicle` (nothing announces via EIP-6963), and x402 pay-per-request interception is off. Use a normal window for anything wallet-related. +- **Decentralized protocols still work**: `bzz://`, `ipfs://`, `ipns://`, and ENS names resolve and load through the shared local nodes. Publishing (which records publish history) is unavailable from private windows. +- **What private windows do NOT protect**: This is local privacy, not anonymity. Websites you sign in to still know it's you; your network operator can still see your traffic; Swarm/IPFS/Radicle peers still see your nodes' requests; and your IP address remains visible to every site and peer. The private new-tab page spells this out. + ### Downloads - **Download Manager**: Every download — http(s), `bzz://`, `ipfs://`/`ipns://`, and data URIs — is tracked with progress, pause/resume, and cancel. @@ -369,7 +393,7 @@ Access built-in browser pages using the `freedom://` protocol: ### Settings & UI - **Theme**: Light, Dark, or System (follows OS preference). -- **Node Auto-start**: Toggle whether Swarm and IPFS nodes start automatically at launch (enabled by default). +- **Node Auto-start**: Toggle whether Swarm, IPFS, and experimental Myotis start automatically at launch. Myotis remains off by default. - **Site Permissions**: When a site asks to use your camera, microphone, notifications, clipboard, location, or MIDI devices, a prompt appears under the address bar (Allow / Block, with "Remember for this site"). Remembered decisions are listed under Settings → Site Permissions with per-permission, per-site, and remove-all revocation; sites with granted permissions show an indicator icon in the address bar with quick revoke. - **Experimental**: Enable Radicle integration (Beta) and set `Start Radicle node when Freedom opens`. - **Auto-Updates**: Toggle automatic update checks (enabled by default). @@ -393,13 +417,15 @@ Freedom automatically manages node connections per profile. The default profile' - **Swarm Ant**: `http://127.0.0.1:11633` - **IPFS**: embedded native `freedom-ipfs` handler; no desktop loopback gateway/API port is started +- **Myotis**: embedded native Ethereum light client; no desktop loopback API or managed port is started - **Radicle httpd**: `http://127.0.0.1:18780` +- **Tor Arti SOCKS5**: `127.0.0.1:19150` -Named profiles use the next profile slot for Ant and Radicle (`11634`, `18781`, and so on). The ecosystem default Swarm/Radicle ports (`1633`, `8780`) are treated as external/system-node endpoints, not Freedom-managed defaults. IPFS is native-only and does not expose or reuse Kubo API/gateway ports. +Named profiles use the next profile slot for Ant, Radicle, and Tor (`11634`, `18781`, `19151`, and so on). The ecosystem default Swarm/Radicle/Tor ports (`1633`, `8780`, `9150`) are treated as external/system-node endpoints, not Freedom-managed defaults. IPFS and Myotis are native-only and do not expose or reuse external daemon ports. -If Freedom detects a compatible Swarm or Radicle daemon on an ecosystem default port for a protocol that would start at launch, it asks whether that profile should use the existing external node or keep an independent managed node. +If Freedom detects a compatible Swarm, Radicle, or Tor daemon on an ecosystem default port while that protocol is starting, it asks whether that profile should use the existing external node or keep an independent managed node. This check runs both during profile startup and when a managed node is started manually from the Nodes menu. -For advanced users who need to connect a profile to a remote or system Bee/Radicle node, use **Settings → Profiles → Node endpoints** and switch the relevant protocol to external mode. Development-only renderer gateway overrides are still available via environment variables: +For advanced users who need to connect a profile to a remote or system Bee/Radicle node, or to an external Tor SOCKS5 endpoint, use **Settings → Profiles → Node endpoints** and switch the relevant protocol to external mode. Development-only renderer gateway overrides are still available via environment variables: ```bash # Connect to a remote Swarm node @@ -410,11 +436,11 @@ npm start ### External Protocol Links And Profiles -Inside Freedom, `bzz://`, `ipfs://`, `ipns://`, and `rad://` URLs always resolve through the active profile's node settings and storage. OS-level protocol launches from other apps are a v1 limitation: they are not profile-aware and should not be used when a link must open in a specific profile. Open the target profile first and paste or navigate to the URL inside that window. +Inside Freedom, `bzz://`, `ipfs://`, `ipns://`, `rad://`, and `.onion` URLs always resolve through the active profile's node settings and storage. OS-level protocol launches from other apps are a v1 limitation: they are not profile-aware and should not be used when a link must open in a specific profile. Open the target profile first and paste or navigate to the URL inside that window. ### Ethereum Name Resolution -ENS, WNS, and GNS domains are resolved using Ethereum JSON-RPC. ENS uses the ENS Universal Resolver; WNS reads the Wei Name Service contract directly; GNS reads the Gwei Name Service contract directly. The browser tries multiple public RPC providers in sequence (see `src/main/ens-resolver.js` for the current list). You can prepend your own endpoint by setting the `ETH_RPC` environment variable. +ENS, WNS, and GNS domains share an ordered, per-profile resolution policy. Myotis resolves through the embedded P2P light client, Colibri verifies remote proofs locally, RPC quorum requires byte-identical responses from independent providers, and direct RPC trusts one configured endpoint. ENS uses the ENS Universal Resolver; WNS reads the Wei Name Service contract directly; GNS reads the Gwei Name Service contract directly. Configure method order under **Settings → Name Resolution** and RPC endpoints under **Settings → Chains**. **Recommended: Helios Light Client** @@ -447,6 +473,7 @@ Edit `src/renderer/pages/home.html` to customize the welcome view shown on start | `npm test` | Run unit tests (Jest) | | `npm run test:e2e` | Run the harness E2E suite (stubbed nodes; fast, no network) | | `npm run test:e2e:live` | Run the live E2E suite (real Ant + IPFS + ENS; manual only) | +| `npm run test:e2e:tor` | Run the live Tor `.onion` E2E suite (real Arti; manual only) | | `npm run ant:download` | Download the Ant (antd) binary for your platform | | `npm run ant:status` | Check the default profile's Freedom-managed Ant (`11633`) | | `npm run system-ant:start` / `system-ant:status` / `system-ant:stop` | Run or inspect a repo-root system Ant on the ecosystem default port (`1633`) | @@ -481,18 +508,76 @@ profile-managed dev data. | `npm run radicle:status` | Check the default profile's Radicle httpd root endpoint | | `npm run radicle:reset` | Delete all Radicle data and start fresh | +### Tor Scripts + +| Script | Description | +|--------|-------------| +| `npm run tor:download` | Build the Arti (Rust Tor client) binary for your platform | +| `npm run test:e2e:tor` | Start real Arti and load a live `.onion` service | +| `npm run tor:reset` | Delete legacy repo-root Tor data from earlier dev builds | + +--- + +## Tor (.onion) Access + +Freedom can reach Tor `.onion` services using [Arti](https://arti.torproject.org/), +the Tor Project's pure-Rust Tor client. It is **off by default** and gated behind +**Settings → Experimental → Enable Tor (.onion access) (Beta)**. + +- **Scope is `.onion`-only.** When enabled, only `*.onion` hostnames are routed + through Tor; clearnet and the decentralized protocols (bzz/ipfs/ipns/rad) keep + connecting directly. Routing is done with a PAC script on the Electron session + (`src/main/tor-proxy.js`) that returns the active profile's SOCKS5 endpoint for `.onion` and + `DIRECT` for everything else. SOCKS5 does remote DNS, so the onion name resolves + at Tor — no custom scheme is needed; `.onion` is just an ordinary http(s) host + (defaulted to `http://` since most onion services are http-only). +- **Every session, including private windows.** A PAC applies to exactly one + Electron session, so `src/main/tor-manager.js` tracks the default session plus + each live private-window partition and applies the same routing to all of them + — a private window opened before *or* during a Tor session routes `.onion` + through the proxy instead of resolving it DIRECT (which would leak the onion + hostname to the system resolver). +- **Fails closed.** If Arti exits unexpectedly, or a start times out, the PAC is + left in place: `.onion` requests fail with a proxy error rather than silently + falling back to a DIRECT (DNS-leaking) lookup. Only a deliberate stop — the + node-status toggle, disabling the integration, or app shutdown — restores + DIRECT. +- **Lifecycle.** `src/main/tor-manager.js` spawns the bundled `arti` binary as a + profile-scoped local SOCKS5 proxy (`arti proxy -c `), waits for Arti + to report that it is sufficiently bootstrapped, health-checks the SOCKS5 + endpoint, applies/clears the proxy, and reports status through the service + registry — the same pattern as the Bee / Radicle managers. +- **Profiles.** Managed Tor uses `127.0.0.1:19150` for the default profile, then + the next slot for named profiles (`19151`, `19152`, and so on). The conventional + Tor Browser SOCKS port `9150` is treated as an external/system endpoint; a + profile can opt into it with **Settings → Profiles → Node endpoints**. +- **Status readout.** The node-status menu's Tor section shows the SOCKS endpoint + and the Arti software version (`arti --version`, via `getArtiVersion`). Exit-node + details are intentionally not shown: `.onion` connections have no exit node, so + an exit indicator only becomes meaningful once clearnet-over-Tor lands. +- **Binary.** Arti has no clean prebuilt-binary distribution, so `npm run + tor:download` builds it from crates.io via `cargo install` (requires a Rust + toolchain). The binary lands in `arti-bin/-/arti` and is bundled + via electron-builder `extraResources`. Bundling is **optional**: if `arti-bin` + hasn't been built, `npm run build/dist` still succeeds (a non-fatal warning from + `check-binaries.js`) and simply ships without Tor — the in-app toggle stays + disabled until the binary is present. Like Radicle, Tor is macOS/Linux-only for + now; the toggle is hidden on Windows. +- **Data.** Arti state/cache live under `/tor-data` (override with + `FREEDOM_TOR_DATA`). + --- ## Project Structure | Directory | Contents | | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `src/main/` | Electron main process — node managers (Ant, IPFS, Radicle), ENS resolver, IPC, settings, history, bookmarks, auto-updater | +| `src/main/` | Electron main process — node managers (Ant, IPFS, Myotis, Radicle), ENS resolver, IPC, settings, history, bookmarks, auto-updater | | `src/renderer/` | UI — tabs, navigation, address bar, menus, context menus, bookmarks bar, debug console, settings modal | | `src/renderer/pages/` | Internal pages (home, history, error, links, protocol-test, rad-browser) | | `src/shared/` | Constants shared between main and renderer | | `config/` | Ant config template, default bookmarks, macOS entitlements | -| `scripts/` | Build and setup helpers (binary downloads, Ant/IPFS/Radicle init) | +| `scripts/` | Build and setup helpers (binary/addon downloads, Ant/IPFS/Myotis/Radicle init) | | `assets/` | App icons | --- @@ -511,7 +596,7 @@ npm test The suite covers most of `src/main/` and `src/renderer/lib/` — see `src/**/*.test.js` for the full list. Notable areas include: -- **Networking & protocols**: `bzz-protocol`, `swarm-probe`, `swarm-service`, `swarm-provider-ipc`, `request-rewriter`, `ens-resolver`, `ipfs-manager`, `radicle-manager`, `ant-manager`, `service-registry` +- **Networking & protocols**: `bzz-protocol`, `swarm-probe`, `swarm-service`, `swarm-provider-ipc`, `request-rewriter`, `ens-resolver`, `ipfs-manager`, `myotis-manager`, `radicle-manager`, `ant-manager`, `service-registry` - **Renderer navigation & UI**: `navigation`, `navigation-utils`, `tabs`, `tabs-ui`, `bookmarks-ui`, `autocomplete`, `menus`, `page-context-menu`, `settings-ui`, `wallet/*` - **Identity, vault & wallet**: `identity/derivation`, `identity/vault`, `identity/formats`, `wallet/dapp-permissions`, `wallet/transaction-service` - **Parsing & utilities**: `url-utils`, `cid-utils`, `origin-utils`, `ethereum-uri`, `page-urls`, `brand` @@ -524,9 +609,9 @@ Two Playwright projects live under `test-e2e/`. The harness suite is run manuall | Suite | Command | Files | What it does | | --- | --- | --- | --- | | `harness` | `npm run test:e2e` | `test-e2e/*.spec.js` | Launches Electron with `FREEDOM_TEST_MODE=1`. The in-process harness in `src/main/test-harness.js` stubs Ant/IPFS startup, ENS resolution, the Swarm probe, and the `bzz:` / `ipfs:` / `ipns:` protocol handlers, so specs are fast (~15 s end-to-end), deterministic, and require no network or downloaded binaries. Covers address-bar normalisation, tabs, bookmarks, settings persistence, and the error-page flow. | -| `live` | `npm run test:e2e:live` | `test-e2e/live/*.spec.js` | Launches Electron without the harness — actual Ant + native IPFS startup, live ENS resolution, real `bzz://` / `ipfs://` protocol handlers. The live smoke waits for Swarm peers and for native IPFS to report running, then navigates to `meinhard.eth` (Swarm) and `vitalik.eth` (IPFS). Requires `npm run ant:download` and `npm run ipfs:download` first; missing binaries/addons skip before Electron launches. | +| `live` | `npm run test:e2e:live` | `test-e2e/live/*.spec.js` | Launches Electron without the harness — actual Ant + native IPFS startup, live ENS resolution, real `bzz://` / `ipfs://` protocol handlers. The live smoke waits for Swarm peers and for native IPFS to report running, then navigates to `meinhard.eth` (Swarm) and `vitalik.eth` (IPFS). `npm run test:e2e:tor` runs the Tor-only live smoke against real Arti and a live `.onion` service. Requires the matching binary download/build first; missing binaries/addons skip before Electron launches. | -Both suites use a per-run temp `userData` directory (`FREEDOM_TEST_USER_DATA`) so they never touch your real settings, bookmarks, or history. Sequential runs only (`workers: 1`) — Electron + protocol-scheme registration and Ant port detection don't tolerate parallel app instances. +`npm run test:e2e:tor` sets `FREEDOM_LIVE_E2E_DISABLE_DEFAULT_NODES=1`, which makes the live fixtures seed settings that keep Ant / IPFS / Radicle / Tor from autostarting, so the Tor smoke doesn't pay for (or inherit the flakiness of) the other nodes booting. Both suites use a per-run temp `userData` directory (`FREEDOM_TEST_USER_DATA`) so they never touch your real settings, bookmarks, or history. Sequential runs only (`workers: 1`) — Electron + protocol-scheme registration and Ant port detection don't tolerate parallel app instances. ### Logging @@ -728,7 +813,7 @@ npm run start:test-updater - **Context Isolation**: Uses `contextIsolation: true` and `nodeIntegration: false`. - **Remote Module Disabled**: The remote module is not available. - **Minimal API Surface**: Only necessary IPC methods are exposed to the renderer. The `freedomAPI` (history, bookmarks, etc.) is restricted to internal `freedom://` pages — external websites cannot call it. -- **Local Nodes**: Ant, IPFS, and Radicle run locally; no external services required for basic operation. +- **Local Nodes**: Ant, IPFS, Myotis, Radicle, and Tor run locally when their integrations are enabled; no external services are required for basic operation. - **Permission Handling**: Web permissions are deny-by-default with per-site prompts for camera, microphone, notifications, clipboard reading, location, and MIDI. Decisions marked "Remember for this site" persist per profile (`permissions.json`, reviewable under Settings → Site Permissions); unremembered decisions last for the session. Pointer lock and fullscreen remain auto-allowed for better UX in Swarm/IPFS apps; everything else (HID, screen capture, …) is denied. Location grants expose the API but may not resolve reliably — Electron lacks Chromium's network location service. - **Public RPC Fallback**: ENS resolution uses public RPCs by default. For trustless verification, use a local Helios client. @@ -758,7 +843,7 @@ npm run start:test-updater ### Using an external node -- If you have a system-wide Swarm or Radicle daemon running, configure external mode in **Settings → Profiles → Node endpoints** +- If you have a system-wide Swarm/Radicle daemon or Tor SOCKS5 endpoint running, configure external mode in **Settings → Profiles → Node endpoints** - External mode is per profile and per protocol - The Nodes panel shows external/shared status when connected to an external node - Freedom does not stop external nodes on quit diff --git a/jest.config.js b/jest.config.js index a0f57a89..95c72a1b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,6 +28,6 @@ module.exports = { '^.+\\.js$': 'babel-jest', }, transformIgnorePatterns: [ - '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed)/)', + '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed|@openlv|websocket-mqtt|ts-pattern)/)', ], }; diff --git a/package-lock.json b/package-lock.json index ca580b2f..97b0f990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", @@ -34,14 +36,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "engines": { "node": ">=18" @@ -1469,6 +1474,16 @@ "@babel/core": "^8.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", @@ -1842,6 +1857,448 @@ "node": ">=18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3829,11 +4286,125 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@openlv/core": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@openlv/core/-/core-0.0.2.tgz", + "integrity": "sha512-Q7ISpgQX8YkKw25hs76SgDjsIosFkiA/28P/p9b+MvpnN8cymCJDauWA31SSNSyMOrsOS/iMK4I5VHCcwwC5RA==", + "license": "LGPL-3.0-only", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@openlv/core/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/core/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/session": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/session/-/session-0.0.3.tgz", + "integrity": "sha512-s5Q6BIYAXEnmGuOIW3IQYbY1qW9YS5R5ytgwEjSYWJHiJCOVBu8gcNCQ9NIZ9wI6XLlprKFSdHkw6pzP7pAx4g==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "@openlv/signaling": "0.0.3", + "@openlv/transport": "0.0.3", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/session/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/signaling": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/signaling/-/signaling-0.0.3.tgz", + "integrity": "sha512-U2WBRN05DjC6keeTWG7p2WAcm71XDkHjqC8GMpU6LeCcmoqAHIO/rfLcRZxpr3E+BImSblLVe6nwuImAN/v5tg==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5", + "globals": "^16", + "gun": "^0.2020.1241", + "ts-pattern": "^5", + "websocket-mqtt": "0.0.7" + } + }, + "node_modules/@openlv/signaling/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/transport": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/transport/-/transport-0.0.3.tgz", + "integrity": "sha512-nqQnYk/jATPx25zIHFG409sVlNRrDYjxrqXFkmr+zmSlbBSq29Blk/XzwK/7xCcaiDf4tZu+hnf2V0b2YU2IuQ==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/transport/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -3845,7 +4416,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -3858,7 +4429,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -3868,7 +4439,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -4490,6 +5061,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -4911,6 +5492,19 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -4934,6 +5528,88 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + } + }, + "node_modules/aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mqtt-packet": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-packet/node_modules/mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/aes-js": { "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", @@ -5309,7 +5985,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -7126,6 +7802,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7510,6 +8228,16 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7614,6 +8342,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -7631,6 +8373,37 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==", + "dev": true, + "license": "ISC" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8114,6 +8887,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gun": { + "version": "0.2020.1241", + "resolved": "https://registry.npmjs.org/gun/-/gun-0.2020.1241.tgz", + "integrity": "sha512-rmGqLuJj4fAuZ/0lddCvXHbENPkEnBOBYpq+kXHrwQ5RdNtQ5p0Io99lD1qUXMFmtwNacQ/iqo3VTmjmMyAYZg==", + "license": "(Zlib OR MIT OR Apache-2.0)", + "dependencies": { + "ws": "^7.2.1" + }, + "engines": { + "node": ">=0.8.4" + }, + "optionalDependencies": { + "@peculiar/webcrypto": "^1.1.1" + } + }, + "node_modules/gun/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8308,6 +9117,29 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -10756,6 +11588,97 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "dev": true, + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mqemitter/node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/mqtt-packet/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/mqtt-packet/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11599,6 +12522,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11691,7 +12624,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11701,12 +12634,22 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.0.0" } }, + "node_modules/qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -12056,6 +12999,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "worker-timers": "^7.0.75" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -12065,6 +13018,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -12748,6 +13712,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -13037,6 +14007,13 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13138,7 +14115,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -13148,6 +14125,12 @@ "tslib": "^2.8.1" } }, + "node_modules/websocket-mqtt": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/websocket-mqtt/-/websocket-mqtt-0.0.7.tgz", + "integrity": "sha512-+33fT52HJ3IlJdybH/em5TNmswwxcDCSyubernwVCaXKf7/RCEg95Lr2U51Cgev1/dqi9dUgOyozhUK/09e4IA==", + "license": "LGPL-3.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13180,6 +14163,43 @@ "node": ">=0.10.0" } }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13281,6 +14301,16 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index fc1b8a64..c89c3078 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:e2e": "playwright test --project=harness", "test:e2e:live": "playwright test --project=live", "test:e2e:onboarding": "playwright test --project=live test-e2e/live/onboarding-identity.spec.js", + "test:e2e:tor": "FREEDOM_LIVE_E2E_DISABLE_DEFAULT_NODES=1 playwright test --project=live test-e2e/live/tor-onion.spec.js", "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", @@ -36,10 +37,12 @@ "dist:mac:notary-status": "dotenv -- node scripts/macos-notary.js status", "dist:mac:notary-log": "dotenv -- node scripts/macos-notary.js log", "dist:mac:staple-notary": "dotenv -- node scripts/macos-notary.js staple", - "dist:linux:arm64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/arm64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run dist -- --linux --arm64\"", - "dist:linux:x64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/amd64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run dist -- --linux --x64\"", + "dist:linux:arm64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/arm64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run myotis:download && npm run dist -- --linux --arm64\"", + "dist:linux:x64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/amd64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run myotis:download && npm run dist -- --linux --x64\"", "adblock:download": "node scripts/fetch-adblock-lists.js", "ant:download": "node scripts/fetch-ant.js", + "myotis:download": "node scripts/fetch-myotis.js", + "myotis:smoke": "node scripts/smoke-myotis.js", "ant:init": "node scripts/init-ant.js", "ant:start": "echo 'ant:start is a legacy alias for system-ant:start (repo-root ant-data on ecosystem port 1633).'; npm run system-ant:start", "ant:stop": "echo 'ant:stop is a legacy alias for system-ant:stop.'; npm run system-ant:stop", @@ -55,12 +58,17 @@ "ipfs:build": "node scripts/fetch-freedom-ipfs-native.js --from-source", "ipfs:native:smoke": "node scripts/smoke-freedom-ipfs-native.js", "ipfs:reset": "rm -rf ipfs-data && echo 'Legacy repo-root IPFS data reset.'", + "tor:download": "node scripts/fetch-arti.js", + "tor:reset": "rm -rf tor-data && echo 'Tor data reset.'", "radicle:download": "node scripts/fetch-radicle.js", "radicle:init": "node scripts/init-radicle.js", "radicle:status": "RADICLE_API=${RADICLE_API:-http://127.0.0.1:18780}; curl -s \"$RADICLE_API/\" | head -c 500", "radicle:reset": "rm -rf radicle-data && echo 'Radicle data reset.'", "identity:reset": "rm -rf ant-data ipfs-data radicle-data identity-data && echo 'All identity data reset (ant, ipfs, radicle, vault).'", "dapp:reset": "node scripts/reset-dapp-permissions.js", + "vendor:openlv": "node scripts/bundle-openlv.js", + "bridge:serve": "node scripts/serve-bridge.js", + "openlv:ios-harness": "node scripts/openlv-ios-harness.js", "serve:updates": "node scripts/serve-updates.js", "start:test-updater": "ENABLE_DEV_UPDATER=true npm start", "postinstall": "electron-builder install-app-deps" @@ -98,6 +106,13 @@ "filter": [ "**/*" ] + }, + { + "from": "arti-bin/${os}-${arch}/", + "to": "arti-bin", + "filter": [ + "**/*" + ] } ] }, @@ -126,6 +141,13 @@ "filter": [ "**/*" ] + }, + { + "from": "arti-bin/${os}-${arch}/", + "to": "arti-bin", + "filter": [ + "**/*" + ] } ] }, @@ -161,6 +183,13 @@ "freedom_ipfs_native.node" ] }, + { + "from": "myotis-bin/${os}-${arch}/", + "to": "myotis-node", + "filter": [ + "myotis-node.node" + ] + }, { "from": "assets/", "to": "assets", @@ -186,14 +215,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "dependencies": { "@adraffy/ens-normalize": "^1.11.1", @@ -204,6 +236,8 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", diff --git a/research/permission-manifest-design.md b/research/permission-manifest-design.md new file mode 100644 index 00000000..2f73d011 --- /dev/null +++ b/research/permission-manifest-design.md @@ -0,0 +1,778 @@ +# Permission Manifests — one-consent Swarm grants for dweb apps + +Status: DESIGN v3.1 — revised after three peer-review passes (2026-07-20). +v1's capability/discovery/provenance gaps and v2's consent-state, +crash-recovery, identity-preservation, and navigation-lifecycle gaps are +addressed below; v3.1 adds non-voluntary privileged-method freshness and +fail-closed transport-switch handling. Implemented as a first version in +[PR #168](https://github.com/solardev-xyz/freedom-browser/pull/168). The +implementation-neutral interoperability profile is +[swarm-app-permission-manifest.md](swarm-app-permission-manifest.md). +Scope: **Swarm permissions only, bzz-hosted apps only (v1).** A wallet +extension is sketched in Appendix A but is explicitly NOT part of this +proposal — see §0. +Audience: freedom-browser implementer (desktop Electron first; iOS later) +Origin: ddrive/Freedom Office onboarding work (2026-07). Companion ideas: +`swarm_deriveAppSecret` (not covered here), origin continuity (partially +already shipped — see §1.4). + +--- + +## 0. TL;DR and scope rationale + +A dweb app ships a declarative `freedom-manifest.json` alongside its +build. On first connect the browser fetches it, renders ONE consolidated +consent sheet, and a single Approve projects the whole batch into the +EXISTING permission stores. Main-process method authorization and limits +do not change; the renderer gains a manifest-freshness gate before any +privileged method may consume stored authority (§5.1). The manifest is a +batch-grant front-end over grants that already exist. +The grant is bound to the manifest's **capability set**: a redeploy that +broadens it triggers a diff prompt; one that narrows or removes it prunes +manifest-managed authority. Manual Settings changes always win over the +manifest (§6.3). + +**Why Swarm-only.** The dialog fatigue is entirely on the Swarm side: +connect → publish approval → feed approval (its own grant + identity +choice) → signing approval → messaging-tier grant + send approvals. The +wallet is quiet by design: ddrive-class apps sign chain transactions with +their own derived agent key over plain RPC — `window.ethereum` never sees +them. The wallet's only touchpoints are one `personal_sign` at login +(one-time via the existing signing auto-approve, +`dapp-permissions.js:185-212`) and funding transfers, which must ALWAYS +prompt (`dapp-permissions.js:217` — hard invariant, manifest or not). +Nothing worth batching there today. + +**Why bzz-only (v1).** Named origins normalize transport away +(`bzz://name.gwei` and `ipfs://name.gwei` share one permission key — +§1.4), but manifest discovery must fetch from the transport the page +actually committed on. Rather than specify same-snapshot binding for +every transport, v1 supports manifests only for pages whose committed URL +is `bzz:` (raw ref or name) — sufficient for ddrive. First-contact and +unmanaged origins on other transports fall back to today's per-action +flow. A previously Bzz-manifest-tracked named origin is pruned before +that fallback so managed authority cannot cross the unsupported +transport boundary (§5.4). + +--- + +## 1. Current state (what the manifest layer sits on) + +### 1.1 Swarm permissions — `src/main/swarm/swarm-permissions.js` + +- Schema (`:8-15`): `{ origin, connectedAt, lastUsed, autoApprove: + { publish, feeds, signing, messaging }, messaging?: { grantedAt } }`. +- `VALID_AUTO_APPROVE_TYPES` (`:156`), all default false. +- `grantPermission(origin)` (`:78`), `grantMessaging(origin)` (`:208`), + `setAutoApprove(origin, type, enabled)` (`:183`). +- **Gaps to close for this design** (prerequisite work, §10): + `revokeMessaging` does not exist (only whole-record + `revokePermission`, `:103`); `onRevoke` (`:124`) holds a SINGLE + listener slot (provider layer uses it for subscription teardown) — turn + it into a listener list. + +### 1.2 Feed identity + feed grant — `src/main/swarm/feed-store.js` + +A separate store the v1 draft of this doc missed. Per-origin schema +(`:13`): `{ activeIdentityId, identities, feedGranted, grantedAt, +feeds }`. Key facts: + +- `feedGranted` (`hasFeedGrant`, `:879`) is its OWN consent, distinct + from `autoApprove.feeds`: the renderer's feed/signing path + (`swarm-provider.js:164-190`) prompts when the feed grant is missing + BEFORE it ever consults auto-approve, then requires an unlocked vault, + then consults auto-approve. Projecting only `autoApprove.feeds = true` + would therefore NOT remove the first feed prompt. +- The feed grant carries a **publisher identity choice** (`:17-23`): + default 'app-scoped' (dedicated key at `m/44'/73406'/{index}'/0/0`) vs + 'bee-wallet' (node-global). Identity switching is an explicit user + action; identities are never silently forgotten. +- Vault unlock is a RUNTIME condition, not a grant — manifests do not + and must not interact with it (the unlock prompt stays, §3). + +### 1.3 Prompt plumbing (renderer) + +`src/renderer/lib/swarm-provider.js` orders the checks (permission → +tier/feed grant → vault → auto-approve) and shows prompts; screens live +in `src/renderer/lib/wallet/swarm-connect.js` — `showSwarmConnect` +(`:250`), publish (`:416`), messaging (`:548`), feed/signing (`:680`). +`handleRequestAccess` (`swarm-provider.js:238`) SHORT-CIRCUITS when a +permission record exists — the manifest check for already-granted origins +must hook exactly there (§5.1). Origin comes from +`getDisplayUrlForWebview` (`:14,61`). + +### 1.4 Origin identity — `src/shared/origin-utils.js` + +Origins with an ENS-name host are keyed by NAME (`bzz://ddrive.gwei/x` → +`ddrive.gwei`); raw content origins by root ref. **Named-app grant +continuity across redeploys already exists.** The manifest layer adds: +continuity is only silent while the capability set is unchanged (§6). +Note the permission key LOSES transport — which is why discovery binds to +the committed page URL, not the key (§5.4). Keep the renderer mirror +`src/renderer/lib/origin-utils.js` in sync. + +--- + +## 2. Design overview + +1. **Manifest file** — `freedom-manifest.json` at the app origin root, + declaring capabilities from an explicit registry (§3) with plain-text + justifications (§4). +2. **Main-process-owned lifecycle** — fetch, validation, hashing, + diffing, and grant application all live in main; the renderer only + displays a model and approves an opaque pending-consent token (§7). +3. **One consent sheet** with three outcomes: Allow all / Connect with + individual approvals / Don't allow (§8). +4. **Projection with provenance** — Approve projects through existing + store APIs; each projected grant is marked manifest-managed; manual + Settings changes detach management and always win (§6). + +Invariants: + +- A manifest can only batch grants the browser could already give through + individual prompts + checkboxes. No new authority; no wallet reach + (unknown capability groups reject the whole manifest). +- Apps without a manifest: today's flow, unchanged. +- Main-process provider dispatch/authorization and LIMITS + (`swarm-provider-ipc.js`) are untouched. Renderer routing adds the + §5.1 freshness gate before stored grants are consulted. +- Vault unlock prompts are untouched. + +--- + +## 3. Capability registry (explicit, not derived) + +The schema is defined by this registry — NOT by whatever +`VALID_AUTO_APPROVE_TYPES` happens to contain. Each capability maps to +the COMPLETE set of grants that today's prompt sequence would produce: + +| Capability | Projects to | +|---|---| +| (implicit) | `swarm-permissions.grantPermission(origin)` — the base connect record | +| `publish` | `autoApprove.publish = true` | +| `feeds` | feed-store: `feedGranted = true` + ensure a publisher identity exists; `autoApprove.feeds = true` | +| `signing` | same feed-store grant + identity as `feeds`; `autoApprove.signing = true` | +| `messaging` | `grantMessaging(origin)` + `autoApprove.messaging = true` | + +Publisher identity projection is **ensure, never replace**: + +- If the origin already has an active publisher identity (identity + metadata survives disconnect), preserve it. Manifest approval never + switches a bee-wallet, Ethereum-wallet, or existing app-scoped choice. + The sheet names the identity that will remain active. +- If the origin has no identity, create the feed-store's + privacy-preserving app-scoped identity metadata and make it active. + The sheet states *"a new app-scoped signing identity will be created + for this app"*. The bee-wallet choice remains available in Settings. + +Creating the app-scoped feed-store record allocates derivation metadata; +it does not derive or expose the private key (`createAppScopedIdentity`, +`feed-store.js:600`, calls the metadata-only `createIdentity` at `:114`). +Key material is resolved later at cryptographic use +(`resolveSignerKey`, `swarm-provider-ipc.js:993-998`). Therefore identity +metadata **and `feedGranted` are projected +immediately even while the vault is locked**. Actual key use +still triggers the existing runtime vault-unlock prompt +(`swarm-provider.js:176-178`). There is no deferred permission state and +no provider-enforcement exception for manifests. + +`feeds` and `signing` share the feed-store projection; granting either +marks the feed grant manifest-managed once (provenance is per projected +FLAG, §6.3, so pruning `signing` alone never removes the feed grant that +`feeds` still justifies). + +--- + +## 4. Manifest schema + +`freedom-manifest.json` at the app origin root. Schema `v1`: + +```json +{ + "schema": "freedom-manifest/1", + "name": "ddrive", + "description": "Encrypted drive + docs on Swarm and Gnosis", + "capabilities": { + "swarm": { + "publish": { "why": "Store your encrypted files and documents" }, + "feeds": { "why": "Keep a stable address for each document" }, + "signing": { "why": "Anchor drive data in single-owner chunks" }, + "messaging": { "why": "Live collaboration presence and sync" } + } + } +} +``` + +Validation is strict and fail-closed (invalid manifest ⇒ per-action flow ++ console warning for the developer): + +- Every JSON object uses an explicit allowlist (`additionalProperties: + false` semantics). Root requires exactly `schema`, `name`, and + `capabilities`, with optional `description`; `schema` must equal + `freedom-manifest/1`. `capabilities` requires exactly a non-empty + `swarm` object. Each capability value requires exactly `{ "why": ... }`. + +- `capabilities.swarm.*` keys MUST be from the §3 registry. Unknown + swarm keys or unknown capability GROUPS (e.g. a future `"wallet"`) + reject the manifest as a whole — the forward-compat rule: an older + browser meeting a newer manifest ignores it entirely rather than + granting a subset its sheet never showed. +- `why`: non-empty string, ≤ 140 Unicode code points, plain text + (attacker-controlled — no markup/URLs honored). +- `name`: non-empty string, ≤ 32 Unicode code points. `description` is an + optional string ≤ 160 Unicode code points. The sheet shows the ORIGIN + as the primary identity; name/description are secondary flavor only + (§8). +- Reject (do not silently strip) C0/C1 controls, line/paragraph + separators, and Unicode bidi embedding/override/isolate controls in + all displayed strings. Validation and consent history therefore refer + to exactly the same sanitized-free values. +- Size cap 8 KB, enforced DURING streaming of the fetch (abort past the + cap, don't buffer-then-check). + +--- + +## 5. Discovery lifecycle + +### 5.1 When to check + +`swarm_requestAccess` is the eager/natural app-init trigger, but it is +**not the security boundary**: an already-authorized page can currently +call publish/feed/signing/messaging methods without calling +`requestAccess` again. Before the renderer consults any stored base, +tier, feed, or auto-approve grant for a privileged method, it calls +`ensureManifestFresh(webview, committedNavigation, origin)`. + +The freshness gate behaves as follows: + +- A manifest-tracked origin whose current committed navigation has not + been checked runs the full §5–6 lifecycle before the privileged method + continues. If a diff sheet appears, Allow all continues with projected + grants, individual approval continues into today's per-action prompt, + and Don't allow rejects the triggering method. +- An origin with no base permission still receives today's UNAUTHORIZED + result and must call `swarm_requestAccess`; a direct privileged call + does not become an alternate connection prompt. +- An existing untracked/legacy origin keeps today's behavior. Its bounded + manifest discovery remains tied to `swarm_requestAccess`; its authority + is user-owned rather than subject to a manifest-binding claim. +- Permission-free public methods (`swarm_getCapabilities`, public + reads/listing) bypass the gate. Teardown such as `swarm_unsubscribe` + also bypasses it so cleanup can never be blocked by discovery or UI. + +Concurrent eager or lazy checks are deduplicated per origin + committed +navigation (one in-flight check + sheet; all callers await it). A +completed check is cached only for that committed top-level navigation, +identified by the renderer-owned webContents/navigation sequence and +committed display URL — **not for the origin's whole browser session**. +Reloading or navigating a named origin starts a new check even when its +display URL is unchanged, so a redeploy observed during the same browser +launch still diffs/prunes promptly. + +- **No permission record** (first contact): fetch manifest. Found → + consent sheet. Not found / invalid / transport unsupported → legacy + `showSwarmConnect`. +- **Record exists, manifest-tracked** (a `manifest-grants.json` entry, + whether its acknowledged rows are managed or individual): fetch once + for every committed navigation that calls `requestAccess` or reaches + the lazy privileged-method gate. Hook both the `handleRequestAccess` + short-circuit (`swarm-provider.js:238`) and the top-level privileged + dispatch paths (`:72-113`). Outcome per §6.2. +- **Record exists, unmanaged** (legacy grant, or app added a manifest + later, or first-contact fetch failed transiently): retry discovery at + a bounded cadence — at most once per committed navigation and with a + browser-session backoff after `unresolved`. Re-enter through the DIFF + path (§6.2), treating a capability as already satisfied only when its + **complete §3 projection** is currently true. Fully satisfied, + user-owned capabilities are acknowledged as `individual` and are not + re-asked; partial projections are additions because the manifest asks + for persistent auto-approval, not merely the underlying tier grant. + This closes the progressive-enhancement trap where one transient + timeout at first contact would otherwise freeze an origin in legacy + mode forever. + +### 5.2 How to fetch + +Main process only. Resolve `/freedom-manifest.json` through +the browser's own content path — never an external gateway: + +- `bzz://` origins: local Bee/Ant HTTP API (`getAntApiUrl()`, + `src/main/service-registry.js`, as `swarm-provider-ipc.js:44`). +- Named origins committed on bzz: `resolveEnsContent(name)` + (`src/main/ens-resolver.js:1451`) → fetch within the snapshot returned + by the same resolver/cache path used by `bzz:` loading. Record that + resolved snapshot ref alongside the result (audit trail, §6.1). + +For a raw-ref URL, this is exact content binding. For a named URL, the +security principal is the normalized name: the committed URL does not +carry its resolved ref, so a later manifest check is not cryptographic +proof that the already-rendered page and manifest are byte-for-byte from +the same snapshot. Capturing the fetch snapshot closes accidental +cross-fetches and provides audit evidence; exact loaded-snapshot binding +is future hardening. Security claims in §9 intentionally use +**same-origin resolution path**, not "same committed bytes." + +Timeout: same bounded retrieval behavior as `bzz:` page content — a cold +collection entry can legitimately take longer than 2s; do NOT use an +aggressive fixed timeout that turns cold-cache into "no manifest". The +8 KB cap is enforced while streaming. + +### 5.3 Outcome classification (drives §6.2) + +- `found(manifestBytes)` — parsed + validated. +- `absent` — DEFINITIVE 404 within a successfully resolved snapshot. +- `unresolved` — timeout, node down, resolution failure. Never treated + as absent. +- `invalid` — present but fails validation. Treated like `absent` for + lifecycle purposes (it cannot express a capability set), plus dev + warning. +- `unsupported_transport` — committed page is not `bzz:`. Classification + is local and definitive; handling depends on whether the origin is + already manifest-tracked (§5.4). + +### 5.4 Transport binding + +Discovery uses the COMMITTED page URL (from the tab's display URL, the +same source the trust model already relies on), not the permission key: +the key has lost transport for named origins (§1.4). Consequently, +`bzz://name.gwei` and `ipfs://name.gwei` can consume the same stored +permission flags even though v1 can validate a manifest only for the +former. + +v1 rules for a committed URL that is not `bzz:`-transported: + +- First-contact or untracked/legacy origin → skip discovery and use the + legacy flow. +- Manifest-tracked origin → under the §7 journal/mutex, treat the managed + capability set as empty: prune every still-managed projection, preserve + user-owned/unmanaged grants, drop manifest tracking, then continue via + the legacy flow. This runs from both `requestAccess` and the lazy + privileged-method freshness gate, so changing transport cannot be used + to retain Bzz-manifest authority. + +Extending manifests to ipfs/ipns/https is future work and requires +per-transport retrieval and snapshot rules. + +--- + +## 6. Grant state: fingerprints, provenance, diffs + +### 6.1 `manifest-grants.json` (new store; same userData-JSON pattern, +module cache, `_resetCache`) + +```js +{ "": { + version: 1, + observed: { // latest successfully fetched manifest + capabilities: ["feeds", "messaging", "publish", "signing"], + capabilityFingerprint, + rawHash, // sha256 of served bytes — audit/debug only + snapshotRef, // snapshot used for this fetch — audit only + observedAt + }, + acknowledged: { // consent baseline, distinct from observation + "publish": { + decision: "managed" | "individual", + source: "sheet" | "existing-grant", + whyShown?, // present only when a sheet showed the row + decidedAt + }, ... + }, + managed: { // projection provenance: flag → owning rows + "swarm.autoApprove.publish": ["publish"], + "feedStore.feedGranted": ["feeds", "signing"], ... + }, + receipts: [{ // bounded audit trail of sheets acted upon + decidedAt, outcome: "managed" | "individual", + originShown, manifestNameShown, manifestDescriptionShown, + rows: [{ capability, browserLabelVersion, whyShown }], + builtInCopyVersion, rawHash, snapshotRef + }], + unresolvedSince?: number, + transaction?: { ... } // write-ahead recovery record (§7) +} } +``` + +`observed` answers "what does the current manifest request?"; +`acknowledged` answers "which rows has the user already made a batch or +individual decision about?" They MUST NOT be collapsed into one +fingerprint. This matters after a mixed diff whose removals were applied +but whose additions were rejected: observed might be `{feeds, +messaging}`, while acknowledged is only `{feeds}`. + +The semantic fingerprint includes the schema identifier plus sorted +capability keys. `rawHash` never drives authority. A redeploy that edits +only description/why/whitespace updates `observed.rawHash`, but existing +`acknowledged.*.whyShown` and receipts remain what the user actually saw; +new wording appears only on a future sheet containing that row. The +sheet says "bound to this set of permissions," not "this exact file." + +Receipts are capped (implementation constant; suggested latest 20 per +origin) so attacker-driven manifest churn cannot grow the store without +bound. `acknowledged` is operational state; receipts are display/audit +history. + +### 6.2 Per-navigation check outcomes (manifest-tracked origins) + +For `found`, let `current` be the served capability set and `known` be +the keys of `acknowledged`. First apply removals `known − current` through +the journaled mutation path (§7): remove their acknowledgement, remove +their ownership from `managed`, and prune flags whose owner list becomes +empty. These removals are unconditional and stick even if later +additions are rejected. + +Then compute additions `current − acknowledged`: + +- Before prompting, an addition whose complete §3 projection is already + true is acknowledged as `individual`; it is not re-asked and does not + acquire new managed provenance. Existing ownership belonging to a + different acknowledged row (for example signing's ownership of the + shared feed grant) remains unchanged. +- No remaining additions → update `observed`, silent continuity. +- Additions remain → show a DIFF sheet listing only those rows. + - **Allow all:** project only the shown rows, acknowledge them as + `managed`, and update provenance dependency-by-dependency: a false + flag is set and owned; an already manifest-managed shared flag gains + the new row as an owner; an already true-but-unmanaged flag stays + user-owned and gains no manifest owner. + - **Connect with individual approvals:** acknowledge the shown rows as + `individual` without projecting them. The same manifest does not + re-raise a batch sheet on the next navigation; those operations use + today's prompts. If a future manifest adds different rows, only the + new rows may be offered in a diff sheet. + - **Don't allow:** do not acknowledge additions and do not project + them; keep session-scoped rejection memory. They may be offered again + after that rejection scope expires. + +This single algorithm covers unchanged, additions-only, removals-only, +and mixed manifests without requiring `observed` to pretend rejected +rows were approved. For an already tracked origin, every successful +`found` updates `observed` even when additions are rejected; +`acknowledged` remains the consent baseline. First-contact **Don't +allow** is the exception: keep the observation/token only in session +memory and do not create a disk record for an origin that has neither a +permission nor an acknowledged decision. + +Non-`found` outcomes: + +- `absent` / `invalid` → treat as an EMPTY capability set: prune all + still-manifest-managed grants, drop the record (origin becomes an + unmanaged legacy grantee of whatever survives, i.e. user-made grants). + This is what makes "bound to the manifest" true — a named-origin + redeploy cannot shed its manifest yet inherit broad auto-approvals. +- `unsupported_transport` → for a manifest-tracked origin, use the same + empty-set prune/drop transition before legacy handling; for an + untracked origin, there is no manifest state to mutate (§5.4). +- `unresolved` → keep everything, set `unresolvedSince`, retry next + qualifying navigation subject to the §5.1 session backoff — never + auto-prune on `unresolved`. For a TRACKED origin the freshness gate + cannot be satisfied, so privileged methods FAIL with a temporary + availability error until a check succeeds (this is what PR #168 + implements and what the interoperability profile §2.3 requires: + "neither broadens nor revokes authority, but blocks its use until + freshness is established" — earlier revisions of this doc were + ambiguous here). Untracked origins are unaffected and continue + through per-action prompts. + +### 6.3 Provenance rules (the manual-override contract) + +Every flag the projection sets records the manifest rows that own it in +`managed`. Rules: + +- **Settings mutations detach.** Any manual toggle of a flag (either + direction) via the Settings/permission UI clears its `managed` entry. + Renderer-exposed Settings and per-action-prompt mutation IPCs call a + user-mutation wrapper that detaches; main-process manifest projection + uses separate internal setters and MUST NOT trigger detachment. Do not + accept a renderer-provided `source: "manifest"` escape hatch. +- **Diffs never touch unmanaged flags.** Additions: if the flag is + already true-but-unmanaged, approving the diff does NOT re-mark it + managed silently — it stays the user's. Removals: only flags still in + `managed` are pruned. Consequently: a user who manually disabled feeds + will never have feeds silently re-enabled by a later diff approval + that only showed messaging (re-projection re-applies ONLY the rows + shown+approved on the diff sheet, never the unchanged remainder). +- **Shared projections prune conservatively**: removing feeds/signing + removes that row from `managed["feedStore.feedGranted"]`; the feed grant + is demoted only when its owner list becomes empty. An `individual` row + is not a manifest owner. Publisher identity records and active-identity + selection are never managed or pruned by the manifest. +- Pruning uses real revocation APIs: `setAutoApprove(…, false)`, the new + `revokeMessaging`, and a feed-store demotion that clears `feedGranted` + WITHOUT deleting identities or feed records (identities are never + silently forgotten — `feed-store.js:23`). + +### 6.4 Full revocation + +Route Settings "disconnect" through a main-process origin-state +coordinator under the same §7 per-origin mutex. It journals the intent, +revokes the base permission, demotes feed access, cancels live resources +through the multi-listener `onRevoke` chain, then removes the manifest +record last. This makes disconnect win over an in-flight consent token +and closes the existing renderer-side two-IPC partial-disconnect window. +The manifest-store revoke listener remains as fallback cleanup for any +legacy/internal caller that invokes `revokePermission` directly; it must +not delete an in-progress disconnect journal before recovery can finish. + +--- + +## 7. Main-process flow: pending-consent tokens, atomicity + +The renderer NEVER sends a manifest as grant authority. One flow, owned +by main: + +``` +renderer main +requestAccess / privileged gate ──▶ manifest check (§5) + fetch, validate, fingerprint, diff +◀── { kind: 'consent'|'diff', + model, consentToken } (token: opaque, single-use, + session-scoped, bound to origin + + navigation + observed fingerprint + + manifest-record revision + shown rows) +render sheet from model +user decides ─────────────────────▶ manifest:decide(consentToken, outcome) + journal + apply outcome (§3, §6.2) +◀── connected/granted/rejected commit manifest record if mutated +``` + +The three authority stores are separate JSON files, so use a write-ahead +transaction in `manifest-grants.json`; **do not write the manifest record +last without a journal**. That would make partially projected flags look +user-owned after a crash and they could escape later pruning. + +All manifest-driven mutations — managed/individual decisions, automatic +removals, absent/invalid pruning, Settings detachment, and full +disconnect — run under a per-origin main-process mutex: + +1. Validate the token/revision when consent is involved. Compute explicit + set-to-value operations, provenance-owner changes, acknowledgement + changes, and the receipt/observed result. +2. Persist `transaction: { id, state: "applying", baseRevision, + observedFingerprint, operations, targetRecord }` **before** changing + any authority store. The durable transaction proves which partial + flags are manifest-owned. +3. Apply operations idempotently to swarm-permissions and feed-store. + Identity creation is ensure-if-absent; setters never toggle. Store + methods used by this coordinator MUST propagate persistence failures + instead of logging-and-returning success. +4. Verify that every authority-store write is durably persisted (not + merely reflected in a module cache). Persist `targetRecord` with the + transaction removed and an incremented revision only after all writes + succeed. The JSON stores, including the journal, use temp-file + + atomic-rename replacement so a process crash cannot leave truncated + JSON. On failure, leave `transaction` applying for recovery. + +On startup, before provider requests are served, recover every +`state:"applying"` transaction by finishing its idempotent operations and +committing `targetRecord`. The user decision or narrowing operation was +durable before projection began, so completion is safer than guessing +which partial writes to keep. The next normal manifest check handles any +deployment that changed while the browser was down. + +Consent tokens are logically single-use, but approval is retry-safe: the +main process keeps the in-flight/completed result for each token for the +session. A duplicate call returns/awaits that same result. An unknown +token, or a token whose origin/navigation/fingerprint/base revision no +longer matches before journaling starts, is stale and triggers a fresh +check. + +The pending-consent model also kills prompt races: one token per origin + +committed navigation at a time; matching `swarm_requestAccess` calls +while a sheet is open await the same resolution. The per-origin mutation +mutex serializes a navigation change, Settings detachment, disconnect, +and manifest approval so a stale token cannot overwrite newer state. + +--- + +## 8. Consent sheet (renderer) + +New screen alongside the existing ones in +`src/renderer/lib/wallet/swarm-connect.js`: + +``` + ddrive.gwei ← ORIGIN, primary identity + "ddrive — Encrypted drive + docs" ← manifest name/desc, secondary + + This app wants to use your Swarm node: + + ✓ Publish data Store your encrypted files and documents + ✓ Create and update feeds Keep a stable address for each document + A new app-scoped signing identity will be created for this app.¹ + ✓ Sign single-owner chunks Anchor drive data in single-owner chunks + ✓ Live messaging (PSS/GSOC) Live collaboration presence and sync + + Publishing uses your node's storage stamps and bandwidth. + This grant is bound to this set of permissions. If a future version + asks for more, you'll be asked again. Manage anytime in Settings. + + [ Don't allow ] [ Use individual approvals ] [ Allow all ] + + ¹ If an identity already exists, instead show: + "Uses your existing ; the manifest will not change it." +``` + +- **Three outcomes.** `Allow all` → token approval (§7). `Use individual + approvals` → grant only the base connection (when needed), persist + a manifest-tracked record acknowledging every shown row as + `individual`, and append an individual-outcome receipt. No + auto-approval/tier projection is performed. The app's later operations + therefore use today's operation/tier-specific prompts, but the same + manifest does not offer the batch sheet again on the next navigation. + A future manifest may offer only genuinely new rows. `Don't allow` → reject the + triggering request without acknowledging rows; rejection memory is + scoped to origin + observed fingerprint for the browser session, so a + different manifest is not accidentally suppressed. Next launch may + ask again. +- Row labels are browser-owned per capability; only the `why` column is + app text. Origin display: names as-is, raw refs truncated. +- Diff sheets render only the added rows with the same outcomes. The + individual option keeps old grants and acknowledges only the shown + additions as individual. + +--- + +## 9. Security analysis + +- **No authority expansion** — every projected grant is reachable today + via prompts + checkboxes; the manifest changes WHEN consent happens, + not WHAT is grantable. Vault-unlock and stamp economics untouched. +- **Consent fatigue is the threat model** — five sequential dialogs + train reflexive approval; one structured sheet is read with context. +- **Manifest = attacker-controlled input** — streaming size cap, schema + fail-closed, plain-text `why`/`name`, browser-owned row labels, origin + as primary identity (a manifest cannot dress up as another app). +- **Origin + resolution-path binding** — grants key off the + renderer-derived committed display URL (`swarm-provider-ipc.js:11-22`) + and manifests use the browser's own local `bzz:`/name-resolution path + (§5.2, §5.4). Raw refs bind exact bytes. Named origins bind the name + principal and record the fetch snapshot for audit; v1 does not claim + cryptographic equality with an already-rendered named snapshot. +- **Downgrade honesty** — capability-fingerprint binding + absent-means- + empty (§6.2) closes the "redeploy without a manifest, keep the broad + grants" hole. Unsupported transport is also empty for tracked origins, + so normalized name-key continuity cannot carry Bzz-managed authority + into IPFS/IPNS content; `unresolved` never prunes and never broadens. +- **Non-voluntary freshness** — `requestAccess` is the eager UX trigger, + but every privileged path is gated before stored manifest-managed + authority is consumed. An app cannot retain stale grants by omitting + `swarm_requestAccess` (§5.1). +- **Manual-override supremacy** — provenance rules (§6.3) guarantee an + unchanged manifest or unrelated diff can never re-enable what a user + turned off, and pruning never touches user-made grants. A capability + removed and later re-added is shown again; explicit approval of that + row is allowed to supersede the older manual choice. +- **Crash-safe provenance** — a durable write-ahead record exists before + any cross-store projection or prune, so recovery cannot misclassify a + partially written manifest flag as user-owned (§7). + +--- + +## 10. Implementation plan + +Foundation work (substantial infrastructure; split into independently +landable changes where practical, but schedule as part of the M1 security +unit): + +- `swarm-permissions.js`: add `revokeMessaging`; convert `onRevoke` to a + listener list. +- `feed-store.js`: expose a projection API — grant feed access with + ensure-if-absent app-scoped identity provisioning while preserving any + active identity, and a demotion that clears `feedGranted` without + touching identities/feeds. +- Manifest store: versioned observed/acknowledged/provenance records, + per-origin revision/mutex, write-ahead transaction recovery before + provider startup, and bounded receipts (§6–7). +- Authority-store mutation APIs used by the coordinator: atomic file + replacement, propagated write failures, and durable-success results; + cache-only verification is insufficient (§7). +- Renderer-exposed Settings and per-action write paths: use dedicated + user-mutation wrappers that detach provenance (§6.3); manifest code + calls internal setters. +- Replace the renderer's sequential permission/feed disconnect calls + with the journaled main-process origin-state coordinator (§6.4). +- Add `ensureManifestFresh` to renderer request routing before every + privileged method that can consume base/tier/feed/auto-approve state; + explicitly exempt permission-free reads and teardown (§5.1). + +**M1 — the security unit (ships together, not separately):** manifest +fetch/validate/fingerprint (§4–5), pending-consent flow (§7), consent + +diff sheets with three outcomes (§8), projection with provenance (§3, +§6.3), full lifecycle including absent-prunes and unresolved handling +(§6.2), privileged-method freshness gating and unsupported-transport +pruning (§5), session rejection memory, AND the Settings surface (show the +acknowledged capability decisions, effective grant state, and consent +receipts per origin; per-row revoke with detach). Launching batch grants +without the downgrade lifecycle or +visibility would be a net security regression — they are one unit. + +Tests: strict-schema fixtures (valid / missing or extra fields / unknown +group or key / empty capabilities / description limits / bidi-controls / +oversize streaming / non-JSON); projection round-trip against the REAL +stores, including locked-vault metadata provisioning and preservation of +each existing identity mode; diff matrix (unchanged / add / remove / +mixed-approve / mixed-reject / absent / invalid / unresolved / +unsupported-transport); +observed-vs-acknowledged and individual-decision persistence; provenance +matrix (manual-disable then unrelated diff; remove/re-add with explicit +approval; manual-enable then manifest-remove; shared feeds/signing +owners); crash recovery after every journal/store write boundary; +durable-write failure with cache divergence and truncated-file recovery; +duplicate-token result replay and stale-token rejection; concurrent +requestAccess dedupe plus a second committed navigation in the same +browser session; direct publish/feed/signing/messaging calls without +`requestAccess`; tracked `bzz://name` → `ipfs://name` transport switch; +public-read and unsubscribe gate bypass; fetch-failure → legacy fallback +→ later unmanaged upgrade. + +**M2 — polish:** unresolved-notice UX, iOS port (separate Swift stores, +same design — `swarm-mobile-ios` SwarmPermissionStore/feed equivalents). + +App-side integration (completed in ddrive): emit `freedom-manifest.json` +from `freedom-drive/scripts/deploy-workspace.mjs` into the collection +root. One manifest per origin — co-deployed drive+docs share it (the +union = ddrive's list above). + +--- + +## Appendix A — future wallet extension (NOT part of this proposal) + +Recorded so the thinking isn't lost; do not build any of this now. + +**Why it's out of scope:** ddrive-class apps sign chain transactions with +their own derived agent key directly over RPC — the browser wallet never +sees them. The wallet's only touchpoints are one `personal_sign` at login +(one-time via the existing signing auto-approve, +`dapp-permissions.js:185-212`) and funding transfers, which must always +prompt (`:217`). There is no wallet dialog fatigue to fix today. + +**The trigger that would change this:** moving app transaction signing +INTO the browser wallet. That would be a real security upgrade — it +eliminates the hot agent private key the app keeps in localStorage — but +it is only ergonomically survivable with pattern-scoped auto-approve, +because every ddrive portal is a freshly minted contract and per-contract +rules (`isTransactionAutoApproved`, `dapp-permissions.js:224-240`) never +generalize. If that day comes, the manifest grows a `wallet` capability +group with: + +- **Named sign messages**: exact message strings, narrower than the + blanket signing flag (store field `autoApprove.signMessages`). +- **Registry-mediated tx scopes**: "allow calls to any contract this + registry attests it created", restricted to declared function + signatures (browser derives selectors), `value == 0` enforced, + fail-closed on RPC errors. Verification against the Fileverse registry + is already confirmed feasible (local `fileverse-smartcontracts` + checkout, `contracts/FileversePortalRegistry.sol`): + `portalInfo(address) → Portal` (`:120`, call-verify — zeroed struct for + unknown addresses), `event Mint(address indexed account, address + indexed portal)` (`:51`, log-verify), and `ownedPortal(owner, …)` + (`:164`) for a tightest-scope "owned-by-caller" variant. Real + signatures for the ddrive case: `addFile(string,string,string,uint8, + uint256)`, `editFile(uint256,string,string,string,uint8,uint256)`, + `updateMetadata(string)`, `mint(string,string,string,bytes32,bytes32, + bytes32,bytes32)`. + +The v1 schema's fail-closed rule for unknown capability groups (§4) is +what makes this a clean later addition: a `wallet` group in a manifest +today rejects the whole manifest (per-action flow), so an M1-era browser +can never be tricked into granting wallet scopes it cannot render. diff --git a/research/swarm-app-permission-manifest.md b/research/swarm-app-permission-manifest.md new file mode 100644 index 00000000..db5de1ac --- /dev/null +++ b/research/swarm-app-permission-manifest.md @@ -0,0 +1,370 @@ +# Swarm Application Permission Manifest + +Status: **Draft interoperability profile, version 1** (2026-07-20). + +This document specifies the portable behavior of the application permission +manifest implemented by Freedom Browser in +[PR #168](https://github.com/solardev-xyz/freedom-browser/pull/168). It is a +starting point for coordination with other Swarm clients and SwarmID, not an +adopted Swarm standard. The filename and schema identifier match the working +implementation and remain open to change in a future, jointly versioned +profile. + +The detailed Freedom Browser design and implementation rationale remain in +[permission-manifest-design.md](permission-manifest-design.md). + +## 1. Scope and terminology + +A Swarm application can publish a small declarative manifest at the root of +its Bzz origin. The manifest tells a compatible client which existing Swarm +capabilities the application wants and why. The client can present those +requests together, remember the user's decision, and safely reconcile that +decision when the application is redeployed with a different capability set. + +The manifest batches consent; it does not define new provider methods, grant +new kinds of authority, unlock keys, select postage stamps, or bypass runtime +resource and policy checks. + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** +describe interoperability or security requirements. + +- **Application origin**: the client's canonical permission principal for the + committed top-level page. A named origin is normally keyed by name; a raw + content origin is normally keyed by its root reference. +- **Capability row**: one entry in `capabilities.swarm`. +- **Projection**: the complete set of existing client grants represented by a + capability row. +- **Manifest-managed grant**: a grant that the client enabled because the user + approved a manifest row and that remains subject to manifest removal. +- **User-owned grant**: a grant created or modified outside manifest approval. + Manifest reconciliation MUST NOT revoke or silently take ownership of it. +- **Tracked origin**: an origin for which the client retains a manifest + observation, acknowledgements, or manifest-managed provenance. + +## 2. Discovery + +### 2.1 Location and transport + +Version 1 applies only to applications whose committed top-level URL uses the +`bzz:` scheme. The manifest URL is: + +```text +bzz:///freedom-manifest.json +``` + +The manifest is rooted at the host and does not inherit the page path. + +The client MUST retrieve the manifest through the same native Bzz content and +name-resolution path it uses to load application content. It MUST NOT use an +unrelated public HTTP gateway. Before applying the result, it MUST verify that +the canonical origin derived from the committed URL equals the origin whose +permissions are being considered. + +For a raw Swarm reference, this binds discovery to immutable content. For a +named origin, version 1 binds authority to the name and the same resolution +path; it does not claim that the page and a later manifest fetch are +byte-for-byte from the same resolved snapshot. A client SHOULD record the +resolved snapshot reference as audit metadata when its resolution layer makes +that value available. The snapshot reference MUST NOT replace the application +origin as the permission principal. + +Other transports are outside version 1. If a tracked named origin is opened +through another transport, the client MUST treat the manifest-managed +capability set as empty before falling back to its non-manifest permission +flow. This prevents permissions established by a Bzz manifest from silently +carrying over to content loaded through an unsupported resolution path. + +### 2.2 When to check + +A client MUST check a tracked origin at least once for every committed +top-level navigation before allowing that navigation to consume stored +manifest-managed authority. Calling an explicit connection method can trigger +discovery early, but it MUST NOT be the only freshness boundary: an already +authorized application might call a privileged operation directly. + +Concurrent checks for the same origin and committed navigation SHOULD share +one in-flight discovery and consent result. A completed result MUST NOT be +cached across later top-level navigations, even when a named URL is unchanged. + +Public reads and capability introspection that require no permission MAY +bypass discovery. Cleanup operations such as unsubscribe SHOULD bypass it so +that transient retrieval failures cannot prevent resource teardown. + +For an untracked origin, a client MAY limit discovery to its explicit +connection flow. A transient discovery failure MUST NOT permanently classify +that origin as manifest-free; later qualifying navigations SHOULD retry with a +bounded session backoff. The current Freedom Browser profile uses delays of 2, +10, 30, and then 60 seconds between unresolved attempts. + +### 2.3 Retrieval and outcomes + +The response body MUST be no larger than 8 KiB. The limit MUST be enforced +while streaming, not after buffering an arbitrarily large response. The body +MUST be decoded as strict UTF-8 and parsed as JSON. + +Discovery has five outcomes: + +| Outcome | Meaning | +|---|---| +| `found` | A successful response was decoded, parsed, and validated. | +| `absent` | Retrieval definitively returned HTTP 404. | +| `invalid` | Content was present but failed retrieval or schema rules, including other definitive 4xx responses. | +| `unresolved` | The node, network, or name resolution failed temporarily, timed out, or returned a non-definitive server failure. | +| `unsupported_transport` | The committed top-level page did not use `bzz:`. | + +For a tracked origin, `absent`, `invalid`, and `unsupported_transport` MUST be +treated as an empty manifest capability set: remove manifest-managed authority +and drop manifest tracking while preserving user-owned grants. An invalid +manifest SHOULD also produce a developer-visible diagnostic. + +`unresolved` MUST NOT be treated as absence and MUST NOT revoke grants. A +tracked navigation MUST NOT consume manifest-managed authority until freshness +can be established; the client should return a temporary availability error +and retry subject to backoff. An untracked origin MAY continue through the +client's ordinary per-action permission flow. + +## 3. Version 1 schema + +The top-level JSON object has this form: + +```json +{ + "schema": "freedom-manifest/1", + "name": "Example app", + "description": "An optional short description", + "capabilities": { + "swarm": { + "publish": { "why": "Store your encrypted files" }, + "feeds": { "why": "Maintain stable document addresses" }, + "signing": { "why": "Create signed Swarm updates" }, + "messaging": { "why": "Synchronize live collaboration" } + } + } +} +``` + +Validation is strict: + +- The root object MUST contain `schema`, `name`, and `capabilities`. It MAY + contain `description`. No other root member is allowed. +- `schema` MUST equal `freedom-manifest/1`. +- `name` MUST be a non-blank string of at most 32 Unicode code points. +- `description`, when present, MUST be a string of at most 160 Unicode code + points. It may be empty. +- `capabilities` MUST contain exactly one member, `swarm`. +- `capabilities.swarm` MUST be a non-empty object. Its keys MUST come from the + version 1 registry in section 4. +- Each capability value MUST be an object containing exactly one member, + `why`. +- `why` MUST be a non-blank string of at most 140 Unicode code points. + +All displayed strings MUST be treated as plain, attacker-controlled text. The +client MUST reject rather than strip a string containing: + +- C0 control characters (`U+0000`-`U+001F`); +- C1 control characters (`U+007F`-`U+009F`); +- line or paragraph separators (`U+2028`, `U+2029`); or +- bidirectional embedding, override, or isolate controls + (`U+202A`-`U+202E`, `U+2066`-`U+2069`). + +Unknown fields, capability keys, capability groups, and schema identifiers +invalidate the whole manifest. A version 1 client MUST NOT silently grant the +subset it recognizes because that subset may not match the consent presentation +intended by a newer application. + +## 4. Capability registry + +Approving a capability represents its complete projection below. A client MAY +use different internal storage, but the resulting consent boundaries MUST be +equivalent. + +| Capability | Projection | +|---|---| +| `publish` | Establish the base application connection and allow publishing without a repeated per-operation approval. | +| `feeds` | Establish the base connection, grant feed access, ensure a publisher identity exists, and allow feed creation and updates without repeated approval. | +| `signing` | Establish the base connection, grant feed/signing access, ensure a publisher identity exists, and allow Swarm content signing without repeated approval. | +| `messaging` | Establish the base connection, grant the messaging tier, and allow supported PSS/GSOC messaging operations without repeated approval. | + +**Informative method mapping.** Capability semantics above are stated +behaviorally because clients differ in internal permission granularity. +For clients exposing the Swarm provider API (the `window.swarm` SWIP +draft and its messaging extension), the reference implementation maps +capabilities to provider methods as follows; a successor profile should +make this mapping normative once the provider API is finalized: + +| Category | Provider methods covered | +|---|---| +| Connection establishment | `swarm_requestAccess` | +| Base connection only | `swarm_getUploadStatus` (origin-owned uploads), `swarm_unsubscribe` (origin-owned teardown; freshness bypassed) | +| `publish` | `swarm_publishData`, `swarm_publishFiles`, `swarm_publishChunk` | +| `feeds` | `swarm_createFeed`, `swarm_updateFeed`, `swarm_writeFeedEntry` | +| `signing` | `swarm_writeSingleOwnerChunk`, `swarm_getSigningIdentity` | +| `messaging` | `swarm_getMessagingIdentity`, `swarm_subscribe`, `swarm_sendPss`, `swarm_sendGsoc` | + +Permission-free methods (`swarm_getCapabilities`, `swarm_readFeedEntry`, +`swarm_readChunk`, `swarm_readSingleOwnerChunk`, `swarm_listFeeds`) are +not affected by any capability. `swarm_unsubscribe` remains connection- and +origin-scoped, but bypasses the freshness boundary so teardown cannot be +blocked (section 2.2). + +The `feeds` and `signing` projections share the feed grant and publisher +identity dependency. A client MUST track those shared dependencies so removing +one capability does not revoke a dependency still justified by the other. + +Publisher identity handling follows an **ensure, never replace** rule: + +- If the application already has an active publisher identity, approval MUST + preserve it regardless of its identity mode. +- If no publisher identity exists, the client creates or reserves its + privacy-preserving app-scoped identity and makes it active. +- Removing capabilities or disconnecting MUST NOT silently delete identity + records or change the user's identity selection. +- Creating identity metadata MUST NOT unlock a vault or expose key material. + Any runtime vault-unlock requirement remains in force when the key is used. + +The base connection is implicit rather than a manifest row. Choosing +individual approvals may establish only that connection; each declared +capability then follows the client's ordinary per-action or per-tier prompts. + +## 5. Consent and change semantics + +### 5.1 Consent presentation + +The application origin MUST be the primary identity shown to the user. +Manifest-provided `name` and `description` are secondary context. Capability +labels and explanations of their authority MUST be client-owned; only the +corresponding `why` text comes from the application. + +The client MUST offer three semantically distinct outcomes: + +1. **Allow all**: acknowledge the displayed rows as managed and apply their + projections. Only grants newly enabled by this decision become + manifest-managed; an already-enabled user-owned grant stays user-owned. +2. **Use individual approvals**: acknowledge the displayed rows as individual + without enabling their capability projections. The client may establish the + implicit base connection. Later operations use ordinary prompts, and the + same rows are not offered again as a batch unless they are removed and later + re-added. +3. **Don't allow**: do not acknowledge or project the displayed additions. + Removals already discovered from a mixed update still apply. On first + contact, the client MUST NOT create a durable manifest record solely because + of rejection. It SHOULD suppress duplicate prompts for at least the current + committed navigation. + +If `feeds` or `signing` would create an app-scoped identity, the consent view +MUST say so. If an identity already exists, it MUST say that the existing +identity will be preserved. + +### 5.2 Semantic comparison + +Authority is bound to the set of capability keys, not to the exact manifest +bytes. Clients MUST compare the schema identifier plus the sorted capability +keys when deciding whether the authority request changed. + +A SHA-256 hash of the exact served bytes MAY be retained for audit and +debugging, but it MUST NOT control authority. Changes only to whitespace, +`name`, `description`, or `why` do not require renewed consent. Consent history +MUST preserve the text actually shown when a decision was made. + +Clients MUST separately represent: + +- the latest successfully observed capability set; and +- the capability rows the user has acknowledged as managed or individual. + +These values differ after, for example, a mixed update whose removals were +applied but whose additions were rejected. + +### 5.3 Additions and removals + +For a successfully validated manifest, let `current` be its capability keys and +`acknowledged` be the rows with a prior managed or individual decision. + +1. Apply removals, `acknowledged - current`, before considering additions. + Remove each acknowledgement and its ownership of projected grants. Disable + a projected grant only when it has no remaining manifest owners and has not + become user-owned. +2. Compute additions, `current - acknowledged`. +3. If an addition's complete projection is already enabled through user action, + acknowledge it as individual without prompting or taking ownership. +4. If additions remain, display only those new rows. Allowing them projects only + those rows; it MUST NOT silently reapply unchanged rows. + +Removing a capability and later adding it again creates a new addition and +requires a new decision. Removed publisher identity records are the exception: +they are retained as described in section 4. + +### 5.4 Manual changes, revocation, and provenance + +A client MUST retain enough provenance to distinguish manifest-managed grants +from user-owned grants. + +- Any manual change to a projected grant detaches that grant from manifest + management, whether the user turns it on or off. +- Manifest reconciliation MUST NOT change a detached or otherwise user-owned + grant. +- Approval of a later diff applies only to the rows displayed in that diff. It + MUST NOT re-enable an unchanged capability that the user manually disabled. +- When multiple rows share a projected grant, the client MUST retain all owning + rows and revoke that grant only after the last owner is removed. +- A full disconnect MUST revoke the base connection and applicable runtime + grants, terminate live resources, demote feed access, and remove manifest + tracking as one serialized logical operation. Identity records remain. + +## 6. Security and state requirements + +- **No new authority:** a manifest projection MUST be equivalent to authority + the client could already grant through its ordinary prompts. Runtime limits, + postage economics, and vault requirements remain independent. +- **Authoritative processing:** fetching, validation, semantic comparison, + provenance, and grant mutation MUST occur in a trusted client component. An + untrusted application or rendering context MUST NOT submit manifest bytes as + the authority to grant permissions. +- **Consent binding:** a consent action MUST be bound to an opaque or otherwise + unforgeable pending decision containing at least the origin, observed + semantic capability set, displayed rows, and current permission-state + revision. Expired, unknown, replayed with a different result, or stale + decisions MUST NOT grant authority. Retrying the same completed decision MAY + return its original result. +- **Origin serialization:** manifest decisions, navigation reconciliation, + manual permission changes, and disconnects for the same origin MUST be + serialized. A stale approval MUST NOT overwrite newer state. +- **Crash consistency:** if one decision updates multiple authority stores, the + client MUST durably record intent before applying changes and MUST recover by + completing idempotent operations. A crash MUST NOT cause a partially applied + manifest grant to be mistaken for a user-owned grant. +- **Fail-closed parsing:** size, UTF-8, schema, key, and displayed-text rules are + enforced before a consent model is created. +- **Fail-safe retrieval:** a definitive empty capability set removes only + manifest-managed authority. A temporary retrieval failure neither broadens + nor revokes authority, but blocks its use until freshness is established. +- **Bounded state:** consent receipts, completed-decision replay state, and + retry bookkeeping SHOULD be bounded so an application cannot cause + unbounded client storage growth. + +## 7. Versioning and coordination + +The on-wire version 1 compatibility points are currently: + +- filename: `freedom-manifest.json`; +- schema identifier: `freedom-manifest/1`; +- one required capability group: `swarm`; and +- capability keys: `publish`, `feeds`, `signing`, and `messaging`. + +The Freedom-specific names are historical and provisional from a standards +perspective, but changing either one requires a new compatibility profile or a +defined dual-discovery transition. + +Most importantly, a SwarmID capability group cannot be added to a version 1 +manifest while remaining compatible with current clients: unknown groups +invalidate the whole file by design. SwarmID permissions therefore require a +coordinated successor schema (or another explicitly negotiated extension +mechanism) that defines: + +- the group name and individual capability semantics; +- whether partial understanding is ever safe; +- how clients advertise supported schema versions and groups; +- how consent and revocation interact with Swarm identity selection; and +- how applications migrate while version 1 clients remain in use. + +Until that successor is agreed, applications targeting the implemented profile +MUST emit exactly the version 1 schema described here. diff --git a/scripts/build.js b/scripts/build.js index ac9eceb9..b11095af 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -58,6 +58,24 @@ if (archs.length === 0) { else archs.push('arm64', 'x64'); // Linux defaults to both } +// Distributables must not ship the interim remote-signing bridge origin +// (personal test deployment — see the pre-merge checklist on PR #159). +// Override for local experiments only: FREEDOM_ALLOW_INTERIM_BRIDGE=1. +if (dist && process.env.FREEDOM_ALLOW_INTERIM_BRIDGE !== '1') { + const remoteSession = fs.readFileSync( + path.join(__dirname, '..', 'src', 'renderer', 'lib', 'wallet', 'remote-session.js'), + 'utf8' + ); + if (remoteSession.includes('florianglatz.eth.limo')) { + console.error( + 'Error: BRIDGE_ORIGIN in src/renderer/lib/wallet/remote-session.js still points at the ' + + 'interim test deployment. Deploy freedom-bridge to the production origin and update the ' + + 'constant before building a distributable (FREEDOM_ALLOW_INTERIM_BRIDGE=1 to override locally).' + ); + process.exit(1); + } +} + // 1. Check binaries for the target platform/arch const checkArgs = [`--${platform}`, ...archs.map((a) => `--${a}`)].join(' '); console.log(`\n→ Checking binaries: npm run check-binaries -- ${checkArgs}\n`); diff --git a/scripts/bundle-openlv.js b/scripts/bundle-openlv.js new file mode 100644 index 00000000..d8d898b3 --- /dev/null +++ b/scripts/bundle-openlv.js @@ -0,0 +1,126 @@ +/** + * Bundle the @openlv/* packages (LGPL-3.0) into a single ES module for the + * chrome renderer, which loads plain `type="module"` scripts and cannot + * resolve bare npm specifiers. + * + * The output (src/renderer/vendor/openlv.esm.js) is committed like the + * other vendor files; re-run this script after bumping the @openlv + * dependencies: + * + * node scripts/bundle-openlv.js + * + * Keeping the LGPL code in one clearly-labelled, regenerable file (rather + * than mixed into app bundles) is also what makes it trivially replaceable, + * as the LGPL asks. + * + * `connectSession` is deliberately left out so tree-shaking drops the + * gundb/ntfy signaling backends it would drag in via dynamic imports — + * the client role (bridge page) instead calls `createSession` with the + * decoded URI parameters and the explicit mqtt layer, which is the same + * thing minus the dynamic protocol lookup. + * + * A second flavor, openlv.iife.js (`window.OpenLV`), is built for freedom + * mobile's hidden-WKWebView engine: WKWebView refuses ES-module imports + * from file:// pages (opaque origin + CORS), but classic scripts load + * fine, and file:// stays a secure context so `crypto.subtle` works. + * + * The bridge page (solardev-xyz/freedom-bridge) and freedom mobile + * (solardev-xyz/freedom-browser-ios) vendor their own copies; when + * their sibling checkouts are present they are refreshed automatically + * from this one build, so the repos can't drift. + */ + +const fs = require('fs'); +const path = require('path'); + +const esbuild = require('esbuild'); + +const openlvVersion = require( + path.join(__dirname, '..', 'node_modules', '@openlv', 'session', 'package.json') +).version; + +const ENTRY = ` +export { createSession, SESSION_STATE } from '@openlv/session'; +export { encodeConnectionURL, decodeConnectionURL, OPENLV_PROTOCOL_VERSION } from '@openlv/core'; +export { mqtt } from '@openlv/signaling/mqtt'; +export { webrtc } from '@openlv/transport/webrtc'; +`; + +const banner = (filename) => `/*! + * ${filename} — bundled from @openlv/session@${openlvVersion} and its + * dependencies (https://github.com/v3xlabs/open-lavatory, LGPL-3.0-only). + * Generated by scripts/bundle-openlv.js — do not edit by hand. + */`; + +// The same surface serves both roles: freedom's renderer hosts sessions +// (shows the QR); the bridge page joins them (client role — createSession +// with the URI's `h` parameter takes that path). Built once, copied — +// the copies can never drift. +const ESM_OUTPUT = path.join(__dirname, '..', 'src', 'renderer', 'vendor', 'openlv.esm.js'); +// Intermediate only — the iife flavor's sole home is the iOS repo. +const IIFE_OUTPUT = path.join(require('os').tmpdir(), 'openlv.iife.js'); + +// Sibling checkouts vendoring their own copies — synced when present +// (dev machines), reported otherwise (CI). +const SIBLING_COPIES = [ + { + from: ESM_OUTPUT, + to: path.join(__dirname, '..', '..', 'freedom-bridge', 'openlv.esm.js'), + }, + { + from: IIFE_OUTPUT, + to: path.join( + __dirname, '..', '..', 'nodes', 'swarm-mobile-ios', + 'Freedom', 'Freedom', 'Wallet', 'OpenLV', 'openlv.iife.js', + ), + }, +]; + +async function main() { + const shared = { + stdin: { + contents: ENTRY, + resolveDir: path.join(__dirname, '..'), + sourcefile: 'openlv-entry.js', + }, + bundle: true, + platform: 'browser', + target: 'es2022', + legalComments: 'inline', + minify: true, + }; + + await esbuild.build({ + ...shared, + format: 'esm', + outfile: ESM_OUTPUT, + banner: { js: banner('openlv.esm.js') }, + }); + + await esbuild.build({ + ...shared, + format: 'iife', + globalName: 'OpenLV', + outfile: IIFE_OUTPUT, + banner: { js: banner('openlv.iife.js') }, + }); + + const outputs = [ESM_OUTPUT]; + for (const { from, to } of SIBLING_COPIES) { + if (fs.existsSync(path.dirname(to))) { + fs.copyFileSync(from, to); + outputs.push(to); + } else { + console.log(`sibling checkout not found — copy ${from} to ${to} by hand`); + } + } + + for (const file of outputs) { + console.log(`Wrote ${file} (${(fs.statSync(file).size / 1024).toFixed(0)} KiB)`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-binaries.js b/scripts/check-binaries.js index 4dfb0ba8..b2812201 100644 --- a/scripts/check-binaries.js +++ b/scripts/check-binaries.js @@ -11,6 +11,12 @@ const FREEDOM_IPFS_NATIVE_PREBUILDS_DIR = path.join( ); const FREEDOM_IPFS_NATIVE_ADDON = 'freedom_ipfs_native.node'; const RADICLE_BIN_DIR = path.join(__dirname, '..', 'radicle-bin'); +const MYOTIS_BIN_DIR = path.join(__dirname, '..', 'myotis-bin'); +// Targets the Myotis release publishes addons for (see scripts/fetch-myotis.js). +// Anything else (e.g. win-arm64) is skipped with a notice — the app degrades +// gracefully to Colibri/quorum when the addon is absent. +const MYOTIS_SUPPORTED = new Set(['mac-x64', 'mac-arm64', 'linux-x64', 'linux-arm64', 'win-x64']); +const ARTI_BIN_DIR = path.join(__dirname, '..', 'arti-bin'); function getPlatformArch() { const args = process.argv.slice(2); @@ -104,6 +110,18 @@ function checkBinaries(platforms) { missing.push(`freedom-ipfs native addon for ${platformDir}: ${freedomIpfsAddonPath}`); } + // Myotis: required where the release publishes an addon; electron-builder + // would otherwise silently skip the missing extraResources dir and ship a + // build with the feature permanently unavailable. + if (MYOTIS_SUPPORTED.has(platformDir)) { + const myotisAddonPath = path.join(MYOTIS_BIN_DIR, platformDir, 'myotis-node.node'); + if (!fs.existsSync(myotisAddonPath)) { + missing.push(`myotis-node addon for ${platformDir}: ${myotisAddonPath}`); + } + } else { + console.log(` (myotis-node: no addon published for ${platformDir} — skipping)`); + } + // Radicle: no official Windows binaries yet — skip check for win targets if (os !== 'win') { const nodePath = path.join(RADICLE_BIN_DIR, platformDir, 'radicle-node'); @@ -121,6 +139,29 @@ function checkBinaries(platforms) { return missing; } +/** + * Arti (Tor) is OPTIONAL and built from source via `npm run tor:download` + * (cargo), unlike the prebuilt Bee/Radicle downloads. It is intentionally not + * a required build binary: when absent, Tor simply isn't bundled and the + * in-app toggle stays disabled. We still create the per-platform resource dir + * so electron-builder's `extraResources` entry resolves cleanly instead of + * failing late during packaging. + */ +function ensureOptionalArti(platforms) { + for (const { os, arch } of platforms) { + if (os === 'win') continue; // Arti is bundled for macOS/Linux only + const platformDir = `${os}-${arch}`; + const artiPath = path.join(ARTI_BIN_DIR, platformDir, 'arti'); + if (!fs.existsSync(artiPath)) { + fs.mkdirSync(path.join(ARTI_BIN_DIR, platformDir), { recursive: true }); + console.warn( + `⚠️ Arti (Tor) binary not found for ${platformDir} — Tor will not be bundled.\n` + + ` Optional; build it with: npm run tor:download (requires a Rust toolchain)` + ); + } + } +} + function main() { const platforms = getPlatformArch(); console.log(`Checking binaries for: ${platforms.map((p) => `${p.os}-${p.arch}`).join(', ')}`); @@ -134,10 +175,14 @@ function main() { console.error(' npm run ant:download'); console.error(' npm run ipfs:download'); console.error(' npm run radicle:download'); + console.error(' npm run myotis:download'); console.error(' npm run adblock:download\n'); process.exit(1); } + // Optional binaries (non-fatal): warn and prepare resource dirs. + ensureOptionalArti(platforms); + console.log('✅ All required binaries found.\n'); process.exit(0); } diff --git a/scripts/fetch-arti.js b/scripts/fetch-arti.js new file mode 100644 index 00000000..526ced2d --- /dev/null +++ b/scripts/fetch-arti.js @@ -0,0 +1,103 @@ +/** + * Fetch (build) the Arti Tor client binary. + * + * Unlike Bee / Radicle, the Tor Project does not publish a clean, scriptable + * set of prebuilt `arti` binaries. The reliable, official, pinnable source is + * crates.io, so we build from source with `cargo install`. This requires a + * Rust toolchain (`cargo`) on the build machine. + * + * The binary is placed at `arti-bin/-/arti` to match the + * layout that `src/main/tor-manager.js#getArtiBinaryPath` and the + * electron-builder `extraResources` entries expect. + * + * Cross-compilation is out of scope here (it needs per-target toolchains), so + * this builds for the host platform/arch only — mirroring how the Docker dist + * jobs fetch host-only Radicle binaries. + * + * Env: + * ARTI_VERSION crates.io version to install (default: pinned below) + * CARGO_BIN path to cargo (default: 'cargo' on PATH) + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +// Pin a known-good Arti release. Bump deliberately and re-test the SOCKS flags. +const ARTI_VERSION = process.env.ARTI_VERSION || '1.4.4'; +const CARGO_BIN = process.env.CARGO_BIN || 'cargo'; + +const OUTPUT_DIR = path.join(__dirname, '..', 'arti-bin'); + +function platformKey() { + const platformMap = { darwin: 'mac', linux: 'linux', win32: 'win' }; + const platform = platformMap[process.platform] || process.platform; + return `${platform}-${process.arch}`; +} + +function hasCargo() { + try { + execFileSync(CARGO_BIN, ['--version'], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function main() { + if (!hasCargo()) { + console.error( + '\nError: `cargo` (Rust toolchain) not found.\n' + + 'Arti has no clean prebuilt-binary distribution, so it is built from\n' + + 'crates.io. Install Rust (https://rustup.rs) and re-run, or set CARGO_BIN.\n' + ); + process.exit(1); + } + + const target = platformKey(); + const targetDir = path.join(OUTPUT_DIR, target); + const binName = process.platform === 'win32' ? 'arti.exe' : 'arti'; + const destBin = path.join(targetDir, binName); + + fs.mkdirSync(targetDir, { recursive: true }); + + // Install into a temp root, then copy just the binary into place. Using a + // dedicated root keeps cargo's bookkeeping out of the repo tree. + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'arti-install-')); + let ok = false; + + console.log(`Building arti ${ARTI_VERSION} for ${target} (this can take several minutes)...`); + try { + execFileSync( + CARGO_BIN, + ['install', 'arti', '--version', ARTI_VERSION, '--locked', '--root', installRoot], + { stdio: 'inherit' } + ); + + const builtBin = path.join(installRoot, 'bin', binName); + if (!fs.existsSync(builtBin)) { + console.error(`\nError: arti binary not found at ${builtBin} after build.`); + } else { + fs.copyFileSync(builtBin, destBin); + if (process.platform !== 'win32') { + fs.chmodSync(destBin, 0o755); + } + console.log(`\nInstalled arti for ${target} -> ${destBin}`); + ok = true; + } + } catch (err) { + console.error(`\nError: cargo install arti failed: ${err.message}`); + } finally { + // Always clean up the temp install root, even on failure. + try { + fs.rmSync(installRoot, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + + process.exit(ok ? 0 : 1); +} + +main(); diff --git a/scripts/fetch-myotis.js b/scripts/fetch-myotis.js new file mode 100644 index 00000000..0006217f --- /dev/null +++ b/scripts/fetch-myotis.js @@ -0,0 +1,252 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); + +// Fetches the Myotis Node addon — the fully-P2P Ethereum light client +// (biafra23/myotis) as a prebuilt napi-rs binding for the current platform. +// Runs invisibly inside Freedom like the ant/IPFS nodes; see +// src/main/myotis/myotis-manager.js. Installs into `myotis-bin/`. +const OUTPUT_DIR = path.join(__dirname, '..', 'myotis-bin'); +const MYOTIS_REPO = process.env.MYOTIS_REPO || 'biafra23/myotis'; +// The known-good Myotis release this app version is built and tested against. +// Bump deliberately (with a live e2e run) — do NOT float on `latest`. The +// engine ABI the addon reports must match myotis-manager's EXPECTED_ABI. +const PINNED_RELEASE_TAG = 'v0.1.7'; +// In-repo trust root for the pinned release: sha256 of its +// myotis-node.SHA256SUMS asset, recorded at pin time. The sums file comes +// from the same GitHub release as the addons, so without this pin a +// compromised release could swap binaries *and* checksums together. Update +// alongside PINNED_RELEASE_TAG on every deliberate bump. +const PINNED_SHA256SUMS_DIGEST = + '458743f281a7886e953a32ccef599bc253781e278c12cfe05a5addc23aa2569a'; +const MYOTIS_RELEASE_TAG = process.env.MYOTIS_RELEASE_TAG || PINNED_RELEASE_TAG; + +// Every target the release publishes, installed in one run (fetch-ant.js +// convention — cross-target dist builds and the Docker recipes then need no +// --target flags). Dir names are electron-builder's ${os}-${arch}. Windows +// ARM64 is deliberately absent: no upstream artifact, and check-binaries.js +// skips it — the app degrades to Colibri/quorum there. +const TARGETS = [ + { runtime: 'darwin-arm64', dir: 'mac-arm64', asset: 'myotis-node.darwin-arm64.node' }, + { runtime: 'darwin-x64', dir: 'mac-x64', asset: 'myotis-node.darwin-x64.node' }, + { runtime: 'linux-x64', dir: 'linux-x64', asset: 'myotis-node.linux-x64-gnu.node' }, + { runtime: 'linux-arm64', dir: 'linux-arm64', asset: 'myotis-node.linux-arm64-gnu.node' }, + { runtime: 'win32-x64', dir: 'win-x64', asset: 'myotis-node.win32-x64-msvc.node' }, +]; + +const REQUEST_TIMEOUT_MS = 60000; + +function httpsGetJson(pathName) { + return new Promise((resolve, reject) => { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const headers = { + 'User-Agent': 'Freedom-Updater', + Accept: 'application/vnd.github+json', + }; + if (token) headers.Authorization = `Bearer ${token}`; + https + .get({ hostname: 'api.github.com', path: pathName, headers }, (res) => { + let data = ''; + res.on('error', reject); + res.on('data', (c) => (data += c)); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to fetch ${pathName}: ${res.statusCode}`)); + return; + } + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error(`Invalid JSON from ${pathName}: ${err.message}`)); + } + }); + }) + .on('error', reject); + }); +} + +function downloadFileOnce(url, dest, redirectCount = 0) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + let settled = false; + const fail = (err) => { + if (settled) return; + settled = true; + file.close(); + fs.unlink(dest, () => reject(err)); + }; + const req = https + .get(url, { headers: { 'User-Agent': 'Freedom-Updater' } }, (response) => { + response.on('error', fail); + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + if (redirectCount >= 5) { + fail(new Error(`Too many redirects while downloading ${url}`)); + return; + } + let location; + try { + location = new URL(response.headers.location, url); + } catch { + fail(new Error(`Invalid redirect while downloading ${url}`)); + return; + } + if (location.protocol !== 'https:') { + fail(new Error(`Refusing non-HTTPS redirect while downloading ${url}`)); + return; + } + // The redirected request owns completion from here. Ignore any late + // error emitted by the response we are deliberately draining. + settled = true; + response.resume(); + file.close(); + fs.unlink(dest, () => { + downloadFileOnce(location.href, dest, redirectCount + 1).then(resolve).catch(reject); + }); + return; + } + if (response.statusCode !== 200) { + fail(new Error(`HTTP ${response.statusCode} for ${url}`)); + return; + } + response.pipe(file); + file.on('finish', () => file.close(() => { + if (settled) return; + settled = true; + resolve(); + })); + file.on('error', fail); + }) + .on('error', fail); + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`Download timed out after ${REQUEST_TIMEOUT_MS}ms: ${url}`)); + }); + }); +} + +async function withRetries(label, fn) { + const maxAttempts = 4; + let lastErr; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt < maxAttempts) { + const delayMs = 1000 * attempt; + console.warn(`${label} attempt ${attempt} failed (${err.message}); retrying in ${delayMs}ms...`); + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } + throw lastErr; +} + +function sha256Bytes(bytes) { + const hash = crypto.createHash('sha256'); + hash.update(bytes); + return hash.digest('hex'); +} + +function sha256File(filePath) { + return sha256Bytes(fs.readFileSync(filePath)); +} + +// `sha256sum`-style lines: `␠␠` or ` *`. +function parseChecksums(text) { + const map = {}; + for (const line of text.split('\n')) { + const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); + if (match) map[path.basename(match[2].trim())] = match[1].toLowerCase(); + } + return map; +} + +async function main() { + try { + const requestedTarget = process.env.MYOTIS_DOWNLOAD_TARGET || ''; + const targets = requestedTarget + ? TARGETS.filter((target) => target.runtime === requestedTarget) + : TARGETS; + if (!targets.length) { + throw new Error(`Unknown MYOTIS_DOWNLOAD_TARGET: ${requestedTarget}`); + } + console.log(`Fetching Myotis release info from ${MYOTIS_REPO} @ ${MYOTIS_RELEASE_TAG}...`); + const releasePath = + MYOTIS_RELEASE_TAG === 'latest' + ? `/repos/${MYOTIS_REPO}/releases/latest` + : `/repos/${MYOTIS_REPO}/releases/tags/${MYOTIS_RELEASE_TAG}`; + const release = await withRetries('Release fetch', () => httpsGetJson(releasePath)); + console.log(`Myotis version: ${release.tag_name}`); + const assets = release.assets || []; + + if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + // Checksums first — a Myotis release always ships myotis-node.SHA256SUMS. + const sumsAsset = assets.find((a) => a.name === 'myotis-node.SHA256SUMS'); + if (!sumsAsset) { + throw new Error( + `Release ${release.tag_name} has no myotis-node.SHA256SUMS — refusing to install unverified addon.` + ); + } + const sumsPath = path.join(OUTPUT_DIR, 'myotis-node.SHA256SUMS'); + await withRetries('SHA256SUMS download', () => + downloadFileOnce(sumsAsset.browser_download_url, sumsPath) + ); + + // Anchor to the in-repo trust root (pinned tag only — a tag override is a + // local-testing escape hatch with no committed digest). + let checksums; + if (MYOTIS_RELEASE_TAG === PINNED_RELEASE_TAG) { + const sumsBytes = fs.readFileSync(sumsPath); + const sumsDigest = sha256Bytes(sumsBytes); + if (sumsDigest !== PINNED_SHA256SUMS_DIGEST) { + throw new Error( + `myotis-node.SHA256SUMS for ${PINNED_RELEASE_TAG} does not match the in-repo pinned digest ` + + `(expected ${PINNED_SHA256SUMS_DIGEST}, got ${sumsDigest}). ` + + 'The release assets may have been re-published or tampered with — refusing to install.' + ); + } + console.log('Verified myotis-node.SHA256SUMS against the in-repo pinned digest'); + checksums = parseChecksums(sumsBytes.toString('utf8')); + } else { + checksums = parseChecksums(fs.readFileSync(sumsPath, 'utf8')); + } + + // Stable install name under an electron-builder-style ${os}-${arch} dir — + // packaging copies myotis-bin/-/ into resources/myotis-node/, + // and myotis-manager loads the dev path directly. + for (const target of targets) { + const asset = assets.find((a) => a.name === target.asset); + if (!asset) { + throw new Error( + `Release ${release.tag_name} has no asset ${target.asset} — refusing to produce an incomplete install.` + ); + } + const platformDir = path.join(OUTPUT_DIR, target.dir); + if (!fs.existsSync(platformDir)) fs.mkdirSync(platformDir, { recursive: true }); + const destPath = path.join(platformDir, 'myotis-node.node'); + await withRetries('Addon download', () => + downloadFileOnce(asset.browser_download_url, destPath) + ); + + const expected = checksums[target.asset]; + if (!expected) { + throw new Error(`${target.asset} missing from myotis-node.SHA256SUMS`); + } + const actual = sha256File(destPath); + if (actual !== expected) { + fs.unlinkSync(destPath); + throw new Error( + `Checksum mismatch for ${target.asset}: expected ${expected}, got ${actual} — deleted.` + ); + } + console.log(`Verified and installed ${target.asset} → ${destPath}`); + } + } catch (err) { + console.error(`fetch-myotis failed: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/scripts/openlv-ios-harness.js b/scripts/openlv-ios-harness.js new file mode 100644 index 00000000..0391d637 --- /dev/null +++ b/scripts/openlv-ios-harness.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Cross-stack test host for the freedom-mobile (iOS) openlv wallet + * endpoint. Plays the "freedom desktop" role over the real openlv + * stack: a local MQTT broker for signaling plus a headless Chromium + * page running the same vendored `openlv.esm.js` the renderer uses + * (Node has no WebRTC, so the host session lives in a browser context, + * exactly like the remote-signing E2E). + * + * The iOS simulator shares the Mac's loopback, so the XCTest suite + * reaches both the control endpoints and the broker at 127.0.0.1. + * + * Control surface (http://127.0.0.1:8798): + * GET /uri → {uri} — openlv:// URI of the current host session + * GET /state → {phase, uri, exchanges: [{method, response}], error} + * GET /reset → reloads the host page, starting a fresh session + * (each XCTest consumes one session; call this first) + * + * Once the wallet endpoint links up, the host sends the same sequence a + * desktop signing job produces — eth_requestAccounts, the + * wallet_switchEthereumChain pre-flight, then personal_sign against the + * connected account — and records every response for the XCTest to + * assert on. + * + * Run from the repo root: `npm run openlv:ios-harness` + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('@playwright/test'); +const { startLocalMqttBroker } = require('../test/helpers/local-mqtt-broker'); +const { WEBRTC_LOCAL_SWITCH } = require('../test/helpers/webrtc'); + +const PORT = Number(process.env.OPENLV_HARNESS_PORT) || 8798; + +const MESSAGE = 'freedom openlv ios harness'; + +function buildHostPage(signalingUrl) { + return ` +`; +} + +async function main() { + const broker = await startLocalMqttBroker(); + let state; + const resetState = () => { + state = { phase: 'starting', uri: null, exchanges: [], error: null }; + }; + resetState(); + let page = null; + + const esmBundle = fs.readFileSync( + path.join(__dirname, '..', 'src', 'renderer', 'vendor', 'openlv.esm.js') + ); + const hostPage = buildHostPage(broker.url); + + const server = http.createServer((req, res) => { + const respond = (status, type, body) => { + res.writeHead(status, { 'content-type': type }); + res.end(body); + }; + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + switch (url.pathname) { + case '/uri': + return respond(200, 'application/json', JSON.stringify({ uri: state.uri })); + case '/state': + return respond(200, 'application/json', JSON.stringify(state)); + // /reset[?mode=tx] — fresh host session; mode picks the request + // sequence the host sends (default: personal_sign; tx: broadcast). + case '/reset': { + if (!page) return respond(503, 'text/plain', 'host page not up yet'); + const mode = url.searchParams.get('mode'); + resetState(); + page + .goto(`http://127.0.0.1:${PORT}/${mode ? `?mode=${encodeURIComponent(mode)}` : ''}`) + .then(() => respond(200, 'application/json', '{"ok":true}')) + .catch((err) => respond(500, 'text/plain', String(err))); + return undefined; + } + case '/': + return respond(200, 'text/html', hostPage); + case '/openlv.esm.js': + return respond(200, 'text/javascript', esmBundle); + default: + return respond(404, 'text/plain', 'not found'); + } + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + + // Raw loopback ICE candidates instead of mDNS names, so the WebKit + // peer can pair against them. + const browser = await chromium.launch({ args: [WEBRTC_LOCAL_SWITCH] }); + page = await browser.newPage(); + page.on('console', (message) => console.log('[host page]', message.text())); + await page.exposeFunction('__report', (updateJson) => { + const update = JSON.parse(updateJson); + if (update.exchange) { + state.exchanges.push(update.exchange); + } else { + Object.assign(state, update); + } + console.log('[harness]', updateJson.slice(0, 300)); + }); + await page.goto(`http://127.0.0.1:${PORT}/`); + + console.log(`[harness] control surface at http://127.0.0.1:${PORT} (uri, state)`); + console.log(`[harness] signaling broker at ${broker.url}`); + + const shutdown = async () => { + await browser.close().catch(() => {}); + await broker.close().catch(() => {}); + server.close(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error('[harness] failed to start:', err); + process.exit(1); +}); diff --git a/scripts/serve-bridge.js b/scripts/serve-bridge.js new file mode 100644 index 00000000..1d6d4302 --- /dev/null +++ b/scripts/serve-bridge.js @@ -0,0 +1,55 @@ +/** + * Serve the wallet bridge page on a local port — for development and + * the remote-signing E2E test. Static files only. + * + * The page lives in its own repo (solardev-xyz/freedom-bridge, deployed + * independently to Swarm); this expects a sibling checkout, overridable + * via FREEDOM_BRIDGE_DIR. + * + * node scripts/serve-bridge.js [port] (default 8797) + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const BRIDGE_DIR = + process.env.FREEDOM_BRIDGE_DIR || path.join(__dirname, '..', '..', 'freedom-bridge'); + +/** False when the sibling checkout is missing — callers skip or error. */ +function bridgeAvailable() { + return fs.existsSync(path.join(BRIDGE_DIR, 'index.html')); +} +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +function createBridgeServer() { + if (!bridgeAvailable()) { + throw new Error( + `freedom-bridge checkout not found at ${BRIDGE_DIR} — clone ` + + 'github.com/solardev-xyz/freedom-bridge next to this repo, or set FREEDOM_BRIDGE_DIR' + ); + } + return http.createServer((req, res) => { + const urlPath = new URL(req.url, 'http://localhost').pathname; + const rel = urlPath === '/' ? 'index.html' : urlPath.slice(1); + const file = path.join(BRIDGE_DIR, rel); + if (!file.startsWith(BRIDGE_DIR) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + res.writeHead(404).end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); + fs.createReadStream(file).pipe(res); + }); +} + +module.exports = { createBridgeServer, bridgeAvailable }; + +if (require.main === module) { + const port = Number(process.argv[2]) || 8797; + createBridgeServer().listen(port, '127.0.0.1', () => { + console.log(`Bridge page at http://127.0.0.1:${port}/`); + }); +} diff --git a/scripts/smoke-myotis.js b/scripts/smoke-myotis.js new file mode 100644 index 00000000..6ab3f4d3 --- /dev/null +++ b/scripts/smoke-myotis.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Cross-platform live smoke for the exact addon Freedom ships. This is kept +// separate from the Playwright spec so CI can cache a cleanly-stopped sync +// data directory even when the first cold run needs another attempt. +const EXPECTED_ABI = 22; +const VITALIK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const network = process.env.MYOTIS_NETWORK || 'mainnet'; +const POLL_INTERVAL_MS = 5000; +const QUERY_TIMEOUT_MS = 120000; + +const TARGET_DIRS = { + 'darwin-arm64': 'mac-arm64', + 'darwin-x64': 'mac-x64', + 'linux-arm64': 'linux-arm64', + 'linux-x64': 'linux-x64', + 'win32-x64': 'win-x64', +}; + +const target = `${process.platform}-${process.arch}`; +const targetDir = TARGET_DIRS[target]; +if (!targetDir) { + console.error(`No Myotis release addon is expected for ${target}`); + process.exit(1); +} + +const addonPath = path.resolve( + process.env.MYOTIS_NODE_PATH || + path.join(__dirname, '..', 'myotis-bin', targetDir, 'myotis-node.node') +); +const dataDir = path.resolve( + process.env.MYOTIS_DATA_DIR || path.join(os.tmpdir(), `freedom-myotis-smoke-${target}`) +); +const syncTimeoutMinutes = Number(process.env.MYOTIS_SMOKE_TIMEOUT_MIN) || 75; +const syncTimeoutMs = syncTimeoutMinutes * 60 * 1000; +const syncStallMinutes = Number(process.env.MYOTIS_SMOKE_STALL_MIN) || 10; +const syncStallMs = syncStallMinutes * 60 * 1000; +const queryAttempts = Number(process.env.MYOTIS_SMOKE_QUERY_ATTEMPTS) || 8; +const readRecoveryAttempts = Number(process.env.MYOTIS_SMOKE_RECOVERY_ATTEMPTS) || 3; +const syncRecoveryAttempts = Number(process.env.MYOTIS_SMOKE_SYNC_RECOVERY_ATTEMPTS) || 3; + +let addon = null; +let handle = -1; +let stopped = false; +const startedAt = Date.now(); + +function elapsed() { + return `${((Date.now() - startedAt) / 1000).toFixed(1)}s`; +} + +function log(...args) { + console.log(`[myotis-smoke ${elapsed()}]`, ...args); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${QUERY_TIMEOUT_MS}ms`)), + QUERY_TIMEOUT_MS + ); + }), + ]).finally(() => clearTimeout(timer)); +} + +function drainLogs() { + if (!addon) return; + const batch = addon.drainLogs(100); + for (const line of batch.split('\n')) { + if (/ERROR|WARN/.test(line)) console.log(` [myotis-engine] ${line}`); + } +} + +function stop() { + if (stopped) return; + stopped = true; + if (addon && handle >= 1) { + try { + addon.stop(handle); + log('stopped cleanly'); + } catch (err) { + console.error(`Myotis stop failed: ${err.message}`); + } + } + handle = -1; +} + +process.once('SIGINT', () => { + stop(); + process.exit(130); +}); +process.once('SIGTERM', () => { + stop(); + process.exit(143); +}); + +async function verifiedReads() { + let lastError = null; + for (let attempt = 1; attempt <= queryAttempts; attempt++) { + try { + if (network === 'gnosis') { + const account = JSON.parse( + await withTimeout( + addon.requestAccountJson(handle, VITALIK_ADDRESS), + 'Gnosis account read' + ) + ); + log(`verified read attempt ${attempt}:`, JSON.stringify({ account })); + if (account.error) throw new Error(`account read: ${account.error}`); + if (account.peerProofValid !== true || account.beaconChainVerified !== true) { + throw new Error(`account was not chain verified: ${JSON.stringify(account)}`); + } + if (account.balanceWei == null || account.nonce == null) { + throw new Error(`account response incomplete: ${JSON.stringify(account)}`); + } + return; + } + const address = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ method: 'addr', name: 'vitalik.eth', root: 'finalized' }) + ), + 'ENS address read' + ) + ); + const contenthash = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ method: 'contenthash', name: 'vitalik.eth', root: 'finalized' }) + ), + 'ENS contenthash read' + ) + ); + const reverse = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ + method: 'reverse', + addressHex: VITALIK_ADDRESS, + root: 'finalized', + }) + ), + 'ENS reverse read' + ) + ); + log(`verified read attempt ${attempt}:`, JSON.stringify({ address, contenthash, reverse })); + + if (address.error) throw new Error(`address read: ${address.error}`); + if (address.status !== 'ok' || address.verified !== true) { + throw new Error(`address was not finalized-root verified: ${JSON.stringify(address)}`); + } + if (address.addressHex?.toLowerCase() !== VITALIK_ADDRESS.toLowerCase()) { + throw new Error(`address mismatch: ${address.addressHex || JSON.stringify(address)}`); + } + if (contenthash.error) throw new Error(`contenthash read: ${contenthash.error}`); + if (contenthash.status !== 'ok' || !contenthash.dataHex) { + throw new Error(`contenthash not resolved: ${JSON.stringify(contenthash)}`); + } + if (contenthash.verified !== true) { + throw new Error( + `contenthash was not finalized-root verified: ${JSON.stringify(contenthash)}` + ); + } + if (reverse.error) throw new Error(`reverse read: ${reverse.error}`); + if (reverse.status !== 'ok' || reverse.verified !== true) { + throw new Error(`reverse was not finalized-root verified: ${JSON.stringify(reverse)}`); + } + if (reverse.name?.toLowerCase() !== 'vitalik.eth') { + throw new Error(`reverse mismatch: ${reverse.name || JSON.stringify(reverse)}`); + } + return; + } catch (err) { + lastError = err; + drainLogs(); + if (attempt < queryAttempts) { + log(`verified read attempt ${attempt} failed (${err.message}); retrying in 15s`); + await delay(15000); + } + } + } + throw lastError || new Error('verified reads failed'); +} + +async function waitUntilReady(deadline) { + let lastSummary = ''; + let lastPeriod = -1; + let lastProgressAt = Date.now(); + let recoveries = 0; + for (;;) { + const status = JSON.parse(addon.statusJson(handle)); + const summary = + `beacon=${status.beaconState} peers=${status.peerCount} snapPeers=${status.snapPeers} ` + + `period=${status.currentPeriod}/${status.targetPeriod} ` + + `el=${status.elReaderAvailable} hunting=${status.elHunting}`; + if (summary !== lastSummary) { + log(summary); + lastSummary = summary; + } + drainLogs(); + + if (status.currentPeriod > lastPeriod) { + lastPeriod = status.currentPeriod; + lastProgressAt = Date.now(); + } + + const ready = + status.beaconState === 'SYNCED' && + status.elReaderAvailable === true && + status.elHunting !== true && + status.snapPeers > 0; + if (ready) return; + if (Date.now() >= deadline) { + throw new Error( + `Myotis did not become ready within ${syncTimeoutMinutes} minutes; last status: ` + + JSON.stringify(status) + ); + } + if ( + Date.now() - lastProgressAt >= syncStallMs && + recoveries < syncRecoveryAttempts && + (status.lcHunting === true || status.peerCount === 0) + ) { + recoveries += 1; + await recoverPeerPool( + `beacon catch-up stalled at period ${status.currentPeriod} for ${syncStallMinutes} minutes ` + + `(recovery ${recoveries}/${syncRecoveryAttempts})` + ); + lastProgressAt = Date.now(); + lastSummary = ''; + continue; + } + await delay(POLL_INTERVAL_MS); + } +} + +async function recoverPeerPool(reason) { + log(`${reason}; recreating the native handle from the warm snapshot`); + addon.stop(handle); + handle = -1; + await delay(1000); + handle = addon.create(network, dataDir); + if (handle < 1) throw new Error(`Myotis recreate failed with handle ${handle}`); + if (!addon.start(handle)) throw new Error('Myotis restart failed during peer-pool recovery'); +} + +async function main() { + if (!fs.existsSync(addonPath)) throw new Error(`Myotis addon not found: ${addonPath}`); + fs.mkdirSync(dataDir, { recursive: true }); + + addon = require(addonPath); + const abi = addon.init(); + if (abi !== EXPECTED_ABI) { + throw new Error(`Myotis ABI mismatch: expected ${EXPECTED_ABI}, got ${abi}`); + } + log(`loaded ${target} addon (ABI ${abi}) from ${addonPath}`); + + handle = addon.create(network, dataDir); + if (handle < 1) throw new Error(`Myotis create failed with handle ${handle}`); + if (!addon.start(handle)) throw new Error('Myotis start failed'); + log(`started handle ${handle}; data dir ${dataDir}`); + + const deadline = Date.now() + syncTimeoutMs; + for (let recovery = 0; recovery <= readRecoveryAttempts; recovery++) { + await waitUntilReady(deadline); + log(`node ready; performing verified ${network} reads`); + try { + await verifiedReads(); + log(`PASS: released addon started and served verified ${network} reads`); + return; + } catch (err) { + if (recovery >= readRecoveryAttempts) throw err; + log(`verified reads failed after peer-pool attempt ${recovery + 1}: ${err.message}`); + await recoverPeerPool('verified reads exhausted the current execution peer pool'); + } + } +} + +main() + .catch((err) => { + console.error(`myotis smoke failed: ${err.stack || err.message}`); + process.exitCode = 1; + }) + .finally(stop); diff --git a/src/main/__tests__/integration/v08-upgrade.test.js b/src/main/__tests__/integration/v08-upgrade.test.js index d5a4f40a..ab7ebdeb 100644 --- a/src/main/__tests__/integration/v08-upgrade.test.js +++ b/src/main/__tests__/integration/v08-upgrade.test.js @@ -218,7 +218,13 @@ describe('v0.8.0 upgrade path (end-to-end, in place)', () => { const networkConfig = JSON.parse( fs.readFileSync(path.join(userDataDir, 'network-config.json'), 'utf-8') ); - expect(networkConfig.networks['1']).toEqual({ verification: { primary: 'direct' } }); + expect(networkConfig.networks['1']).toEqual({ + verification: { + primary: 'direct', + order: ['myotis', 'direct', 'quorum'], + preferVerified: false, + }, + }); expect(networkConfig.endpointSources['migrated-eth-custom']).toEqual({ role: 'rpc', keyed: false, diff --git a/src/main/downloads/downloads-manager.js b/src/main/downloads/downloads-manager.js index bcab24fc..9fdf4237 100644 --- a/src/main/downloads/downloads-manager.js +++ b/src/main/downloads/downloads-manager.js @@ -10,18 +10,27 @@ * for attachment dispositions, `download`-attribute clicks, data: URIs, and * non-renderable main-frame navigations. * - * Persistence lives in downloads-store.js (per-profile downloads.sqlite). + * Persistence lives in downloads-store.js (per-profile downloads.sqlite) + * for normal windows only. PRIVATE MODE GUARD (downloads): downloads from + * private windows never touch SQLite — their rows live in the in-memory + * private-downloads-store, scoped to the window's partition, merged into + * query results served to that window's renderers alone, and dropped when + * the window closes. Ids route by sign: SQLite rowids are positive, private + * in-memory ids are negative. + * * Completed files are never opened automatically; open / show-in-folder are * explicit user actions arriving over IPC and resolved against the stored * row, never against a renderer-supplied path. */ const log = require('../logger'); -const { app, ipcMain, shell, BrowserWindow } = require('electron'); +const { app, ipcMain, shell, BrowserWindow, webContents } = require('electron'); const path = require('path'); const fs = require('fs'); const IPC = require('../../shared/ipc-channels'); const store = require('./downloads-store'); +const privateStore = require('./private-downloads-store'); +const { getPartitionForWebContents } = require('../private/private-windows'); const { loadSettings } = require('../settings-store'); const { broadcastToAllWebContents } = require('../lib/broadcast-to-all-webcontents'); @@ -29,6 +38,12 @@ const { broadcastToAllWebContents } = require('../lib/broadcast-to-all-webconten // through this map; settled items are removed. const activeItems = new Map(); +// Per-item bookkeeping for the live items above: the owning private +// partition (null for normal windows) and the save path the item claimed. +// Lets a closing private window cancel and unwind its own transfers without +// waiting for Chromium's asynchronous 'done'. +const activeItemMeta = new Map(); + // Store row ids whose live DownloadItem is in Chromium's 'interrupted' // updated-state: still live (not `done`), usually resumable, but not // transferring. Kept out of activeItems so pause/resume/cancel stay simple. @@ -118,7 +133,7 @@ function releaseSavePath(savePath) { * store rows (snake_case columns) so the downloads page renders both the * same way, plus live-only flags for pause/resume affordances. */ -function serializeDownload(id, item) { +function serializeDownload(id, item, { isPrivate = false } = {}) { return { id, url: item.getURL(), @@ -133,6 +148,7 @@ function serializeDownload(id, item) { // Live-but-stalled: the transfer broke mid-session and Chromium has not // given up on the item yet. The UI must offer Resume, not Pause. is_interrupted: interruptedItems.has(id), + is_private: isPrivate ? 1 : 0, }; } @@ -144,20 +160,37 @@ function ownerWindowOf(webContents) { return BrowserWindow.fromWebContents(host); } -function sendToOwner(ownerWindow, payload) { +function sendToOwner(ownerWindow, payload, privatePartition = null) { if (ownerWindow && !ownerWindow.isDestroyed()) { ownerWindow.webContents.send(IPC.DOWNLOADS_UPDATED, payload); } + if (privatePartition) { + // PRIVATE MODE GUARD (downloads): change hints for private downloads + // carry URL and save-path metadata — deliver them only to renderers of + // the owning private window, never to normal windows. + if (!webContents?.getAllWebContents) return; + for (const wc of webContents.getAllWebContents()) { + try { + if (getPartitionForWebContents(wc) === privatePartition) { + wc.send(IPC.DOWNLOADS_CHANGED, payload); + } + } catch { + // webContents may be destroyed mid-iteration + } + } + return; + } // The freedom://downloads page may be open in any window; it re-queries // the store on this signal. broadcastToAllWebContents(IPC.DOWNLOADS_CHANGED, payload); } -function handleWillDownload(_event, item, webContents) { +function handleWillDownload(item, webContents, { privatePartition = null } = {}) { const filename = sanitizeFilename(item.getFilename()); const settings = loadSettings(); const downloadsDir = app.getPath('downloads'); let reservedPath = null; + const isPrivate = !!privatePartition; if (settings.askWhereToSave === true) { // No savePath set → Electron shows its native save dialog; we only seed @@ -173,20 +206,34 @@ function handleWillDownload(_event, item, webContents) { item.setSavePath(reservedPath); } - const row = store.insertDownload({ + // PRIVATE MODE GUARD (downloads): downloads from private windows are + // allowed, but their metadata (URL, save path) must never be written to + // the profile database — a crash would strand the rows, and SQLite + // DELETE/WAL does not scrub previously written pages. Private rows live + // in the in-memory partition-scoped store instead and evaporate with the + // window (src/main/index.js registers the dropPartition close hook). + const rowStore = isPrivate ? privateStore : store; + const row = rowStore.insertDownload({ url: item.getURL(), filename, savePath: item.getSavePath() || null, mimeType: item.getMimeType() || null, totalBytes: item.getTotalBytes(), startTime: Date.now(), + partition: privatePartition, }); const id = row.id; activeItems.set(id, item); + activeItemMeta.set(id, { privatePartition, reservedPath }); const ownerWindow = ownerWindowOf(webContents); - log.info('[Downloads] Download started:', filename, `(id ${id})`); - sendToOwner(ownerWindow, serializeDownload(id, item)); + // PRIVATE MODE GUARD (download logging): the row lives in the in-memory + // private store precisely so nothing durable records what was fetched — + // logging the filename to the persistent main.log would reinstate exactly + // that trace, and outlive the window. Log the id only. + const logName = isPrivate ? '' : filename; + log.info('[Downloads] Download started:', logName, `(id ${id})`); + sendToOwner(ownerWindow, serializeDownload(id, item, { isPrivate }), privatePartition); let lastProgressAt = 0; let lastUpdatedState = 'progressing'; @@ -206,19 +253,29 @@ function handleWillDownload(_event, item, webContents) { if (!stateChanged && now - lastProgressAt < PROGRESS_THROTTLE_MS) return; lastProgressAt = now; - store.updateDownload(id, { + rowStore.updateDownload(id, { receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), // The save dialog resolves the path after insert; keep the row current. savePath: item.getSavePath() || null, }); - sendToOwner(ownerWindow, serializeDownload(id, item)); + sendToOwner(ownerWindow, serializeDownload(id, item, { isPrivate }), privatePartition); }); item.once('done', (_doneEvent, doneState) => { + // `cancelPartitionDownloads` (private-window close) force-unwinds items + // synchronously and releases their path claim there; Chromium's 'done' + // then arrives afterwards for the very same item. `reservedSavePaths` is + // a plain Set, not refcounted, so a second release would free whatever + // *new* download had meanwhile reserved the same path — and a third + // same-named download would be handed that identical path, leaving two + // transfers writing one file. The unwind clears `activeItemMeta`, so its + // membership is the "do we still own the claim?" flag. + const ownsReservation = activeItemMeta.has(id); activeItems.delete(id); + activeItemMeta.delete(id); interruptedItems.delete(id); - releaseSavePath(reservedPath); + if (ownsReservation) releaseSavePath(reservedPath); // Electron reports 'completed' | 'cancelled' | 'interrupted'; the store // uses the same vocabulary (snake-cased in_progress aside). @@ -229,7 +286,7 @@ function handleWillDownload(_event, item, webContents) { ? store.STATES.CANCELLED : store.STATES.INTERRUPTED; - store.updateDownload(id, { + rowStore.updateDownload(id, { receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), savePath: item.getSavePath() || null, @@ -237,29 +294,80 @@ function handleWillDownload(_event, item, webContents) { endTime: Date.now(), }); - log.info('[Downloads] Download', doneState + ':', filename, `(id ${id})`); - sendToOwner(ownerWindow, { - ...serializeDownload(id, item), - state, - is_paused: false, - can_resume: false, - is_interrupted: false, - }); + log.info('[Downloads] Download', doneState + ':', logName, `(id ${id})`); + sendToOwner( + ownerWindow, + { + ...serializeDownload(id, item, { isPrivate }), + state, + is_paused: false, + can_resume: false, + is_interrupted: false, + }, + privatePartition + ); }); } /** * Hook `will-download` on the given session. Call once per session that - * hosts downloadable content (today: the default session only). + * hosts downloadable content: the default session at startup, and every + * private window's `private-` session when it is created (see + * src/main/index.js) — private partitions must be covered too, or their + * downloads would silently bypass the manager. * @param {Electron.Session} targetSession + * @param {{ privatePartition?: string|null }} [options] - set for private + * sessions so their rows stay in the in-memory partition store and never + * reach the profile database */ -function attachDownloadsManager(targetSession) { +function attachDownloadsManager(targetSession, { privatePartition = null } = {}) { if (!targetSession || typeof targetSession.on !== 'function') { log.warn('[Downloads] session unavailable — skipping will-download hook'); return; } - targetSession.on('will-download', handleWillDownload); - log.info('[Downloads] will-download hook attached'); + targetSession.on('will-download', (_event, item, webContents) => + handleWillDownload(item, webContents, { privatePartition }) + ); + log.info( + '[Downloads] will-download hook attached' + (privatePartition ? ' (private session)' : '') + ); +} + +/** + * PRIVATE MODE GUARD (downloads): cancel every in-flight download owned by a + * private partition. Runs from the private-window close hook (registered in + * src/main/index.js, before the in-memory rows are dropped). + * + * Without this a transfer started in a private window outlives the window + * that owned it: its row disappears with the partition, so no renderer can + * see it and pause/cancel refuse it (they authorize against that row), yet + * Chromium keeps writing bytes to disk and a completed file would appear + * with no record anywhere. Cancelling also discards the partial file and + * frees the save-path reservation. + * @param {string} partition + * @returns {number} Number of items cancelled + */ +function cancelPartitionDownloads(partition) { + if (!partition) return 0; + let cancelled = 0; + for (const [id, meta] of [...activeItemMeta]) { + if (meta.privatePartition !== partition) continue; + const item = activeItems.get(id); + activeItems.delete(id); + activeItemMeta.delete(id); + interruptedItems.delete(id); + releaseSavePath(meta.reservedPath); + try { + item?.cancel(); + cancelled++; + } catch (err) { + log.warn('[Downloads] Could not cancel private download', id + ':', err?.message || err); + } + } + if (cancelled > 0) { + log.info(`[Downloads] Cancelled ${cancelled} in-flight private download(s) on ${partition}`); + } + return cancelled; } /** @@ -281,21 +389,68 @@ function withLiveFlags(rows) { }); } +/** + * Resolve a row for an id-based IPC request. Negative ids are in-memory + * private rows; only renderers of the owning private window may act on + * them — any other sender resolves to null. Positive ids are SQLite rows. + */ +function resolveRowForSender(event, id) { + if (typeof id === 'number' && id < 0) { + const row = privateStore.getDownloadById(id); + if (!row || getPartitionForWebContents(event?.sender) !== row.session_partition) { + return null; + } + return row; + } + return store.getDownloadById(id); +} + +/** + * Resolve a live DownloadItem for an id-based IPC request, enforcing the + * same ownership rule as resolveRowForSender: private ids are predictable + * negative integers, so pause / resume / cancel must refuse senders outside + * the owning private window's partition just like open / show / remove do. + */ +function resolveActiveItemForSender(event, id) { + if (!resolveRowForSender(event, id)) return null; + return activeItems.get(id) || null; +} + /** * Register IPC handlers for download operations */ function registerDownloadsIpc() { // Crash recovery: rows a previous run left in_progress are dead. store.markStaleInProgressAsInterrupted(); + // Legacy sweep: builds before the in-memory private store wrote private + // rows into SQLite; drop any still lingering. Current code never inserts + // them (see handleWillDownload), so this only cleans up old profiles. + store.removeAllPrivateDownloads(); - ipcMain.handle(IPC.DOWNLOADS_GET, (_event, options = {}) => { + ipcMain.handle(IPC.DOWNLOADS_GET, (event, options = {}) => { const { query, limit } = options; - const rows = query ? store.searchDownloads(query, limit || 100) : store.getAllDownloads(); + const max = limit || 100; + let rows = query ? store.searchDownloads(query, max) : store.getAllDownloads(); + // PRIVATE MODE GUARD (downloads): in-memory private rows are merged in + // only for renderers of the owning private window; normal windows (and + // other private windows) never see them. + const partition = getPartitionForWebContents(event?.sender); + if (partition) { + const privateRows = query + ? privateStore.searchDownloads(partition, query, max) + : privateStore.getDownloads(partition); + if (privateRows.length > 0) { + rows = [...privateRows, ...rows].sort((a, b) => b.start_time - a.start_time); + if (query) rows = rows.slice(0, max); + } + } return withLiveFlags(rows); }); - ipcMain.handle(IPC.DOWNLOADS_PAUSE, (_event, id) => { - const item = activeItems.get(id); + // Live controls authorize through the stored row too: a private row's + // item is only reachable from renderers of the owning private window. + ipcMain.handle(IPC.DOWNLOADS_PAUSE, (event, id) => { + const item = resolveActiveItemForSender(event, id); // Chromium ignores pause() on an interrupted item — refuse rather than // pretend it worked. if (!item || interruptedItems.has(id)) return false; @@ -303,15 +458,15 @@ function registerDownloadsIpc() { return true; }); - ipcMain.handle(IPC.DOWNLOADS_RESUME, (_event, id) => { - const item = activeItems.get(id); + ipcMain.handle(IPC.DOWNLOADS_RESUME, (event, id) => { + const item = resolveActiveItemForSender(event, id); if (!item || !item.canResume()) return false; item.resume(); return true; }); - ipcMain.handle(IPC.DOWNLOADS_CANCEL, (_event, id) => { - const item = activeItems.get(id); + ipcMain.handle(IPC.DOWNLOADS_CANCEL, (event, id) => { + const item = resolveActiveItemForSender(event, id); if (!item) return false; item.cancel(); return true; @@ -320,8 +475,8 @@ function registerDownloadsIpc() { // Open and show-in-folder resolve the path from the stored row — a // renderer can only ever act on files this manager wrote, never on an // arbitrary path. Files are never opened without this explicit request. - ipcMain.handle(IPC.DOWNLOADS_OPEN_FILE, async (_event, id) => { - const row = store.getDownloadById(id); + ipcMain.handle(IPC.DOWNLOADS_OPEN_FILE, async (event, id) => { + const row = resolveRowForSender(event, id); if (!row || row.state !== store.STATES.COMPLETED || !row.save_path) { return { success: false, error: 'Download is not completed' }; } @@ -335,8 +490,8 @@ function registerDownloadsIpc() { return { success: true }; }); - ipcMain.handle(IPC.DOWNLOADS_SHOW_IN_FOLDER, (_event, id) => { - const row = store.getDownloadById(id); + ipcMain.handle(IPC.DOWNLOADS_SHOW_IN_FOLDER, (event, id) => { + const row = resolveRowForSender(event, id); if (!row || !row.save_path || !fs.existsSync(row.save_path)) { return { success: false, error: 'File no longer exists' }; } @@ -344,15 +499,25 @@ function registerDownloadsIpc() { return { success: true }; }); - ipcMain.handle(IPC.DOWNLOADS_REMOVE, (_event, id) => { + ipcMain.handle(IPC.DOWNLOADS_REMOVE, (event, id) => { // Removing from the list never deletes the file, and an in-flight // download must be cancelled first so its row can't be orphaned. if (activeItems.has(id)) return false; + if (typeof id === 'number' && id < 0) { + return resolveRowForSender(event, id) ? privateStore.removeDownload(id) : false; + } return store.removeDownload(id); }); - ipcMain.handle(IPC.DOWNLOADS_CLEAR, () => { - return store.clearDownloads(); + ipcMain.handle(IPC.DOWNLOADS_CLEAR, (event) => { + let cleared = store.clearDownloads(); + // A private window's "Clear All" also drops its own settled in-memory + // rows (they are part of the merged view it sees). + const partition = getPartitionForWebContents(event?.sender); + if (partition) { + cleared += privateStore.clearSettled(partition); + } + return cleared; }); log.info('[Downloads] IPC handlers registered'); @@ -360,6 +525,7 @@ function registerDownloadsIpc() { module.exports = { attachDownloadsManager, + cancelPartitionDownloads, registerDownloadsIpc, sanitizeFilename, uniqueSavePath, diff --git a/src/main/downloads/downloads-manager.test.js b/src/main/downloads/downloads-manager.test.js index 5cedd0ff..87d6639d 100644 --- a/src/main/downloads/downloads-manager.test.js +++ b/src/main/downloads/downloads-manager.test.js @@ -485,3 +485,428 @@ describe('downloads-manager', () => { ); }); }); + +// PRIVATE MODE GUARD coverage: private-window downloads must never touch +// the profile SQLite store — their rows live in the in-memory partition +// store, are merged only into the owning private window's queries, and +// evaporate when the partition is dropped. A crash can never leave private +// rows on disk because none are ever written. +describe('downloads-manager private sessions', () => { + const PARTITION = 'private-e2e'; + + // The manager resolves a requester's partition through + // private-windows.getPartitionForWebContents; the module is mocked below + // to read this marker property off the fake senders. + const privateSender = { privatePartition: PARTITION }; + const normalSender = {}; + + let userDataDir; + let downloadsDir; + let ipcMain; + let ownerWindow; + let allWebContents; + let shell; + let mod; + let store; + let privateStore; + let log; + + const load = () => { + ipcMain = createIpcMainMock(); + log = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + allWebContents = []; + ownerWindow = { + isDestroyed: () => false, + webContents: { send: jest.fn() }, + }; + shell = { openPath: jest.fn(async () => ''), showItemInFolder: jest.fn() }; + ({ mod } = loadMainModule(require.resolve('./downloads-manager'), { + userDataDir, + appPaths: { userData: userDataDir, downloads: downloadsDir }, + ipcMain, + electronOverrides: { + shell, + BrowserWindow: { + getAllWindows: jest.fn(() => [ownerWindow]), + fromWebContents: jest.fn(() => ownerWindow), + }, + webContents: { getAllWebContents: jest.fn(() => allWebContents) }, + }, + extraMocks: { + 'better-sqlite3': () => FakeBetterSqlite3DownloadsDatabase, + [require.resolve('../private/private-windows')]: () => ({ + getPartitionForWebContents: (wc) => wc?.privatePartition || null, + }), + [require.resolve('../logger')]: () => log, + }, + })); + // Same jest module registry → the exact store instances the manager uses. + store = require('./downloads-store'); + privateStore = require('./private-downloads-store'); + }; + + // IPC events carry the requesting sender so partition scoping can be + // asserted (the shared mock's invoke() always passes an empty event). + const invokeAs = (sender, channel, ...args) => { + const handler = ipcMain.handlers.get(channel); + if (!handler) throw new Error(`No IPC handler registered for ${channel}`); + return handler({ sender }, ...args); + }; + + const startOn = (session, itemProps) => { + const item = new FakeDownloadItem(itemProps); + session.emit('will-download', {}, item, { hostWebContents: { id: 7 } }); + return item; + }; + + beforeEach(() => { + userDataDir = createTempUserDataDir(); + downloadsDir = path.join(userDataDir, 'downloads'); + load(); + }); + + afterEach(() => { + privateStore._resetState(); + store.closeDb(); + removeTempUserDataDir(userDataDir); + }); + + test('a private download never touches the SQLite store', () => { + const insertSpy = jest.spyOn(store, 'insertDownload'); + const updateSpy = jest.spyOn(store, 'updateDownload'); + + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + + const item = startOn(privateSession, { + url: 'https://example.com/secret.bin', + filename: 'secret.bin', + totalBytes: 100, + }); + item.receivedBytes = 50; + item.emit('updated'); + item.receivedBytes = 100; + item.emit('done', {}, 'completed'); + + // Full lifecycle — start, progress, terminal state — with zero SQLite + // writes; the row lives only in the in-memory partition store. + expect(insertSpy).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(store.getDownloadCount()).toBe(0); + expect(privateStore.getCount()).toBe(1); + expect(privateStore.getDownloads(PARTITION)[0]).toEqual( + expect.objectContaining({ + filename: 'secret.bin', + state: 'completed', + received_bytes: 100, + is_private: 1, + session_partition: PARTITION, + }) + ); + }); + + // The in-memory private store exists so nothing durable records what was + // fetched; log.info goes to the persistent /logs/main.log, which + // outlives the window and the app, so the filename must not appear there. + test('a private download never writes its filename to the persistent log', () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + + const item = startOn(privateSession, { + url: 'https://example.com/secret.bin', + filename: 'secret.bin', + totalBytes: 100, + }); + item.emit('done', {}, 'completed'); + + // Both the start and the terminal line are emitted (the id is still + // traceable) — neither carries the filename. + const lines = log.info.mock.calls.map((call) => call.join(' ')); + expect(lines.filter((line) => line.includes('[Downloads] Download')).length).toBe(2); + expect(lines.join('\n')).not.toContain('secret.bin'); + + // Normal downloads keep the diagnostic filename. + const normalSession = new EventEmitter(); + mod.attachDownloadsManager(normalSession); + const normalItem = startOn(normalSession, { + url: 'https://example.com/public.bin', + filename: 'public.bin', + totalBytes: 10, + }); + normalItem.emit('done', {}, 'completed'); + expect(log.info.mock.calls.map((call) => call.join(' ')).join('\n')).toContain('public.bin'); + }); + + test('private rows merge into the private window view only; ids are negative', async () => { + const defaultSession = new EventEmitter(); + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(defaultSession); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.registerDownloadsIpc(); + + startOn(privateSession, { url: 'https://example.com/secret.bin', filename: 'secret.bin' }); + startOn(defaultSession, { url: 'https://example.com/public.bin', filename: 'public.bin' }); + + // The owning private window sees the merged view. + const privateView = await invokeAs(privateSender, IPC.DOWNLOADS_GET, {}); + expect(privateView.map((r) => r.filename).sort()).toEqual(['public.bin', 'secret.bin']); + const privateRow = privateView.find((r) => r.filename === 'secret.bin'); + expect(privateRow.id).toBeLessThan(0); + expect(privateRow).toEqual( + expect.objectContaining({ is_private: 1, session_partition: PARTITION }) + ); + + // Normal windows — and other private windows — never see private rows. + const normalView = await invokeAs(normalSender, IPC.DOWNLOADS_GET, {}); + expect(normalView.map((r) => r.filename)).toEqual(['public.bin']); + const otherPrivateView = await invokeAs( + { privatePartition: 'private-other' }, + IPC.DOWNLOADS_GET, + {} + ); + expect(otherPrivateView.map((r) => r.filename)).toEqual(['public.bin']); + + // Search queries scope the same way. + const privateSearch = await invokeAs(privateSender, IPC.DOWNLOADS_GET, { query: 'secret' }); + expect(privateSearch).toHaveLength(1); + const normalSearch = await invokeAs(normalSender, IPC.DOWNLOADS_GET, { query: 'secret' }); + expect(normalSearch).toHaveLength(0); + }); + + test('private change hints reach only the private window renderers', () => { + const privateWc = { privatePartition: PARTITION, send: jest.fn() }; + const normalWc = { send: jest.fn() }; + allWebContents.push(privateWc, normalWc); + + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + startOn(privateSession, { url: 'https://example.com/secret.bin', filename: 'secret.bin' }); + + // Shelf update goes to the owning window; the downloads:changed hint + // (which carries URL and save path) stays inside the private window. + expect(ownerWindow.webContents.send).toHaveBeenCalledWith( + IPC.DOWNLOADS_UPDATED, + expect.objectContaining({ is_private: 1 }) + ); + expect(privateWc.send).toHaveBeenCalledWith( + IPC.DOWNLOADS_CHANGED, + expect.objectContaining({ filename: 'secret.bin' }) + ); + expect(normalWc.send).not.toHaveBeenCalled(); + }); + + test('open / show / remove on a private row are refused for other windows', async () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.registerDownloadsIpc(); + + fs.mkdirSync(downloadsDir, { recursive: true }); + const item = startOn(privateSession, { + url: 'https://example.com/secret.pdf', + filename: 'secret.pdf', + }); + fs.writeFileSync(item.savePath, 'content'); + item.emit('done', {}, 'completed'); + + const [row] = await invokeAs(privateSender, IPC.DOWNLOADS_GET, {}); + expect(row.id).toBeLessThan(0); + + // A normal window cannot act on (or probe) the private row by id. + await expect(invokeAs(normalSender, IPC.DOWNLOADS_OPEN_FILE, row.id)).resolves.toEqual( + expect.objectContaining({ success: false }) + ); + expect(await invokeAs(normalSender, IPC.DOWNLOADS_SHOW_IN_FOLDER, row.id)).toEqual( + expect.objectContaining({ success: false }) + ); + expect(await invokeAs(normalSender, IPC.DOWNLOADS_REMOVE, row.id)).toBe(false); + expect(shell.openPath).not.toHaveBeenCalled(); + + // The owning private window can. + await expect(invokeAs(privateSender, IPC.DOWNLOADS_OPEN_FILE, row.id)).resolves.toEqual({ + success: true, + }); + expect(shell.openPath).toHaveBeenCalledWith(item.savePath); + expect(await invokeAs(privateSender, IPC.DOWNLOADS_REMOVE, row.id)).toBe(true); + expect(privateStore.getCount()).toBe(0); + }); + + test('pause / resume / cancel on a private download are refused for other windows', async () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.registerDownloadsIpc(); + + const item = startOn(privateSession, { + url: 'https://example.com/secret.iso', + filename: 'secret.iso', + }); + + const [row] = await invokeAs(privateSender, IPC.DOWNLOADS_GET, {}); + expect(row.id).toBeLessThan(0); + + // A normal window cannot control the private window's live download, + // even though private ids are predictable negative integers. + expect(await invokeAs(normalSender, IPC.DOWNLOADS_PAUSE, row.id)).toBe(false); + expect(await invokeAs(normalSender, IPC.DOWNLOADS_RESUME, row.id)).toBe(false); + expect(await invokeAs(normalSender, IPC.DOWNLOADS_CANCEL, row.id)).toBe(false); + + // Neither can a different private window (another partition). + const otherPrivateSender = { privatePartition: 'private-other' }; + expect(await invokeAs(otherPrivateSender, IPC.DOWNLOADS_PAUSE, row.id)).toBe(false); + expect(await invokeAs(otherPrivateSender, IPC.DOWNLOADS_RESUME, row.id)).toBe(false); + expect(await invokeAs(otherPrivateSender, IPC.DOWNLOADS_CANCEL, row.id)).toBe(false); + + expect(item.pause).not.toHaveBeenCalled(); + expect(item.resume).not.toHaveBeenCalled(); + expect(item.cancel).not.toHaveBeenCalled(); + + // The owning private window still can. + expect(await invokeAs(privateSender, IPC.DOWNLOADS_PAUSE, row.id)).toBe(true); + expect(item.pause).toHaveBeenCalled(); + expect(await invokeAs(privateSender, IPC.DOWNLOADS_RESUME, row.id)).toBe(true); + expect(item.resume).toHaveBeenCalled(); + expect(await invokeAs(privateSender, IPC.DOWNLOADS_CANCEL, row.id)).toBe(true); + expect(item.cancel).toHaveBeenCalled(); + }); + + test('dropping the partition clears the memory store (window-close hook)', async () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.registerDownloadsIpc(); + + const item = startOn(privateSession, { + url: 'https://example.com/a.bin', + filename: 'a.bin', + }); + item.emit('done', {}, 'completed'); + expect(privateStore.getCount()).toBe(1); + + // This is the cleanup hook src/main/index.js registers for window close. + expect(privateStore.dropPartition(PARTITION)).toBe(1); + expect(privateStore.getCount()).toBe(0); + expect(await invokeAs(privateSender, IPC.DOWNLOADS_GET, {})).toEqual([]); + }); + + test('closing a private window cancels its in-flight downloads', async () => { + const privateSession = new EventEmitter(); + const otherPrivateSession = new EventEmitter(); + const normalSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.attachDownloadsManager(otherPrivateSession, { privatePartition: 'private-other' }); + mod.attachDownloadsManager(normalSession); + mod.registerDownloadsIpc(); + + const item = startOn(privateSession, { + url: 'https://example.com/big.iso', + filename: 'big.iso', + }); + const otherItem = startOn(otherPrivateSession, { + url: 'https://example.com/other.iso', + filename: 'other.iso', + }); + const normalItem = startOn(normalSession, { + url: 'https://example.com/public.iso', + filename: 'public.iso', + }); + + // The window-close hook src/main/index.js registers, in order. + expect(mod.cancelPartitionDownloads(PARTITION)).toBe(1); + expect(item.cancel).toHaveBeenCalled(); + // Other partitions — private or not — keep transferring. + expect(otherItem.cancel).not.toHaveBeenCalled(); + expect(normalItem.cancel).not.toHaveBeenCalled(); + privateStore.dropPartition(PARTITION); + + // Bookkeeping is unwound synchronously: the cancelled item is no longer + // live (so DOWNLOADS_REMOVE would not refuse it) and its claimed save + // path is free again for the next download of the same name. + item.emit('done', {}, 'cancelled'); + const reclaimed = startOn(normalSession, { + url: 'https://example.com/big.iso', + filename: 'big.iso', + }); + expect(reclaimed.getSavePath()).toBe(path.join(downloadsDir, 'big.iso')); + }); + + // `reservedSavePaths` is a plain Set, not refcounted. cancelPartitionDownloads + // releases the claim synchronously and Chromium's 'done' arrives afterwards + // for the same item; if 'done' released it a SECOND time it would free + // whatever new download had meanwhile reserved that path, and the next + // same-named download would be handed the identical path — two transfers + // writing one file. + test('a cancelled private download does not free a later download of the same name', () => { + const privateSession = new EventEmitter(); + const normalSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + mod.attachDownloadsManager(normalSession); + + const item = startOn(privateSession, { + url: 'https://example.com/big.iso', + filename: 'big.iso', + }); + expect(item.getSavePath()).toBe(path.join(downloadsDir, 'big.iso')); + + // Private window closes: the claim on big.iso is released here. + expect(mod.cancelPartitionDownloads(PARTITION)).toBe(1); + + // A new download reclaims the freed name BEFORE the cancelled item's + // asynchronous 'done' lands. This is the race window. + const reclaimed = startOn(normalSession, { + url: 'https://example.com/big.iso', + filename: 'big.iso', + }); + expect(reclaimed.getSavePath()).toBe(path.join(downloadsDir, 'big.iso')); + + // The late 'done' for the already-unwound item must NOT release the + // reservation now owned by `reclaimed`. + item.emit('done', {}, 'cancelled'); + + const third = startOn(normalSession, { + url: 'https://example.com/big.iso', + filename: 'big.iso', + }); + expect(third.getSavePath()).toBe(path.join(downloadsDir, 'big (1).iso')); + }); + + test('cancelPartitionDownloads ignores a missing/blank partition', () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + const item = startOn(privateSession, { url: 'https://example.com/a.bin', filename: 'a.bin' }); + + expect(mod.cancelPartitionDownloads(null)).toBe(0); + expect(mod.cancelPartitionDownloads('private-nobody')).toBe(0); + expect(item.cancel).not.toHaveBeenCalled(); + }); + + test('crash scenario: nothing was persisted, so the next run has nothing to sweep', () => { + const privateSession = new EventEmitter(); + mod.attachDownloadsManager(privateSession, { privatePartition: PARTITION }); + + // Simulate a crash mid-download: no window-close hook ever runs. + startOn(privateSession, { url: 'https://example.com/secret.bin', filename: 'secret.bin' }); + + // Nothing reached the profile database… + expect(store.getDownloadCount()).toBe(0); + // …so the startup legacy sweep of the "next run" finds nothing. + expect(store.removeAllPrivateDownloads()).toBe(0); + expect(store.getAllDownloads()).toEqual([]); + }); + + test('registerDownloadsIpc sweeps legacy private rows written by old builds', () => { + store.insertDownload({ + url: 'https://example.com/stale.bin', + filename: 'stale.bin', + isPrivate: true, + partition: 'private-crashed', + }); + store.insertDownload({ + url: 'https://example.com/keep.bin', + filename: 'keep.bin', + }); + + mod.registerDownloadsIpc(); + + const rows = store.getAllDownloads(); + expect(rows).toHaveLength(1); + expect(rows[0].filename).toBe('keep.bin'); + }); +}); diff --git a/src/main/downloads/downloads-store.js b/src/main/downloads/downloads-store.js index f247aeed..13db2ab0 100644 --- a/src/main/downloads/downloads-store.js +++ b/src/main/downloads/downloads-store.js @@ -3,6 +3,12 @@ * accepted via `will-download`, mirrored live while the DownloadItem is in * flight and left behind as history once it settles. * + * PRIVATE MODE GUARD (downloads): private-window downloads never reach + * this store — their rows live in private-downloads-store.js (in-memory, + * partition-scoped). The is_private / session_partition columns remain for + * schema continuity and the legacy startup sweep (removeAllPrivateDownloads) + * that cleans rows written by older builds. + * * States: 'in_progress' | 'completed' | 'cancelled' | 'interrupted'. * Rows left 'in_progress' by a crash are swept to 'interrupted' on the * next startup (see markStaleInProgressAsInterrupted). @@ -86,8 +92,22 @@ function migrateDatabase() { db.pragma('user_version = 1'); } + if (version < 2) { + log.info('[Downloads] Running migration to version 2 (private-window columns)'); + // Historical: older builds flagged private-window rows here so they + // could be purged later. Private rows no longer reach SQLite at all + // (see private-downloads-store.js); the columns stay for schema + // continuity and the legacy startup sweep. + // (`session_partition`, not `partition` — PARTITION is an SQL keyword.) + db.exec(` + ALTER TABLE downloads ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0; + ALTER TABLE downloads ADD COLUMN session_partition TEXT; + `); + db.pragma('user_version = 2'); + } + // Future migrations go here: - // if (version < 2) { ... db.pragma('user_version = 2'); } + // if (version < 3) { ... db.pragma('user_version = 3'); } } // Prepared statements (lazily initialized) @@ -102,8 +122,8 @@ function getStatements() { insert: database.prepare(` INSERT INTO downloads ( url, filename, save_path, mime_type, total_bytes, received_bytes, - state, start_time, end_time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + state, start_time, end_time, is_private, session_partition + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), update: database.prepare(` UPDATE downloads SET @@ -135,6 +155,9 @@ function getStatements() { sweepStale: database.prepare(` UPDATE downloads SET state = 'interrupted', end_time = ? WHERE state = 'in_progress' `), + removeAllPrivate: database.prepare(` + DELETE FROM downloads WHERE is_private = 1 + `), count: database.prepare(` SELECT COUNT(*) as count FROM downloads `), @@ -144,13 +167,17 @@ function getStatements() { } /** - * Insert a new download row (state starts as in_progress) + * Insert a new download row (state starts as in_progress). + * `isPrivate` / `partition` write the legacy private columns; the manager + * never passes them anymore (private rows are in-memory only) — they exist + * so tests can fabricate rows for the legacy startup sweep. * @param {object} entry - { url, filename, savePath, mimeType, totalBytes, startTime } * @returns {object} The inserted row shape (with id) */ function insertDownload(entry) { - const { url, filename, savePath, mimeType, totalBytes, startTime } = entry; + const { url, filename, savePath, mimeType, totalBytes, startTime, isPrivate, partition } = entry; const start = startTime || Date.now(); + const privateFlag = isPrivate ? 1 : 0; const stmt = getStatements().insert; const result = stmt.run( @@ -162,10 +189,12 @@ function insertDownload(entry) { 0, STATES.IN_PROGRESS, start, - null + null, + privateFlag, + partition || null ); - log.info('[Downloads] Recorded download start:', filename); + log.info('[Downloads] Recorded download start:', filename, privateFlag ? '(private)' : ''); return { id: result.lastInsertRowid, @@ -178,6 +207,8 @@ function insertDownload(entry) { state: STATES.IN_PROGRESS, start_time: start, end_time: null, + is_private: privateFlag, + session_partition: partition || null, }; } @@ -254,6 +285,25 @@ function clearDownloads() { return result.changes; } +/** + * PRIVATE MODE GUARD (downloads history): legacy startup sweep. Current + * code never writes private rows to SQLite (they live in + * private-downloads-store.js), but builds before that change did — drop + * any such rows left behind in an old profile. Files on disk are + * untouched. Note: a plain DELETE cannot scrub previously written + * SQLite/WAL pages, which is exactly why private rows are no longer + * written here in the first place. + * @returns {number} Number of rows removed + */ +function removeAllPrivateDownloads() { + const stmt = getStatements().removeAllPrivate; + const result = stmt.run(); + if (result.changes > 0) { + log.info('[Downloads] Purged', result.changes, 'stale private download entries at startup'); + } + return result.changes; +} + /** * Sweep rows left 'in_progress' by a previous run (crash / force quit) to * 'interrupted'. Call once at startup, before any UI reads the table. @@ -289,6 +339,7 @@ module.exports = { getDownloadById, removeDownload, clearDownloads, + removeAllPrivateDownloads, markStaleInProgressAsInterrupted, getDownloadCount, }; diff --git a/src/main/downloads/downloads-store.test.js b/src/main/downloads/downloads-store.test.js index 88c6f261..921f33fe 100644 --- a/src/main/downloads/downloads-store.test.js +++ b/src/main/downloads/downloads-store.test.js @@ -128,3 +128,58 @@ describe('downloads-store', () => { expect(rows.find((row) => row.filename === 'b.bin').state).toBe('completed'); }); }); + +// PRIVATE MODE GUARD coverage: current code never writes private rows to +// SQLite (they live in private-downloads-store.js); what remains here is +// the legacy startup sweep that cleans rows older builds left behind. +describe('downloads-store legacy private rows', () => { + let userDataDir; + let storeModule; + + beforeEach(() => { + userDataDir = createTempUserDataDir(); + storeModule = null; + }); + + afterEach(() => { + if (storeModule?.closeDb) { + storeModule.closeDb(); + } + removeTempUserDataDir(userDataDir); + }); + + const insertNormal = (mod, url = 'https://example.com/keep.zip') => + mod.insertDownload({ url, filename: 'keep.zip', totalBytes: 1 }); + + const insertPrivate = (mod, partition, url = 'https://example.com/secret.zip') => + mod.insertDownload({ + url, + filename: 'secret.zip', + totalBytes: 1, + isPrivate: true, + partition, + }); + + test('insertDownload writes normal rows unflagged by default', () => { + const { mod } = loadDownloadsStore({ userDataDir }); + storeModule = mod; + + const normal = insertNormal(mod); + expect(normal.is_private).toBe(0); + expect(normal.session_partition).toBe(null); + }); + + test('removeAllPrivateDownloads sweeps legacy private rows at startup', () => { + const { mod } = loadDownloadsStore({ userDataDir }); + storeModule = mod; + + insertNormal(mod); + insertPrivate(mod, 'private-a'); + insertPrivate(mod, 'private-b'); + + expect(mod.removeAllPrivateDownloads()).toBe(2); + const rows = mod.getAllDownloads(); + expect(rows).toHaveLength(1); + expect(rows[0].is_private).toBe(0); + }); +}); diff --git a/src/main/downloads/private-downloads-store.js b/src/main/downloads/private-downloads-store.js new file mode 100644 index 00000000..d99f1cba --- /dev/null +++ b/src/main/downloads/private-downloads-store.js @@ -0,0 +1,190 @@ +/** + * PRIVATE MODE GUARD (downloads): in-memory download history for private + * windows, keyed by their `private-` session partition. + * + * Rows for downloads started on a private partition must NEVER reach the + * profile's downloads.sqlite: a crash would strand them on disk, and even a + * clean DELETE does not scrub previously written SQLite/WAL pages — the + * URL and save path could remain recoverable. So private rows live only in + * this process-memory Map and evaporate with the window (`dropPartition` + * runs from the private-window close hook in src/main/index.js). + * + * Row shape mirrors downloads-store.js (snake_case columns) so renderers + * treat both sources identically. Ids are NEGATIVE so they can never + * collide with SQLite AUTOINCREMENT rowids and id-based IPC (pause / + * open / remove) can route to the right store by sign alone. + * + * Deliberately dependency-free (no electron, no better-sqlite3): the state + * vocabulary is duplicated from downloads-store.STATES rather than + * imported, so requiring this module can never touch the SQLite store. + */ + +const STATE_IN_PROGRESS = 'in_progress'; + +// Negative id sequence; never reset while the process lives so ids are +// unique across all private windows of a session. +let nextId = -1; + +// id -> row. Insertion order is not relied upon — queries sort by +// start_time like the SQLite store does. +const rows = new Map(); + +/** + * Record a private download. Mirrors downloads-store.insertDownload but + * requires the owning partition and always flags the row private. + * @param {object} entry - { url, filename, savePath, mimeType, totalBytes, + * startTime, partition } + * @returns {object} The inserted row (with its negative id) + */ +function insertDownload(entry) { + const { url, filename, savePath, mimeType, totalBytes, startTime, partition } = entry; + const row = { + id: nextId--, + url, + filename, + save_path: savePath || null, + mime_type: mimeType || null, + total_bytes: totalBytes || 0, + received_bytes: 0, + state: STATE_IN_PROGRESS, + start_time: startTime || Date.now(), + end_time: null, + is_private: 1, + session_partition: partition, + }; + rows.set(row.id, row); + return { ...row }; +} + +/** + * Patch a private row. Same COALESCE semantics as the SQLite store: only + * the provided fields are written. + * @param {number} id - Row id (negative) + * @param {object} patch - { receivedBytes, totalBytes, state, savePath, endTime } + * @returns {boolean} Whether a row was updated + */ +function updateDownload(id, patch = {}) { + const row = rows.get(id); + if (!row) return false; + if (patch.receivedBytes != null) row.received_bytes = patch.receivedBytes; + if (patch.totalBytes != null) row.total_bytes = patch.totalBytes; + if (patch.state != null) row.state = patch.state; + if (patch.savePath != null) row.save_path = patch.savePath; + if (patch.endTime != null) row.end_time = patch.endTime; + return true; +} + +/** + * Get a private row by id. + * @param {number} id - Row id (negative) + * @returns {object|null} + */ +function getDownloadById(id) { + const row = rows.get(id); + return row ? { ...row } : null; +} + +/** + * All rows for one partition, newest first (matches getAllDownloads order). + * @param {string} partition + * @returns {Array} Row copies + */ +function getDownloads(partition) { + const result = []; + for (const row of rows.values()) { + if (row.session_partition === partition) result.push({ ...row }); + } + return result.sort((a, b) => b.start_time - a.start_time); +} + +/** + * Search one partition's rows by filename or URL substring. Matching is + * case-insensitive to mirror SQLite's LIKE on the persistent store. + * @param {string} partition + * @param {string} query + * @param {number} limit + * @returns {Array} Matching row copies, newest first + */ +function searchDownloads(partition, query, limit = 100) { + const needle = String(query || '').toLowerCase(); + return getDownloads(partition) + .filter( + (row) => + String(row.filename || '') + .toLowerCase() + .includes(needle) || + String(row.url || '') + .toLowerCase() + .includes(needle) + ) + .slice(0, limit); +} + +/** + * Remove one private row (history entry only — never touches the file). + * @param {number} id - Row id (negative) + * @returns {boolean} Whether the row was removed + */ +function removeDownload(id) { + return rows.delete(id); +} + +/** + * Clear one partition's settled rows (in-progress rows are kept — cancel + * first), mirroring clearDownloads on the persistent store. + * @param {string} partition + * @returns {number} Number of rows removed + */ +function clearSettled(partition) { + let removed = 0; + for (const [id, row] of rows) { + if (row.session_partition === partition && row.state !== STATE_IN_PROGRESS) { + rows.delete(id); + removed++; + } + } + return removed; +} + +/** + * Drop every row for a partition. Called from the private-window close + * hook — this is the entire "purge": the rows only ever existed in this + * process's memory, so there is nothing on disk to clean up, crash or not. + * @param {string} partition + * @returns {number} Number of rows dropped + */ +function dropPartition(partition) { + if (!partition) return 0; + let removed = 0; + for (const [id, row] of rows) { + if (row.session_partition === partition) { + rows.delete(id); + removed++; + } + } + return removed; +} + +/** Total row count across all partitions (test/diagnostic helper). */ +function getCount() { + return rows.size; +} + +// Test-only: drop all rows and restart the id sequence. +function _resetState() { + rows.clear(); + nextId = -1; +} + +module.exports = { + insertDownload, + updateDownload, + getDownloadById, + getDownloads, + searchDownloads, + removeDownload, + clearSettled, + dropPartition, + getCount, + _resetState, +}; diff --git a/src/main/downloads/private-downloads-store.test.js b/src/main/downloads/private-downloads-store.test.js new file mode 100644 index 00000000..dd81f3db --- /dev/null +++ b/src/main/downloads/private-downloads-store.test.js @@ -0,0 +1,127 @@ +// PRIVATE MODE GUARD (downloads): the in-memory partition-scoped store. +// Deliberately requires the module directly, with no electron or +// better-sqlite3 mocks — proving it has no persistence dependencies is +// part of the guarantee under test. + +const store = require('./private-downloads-store'); + +describe('private-downloads-store', () => { + beforeEach(() => { + store._resetState(); + }); + + const insert = (overrides = {}) => + store.insertDownload({ + url: 'https://example.com/secret.zip', + filename: 'secret.zip', + savePath: '/tmp/secret.zip', + mimeType: 'application/zip', + totalBytes: 1024, + partition: 'private-a', + ...overrides, + }); + + test('inserts rows with negative, unique ids and the SQLite row shape', () => { + const first = insert(); + const second = insert({ filename: 'other.zip' }); + + expect(first.id).toBeLessThan(0); + expect(second.id).toBeLessThan(0); + expect(second.id).not.toBe(first.id); + expect(first).toEqual( + expect.objectContaining({ + url: 'https://example.com/secret.zip', + filename: 'secret.zip', + save_path: '/tmp/secret.zip', + mime_type: 'application/zip', + total_bytes: 1024, + received_bytes: 0, + state: 'in_progress', + start_time: expect.any(Number), + end_time: null, + is_private: 1, + session_partition: 'private-a', + }) + ); + expect(store.getCount()).toBe(2); + }); + + test('updates use patch semantics and untouched fields survive', () => { + const row = insert(); + + expect(store.updateDownload(row.id, { receivedBytes: 500 })).toBe(true); + let stored = store.getDownloadById(row.id); + expect(stored.received_bytes).toBe(500); + expect(stored.state).toBe('in_progress'); + expect(stored.total_bytes).toBe(1024); + + expect( + store.updateDownload(row.id, { receivedBytes: 1024, state: 'completed', endTime: 123 }) + ).toBe(true); + stored = store.getDownloadById(row.id); + expect(stored.state).toBe('completed'); + expect(stored.end_time).toBe(123); + + expect(store.updateDownload(-9999, { state: 'completed' })).toBe(false); + }); + + test('queries are partition-scoped and newest first', () => { + insert({ filename: 'a.zip', startTime: 100 }); + insert({ filename: 'b.zip', startTime: 300 }); + insert({ filename: 'other-window.zip', partition: 'private-b', startTime: 200 }); + + const rows = store.getDownloads('private-a'); + expect(rows.map((r) => r.filename)).toEqual(['b.zip', 'a.zip']); + expect(store.getDownloads('private-b').map((r) => r.filename)).toEqual(['other-window.zip']); + expect(store.getDownloads('private-unknown')).toEqual([]); + }); + + test('search matches filename or url case-insensitively within the partition', () => { + insert({ filename: 'Report.PDF', url: 'https://example.com/Report.PDF' }); + insert({ filename: 'photo.png', url: 'bzz://somehash/photo.png' }); + insert({ filename: 'Report.PDF', partition: 'private-b' }); + + expect(store.searchDownloads('private-a', 'report')).toHaveLength(1); + expect(store.searchDownloads('private-a', 'SOMEHASH')).toHaveLength(1); + expect(store.searchDownloads('private-a', 'nothing')).toHaveLength(0); + expect(store.searchDownloads('private-a', 'report', 1)).toHaveLength(1); + }); + + test('returned rows are copies — mutating them never corrupts the store', () => { + const row = insert(); + const fetched = store.getDownloadById(row.id); + fetched.url = 'tampered'; + expect(store.getDownloadById(row.id).url).toBe('https://example.com/secret.zip'); + }); + + test('removeDownload drops a single row', () => { + const row = insert(); + expect(store.removeDownload(row.id)).toBe(true); + expect(store.removeDownload(row.id)).toBe(false); + expect(store.getDownloadById(row.id)).toBe(null); + }); + + test('clearSettled keeps in-progress rows and other partitions', () => { + const settled = insert({ filename: 'done.zip' }); + store.updateDownload(settled.id, { state: 'completed', endTime: Date.now() }); + insert({ filename: 'live.zip' }); + const otherSettled = insert({ filename: 'other.zip', partition: 'private-b' }); + store.updateDownload(otherSettled.id, { state: 'cancelled', endTime: Date.now() }); + + expect(store.clearSettled('private-a')).toBe(1); + expect(store.getDownloads('private-a').map((r) => r.filename)).toEqual(['live.zip']); + expect(store.getDownloads('private-b')).toHaveLength(1); + }); + + test('dropPartition evaporates every row for that window only', () => { + insert(); + insert({ filename: 'two.zip' }); + insert({ filename: 'keep.zip', partition: 'private-b' }); + + expect(store.dropPartition('private-a')).toBe(2); + expect(store.getCount()).toBe(1); + expect(store.getDownloads('private-a')).toEqual([]); + expect(store.dropPartition(null)).toBe(0); + expect(store.dropPartition('')).toBe(0); + }); +}); diff --git a/src/main/ens-resolver.js b/src/main/ens-resolver.js index c45bbd3b..a4c30c52 100644 --- a/src/main/ens-resolver.js +++ b/src/main/ens-resolver.js @@ -6,7 +6,22 @@ const IPC = require('../shared/ipc-channels'); const { cidV1BytesToBase32 } = require('../shared/cid-utils'); const registry = require('./networks/network-registry'); const { prefetchGatewayUrl, NOOP_HANDLE: NOOP_PREFETCH } = require('./ens-prefetch'); +const myotisManager = require('./myotis/myotis-manager'); const { capCache } = require('./cache-utils'); +const { + runWithPrivateLogContext, + redactForLog, +} = require('./private/private-log-context'); +const { isPrivateWebContents } = require('./private/private-windows'); + +// PRIVATE MODE GUARD (name logging): the name being resolved IS the +// browsing history of the tab that asked for it, and log.info/warn land in +// the persistent /logs/main.log, which outlives the private +// window and the app. Every log line below that carries a name, an address +// or a resolved target goes through these. The context is set by the IPC +// handlers (from event.sender) and by the dweb protocol handlers (from +// their session) — see src/main/private/private-log-context.js. +const nameForLog = (name) => redactForLog(name); // Canonical ENS Universal Resolver — a DAO-owned proxy that delegates to // the current implementation, so future UR upgrades don't require a code @@ -30,6 +45,7 @@ const NAME_NFT_ABI = [ 'function addr(bytes32 node) view returns (address)', 'function reverseResolve(address addr) view returns (string)', ]; +const NAME_NFT_INTERFACE = new ethers.Interface(NAME_NFT_ABI); const NAME_SYSTEMS = { ens: { id: 'ens', label: 'ENS' }, @@ -60,6 +76,14 @@ const CONTENTHASH_SELECTOR = '0xbc1c58d1'; // bytes4(keccak256("addr(bytes32)")) const ADDR_SELECTOR = '0x3b3b57de'; +// Myotis's native ENS API returns ERC-3668 OffchainLookup envelopes to the +// host. Freedom drives one bounded gateway round and re-enters the engine so +// the callback executes against the same beacon-anchored state root. +const MYOTIS_CCIP_MAX_ROUNDS = 1; +const MYOTIS_CCIP_TIMEOUT_MS = 15000; +const MYOTIS_CCIP_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const CCIP_HTTP_ERROR_SELECTOR = 'ca7a4e75'; + // SLIP-0044 coin type for Ethereum mainnet, used by UR.reverse. const ETH_COIN_TYPE = 60n; @@ -81,8 +105,7 @@ const SWARM_CONTENTHASH_RE = /^0xe40101fa011b20(?[0-9a-f]{64})$/; // --------------------------------------------------------------------------- // Session-shuffled public-RPC pool with per-provider quarantine. Backs the -// `consensusResolve` primitive defined later; the one remaining caller of -// the older `getWorkingProvider` path is reverse resolution. +// quorum strategy for forward and reverse resolution. // --------------------------------------------------------------------------- // Per-provider sticky failure with exponential cooldown. In-memory only, @@ -201,11 +224,27 @@ const ANCHOR_SAFETY_DEPTH = { latest: 8, 'latest-32': 32, finalized: 0 }; // to the single-source unverified path instead of claiming "verified". const MIN_QUORUM_PROVIDERS = 3; +// Every JSON-RPC endpoint used in this module serves Ethereum Mainnet. Tell +// ethers that up front so its short-lived providers do not start an +// independent eth_chainId detection loop. A failed or cancelled quorum leg +// otherwise leaves ethers printing its generic "failed to detect network" +// retry message until teardown catches up. +const ETHEREUM_MAINNET_CHAIN_ID = 1; +const EPHEMERAL_PROVIDER_OPTIONS = { staticNetwork: true }; + +function createEthereumProvider(url) { + return new ethers.JsonRpcProvider( + url, + ETHEREUM_MAINNET_CHAIN_ID, + EPHEMERAL_PROVIDER_OPTIONS, + ); +} + // Create a short-lived provider for a single scoped operation, wrap the // caller's work in withTimeout, and guarantee the provider is destroyed // on both success and timeout. `fn` receives the bound provider. async function withEphemeralProvider(url, timeoutMs, fn) { - const provider = new ethers.JsonRpcProvider(url); + const provider = createEthereumProvider(url); const cleanup = () => { try { provider.destroy(); } catch { /* already torn down */ } }; try { return await withTimeout(fn(provider), timeoutMs, cleanup); @@ -406,15 +445,11 @@ function withTimeout(promise, ms, onTimeout) { }); } -let cachedProvider = null; -let cachedProviderUrl = null; - // Outcome-specific TTLs. Indexed by trust.level; the default fallback -// applies to legacy code paths (e.g. reverse resolution) that don't carry -// a trust field. Verified/user-configured outcomes are stable enough for -// 15min; unverified answers expire in 60s so transient public-RPC noise -// doesn't pin the user-facing result for long; conflict outcomes are -// negative-cached for 10s purely to avoid re-entry storms on repeated +// applies to results that don't carry a trust field. Verified/user-configured +// outcomes are stable enough for 15min; unverified answers expire in 60s so +// transient public-RPC noise doesn't pin the user-facing result for long; +// conflict outcomes are negative-cached for 10s purely to avoid re-entry storms on repeated // navigation attempts during an active lie. const TTL_BY_LEVEL = { verified: 15 * 60 * 1000, @@ -447,82 +482,33 @@ const ensAddressCache = new Map(); // Address (lowercased 0x) → { result, expiresAt } for reverse lookups. const ensReverseCache = new Map(); +let resolutionLifecycleEpoch = 0; + +// Any Ethereum Myotis lifecycle boundary invalidates both cached and in-flight +// policy decisions. Ready transitions let the preferred local tier overtake a +// fallback answer; unavailable/stopping transitions prevent stale local reads +// from being cached after shutdown. +myotisManager.onAvailabilityTransition((event) => { + if (event?.chainId != null && Number(event.chainId) !== 1) return; + const swept = ensResultCache.size + ensAddressCache.size + ensReverseCache.size; + resolutionLifecycleEpoch += 1; + ensResultCache.clear(); + ensAddressCache.clear(); + ensReverseCache.clear(); + inFlightResolves.clear(); + log.info( + `[ens] myotis ${event?.ready ? 'ready' : 'unavailable'} reason=${event?.reason || 'unknown'} ` + + `epoch=${event?.epoch ?? 'unknown'} swept=${swept} cached resolution result(s)` + ); +}); -// Get a working provider, trying each in sequence with fallback -async function getWorkingProvider() { - // If the cached provider's URL no longer matches the current settings, invalidate it - if (cachedProvider && cachedProviderUrl) { - const providers = getEffectiveRpcEndpoints(); - if (providers[0] !== cachedProviderUrl) { - log.info(`[ens] Settings changed, invalidating cached provider: ${cachedProviderUrl}`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } - } - - // Return cached provider if still working - if (cachedProvider && cachedProviderUrl) { - try { - await cachedProvider.getBlockNumber(); - log.info(`[ens] Reusing cached provider: ${cachedProviderUrl}`); - return cachedProvider; - } catch { - log.warn(`[ens] Cached provider ${cachedProviderUrl} failed, trying fallbacks...`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } - } - - // Try each provider in sequence - const providers = getEffectiveRpcEndpoints(); - const total = providers.length; - for (let i = 0; i < total; i++) { - const rpcUrl = providers[i]; - const providerNum = `${i + 1}/${total}`; - let provider; - try { - log.info(`[ens] Trying provider ${providerNum}: ${rpcUrl}`); - provider = new ethers.JsonRpcProvider(rpcUrl); - await provider.getBlockNumber(); // Health check - log.info(`[ens] Using provider ${providerNum}: ${rpcUrl}`); - cachedProvider = provider; - cachedProviderUrl = rpcUrl; - return provider; - } catch (err) { - log.warn(`[ens] Provider ${providerNum} failed: ${err.message}`); - if (provider) { - provider.destroy(); - } - } - } - - throw new Error('All RPC providers failed. Check your network connection.'); -} - -// Drop the cached single-provider used by getWorkingProvider. Cheap reset -// for the legacy retry loop — keeps quorum-path state (shuffled order, -// quarantine memory, pinned block anchor) intact, so a transient flake -// during reverse resolution doesn't make the next quorum wave pay an -// extra anchor RTT. -function dropCachedProvider() { - if (cachedProvider) { - log.info(`[ens] Invalidating cached provider: ${cachedProviderUrl}`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } -} - -// Full reset: drop the legacy cached provider, wipe the quorum pool -// (shuffled order, quarantine, pinned block), AND flush the per-name -// resolution caches. External callers use this after a settings edit: +// Full reset: wipe the quorum pool (shuffled order, quarantine, pinned block), +// AND flush the per-name resolution caches. External callers use this after +// a settings edit: // the resolution caches store each name's trust level, which is derived // from the verification method — so a method change must drop them or // stale results keep their old trust until their TTL expires. function invalidateCachedProvider() { - dropCachedProvider(); invalidateProviderPool(); clearEnsResolutionCaches(); registry.invalidate(); @@ -557,9 +543,6 @@ function isProviderError(err) { return false; } -// Maximum retries for provider errors during resolution -const MAX_RESOLUTION_RETRIES = 3; - // Canonical UR custom errors we classify. ethers v6 surfaces the 4-byte // selector via err.data on CALL_EXCEPTION; some wrappers (JSON-RPC // proxies) expose it under err.info.error.data instead — check both. @@ -580,6 +563,40 @@ function getRevertData(err) { return typeof data === 'string' && data.length >= 10 ? data : null; } +// Keep provider diagnostics useful without dumping full transaction calldata, +// endpoint URLs, or arbitrarily large upstream messages into the application +// log. ethers' `shortMessage` is preferred because its full CALL_EXCEPTION +// message includes the complete Universal Resolver request. +function sanitizeErrorDetail(value, maxLength = 240) { + if (value == null || value === '') return ''; + const cleaned = String(value) + .replace(/https?:\/\/[^\s"'<>]+/gi, '') + .replace(/0x[0-9a-fA-F]{66,}/g, (hex) => + `${hex.slice(0, 10)}…(${Math.floor((hex.length - 2) / 2)} bytes)` + ) + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length <= maxLength + ? cleaned + : `${cleaned.slice(0, maxLength - 1)}…`; +} + +function formatResolutionErrorForLog(err) { + const nested = err?.info?.error; + const message = sanitizeErrorDetail( + err?.shortMessage || err?.reason || err?.message || String(err) + ); + const fields = [`error=${JSON.stringify(message || 'unknown error')}`]; + if (err?.code != null) fields.push(`code=${sanitizeErrorDetail(err.code, 40)}`); + if (nested?.code != null) fields.push(`rpcCode=${sanitizeErrorDetail(nested.code, 40)}`); + if (nested?.message) { + fields.push(`rpcMessage=${JSON.stringify(sanitizeErrorDetail(nested.message))}`); + } + const revertData = getRevertData(err); + fields.push(`revert=${revertData ? revertData.slice(0, 10) : 'none'}`); + return fields.join(' '); +} + function urErrorSelector(err) { const data = getRevertData(err); return data ? data.slice(0, 10).toLowerCase() : null; @@ -709,6 +726,89 @@ async function universalResolverReverse(provider, addressBytes, overrides = {}) return { name }; } +// Reverse calls need to participate in the same byte-agreement machinery as +// forward records. Encode every semantic UR outcome as deterministic bytes so +// quorum can compare successful names, empty records, mismatches, and verified +// contract errors without collapsing them into the forward resolver's generic +// NO_CONTENTHASH bucket. +const REVERSE_OUTCOME = Object.freeze({ + noRecord: 0, + name: 1, + mismatch: 2, + error: 3, +}); + +function encodeReverseOutcome(status, detail = '') { + return ethers.AbiCoder.defaultAbiCoder().encode(['uint8', 'string'], [status, detail]); +} + +function decodeReverseOutcome(resolvedData) { + const [status, detail] = ethers.AbiCoder.defaultAbiCoder().decode( + ['uint8', 'string'], + resolvedData + ); + return { status: Number(status), detail: String(detail || '') }; +} + +async function universalResolverReverseCall(provider, normalizedAddress, _callData, overrides = {}) { + try { + const { name } = await universalResolverReverse( + provider, + ethers.getBytes(normalizedAddress), + overrides + ); + return { + resolvedData: encodeReverseOutcome( + name ? REVERSE_OUTCOME.name : REVERSE_OUTCOME.noRecord, + name || '' + ), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } catch (err) { + if (isProviderError(err)) throw err; + if (isResolverNotFoundError(err)) { + return { + resolvedData: encodeReverseOutcome(REVERSE_OUTCOME.noRecord), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } + if (isReverseAddressMismatchError(err)) { + return { + resolvedData: encodeReverseOutcome( + REVERSE_OUTCOME.mismatch, + decodeReverseMismatchClaimedName(err) || '' + ), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } + return { + resolvedData: encodeReverseOutcome(REVERSE_OUTCOME.error, err.message), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } +} + +// NameNFT reverseResolve(address) adapter. `name` is a synthetic value with +// the target system's suffix, allowing the existing NameNFT routing helper to +// select the correct contract while quorum compares ABI-identical strings. +async function nameNftReverseResolverCall(provider, name, callData, overrides = {}) { + const nameSystem = nameSystemForName(name); + if (!nameSystem.contractAddress) { + throw new Error(`No NameNFT contract configured for ${nameSystem.label}`); + } + const [address] = NAME_NFT_INTERFACE.decodeFunctionData('reverseResolve', callData); + const registryContract = new ethers.Contract( + nameSystem.contractAddress, + NAME_NFT_ABI, + provider + ); + const claimedName = await registryContract.reverseResolve(address, overrides); + return { + resolvedData: ethers.AbiCoder.defaultAbiCoder().encode(['string'], [claimedName || '']), + resolverAddress: nameSystem.contractAddress, + }; +} + // --------------------------------------------------------------------------- // Consensus resolution: hedged-quorum over K public RPCs at a shared pinned // block. Detects a lying RPC by requiring M byte-identical responses; @@ -751,7 +851,7 @@ async function runQuorumLeg( }; if (cancelToken) cancelToken.cleanups.add(cleanup); try { - provider = new ethers.JsonRpcProvider(url); + provider = createEthereumProvider(url); const urCall = callResolver(provider, name, callData, { blockTag: blockHash }); const result = await withTimeout(urCall, timeoutMs, cleanup); markProviderSuccess(url); @@ -937,6 +1037,243 @@ function classifyNoAgreement({ results }) { return { kind: 'conflict' }; } +async function readMyotisCcipBody(response) { + const declared = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(declared) && declared > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + + if (!response.body?.getReader) { + const body = await response.text(); + if (Buffer.byteLength(body) > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + return body; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let body = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + try { await reader.cancel(); } catch { /* best-effort response teardown */ } + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + body += decoder.decode(value, { stream: true }); + } + return body + decoder.decode(); +} + +function containsCcipHttpError(dataHex) { + const bare = String(dataHex).replace(/^0x/i, '').toLowerCase(); + for (let i = 0; i + CCIP_HTTP_ERROR_SELECTOR.length <= bare.length; i += 2) { + if (bare.slice(i, i + CCIP_HTTP_ERROR_SELECTOR.length) === CCIP_HTTP_ERROR_SELECTOR) { + return true; + } + } + return false; +} + +async function fetchMyotisCcipResponse(rec) { + const senderHex = rec.senderHex; + const callDataHex = rec.callDataHex; + const urls = Array.isArray(rec.urls) ? rec.urls.filter((url) => typeof url === 'string') : []; + if (!senderHex || !callDataHex || urls.length === 0) { + throw new Error('CCIP-Read OffchainLookup did not include an actionable gateway tuple'); + } + + const reasons = []; + for (const template of urls) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MYOTIS_CCIP_TIMEOUT_MS); + try { + const useGet = template.includes('{data}'); + const url = template.replaceAll('{sender}', senderHex).replaceAll('{data}', callDataHex); + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`unsupported gateway protocol ${parsed.protocol}`); + } + const response = await fetch(url, { + method: useGet ? 'GET' : 'POST', + headers: useGet + ? { Accept: 'application/json' } + : { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: useGet ? undefined : JSON.stringify({ sender: senderHex, data: callDataHex }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await readMyotisCcipBody(response); + let payload; + try { + payload = JSON.parse(body); + } catch { + throw new Error('response is not valid JSON'); + } + const dataHex = typeof payload?.data === 'string' && /^0x[0-9a-fA-F]*$/.test(payload.data) + ? payload.data + : null; + if (!dataHex) throw new Error('response has no hex data field'); + if ((dataHex.length - 2) % 2 !== 0) throw new Error('response data has odd-length hex'); + if (containsCcipHttpError(dataHex)) throw new Error('gateway returned HttpError'); + return dataHex; + } catch (err) { + reasons.push(`${template.slice(0, 200)}: ${err.message}`); + } finally { + clearTimeout(timeout); + } + } + throw new Error(`CCIP-Read gateway failed: ${reasons.join('; ')}`); +} + +// Drive the host half of ERC-3668 for the native engine. Myotis performs the +// resolver walk and verified callback execution; Freedom only fetches the +// gateway payload carried by the on-chain OffchainLookup tuple. One round is +// allowed, matching the upstream host driver and preventing recursive fetches. +async function resolveMyotisEnsRecord(params) { + const original = { ...params, root: params.root || 'auto' }; + let rec = await myotisManager.resolveEnsRecord(original); + if (rec.error) throw new Error(rec.error); + + for (let round = 0; rec.status === 'offchain'; round++) { + if (round >= MYOTIS_CCIP_MAX_ROUNDS) { + throw new Error(`CCIP-Read recursion exceeds ${MYOTIS_CCIP_MAX_ROUNDS} round`); + } + const responseHex = await fetchMyotisCcipResponse(rec); + rec = await myotisManager.resolveEnsRecord({ + ...original, + method: 'ccipCallback', + queryMethod: original.method, + senderHex: rec.senderHex, + callbackFunctionHex: rec.callbackFunctionHex, + responseHex, + extraDataHex: rec.extraDataHex, + wrapped: rec.wrapped === true, + finalized: rec.verified === true, + }); + if (rec.error) throw new Error(rec.error); + } + return rec; +} + +// The specialized ENS record API can pin to finalized state and reports that +// fact explicitly. Re-encode its decoded values to the raw ABI return shape +// shared by Colibri and RPC quorum so downstream decoders remain unchanged. +async function tryMyotisEnsPath(name, callData, nameSystem) { + const selector = String(callData).slice(0, 10).toLowerCase(); + const method = + selector === CONTENTHASH_SELECTOR + ? 'contenthash' + : selector === ADDR_SELECTOR + ? 'addr' + : null; + if (!method) return null; + + const rec = await resolveMyotisEnsRecord({ method, name }); + const trust = buildMyotisTrust(rec, nameSystem); + if (rec.status === 'noRecord') { + return { + outcome: 'data', + resolvedData: + method === 'contenthash' + ? ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], ['0x']) + : ethers.AbiCoder.defaultAbiCoder().encode(['address'], [ethers.ZeroAddress]), + resolverAddress: null, + trust, + block: rec.blockNumber ?? null, + }; + } + if (rec.status !== 'ok') { + throw new Error(`unexpected ${method} record shape: ${JSON.stringify(rec).slice(0, 200)}`); + } + const value = method === 'contenthash' ? rec.dataHex : rec.addressHex; + if (!value) { + throw new Error(`missing ${method} value: ${JSON.stringify(rec).slice(0, 200)}`); + } + return { + outcome: 'data', + resolvedData: ethers.AbiCoder.defaultAbiCoder().encode( + [method === 'contenthash' ? 'bytes' : 'address'], + [value] + ), + resolverAddress: null, + trust, + block: rec.blockNumber ?? null, + }; +} + +// WNS and GNS are separate NameNFT contracts, not ENS registries. Myotis's +// generic verified eth_call can execute their existing calldata locally. The +// current v0.1.7 addon pins generic calls to the beacon optimistic head. Every +// state fetch is MPT-verified against that root and the root is authenticated +// by the light client's sync committee; `ok` therefore means cryptographically +// verified, while the trust metadata still states that it is not finalized. +async function tryMyotisContractPath(callData, nameSystem) { + const rec = await myotisManager.ethCall({ + to: nameSystem.contractAddress, + data: callData, + block: 'latest', + }); + if (rec.error) throw new Error(rec.error); + if (rec.status !== 'ok' || typeof rec.resultHex !== 'string') { + throw new Error(`Myotis contract call unavailable: ${rec.reason || rec.status || 'unknown'}`); + } + const status = myotisManager.getStatus() || {}; + const blockNumber = status.optimisticBlockNumber ?? null; + const trust = buildMyotisTrust({ verified: false, blockNumber }, nameSystem); + return { + outcome: 'data', + resolvedData: rec.resultHex, + resolverAddress: nameSystem.contractAddress, + trust, + block: blockNumber, + }; +} + +// Returns null only while the node cannot serve or when the requested selector +// is outside Freedom's current content/address surface. Real read failures +// throw so the orchestrator logs the handoff to the configured fallback. +async function tryMyotisPath(name, callData, nameSystem) { + if (!myotisManager.isReady()) return null; + const epoch = myotisManager.getAvailabilityEpoch(); + const result = nameSystem.contractAddress + ? await tryMyotisContractPath(callData, nameSystem) + : await tryMyotisEnsPath(name, callData, nameSystem); + if (epoch !== myotisManager.getAvailabilityEpoch() || !myotisManager.isReady()) { + const err = new Error('Myotis availability changed during verified read'); + err.code = 'MYOTIS_LIFECYCLE_CHANGED'; + throw err; + } + return result; +} + +// `verified` on the engine result is a FINALITY flag: true means the query ran +// against the beacon-finalized root; false means the sync-committee-attested +// optimistic root. Both paths cryptographically verify state and fail closed. +// Keep the distinction in `finality` and proof copy instead of mislabelling an +// authenticated optimistic result as an unverified RPC response. This matches +// Colibri, whose verified proof also targets a recent sync-committee head. +function buildMyotisTrust(rec, nameSystem = NAME_SYSTEMS.ens) { + const finalized = rec.verified === true; + return { + level: 'verified', + system: nameSystem.id, + method: 'myotis', + finality: finalized ? 'finalized' : 'optimistic', + proof: finalized + ? 'P2P light client (beacon-finalized, sync-committee proof)' + : 'P2P light client (optimistic beacon root — attested, not finalized)', + block: rec.blockNumber ?? null, + agreed: ['myotis-p2p'], + dissented: [], + queried: ['myotis-p2p'], + quorum: { k: 1, m: 1, achieved: true }, + }; +} + // Colibri primary path: a single cryptographically-verified eth_call against // the Universal Resolver via @corpus-core/colibri-stateless. The verifier // runs the EVM locally against proven storage, so a successful return is @@ -946,7 +1283,7 @@ function classifyNoAgreement({ results }) { // block N lives in block N+1). // // Throws on proof-verification failure / network error / prover outage so -// the orchestrator can fall through to the always-on quorum fallback. +// the orchestrator can fall through to the next configured method. async function tryColibriPath( name, callData, @@ -1027,7 +1364,7 @@ function buildColibriTrust(proverHost, nameSystem = NAME_SYSTEMS.ens) { // honest trust basis: true when the endpoint is a user-added source // (trust='user-configured'), false when `direct` was chosen without a // custom endpoint so it's just an unverified builtin public RPC. On any -// failure, return null so the caller falls back to the public quorum path. +// failure, return null so the caller can try the next configured method. // Pinned block is fetched from the same RPC — we don't want to send // user-node requests to public RPCs behind their back. async function tryDirectResolve( @@ -1172,50 +1509,11 @@ function getDirectRpcCandidate(chainId) { // { outcome: 'not_found', reason, trust, block } // { outcome: 'conflict', groups, trust, block } // Throws when there are no providers or both waves all-errored. -async function consensusResolve(normalizedName, callData, kind = 'content', options = {}) { +async function resolveViaQuorum(normalizedName, callData, kind = 'content', options = {}) { const network = registry.getNetwork(1); - const strategy = network.verification.primary; const callResolver = options.callResolver || universalResolverCall; const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; - // Colibri primary: cryptographic verification via the sync committee - // (or zk sync proof). On verification failure or network/prover error - // we log loudly and fall through to the quorum path below — fallback to - // quorum is structural and always-on. Loud-fallback is load-bearing: a - // silent fall-through would hide both prover health regressions and the - // (rare) "active attack" signal. - if (strategy === 'colibri') { - try { - return await tryColibriPath(normalizedName, callData, callResolver, nameSystem); - } catch (err) { - log.warn( - `[ens] colibri-fallback name=${normalizedName} kind=${kind} ` + - `error=${err.message}` - ); - } - } - - // Direct strategy: a single trusted endpoint (typically the user's own - // node). Try it first, fall back to public quorum on any failure so a - // misbehaving own-node still resolves. User-added sources sort ahead of - // builtins, so directUrl is the user's own endpoint when one exists — - // only then is the answer honestly 'user-configured'; with no custom - // endpoint added, `direct` is just an unverified builtin public RPC. - if (strategy === 'direct') { - const direct = getDirectRpcCandidate(1); - if (direct.url) { - const directResult = await tryDirectResolve( - direct.url, - normalizedName, - callData, - direct.userConfigured, - callResolver, - nameSystem, - ); - if (directResult) return directResult; - } - } - const { quorum } = network; const desiredK = Math.max(1, Math.min(Number(quorum.k) || 3, 9)); const desiredM = Math.max(1, Math.min(Number(quorum.m) || 2, desiredK)); @@ -1295,7 +1593,7 @@ async function consensusResolve(normalizedName, callData, kind = 'content', opti const firstSelection = waveAvailable.slice(0, effectiveK); log.info( - `[ens] consensus kind=${kind} name=${normalizedName} k=${effectiveK} m=${effectiveM} ` + + `[ens] consensus kind=${kind} name=${nameForLog(normalizedName)} k=${effectiveK} m=${effectiveM} ` + `block=${block.hash}@${block.number} providers=[${firstSelection.map(hostOf).join(',')}]` ); @@ -1428,6 +1726,189 @@ async function consensusResolve(normalizedName, callData, kind = 'content', opti }; } +const RESOLUTION_METHOD_IDS = new Set(['myotis', 'colibri', 'quorum', 'direct']); + +function resolutionPolicy(network) { + const verification = network?.verification || {}; + const configured = Array.isArray(verification.order) + ? verification.order.filter((method, index, all) => + RESOLUTION_METHOD_IDS.has(method) && all.indexOf(method) === index + ) + : []; + if (configured.length > 0) { + return { + order: configured, + preferVerified: verification.preferVerified === true, + }; + } + + // Legacy network-config.json files only have `primary`. Preserve their old + // behavior until the user saves the new policy: Myotis first when enabled, + // the selected remote strategy next, and quorum as Colibri/direct fallback. + const primary = ['colibri', 'quorum', 'direct'].includes(verification.primary) + ? verification.primary + : 'colibri'; + return { + order: [...new Set(['myotis', primary, ...(primary === 'quorum' ? [] : ['quorum'])])], + preferVerified: false, + }; +} + +function isProvisionalResolution(result) { + return result?.trust?.level === 'unverified'; +} + +function unavailableResolutionReason(method) { + if (method === 'myotis') { + return myotisManager.isEnabled() ? 'not-ready' : 'disabled'; + } + if (method === 'direct') return 'not-configured'; + return 'unavailable'; +} + +function logForwardMethodOutcome({ + method, normalizedName, kind, result, action, reason, durationMs, +}) { + const outcome = result?.outcome ? String(result.outcome).toUpperCase() : 'SKIP'; + const trust = result?.trust?.level || 'none'; + log.info( + `[ens] method=${method} name=${nameForLog(normalizedName)} kind=${kind} outcome=${outcome} ` + + `trust=${trust} action=${action} reason=${reason || 'none'} durationMs=${durationMs}` + ); +} + +// Execute exactly one configured method. Keeping this separate from the +// fallback loop is important for reverse records: a WNS/GNS reverse claim +// must be forward-verified through the same source that produced the claim. +async function resolveWithMethod(method, normalizedName, callData, kind, options = {}) { + const callResolver = options.callResolver || universalResolverCall; + const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; + + if (method === 'myotis') { + if (!myotisManager.isEnabled()) return null; + return tryMyotisPath(normalizedName, callData, nameSystem); + } + if (method === 'colibri') { + return tryColibriPath(normalizedName, callData, callResolver, nameSystem); + } + if (method === 'direct') { + const direct = getDirectRpcCandidate(1); + if (!direct.url) return null; + return tryDirectResolve( + direct.url, + normalizedName, + callData, + direct.userConfigured, + callResolver, + nameSystem, + ); + } + if (method === 'quorum') { + return resolveViaQuorum(normalizedName, callData, kind, options); + } + return null; +} + +// Execute the user's unified resolution order. An unverified answer can be +// retained provisionally while later methods get a chance to produce a +// verified result; conflicts and user-configured/verified answers always stop. +async function consensusResolve(normalizedName, callData, kind = 'content', options = {}) { + const network = registry.getNetwork(1); + const callResolver = options.callResolver || universalResolverCall; + const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; + const { order, preferVerified } = resolutionPolicy(network); + let provisional = null; + let provisionalMethod = null; + let lastError = null; + + log.info( + `[ens] policy name=${nameForLog(normalizedName)} kind=${kind} order=[${order.join(',')}] ` + + `preferVerified=${preferVerified}` + ); + + for (const method of order) { + const startedAt = Date.now(); + let result; + try { + result = await resolveWithMethod(method, normalizedName, callData, kind, { + ...options, + callResolver, + nameSystem, + }); + } catch (err) { + if (err?.code === 'MYOTIS_LIFECYCLE_CHANGED') throw err; + lastError = err; + log.warn( + `[ens] ${method}-fallback name=${nameForLog(normalizedName)} kind=${kind} ` + + formatResolutionErrorForLog(err) + ); + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result: { outcome: 'error' }, + action: 'continue', + reason: err?.code || 'error', + durationMs: Date.now() - startedAt, + }); + continue; + } + + if (!result) { + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'continue', + reason: unavailableResolutionReason(method), + durationMs: Date.now() - startedAt, + }); + continue; + } + if (preferVerified && isProvisionalResolution(result)) { + if (!provisional) { + provisional = result; + provisionalMethod = method; + } + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'continue', + reason: 'prefer-verified', + durationMs: Date.now() - startedAt, + }); + continue; + } + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'accept', + durationMs: Date.now() - startedAt, + }); + return result; + } + + if (provisional) { + logForwardMethodOutcome({ + method: provisionalMethod, + normalizedName, + kind, + result: provisional, + action: 'accept', + reason: 'fallback-exhausted', + durationMs: 0, + }); + return provisional; + } + if (lastError) throw lastError; + throw new Error(`No enabled name-resolution method could resolve ${normalizedName}`); +} + async function resolveEnsContent(name) { return resolveWithCache(name, ensResultCache, doResolveEnsContent, 'content'); } @@ -1489,7 +1970,7 @@ async function doResolveEnsContent(normalized) { if (out.reason === 'NO_CONTENTHASH' && out.error) { // Unknown UR/CCIP reverts are transient failures, not authoritative // empty records. Let reloads re-probe instead of pinning a negative. - log.info(`[ens] NO_CONTENTHASH for ${normalized} (not cached)`); + log.info(`[ens] NO_CONTENTHASH for ${nameForLog(normalized)} (not cached)`); return out; } return cacheContentResult(normalized, out); @@ -1500,7 +1981,9 @@ async function doResolveEnsContent(normalized) { try { [innerBytes] = ethers.AbiCoder.defaultAbiCoder().decode(['bytes'], consensus.resolvedData); } catch (err) { - log.warn(`[ens] Failed to decode contenthash bytes for ${normalized}: ${err.message}`); + log.warn( + `[ens] Failed to decode contenthash bytes for ${nameForLog(normalized)}: ${err.message}` + ); return cacheContentResult(normalized, { type: 'unsupported', reason: 'UNSUPPORTED_CONTENTHASH_FORMAT', @@ -1523,7 +2006,9 @@ async function doResolveEnsContent(normalized) { const parsed = parseContentHashBytes(innerBytes); if (!parsed) { - log.warn(`[ens] UNSUPPORTED_CONTENTHASH_FORMAT for ${normalized}: ${innerBytes}`); + log.warn( + `[ens] UNSUPPORTED_CONTENTHASH_FORMAT for ${nameForLog(normalized)}: ${redactForLog(innerBytes)}` + ); return cacheContentResult(normalized, { type: 'unsupported', reason: 'UNSUPPORTED_CONTENTHASH_FORMAT', @@ -1618,10 +2103,9 @@ async function resolveEnsAddress(name) { // selectors and need independent caches. const inFlightResolves = new Map(); -// Shared validation + cache wrapper for the content-hash and addr lookup -// paths. The consensusResolve primitive handles provider-error escalation -// internally via its second-wave logic, so no outer retry loop is needed -// for the new path. Legacy reverse-resolution uses its own retry below. +// Shared validation + cache wrapper for content-hash, addr, and reverse +// lookups. Each strategy handles provider fallback internally, so this layer +// only normalizes, caches, and deduplicates concurrent work. // // Normalization goes through @adraffy/ens-normalize (UTS-46 / ENSIP-15), // not a bare .toLowerCase(). That's correct for unicode ENS names @@ -1636,6 +2120,7 @@ const inFlightResolves = new Map(); // path (the bzz protocol handler now resolves on every subresource // request, so a busy page can fan out dozens of cache hits per page load). const PURE_ASCII_HOST = /^[a-z0-9-.]+$/; +const MAX_LIFECYCLE_RESTARTS = 3; function fastNormalize(trimmed) { const lowered = trimmed.toLowerCase(); @@ -1652,50 +2137,59 @@ async function resolveWithCache(name, cache, doResolve, label) { const cached = cache.get(normalized); if (cached && Date.now() < cached.expiresAt) { - log.debug(`[ens] ${label} cache hit for ${normalized}`); + log.debug(`[ens] ${label} cache hit for ${nameForLog(normalized)}`); return cached.result; } const dedupKey = `${label}:${normalized}`; const existing = inFlightResolves.get(dedupKey); if (existing) { - log.info(`[ens] ${label} joining in-flight resolution for ${normalized}`); + log.info(`[ens] ${label} joining in-flight resolution for ${nameForLog(normalized)}`); return existing; } - // consensusResolve (content/addr paths) handles provider-error escalation - // internally via its second-wave logic, so the outer retry loop only runs - // for the legacy reverse-resolution path. - const needsLegacyRetry = label === 'reverse'; - - const promise = (async () => { - if (!needsLegacyRetry) return doResolve(normalized); - - let lastError; - for (let attempt = 1; attempt <= MAX_RESOLUTION_RETRIES; attempt++) { - try { - return await doResolve(normalized); - } catch (err) { - lastError = err; - if (isProviderError(err) && attempt < MAX_RESOLUTION_RETRIES) { - log.warn( - `[ens] ${label} provider error on attempt ${attempt}/${MAX_RESOLUTION_RETRIES}: ${err.message}` - ); - dropCachedProvider(); - continue; - } - throw err; - } + let promise; + promise = resolveAcrossStableLifecycle(normalized, cache, doResolve, label).finally(() => { + // A Myotis transition clears the map so a fresh request can begin. Do not + // let the older promise's finally handler delete that replacement entry. + if (inFlightResolves.get(dedupKey) === promise) { + inFlightResolves.delete(dedupKey); } - throw lastError; - })().finally(() => { - inFlightResolves.delete(dedupKey); }); inFlightResolves.set(dedupKey, promise); return promise; } +async function resolveAcrossStableLifecycle(normalized, cache, doResolve, label) { + for (let attempt = 1; attempt <= MAX_LIFECYCLE_RESTARTS; attempt++) { + const epoch = resolutionLifecycleEpoch; + let result; + try { + result = await doResolve(normalized); + } catch (err) { + if (epoch === resolutionLifecycleEpoch) throw err; + log.info( + `[ens] ${label} lifecycle changed during failed resolution for ${nameForLog(normalized)}; ` + + `restarting (${attempt}/${MAX_LIFECYCLE_RESTARTS})` + ); + continue; + } + + if (epoch === resolutionLifecycleEpoch) return result; + + // doResolve helpers cache before returning. Remove only this stale result; + // a newer concurrent request may already have populated the same key. + const cached = cache.get(normalized); + if (cached?.result === result) cache.delete(normalized); + log.info( + `[ens] ${label} lifecycle changed during resolution for ${nameForLog(normalized)}; ` + + `discarding stale result and restarting (${attempt}/${MAX_LIFECYCLE_RESTARTS})` + ); + } + throw new Error(`Myotis availability changed repeatedly while resolving ${normalized}`); +} + async function doResolveEnsAddress(normalized) { const node = ethers.namehash(normalized); const callData = ADDR_SELECTOR + node.slice(2); @@ -1755,7 +2249,7 @@ async function doResolveEnsAddress(normalized) { try { [address] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], consensus.resolvedData); } catch (err) { - log.warn(`[ens] Failed to decode addr bytes for ${normalized}: ${err.message}`); + log.warn(`[ens] Failed to decode addr bytes for ${nameForLog(normalized)}: ${err.message}`); return cacheAddressResult(normalized, { success: false, name: normalized, @@ -1805,9 +2299,11 @@ function cacheAndLog(cache, normalized, result, okValue) { cache.set(normalized, { result, expiresAt: Date.now() + ttl }); capCache(cache); if (okValue) { - log.info(`[ens] Resolved: ${normalized} → ${okValue} (ttl=${ttl}ms)`); + log.info( + `[ens] Resolved: ${nameForLog(normalized)} → ${redactForLog(okValue)} (ttl=${ttl}ms)` + ); } else { - log.info(`[ens] ${result.reason || result.type} for ${normalized} (ttl=${ttl}ms)`); + log.info(`[ens] ${result.reason || result.type} for ${nameForLog(normalized)} (ttl=${ttl}ms)`); } return result; } @@ -1829,7 +2325,7 @@ async function resolveEnsReverse(address) { } // Colibri reverse path: cryptographically-verified `ur.reverse`. Returns -// the same result shape as the legacy path, plus a `trust` object so the +// the common reverse-result shape plus a `trust` object so the // renderer can surface a "verified" indicator. ReverseAddressMismatch // surfaces as UNVERIFIED — the proof was valid but the contract reverted, // which is the spoofed-reverse-record signal. @@ -1877,153 +2373,356 @@ function unverifiedReverseResult(normalizedAddress, nameSystem, claimedName, det }; } -async function verifyContractBackedReverseName(normalizedAddress, nameSystem, claimedName) { - if (!claimedName) return null; - const claimedSystem = nameSystemForName(claimedName); - if (claimedSystem.id !== nameSystem.id) { - return unverifiedReverseResult( +async function readMyotisReverse(normalizedAddress) { + const ensRec = await resolveMyotisEnsRecord({ + method: 'reverse', + addressHex: normalizedAddress, + }); + const ensTrust = buildMyotisTrust(ensRec, NAME_SYSTEMS.ens); + if (ensRec.status === 'ok' && ensRec.name) { + return { + success: true, + address: normalizedAddress, + name: ensRec.name, + system: 'ens', + trust: ensTrust, + }; + } + if (ensRec.status !== 'noRecord') { + throw new Error(`unexpected reverse record shape: ${JSON.stringify(ensRec).slice(0, 200)}`); + } + + // WNS/GNS reverse records live on their NameNFT contracts. Query each over + // Myotis and forward-check any claim through the same local verified-call + // path before presenting it as a name. + let firstUnverified = null; + let lastTrust = ensTrust; + for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { + const reverseCall = NAME_NFT_INTERFACE.encodeFunctionData('reverseResolve', [ + normalizedAddress, + ]); + const reverseOutcome = await tryMyotisContractPath(reverseCall, nameSystem); + lastTrust = reverseOutcome.trust; + let claimedName; + try { + [claimedName] = NAME_NFT_INTERFACE.decodeFunctionResult( + 'reverseResolve', + reverseOutcome.resolvedData + ); + } catch (err) { + throw new Error(`invalid ${nameSystem.label} reverse response: ${err.message}`, { + cause: err, + }); + } + if (!claimedName) continue; + + const claimedSystem = nameSystemForName(claimedName); + if (claimedSystem.id !== nameSystem.id) { + const invalid = unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name`, + reverseOutcome.trust + ); + if (!firstUnverified) firstUnverified = invalid; + continue; + } + + let forwardOutcome; + try { + const normalizedClaim = fastNormalize(String(claimedName).trim()); + const forwardCall = ADDR_SELECTOR + ethers.namehash(normalizedClaim).slice(2); + forwardOutcome = await tryMyotisContractPath(forwardCall, nameSystem); + const [forwardAddress] = ethers.AbiCoder.defaultAbiCoder().decode( + ['address'], + forwardOutcome.resolvedData + ); + if (String(forwardAddress).toLowerCase() === normalizedAddress) { + return { + success: true, + address: normalizedAddress, + name: normalizedClaim, + system: nameSystem.id, + trust: forwardOutcome.trust, + }; + } + } catch (err) { + log.info( + `[${nameSystem.id}] myotis forward verification failed for ${nameForLog(normalizedAddress)}: ` + + `${err.message}` + ); + } + + const invalid = unverifiedReverseResult( normalizedAddress, nameSystem, claimedName, - `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name` + `Reverse record for ${normalizedAddress} does not forward-verify`, + forwardOutcome?.trust || reverseOutcome.trust ); + if (!firstUnverified) firstUnverified = invalid; } - let forwardResult; - try { - forwardResult = await resolveEnsAddress(claimedName); - } catch (err) { + return firstUnverified || { + ...noReverseResult(normalizedAddress), + trust: lastTrust, + }; +} + +async function tryMyotisReverse(normalizedAddress) { + if (!myotisManager.isReady()) return null; + const epoch = myotisManager.getAvailabilityEpoch(); + const result = await readMyotisReverse(normalizedAddress); + if (epoch !== myotisManager.getAvailabilityEpoch() || !myotisManager.isReady()) { + const err = new Error('Myotis availability changed during verified reverse read'); + err.code = 'MYOTIS_LIFECYCLE_CHANGED'; + throw err; + } + return result; +} + +function reverseConflictResult(normalizedAddress, nameSystem, outcome) { + return { + success: false, + address: normalizedAddress, + system: nameSystem.id, + reason: 'CONFLICT', + error: `Providers disagreed about the reverse record for ${normalizedAddress}`, + trust: outcome.trust, + groups: outcome.groups, + }; +} + +async function resolveEnsReverseWithMethod(method, normalizedAddress) { + if (method === 'colibri') return tryColibriReverse(normalizedAddress); + + const outcome = await resolveWithMethod(method, normalizedAddress, '0x', 'reverse-ens', { + callResolver: universalResolverReverseCall, + nameSystem: NAME_SYSTEMS.ens, + }); + if (!outcome) return null; + if (outcome.outcome === 'conflict') { + return reverseConflictResult(normalizedAddress, NAME_SYSTEMS.ens, outcome); + } + if (outcome.outcome === 'not_found') { + return { ...noReverseResult(normalizedAddress), trust: outcome.trust }; + } + + const decoded = decodeReverseOutcome(outcome.resolvedData); + if (decoded.status === REVERSE_OUTCOME.name) { + return { + success: true, + address: normalizedAddress, + name: decoded.detail, + system: 'ens', + trust: outcome.trust, + }; + } + if (decoded.status === REVERSE_OUTCOME.mismatch) { return unverifiedReverseResult( normalizedAddress, - nameSystem, - claimedName, - `Reverse record for ${normalizedAddress} could not be forward-verified: ${err.message}` + NAME_SYSTEMS.ens, + decoded.detail || null, + `Reverse record for ${normalizedAddress} does not forward-verify`, + outcome.trust ); } - - const forwardAddress = String(forwardResult?.address || '').toLowerCase(); - if (forwardResult?.success && forwardAddress === normalizedAddress) { + if (decoded.status === REVERSE_OUTCOME.error) { return { - success: true, + success: false, address: normalizedAddress, - name: forwardResult.name || claimedName, - system: nameSystem.id, - trust: forwardResult.trust, + system: 'ens', + reason: 'RESOLUTION_ERROR', + error: decoded.detail, + trust: outcome.trust, }; } - - return unverifiedReverseResult( - normalizedAddress, - nameSystem, - claimedName, - `Reverse record for ${normalizedAddress} does not forward-verify`, - forwardResult?.trust - ); + return { ...noReverseResult(normalizedAddress), trust: outcome.trust }; } -async function withContractBackedReverseFallback(normalizedAddress, ensResult) { +async function resolveContractBackedReverseWithMethod(method, normalizedAddress, ensResult) { if (ensResult?.reason !== 'NO_REVERSE') return ensResult; - let provider; - try { - provider = await getWorkingProvider(); - // Return the first forward-verified name across systems. A claim that - // doesn't forward-verify shouldn't stop us from checking the next system - // (an address can have a stale/spoofed .wei record but a valid .gwei - // primary), so keep the first unverified claim only as a fallback so its - // warning still surfaces when no system verifies. - let firstUnverified = null; - for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { - try { - const registryContract = new ethers.Contract( - nameSystem.contractAddress, - NAME_NFT_ABI, - provider + let firstUnverified = null; + let lastTrust = ensResult.trust; + for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { + const reverseCall = NAME_NFT_INTERFACE.encodeFunctionData('reverseResolve', [ + normalizedAddress, + ]); + const syntheticName = `reverse${nameSystem.suffix}`; + const reverseOutcome = await resolveWithMethod( + method, + syntheticName, + reverseCall, + `reverse-${nameSystem.id}`, + { callResolver: nameNftReverseResolverCall, nameSystem } + ); + if (!reverseOutcome) return null; + lastTrust = reverseOutcome.trust || lastTrust; + if (reverseOutcome.outcome === 'conflict') { + return reverseConflictResult(normalizedAddress, nameSystem, reverseOutcome); + } + if (reverseOutcome.outcome === 'not_found') continue; + + let claimedName; + try { + [claimedName] = ethers.AbiCoder.defaultAbiCoder().decode( + ['string'], + reverseOutcome.resolvedData + ); + } catch (err) { + throw new Error(`invalid ${nameSystem.label} reverse response: ${err.message}`, { + cause: err, + }); + } + if (!claimedName) continue; + + const claimedSystem = nameSystemForName(claimedName); + if (claimedSystem.id !== nameSystem.id) { + firstUnverified ||= unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name`, + reverseOutcome.trust + ); + continue; + } + + let forwardOutcome; + try { + const normalizedClaim = fastNormalize(String(claimedName).trim()); + const forwardCall = ADDR_SELECTOR + ethers.namehash(normalizedClaim).slice(2); + forwardOutcome = await resolveWithMethod( + method, + normalizedClaim, + forwardCall, + `reverse-forward-${nameSystem.id}`, + { callResolver: nameNftResolverCall, nameSystem } + ); + if (forwardOutcome?.outcome === 'data') { + const [forwardAddress] = ethers.AbiCoder.defaultAbiCoder().decode( + ['address'], + forwardOutcome.resolvedData ); - const name = await registryContract.reverseResolve(normalizedAddress); - if (!name) continue; - const verified = await verifyContractBackedReverseName(normalizedAddress, nameSystem, name); - if (verified?.success) return verified; - if (!firstUnverified) firstUnverified = verified; - } catch (err) { - if (isProviderError(err)) throw err; - log.info(`[${nameSystem.id}] reverse failed for ${normalizedAddress}: ${err.message}`); + if (String(forwardAddress).toLowerCase() === normalizedAddress) { + return { + success: true, + address: normalizedAddress, + name: normalizedClaim, + system: nameSystem.id, + trust: forwardOutcome.trust, + }; + } } + } catch (err) { + log.info( + `[${nameSystem.id}] ${method} forward verification failed for ${nameForLog(normalizedAddress)}: ` + + `${err.message}` + ); } - return firstUnverified || ensResult; - } catch (err) { - if (isProviderError(err)) throw err; - log.info(`[ens] contract-backed reverse fallback failed for ${normalizedAddress}: ${err.message}`); - return ensResult; + + firstUnverified ||= unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} does not forward-verify`, + forwardOutcome?.trust || reverseOutcome.trust + ); } + + return firstUnverified || { + ...noReverseResult(normalizedAddress), + trust: lastTrust, + }; +} + +function isProvisionalReverse(result) { + return result?.trust?.level === 'unverified' || result?.reason === 'UNVERIFIED'; +} + +function logReverseMethodOutcome(method, normalizedAddress, result, action, reason = '') { + const outcome = result?.success ? 'RESOLVED' : result?.reason || 'UNAVAILABLE'; + const system = outcome === 'NO_REVERSE' + ? 'ens,wns,gns' + : result?.system || result?.trust?.system || 'none'; + const trust = result?.trust?.level || 'none'; + log.info( + `[ens] reverse method=${method} address=${nameForLog(normalizedAddress)} outcome=${outcome} ` + + `system=${system} trust=${trust} action=${action}${reason ? ` reason=${reason}` : ''}` + ); } async function doResolveEnsReverse(normalizedAddress) { - const strategy = registry.getNetwork(1).verification.primary; + const network = registry.getNetwork(1); + const { order, preferVerified } = resolutionPolicy(network); + let provisional = null; + let provisionalMethod = null; + let lastError = null; + + log.info( + `[ens] reverse policy address=${nameForLog(normalizedAddress)} order=[${order.join(',')}] ` + + `preferVerified=${preferVerified}` + ); - if (strategy === 'colibri') { + for (const method of order) { + let result = null; + let unavailableReason = 'unavailable'; try { - const ensResult = await tryColibriReverse(normalizedAddress); - return cacheReverseResult( - normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, ensResult) - ); + if (method === 'myotis') { + if (myotisManager.isEnabled()) { + result = await tryMyotisReverse(normalizedAddress); + if (!result) unavailableReason = 'not-ready'; + } else { + unavailableReason = 'disabled'; + } + } else { + const ensResult = await resolveEnsReverseWithMethod(method, normalizedAddress); + result = await resolveContractBackedReverseWithMethod( + method, + normalizedAddress, + ensResult + ); + } } catch (err) { + if (err?.code === 'MYOTIS_LIFECYCLE_CHANGED') throw err; + lastError = err; log.warn( - `[ens] colibri-fallback reverse address=${normalizedAddress} error=${err.message}` + `[ens] reverse method=${method} address=${nameForLog(normalizedAddress)} outcome=ERROR ` + + `action=continue error=${err.message}` ); + continue; } - } - const provider = await getWorkingProvider(); - const ur = new ethers.Contract(UNIVERSAL_RESOLVER_ADDRESS, UR_ABI, provider); - const addrBytes = ethers.getBytes(normalizedAddress); - - let claimedName; - try { - const [name] = await ur.reverse(addrBytes, ETH_COIN_TYPE, { enableCcipRead: true }); - claimedName = name; - } catch (err) { - if (isProviderError(err)) throw err; - if (isResolverNotFoundError(err)) { - return cacheReverseResult( - normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, noReverseResult(normalizedAddress)) - ); + if (!result) { + logReverseMethodOutcome(method, normalizedAddress, result, 'continue', unavailableReason); + continue; } - if (isReverseAddressMismatchError(err)) { - return cacheReverseResult(normalizedAddress, { - success: false, - address: normalizedAddress, - system: 'ens', - reason: 'UNVERIFIED', - claimedName: decodeReverseMismatchClaimedName(err), - error: `Reverse record for ${normalizedAddress} does not forward-verify`, - }); + if (preferVerified && isProvisionalReverse(result)) { + if (!provisional) { + provisional = result; + provisionalMethod = method; + } + logReverseMethodOutcome(method, normalizedAddress, result, 'continue', 'prefer-verified'); + continue; } - log.info(`[ens] UR reverse failed for ${normalizedAddress}: ${err.message}`); - return cacheReverseResult(normalizedAddress, { - success: false, - address: normalizedAddress, - system: 'ens', - reason: 'RESOLUTION_ERROR', - error: err.message, - }); + logReverseMethodOutcome(method, normalizedAddress, result, 'accept'); + return cacheReverseResult(normalizedAddress, result); } - if (!claimedName) { - return cacheReverseResult( + if (provisional) { + logReverseMethodOutcome( + provisionalMethod, normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, noReverseResult(normalizedAddress)) + provisional, + 'accept', + 'no-better-result' ); + return cacheReverseResult(normalizedAddress, provisional); } - - return cacheReverseResult(normalizedAddress, { - success: true, - address: normalizedAddress, - name: claimedName, - system: 'ens', - }); + if (lastError) throw lastError; + throw new Error(`No enabled name-resolution method could reverse-resolve ${normalizedAddress}`); } function noReverseResult(normalizedAddress) { @@ -2031,7 +2730,7 @@ function noReverseResult(normalizedAddress) { success: false, address: normalizedAddress, reason: 'NO_REVERSE', - error: `No primary ENS name set for ${normalizedAddress}`, + error: `No primary name set for ${normalizedAddress}`, }; } @@ -2039,60 +2738,79 @@ function cacheReverseResult(normalizedAddress, result) { return cacheAndLog(ensReverseCache, normalizedAddress, result, result.name); } +// PRIVATE MODE GUARD (name logging): a name typed in a private window's +// address bar reaches the resolver through these handlers, so they are the +// point where the sender's private-ness is still known. Marking the async +// subtree redacts every downstream log site — including the ones inside +// the consensus wave, the per-method dispatch and the shared +// cache-and-log — without threading a flag through every resolver hop. The +// resolution itself is unchanged. +function privateResolveContext(event) { + return isPrivateWebContents(event?.sender); +} + function registerEnsIpc() { - ipcMain.handle(IPC.ENS_RESOLVE, async (_event, payload = {}) => { + ipcMain.handle(IPC.ENS_RESOLVE, async (event, payload = {}) => { const { name } = payload; - try { - const result = await resolveEnsContent(name); - return result; - } catch (err) { - log.error('[ens] resolution error', err); - return { - type: 'error', - name: (name || '').trim().toLowerCase(), - reason: 'RESOLUTION_ERROR', - error: err.message, - }; - } + return runWithPrivateLogContext(privateResolveContext(event), async () => { + try { + const result = await resolveEnsContent(name); + return result; + } catch (err) { + log.error('[ens] resolution error', redactForLog(err)); + return { + type: 'error', + name: (name || '').trim().toLowerCase(), + reason: 'RESOLUTION_ERROR', + error: err.message, + }; + } + }); }); - ipcMain.handle(IPC.ENS_RESOLVE_ADDRESS, async (_event, payload = {}) => { + ipcMain.handle(IPC.ENS_RESOLVE_ADDRESS, async (event, payload = {}) => { const { name } = payload; - try { - return await resolveEnsAddress(name); - } catch (err) { - log.error('[ens] address resolution error', err); - return { - success: false, - name: (name || '').trim().toLowerCase(), - reason: 'RESOLUTION_ERROR', - error: err.message, - }; - } + return runWithPrivateLogContext(privateResolveContext(event), async () => { + try { + return await resolveEnsAddress(name); + } catch (err) { + log.error('[ens] address resolution error', redactForLog(err)); + return { + success: false, + name: (name || '').trim().toLowerCase(), + reason: 'RESOLUTION_ERROR', + error: err.message, + }; + } + }); }); - ipcMain.handle(IPC.ENS_RESOLVE_REVERSE, async (_event, payload = {}) => { + ipcMain.handle(IPC.ENS_RESOLVE_REVERSE, async (event, payload = {}) => { const { address } = payload; - try { - return await resolveEnsReverse(address); - } catch (err) { - log.error('[ens] reverse resolution error', err); - return { - success: false, - address: typeof address === 'string' ? address.toLowerCase() : null, - reason: 'RESOLUTION_ERROR', - error: err.message, - }; - } + return runWithPrivateLogContext(privateResolveContext(event), async () => { + try { + return await resolveEnsReverse(address); + } catch (err) { + log.error('[ens] reverse resolution error', redactForLog(err)); + return { + success: false, + address: typeof address === 'string' ? address.toLowerCase() : null, + reason: 'RESOLUTION_ERROR', + error: err.message, + }; + } + }); }); // Drop the cached contenthash for `name`. Used by the renderer's // swarm-probe failure handler so a "Try Again" click does a fresh // resolution rather than re-probing a stale contenthash. - ipcMain.handle(IPC.ENS_INVALIDATE_CONTENT, async (_event, payload = {}) => { + ipcMain.handle(IPC.ENS_INVALIDATE_CONTENT, async (event, payload = {}) => { const { name } = payload; - return invalidateEnsContent(name); + return runWithPrivateLogContext(privateResolveContext(event), () => + invalidateEnsContent(name) + ); }); } @@ -2110,7 +2828,7 @@ function invalidateEnsContent(name) { const had = ensResultCache.has(key); ensResultCache.delete(key); if (had) { - log.info(`[ens] content cache invalidated for ${key}`); + log.info(`[ens] content cache invalidated for ${nameForLog(key)}`); } return had; } @@ -2119,6 +2837,7 @@ function invalidateEnsContent(name) { // after a settings edit (equivalent to waiting out the TTLs); tests also // call it directly to share ENS names across cases without cross-pollution. function clearEnsResolutionCaches() { + resolutionLifecycleEpoch += 1; ensResultCache.clear(); ensAddressCache.clear(); ensReverseCache.clear(); diff --git a/src/main/ens-resolver.test.js b/src/main/ens-resolver.test.js index d3ea79b7..39b55855 100644 --- a/src/main/ens-resolver.test.js +++ b/src/main/ens-resolver.test.js @@ -3,6 +3,17 @@ jest.mock('electron', () => ({ ipcMain: { handle: jest.fn() }, })); +// The persistent-log guard asserts on what reaches the logger, so the +// logger is a mock rather than electron-log's test-mode no-op. +const mockLog = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; +jest.mock('./logger', () => mockLog); + +// Private-ness is decided from the IPC sender; the registry itself needs a +// real BrowserWindow, so stub the one predicate ens-resolver uses. +jest.mock('./private/private-windows', () => ({ + isPrivateWebContents: (webContents) => webContents?.isPrivate === true, +})); + // Mock ens-prefetch so tests can assert when it fires and when it aborts, // without spinning up a real net.request. Default impl returns a fresh // abort-recording handle each call. @@ -73,7 +84,13 @@ jest.mock('./networks/network-registry', () => { return { chainId: 1, name: 'Ethereum', - verification: { primary }, + verification: { + primary, + ...(Array.isArray(s.ensResolutionOrder) ? { order: s.ensResolutionOrder } : {}), + ...(typeof s.ensPreferVerified === 'boolean' + ? { preferVerified: s.ensPreferVerified } + : {}), + }, quorum: { k: s.ensQuorumK ?? 3, m: s.ensQuorumM ?? 2, @@ -121,6 +138,28 @@ jest.mock('./ens/colibri-resolver', () => ({ resolveReverseViaColibri: (...args) => mockResolveReverseViaColibri(...args), })); +// Myotis manager (experimental P2P light-client tier). Defaults to disabled +// so every pre-existing test sees the resolver exactly as before; the +// myotis-path suite flips these per test. +const mockMyotisIsEnabled = jest.fn(() => false); +const mockMyotisIsReady = jest.fn(() => false); +const mockMyotisResolveEnsRecord = jest.fn(); +const mockMyotisEthCall = jest.fn(); +const mockMyotisGetStatus = jest.fn(() => ({ optimisticBlockNumber: 23456800 })); +const mockMyotisGetAvailabilityEpoch = jest.fn(() => 0); +// Captures the resolver's module-load registration so tests can fire either +// availability direction and exercise cache/in-flight lifecycle behavior. +const mockMyotisAvailabilityListeners = []; +jest.mock('./myotis/myotis-manager', () => ({ + isEnabled: (...args) => mockMyotisIsEnabled(...args), + isReady: (...args) => mockMyotisIsReady(...args), + resolveEnsRecord: (...args) => mockMyotisResolveEnsRecord(...args), + ethCall: (...args) => mockMyotisEthCall(...args), + getStatus: (...args) => mockMyotisGetStatus(...args), + getAvailabilityEpoch: (...args) => mockMyotisGetAvailabilityEpoch(...args), + onAvailabilityTransition: (cb) => mockMyotisAvailabilityListeners.push(cb), +})); + // Mock ethers with controllable provider and resolver behavior. // `mockUrResolve` is shared across all Contract instances — this is fine // for tests that use `mockResolvedValue(X)` (every quorum leg returns X @@ -158,6 +197,7 @@ let mockProviderAnchorMap = null; const WNS_ADDRESS = '0x0000000000696760e15f265e828db644a0c242eb'; const GNS_ADDRESS = '0x9d51d507bc7264d4fe8ad1cf7fe191933a0a81d6'; +const ADDR_SELECTOR = '0x3b3b57de'; jest.mock('ethers', () => { const actual = jest.requireActual('ethers').ethers; @@ -233,19 +273,25 @@ jest.mock('ethers', () => { decodeBase58: actual.decodeBase58, getBytes: actual.getBytes, ZeroAddress: actual.ZeroAddress, + Interface: actual.Interface, }, }; }); const { ethers } = require('ethers'); const { + registerEnsIpc, resolveEnsContent, resolveEnsAddress, resolveEnsReverse, invalidateCachedProvider, + clearEnsResolutionCaches, universalResolverCall, isResolverNotFoundError, } = require('./ens-resolver'); +const resolverLog = require('./logger'); +const { ipcMain } = require('electron'); +const IPC = require('../shared/ipc-channels'); // Fake block anchor — stable hash so consensus legs querying the same // block get deterministic agreement. @@ -253,6 +299,7 @@ const FAKE_BLOCK = { number: 12345678, hash: '0xabcdef00000000000000000000000000 beforeEach(() => { jest.clearAllMocks(); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => 0); invalidateCachedProvider(); lastProviderUrl = null; mockProviderRouteMap = null; @@ -268,6 +315,10 @@ beforeEach(() => { mockGnsContenthash.mockResolvedValue('0x'); mockGnsAddr.mockResolvedValue('0x0000000000000000000000000000000000000000'); mockGnsReverseResolve.mockResolvedValue(''); + mockResolveViaColibri.mockResolvedValue({ + resolvedData: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + resolverAddress: WNS_ADDRESS, + }); mockLoadSettings.mockReturnValue({ enableEnsCustomRpc: false, ensRpcUrl: '', @@ -598,6 +649,21 @@ describe('ens-resolver', () => { // Default test settings: K=3, matching TEST_PROVIDERS.length. expect(mockUrResolve).toHaveBeenCalledTimes(3); }); + + test('pins every short-lived RPC provider to Ethereum Mainnet', async () => { + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + await resolveEnsContent('static-network.eth'); + + expect(ethers.JsonRpcProvider).toHaveBeenCalled(); + for (const call of ethers.JsonRpcProvider.mock.calls) { + expect(call).toEqual([ + expect.any(String), + 1, + { staticNetwork: true }, + ]); + } + }); }); describe('custom RPC URL', () => { @@ -836,12 +902,41 @@ describe('ens-resolver', () => { const result = await resolveEnsReverse(input); - expect(result).toEqual({ + expect(result).toMatchObject({ success: true, address: input.toLowerCase(), name: 'verified1.eth', system: 'ens', + trust: { + level: 'verified', + system: 'ens', + quorum: { k: 3, m: 2, achieved: true }, + }, + }); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + }); + + test('surfaces conflicting reverse names when the RPC quorum disagrees', async () => { + const input = addr('1014'); + mockUrReverse + .mockResolvedValueOnce(['alice.eth', RESOLVER, RESOLVER]) + .mockResolvedValueOnce(['bob.eth', RESOLVER, RESOLVER]) + .mockResolvedValueOnce(['carol.eth', RESOLVER, RESOLVER]); + + const result = await resolveEnsReverse(input); + + expect(result).toMatchObject({ + success: false, + address: input.toLowerCase(), + system: 'ens', + reason: 'CONFLICT', + trust: { + level: 'conflict', + quorum: { k: 3, m: 2, achieved: false }, + }, }); + expect(result.groups).toHaveLength(3); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('falls back to WNS reverse when ENS has no primary name', async () => { @@ -859,8 +954,8 @@ describe('ens-resolver', () => { system: 'wns', }); expect(result.trust).toMatchObject({ level: 'verified', system: 'wns' }); - expect(mockUrReverse).toHaveBeenCalledTimes(1); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockWnsAddr).toHaveBeenCalledTimes(3); }); @@ -880,9 +975,9 @@ describe('ens-resolver', () => { system: 'gns', }); expect(result.trust).toMatchObject({ level: 'verified', system: 'gns' }); - expect(mockUrReverse).toHaveBeenCalledTimes(1); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); - expect(mockGnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); + expect(mockGnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockGnsAddr).toHaveBeenCalledTimes(3); }); @@ -903,7 +998,7 @@ describe('ens-resolver', () => { }); expect(result.name).toBeUndefined(); expect(result.trust).toMatchObject({ level: 'verified', system: 'wns' }); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockWnsAddr).toHaveBeenCalledTimes(3); }); @@ -925,7 +1020,7 @@ describe('ens-resolver', () => { }); expect(result.name).toBeUndefined(); expect(result.trust).toMatchObject({ level: 'verified', system: 'gns' }); - expect(mockGnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockGnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockGnsAddr).toHaveBeenCalledTimes(3); }); @@ -940,12 +1035,12 @@ describe('ens-resolver', () => { try { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); now.mockReturnValue(1_061_000); await resolveEnsReverse(input); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(2); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(6); } finally { now.mockRestore(); } @@ -1011,13 +1106,13 @@ describe('ens-resolver', () => { mockUrReverse .mockRejectedValueOnce(providerError) - .mockResolvedValueOnce(['retry-reverse.eth', RESOLVER, RESOLVER]); + .mockResolvedValue(['retry-reverse.eth', RESOLVER, RESOLVER]); const result = await resolveEnsReverse(input); expect(result.success).toBe(true); expect(result.name).toBe('retry-reverse.eth'); - expect(mockUrReverse).toHaveBeenCalledTimes(2); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('caches successful verified results', async () => { @@ -1027,7 +1122,7 @@ describe('ens-resolver', () => { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('caches NO_REVERSE negative results too', async () => { @@ -1037,7 +1132,7 @@ describe('ens-resolver', () => { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('normalizes input address to lowercase for caching', async () => { @@ -1048,7 +1143,28 @@ describe('ens-resolver', () => { await resolveEnsReverse(input.toLowerCase()); // Second call hits the cache keyed on lowercase form. + expect(mockUrReverse).toHaveBeenCalledTimes(3); + }); + + test('direct reverse resolution only queries the selected custom RPC', async () => { + const input = addr('1013'); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + enableEnsCustomRpc: true, + ensRpcUrl: 'https://user-rpc.example.com', + ensResolutionOrder: ['direct'], + }); + mockUrReverse.mockResolvedValue(['direct.eth', RESOLVER, RESOLVER]); + + const result = await resolveEnsReverse(input); + + expect(result).toMatchObject({ + success: true, + name: 'direct.eth', + trust: { level: 'user-configured' }, + }); expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockGetBlockNumber).toHaveBeenCalledTimes(1); }); }); @@ -1160,6 +1276,802 @@ describe('ens-resolver', () => { // exist in the legacy single-provider flow: conflict, degraded K=1 // unverified, user-configured fast-path labelling, block pinning. // -------------------------------------------------------------------- + describe('experimental myotis path', () => { + const IPFS_V0 = 'QmW81r84Aihiqqi2Jw6nM1LnpeMfRCenRxtjwHNkXVkZYa'; + + const myotisUp = () => { + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => true); + }; + + afterEach(() => { + mockMyotisIsEnabled.mockImplementation(() => false); + mockMyotisIsReady.mockImplementation(() => false); + }); + + test('serves verified contenthash from the local P2P node without touching RPC or colibri', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await resolveEnsContent('myotis-ok.eth'); + + expect(result).toMatchObject({ + type: 'ok', + codec: 'ipfs-ns', + protocol: 'ipfs', + uri: `ipfs://${IPFS_V0}`, + }); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + block: 23456789, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'contenthash', + name: 'myotis-ok.eth', + root: 'auto', + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + }); + + test('serves verified ENS addr records through the same Myotis tier', async () => { + myotisUp(); + const address = '0x1111111111111111111111111111111111111111'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + addressHex: address, + }); + + const result = await resolveEnsAddress('myotis-addr.eth'); + + expect(result).toMatchObject({ + success: true, + name: 'myotis-addr.eth', + address, + trust: { level: 'verified', method: 'myotis', block: 23456789 }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'addr', + name: 'myotis-addr.eth', + root: 'auto', + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('serves forward-verified ENS reverse records through Myotis', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001201'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + name: 'reverse-myotis.eth', + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + address, + name: 'reverse-myotis.eth', + system: 'ens', + trust: { level: 'verified', method: 'myotis' }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'reverse', + addressHex: address, + root: 'auto', + }); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('accepts an optimistic beacon-verified reverse answer without duplicate fallback', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001210'; + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + name: 'optimistic.eth', + }); + mockResolveReverseViaColibri.mockResolvedValue({ name: 'verified.eth' }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + name: 'optimistic.eth', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('accepts a complete optimistic Myotis reverse miss without repeating ENS remotely', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001211'; + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: false, + blockNumber: 23456791, + }); + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: false, + reason: 'NO_REVERSE', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(1); + expect(mockMyotisEthCall).toHaveBeenCalledTimes(2); + expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('optimistic answers are verified while remaining explicitly non-finalized', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await resolveEnsContent('myotis-peerhead.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + }); + + test('does not repeat an optimistic Myotis answer through Colibri', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + const result = await resolveEnsContent('prefer-verified.eth'); + + expect(mockMyotisResolveEnsRecord).toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + }); + + test('honors custom method order and does not invoke lower-priority methods after success', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['colibri', 'myotis', 'quorum'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const result = await resolveEnsContent('colibri-first.eth'); + + expect(result.trust).toMatchObject({ level: 'verified', method: 'colibri' }); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('does not consult a later method after an optimistic verified answer', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + const result = await resolveEnsContent('provisional-fallback.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('excludes disabled methods from resolution entirely', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionOrder: ['quorum'], + ensPreferVerified: true, + }); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('quorum-only.eth'); + + expect(result.type).toBe('ok'); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('verified absence maps to EMPTY_CONTENTHASH', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456791, + }); + + const result = await resolveEnsContent('myotis-norecord.eth'); + + expect(result).toMatchObject({ type: 'not_found', reason: 'EMPTY_CONTENTHASH' }); + expect(result.trust).toMatchObject({ level: 'verified', method: 'myotis' }); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('node not synced yet: silent fall-through to the quorum path', async () => { + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => false); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + const infoSpy = jest.spyOn(resolverLog, 'info').mockImplementation(() => {}); + + const result = await resolveEnsContent('myotis-notready.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockUrResolve).toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith( + '[ens] policy name=myotis-notready.eth kind=content ' + + 'order=[myotis,quorum] preferVerified=false' + ); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /^\[ens\] method=myotis name=myotis-notready\.eth kind=content outcome=SKIP trust=none action=continue reason=not-ready durationMs=\d+$/ + ) + ); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /^\[ens\] method=quorum name=myotis-notready\.eth kind=content outcome=DATA trust=verified action=accept reason=none durationMs=\d+$/ + ) + ); + infoSpy.mockRestore(); + }); + + test('discards a Myotis read interrupted by shutdown and resolves once through Colibri', async () => { + let ready = true; + let epoch = 1; + let finishMyotis; + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => ready); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => epoch); + mockMyotisResolveEnsRecord.mockImplementation(() => new Promise((resolve) => { + finishMyotis = resolve; + })); + withColibri({ + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const pending = resolveEnsContent('shutdown-race.eth'); + await Promise.resolve(); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(1); + + ready = false; + epoch = 2; + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: false, reason: 'stopping', epoch }); + } + finishMyotis({ + status: 'ok', + verified: true, + blockNumber: 23456801, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await pending; + + expect(result).toMatchObject({ + type: 'ok', + uri: `ipfs://${IPFS_V0}`, + trust: { level: 'verified', method: 'colibri' }, + }); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(1); + }); + + test('unavailable transition evicts a cached Myotis answer before the next lookup', async () => { + let ready = true; + let epoch = 1; + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => ready); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => epoch); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456802, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const first = await resolveEnsContent('shutdown-cache.eth'); + expect(first.trust.method).toBe('myotis'); + + ready = false; + epoch = 2; + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: false, reason: 'stopping', epoch }); + } + withColibri({ + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const second = await resolveEnsContent('shutdown-cache.eth'); + + expect(second.trust.method).toBe('colibri'); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(1); + }); + + test('engine failure falls through to the quorum path', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockRejectedValue(new Error('no snap peer')); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('myotis-enginefail.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('malformed CCIP offchain envelopes fall back to the configured resolver', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'offchain', + verified: true, + blockNumber: 23456792, + }); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('myotis-offchain.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('completes a CCIP-Read gateway round and verifies the callback in Myotis', async () => { + myotisUp(); + const originalFetch = global.fetch; + const gatewayData = '0xabcdef'; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue(JSON.stringify({ data: gatewayData })), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: true, + blockNumber: 23456792, + senderHex: '0x1111111111111111111111111111111111111111', + urls: ['https://ccip.example/{sender}/{data}.json'], + callDataHex: '0x1234', + callbackFunctionHex: '0xaabbccdd', + extraDataHex: '0x5678', + wrapped: true, + }) + .mockResolvedValueOnce({ + status: 'ok', + verified: true, + blockNumber: 23456792, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + try { + const result = await resolveEnsContent('myotis-ccip.box'); + + expect(result).toMatchObject({ + type: 'ok', + uri: `ipfs://${IPFS_V0}`, + trust: { level: 'verified', method: 'myotis' }, + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://ccip.example/0x1111111111111111111111111111111111111111/0x1234.json', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockMyotisResolveEnsRecord).toHaveBeenNthCalledWith(2, { + method: 'ccipCallback', + name: 'myotis-ccip.box', + root: 'auto', + queryMethod: 'contenthash', + senderHex: '0x1111111111111111111111111111111111111111', + callbackFunctionHex: '0xaabbccdd', + responseHex: gatewayData, + extraDataHex: '0x5678', + wrapped: true, + finalized: true, + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } + }); + + test('uses POST CCIP gateways and tries the next URL after a bad response', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"notData":"0x"}'), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"data":"0xcafe"}'), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: false, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://bad.example/query', 'https://good.example/query'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }) + .mockResolvedValueOnce({ + status: 'noRecord', + verified: false, + blockNumber: 23456793, + }); + + try { + const result = await resolveEnsContent('myotis-ccip-post.box'); + + expect(result).toMatchObject({ + type: 'not_found', + reason: 'EMPTY_CONTENTHASH', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch.mock.calls[1][1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ + sender: '0x2222222222222222222222222222222222222222', + data: '0xbeef', + }), + }); + } finally { + global.fetch = originalFetch; + } + }); + + test('reads the top-level CCIP data field instead of matching JSON string contents', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue( + JSON.stringify({ message: 'ignore "data": "0x00"', data: '0xcafe' }) + ), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: true, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://ccip.example/query'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }) + .mockResolvedValueOnce({ + status: 'noRecord', + verified: true, + blockNumber: 23456793, + }); + + try { + await resolveEnsContent('myotis-ccip-json.box'); + expect(mockMyotisResolveEnsRecord).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ responseHex: '0xcafe' }) + ); + } finally { + global.fetch = originalFetch; + } + }); + + test('caps recursive CCIP-Read at one gateway round and falls back safely', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"data":"0xcafe"}'), + }); + const offchain = { + status: 'offchain', + verified: true, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://recursive.example/{data}'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }; + mockMyotisResolveEnsRecord.mockResolvedValue(offchain); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + try { + const result = await resolveEnsContent('myotis-recursive.box'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(2); + expect(mockUrResolve).toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } + }); + + test('ready transition sweeps cached content, address, and reverse fallback answers', async () => { + // 1. Node syncing: RPC paths serve and cache every lookup kind. + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => false); + const address = '0x0000000000000000000000000000000000001203'; + mockUrResolve.mockImplementation((_name, callData) => + String(callData).startsWith(ADDR_SELECTOR) + ? urReturnsAddress(address) + : urReturnsBytes(ipfsContenthashFor(IPFS_V0)) + ); + mockUrReverse.mockResolvedValue(['myotis-overtake-reverse.eth']); + const firstContent = await resolveEnsContent('myotis-overtake.eth'); + const firstAddress = await resolveEnsAddress('myotis-overtake-addr.eth'); + const firstReverse = await resolveEnsReverse(address); + expect(firstContent.trust.method).not.toBe('myotis'); + expect(firstAddress.trust.method).not.toBe('myotis'); + expect(firstReverse.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); + + // 2. Node becomes ready — but the cached fallback answers still win… + mockMyotisIsReady.mockImplementation(() => true); + mockMyotisResolveEnsRecord.mockImplementation(async (params) => { + if (params.method === 'contenthash') { + return { + status: 'ok', + verified: true, + blockNumber: 23456799, + dataHex: ipfsContenthashFor(IPFS_V0), + }; + } + if (params.method === 'addr') { + return { status: 'ok', verified: true, blockNumber: 23456799, addressHex: address }; + } + return { + status: 'ok', + verified: true, + blockNumber: 23456799, + name: 'myotis-overtake-reverse.eth', + }; + }); + expect((await resolveEnsContent('myotis-overtake.eth')).trust.method).not.toBe('myotis'); + expect((await resolveEnsAddress('myotis-overtake-addr.eth')).trust.method).not.toBe('myotis'); + expect((await resolveEnsReverse(address)).trust.method).not.toBe('myotis'); + + // 3. …until the ready transition sweeps all three caches. + expect(mockMyotisAvailabilityListeners.length).toBeGreaterThan(0); + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: true, reason: 'ready', epoch: 1 }); + } + expect((await resolveEnsContent('myotis-overtake.eth')).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + expect((await resolveEnsAddress('myotis-overtake-addr.eth')).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + expect((await resolveEnsReverse(address)).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + }); + + test('resolves WNS content through Myotis generic verified eth_call', async () => { + myotisUp(); + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode( + ['bytes'], + [ipfsContenthashFor(IPFS_V0)] + ), + }); + + const result = await resolveEnsContent('myotis-wns.wei'); + + expect(result).toMatchObject({ + type: 'ok', + system: 'wns', + uri: `ipfs://${IPFS_V0}`, + trust: { + level: 'verified', + method: 'myotis', + system: 'wns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall.mock.calls[0][0].to.toLowerCase()).toBe(WNS_ADDRESS); + expect(mockMyotisEthCall.mock.calls[0][0].block).toBe('latest'); + expect(mockWnsContenthash).not.toHaveBeenCalled(); + }); + + test('resolves GNS addr records through Myotis generic verified eth_call', async () => { + myotisUp(); + const address = '0x3333333333333333333333333333333333333333'; + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [address]), + }); + + const result = await resolveEnsAddress('myotis-gns.gwei'); + + expect(result).toMatchObject({ + success: true, + system: 'gns', + address, + trust: { + level: 'verified', + method: 'myotis', + system: 'gns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall.mock.calls[0][0].to.toLowerCase()).toBe(GNS_ADDRESS); + expect(mockMyotisEthCall.mock.calls[0][0].block).toBe('latest'); + expect(mockGnsAddr).not.toHaveBeenCalled(); + }); + + test('forward-verifies WNS reverse claims through Myotis contract calls', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001202'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456794, + }); + mockMyotisEthCall + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['alice.wei']), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [address]), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + name: 'alice.wei', + system: 'wns', + trust: { + level: 'verified', + method: 'myotis', + system: 'wns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall).toHaveBeenCalledTimes(2); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + }); + + test('rejects a WNS reverse claim that does not forward-resolve through Myotis', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001204'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456795, + }); + mockMyotisEthCall + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['spoof.wei']), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode( + ['address'], + ['0x9999999999999999999999999999999999999999'] + ), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: false, + reason: 'UNVERIFIED', + claimedName: 'spoof.wei', + system: 'wns', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + }); + describe('consensus quorum', () => { const IPFS_HASH = 'QmW81r84Aihiqqi2Jw6nM1LnpeMfRCenRxtjwHNkXVkZYa'; @@ -2173,12 +3085,16 @@ describe('ens-resolver', () => { test('CALL_EXCEPTION without revert data falls through to public RPC quorum', async () => { withColibri(); - const err = Object.assign(new Error('missing response from Colibri prover'), { + const err = Object.assign(new Error( + 'missing revert data transaction={ data: "0x' + 'ab'.repeat(200) + '" }' + ), { code: 'CALL_EXCEPTION', - info: { error: { code: -32603, message: 'no response' } }, + shortMessage: 'missing revert data', + info: { error: { code: -32603, message: 'no response from prover' } }, }); mockResolveViaColibri.mockRejectedValue(err); mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + const warnSpy = jest.spyOn(resolverLog, 'warn').mockImplementation(() => {}); const result = await resolveEnsContent('prover-down.eth'); @@ -2187,6 +3103,13 @@ describe('ens-resolver', () => { expect(mockUrResolve).toHaveBeenCalled(); expect(result.trust.method).not.toBe('colibri'); expect(result.trust.quorum).toEqual({ k: 3, m: 2, achieved: true }); + expect(warnSpy).toHaveBeenCalledWith( + '[ens] colibri-fallback name=prover-down.eth kind=content ' + + 'error="missing revert data" code=CALL_EXCEPTION rpcCode=-32603 ' + + 'rpcMessage="no response from prover" revert=none' + ); + expect(warnSpy.mock.calls.flat().join(' ')).not.toContain('abababababababab'); + warnSpy.mockRestore(); }); test('non-revert error falls through to the quorum path by default', async () => { @@ -2204,7 +3127,7 @@ describe('ens-resolver', () => { expect(result.trust.quorum).toEqual({ k: 3, m: 2, achieved: true }); }); - test('default ensResolutionMethod=quorum leaves the legacy path untouched (regression)', async () => { + test('default ensResolutionMethod=quorum bypasses Colibri (regression)', async () => { // Don't call withColibri — defaults to 'quorum'. mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); @@ -2237,7 +3160,7 @@ describe('ens-resolver', () => { }); test('no primary set surfaces as NO_REVERSE with trust attached', async () => { - withColibri(); + withColibri({ ensResolutionOrder: ['colibri'] }); mockResolveReverseViaColibri.mockResolvedValue({ name: '' }); const result = await resolveEnsReverse(ADDR); @@ -2245,6 +3168,41 @@ describe('ens-resolver', () => { expect(result.success).toBe(false); expect(result.reason).toBe('NO_REVERSE'); expect(result.trust.method).toBe('colibri'); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(2); + expect(mockResolveViaColibri.mock.calls.map(([name]) => name)).toEqual([ + 'reverse.wei', + 'reverse.gwei', + ]); + expect(mockGetBlockNumber).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + expect(mockGnsReverseResolve).not.toHaveBeenCalled(); + }); + + test('resolves and forward-verifies WNS through Colibri without public RPC', async () => { + withColibri(); + mockResolveReverseViaColibri.mockResolvedValue({ name: '' }); + mockResolveViaColibri.mockImplementation(async (name) => { + const resolvedData = name === 'reverse.wei' + ? actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['alice.wei']) + : actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [ADDR]); + return { resolvedData, resolverAddress: WNS_ADDRESS }; + }); + + const result = await resolveEnsReverse(ADDR); + + expect(result).toMatchObject({ + success: true, + address: ADDR, + name: 'alice.wei', + system: 'wns', + trust: { level: 'verified', method: 'colibri', system: 'wns' }, + }); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(2); + expect(mockGetBlockNumber).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + expect(mockWnsAddr).not.toHaveBeenCalled(); }); test('ResolverNotFound surfaces as NO_REVERSE (no record at all)', async () => { @@ -2279,6 +3237,43 @@ describe('ens-resolver', () => { expect(result.trust.level).toBe('verified'); }); + test('logs why an unverified Colibri reverse result continues to quorum', async () => { + withColibri({ + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + const infoSpy = jest.spyOn(resolverLog, 'info').mockImplementation(() => {}); + const err = Object.assign(new Error('ReverseAddressMismatch'), { + data: '0xef9c03ce', + }); + mockResolveReverseViaColibri.mockRejectedValue(err); + mockUrReverse.mockResolvedValue(['']); + + try { + const result = await resolveEnsReverse(ADDR); + + expect(result).toMatchObject({ success: false, reason: 'NO_REVERSE' }); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse policy address=${ADDR} order=[myotis,colibri,quorum] ` + + 'preferVerified=true' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=myotis address=${ADDR} outcome=UNAVAILABLE ` + + 'system=none trust=none action=continue reason=disabled' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=colibri address=${ADDR} outcome=UNVERIFIED ` + + 'system=ens trust=verified action=continue reason=prefer-verified' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=quorum address=${ADDR} outcome=NO_REVERSE ` + + 'system=ens,wns,gns trust=verified action=accept' + ); + } finally { + infoSpy.mockRestore(); + } + }); + test('UNVERIFIED carries the decoded claimedName from revert data', async () => { withColibri(); // ReverseAddressMismatch(string,bytes) — claimed name + address bytes. @@ -2310,7 +3305,7 @@ describe('ens-resolver', () => { expect(result.claimedName).toBeNull(); }); - test('non-revert error falls through to the legacy path by default', async () => { + test('non-revert error falls through to the configured quorum path', async () => { withColibri(); mockResolveReverseViaColibri.mockRejectedValue(new Error('prover unreachable')); mockUrReverse.mockResolvedValue(['legacy.eth']); @@ -2319,20 +3314,26 @@ describe('ens-resolver', () => { expect(result.success).toBe(true); expect(result.name).toBe('legacy.eth'); - // No trust field on the legacy path — additive design. - expect(result.trust).toBeUndefined(); - expect(mockUrReverse).toHaveBeenCalled(); + expect(result.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); - test('default ensResolutionMethod=quorum leaves the legacy reverse path untouched', async () => { + test('default ensResolutionMethod=quorum queries the configured provider quorum', async () => { // Don't call withColibri — defaults to 'quorum'. mockUrReverse.mockResolvedValue(['legacy.eth']); const result = await resolveEnsReverse(ADDR); expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); - expect(mockUrReverse).toHaveBeenCalled(); + expect(mockUrReverse).toHaveBeenCalledTimes(3); expect(result.name).toBe('legacy.eth'); + expect(result.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); }); test('invalid address rejects before either path is hit', async () => { @@ -2345,3 +3346,164 @@ describe('ens-resolver', () => { }); }); }); + +// PRIVATE MODE GUARD (name logging). log.info/warn/error land in the +// persistent /logs/main.log, which outlives the private window +// and the app — so a name resolved for a private tab (and the target it +// resolved to) must never appear there, while normal browsing keeps the +// full diagnostic line. +describe('ens-resolver private-window logging', () => { + const IPFS_V0 = 'QmW81r84Aihiqqi2Jw6nM1LnpeMfRCenRxtjwHNkXVkZYa'; + const SECRET = 'whistleblower-site.eth'; + const RESOLVER_ADDR = '0x0000000000000000000000000000000000001234'; + + function handlerFor(channel) { + const entry = ipcMain.handle.mock.calls.find(([name]) => name === channel); + if (!entry) throw new Error(`no handler registered for ${channel}`); + return entry[1]; + } + + // Every string the resolver logged, across all levels. + function loggedText() { + return [mockLog.info, mockLog.warn, mockLog.error] + .flatMap((fn) => fn.mock.calls) + .map((call) => call.map((arg) => String(arg?.message || arg)).join(' ')) + .join('\n'); + } + + const privateEvent = { sender: { isPrivate: true } }; + const normalEvent = { sender: { isPrivate: false } }; + + beforeEach(() => { + clearEnsResolutionCaches(); + ipcMain.handle.mockClear(); + registerEnsIpc(); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + }); + + test('a private ENS_RESOLVE logs neither the name nor the resolved target', async () => { + const result = await handlerFor(IPC.ENS_RESOLVE)(privateEvent, { name: SECRET }); + + // The resolution itself is unchanged — only the logging is redacted. + expect(result).toMatchObject({ type: 'ok', name: SECRET, uri: `ipfs://${IPFS_V0}` }); + const text = loggedText(); + expect(text).not.toContain(SECRET); + expect(text).not.toContain(IPFS_V0); + // The line is still emitted, so the log still shows a resolve happened. + expect(text).toContain('[ens] Resolved: '); + }); + + test('a normal ENS_RESOLVE keeps the full diagnostic line', async () => { + await handlerFor(IPC.ENS_RESOLVE)(normalEvent, { name: 'public-site.eth' }); + + const text = loggedText(); + expect(text).toContain('public-site.eth'); + expect(text).toContain(IPFS_V0); + }); + + test('the redaction covers the consensus wave, not just the final line', async () => { + await handlerFor(IPC.ENS_RESOLVE)(privateEvent, { name: SECRET }); + const consensusLines = mockLog.info.mock.calls + .map((call) => call.join(' ')) + .filter((line) => line.includes('[ens] consensus kind=')); + expect(consensusLines.length).toBeGreaterThan(0); + for (const line of consensusLines) { + expect(line).toContain('name='); + expect(line).not.toContain(SECRET); + } + }); + + test('a private address lookup and cache invalidation stay out of the log', async () => { + mockUrResolve.mockResolvedValue( + urReturnsAddress('0x1111111111111111111111111111111111111111') + ); + await handlerFor(IPC.ENS_RESOLVE_ADDRESS)(privateEvent, { name: SECRET }); + expect(loggedText()).not.toContain(SECRET); + + // Populate the content cache from a normal window, then invalidate it + // from the private one: the eviction line must not name it either. + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + await handlerFor(IPC.ENS_RESOLVE)(normalEvent, { name: SECRET }); + mockLog.info.mockClear(); + await handlerFor(IPC.ENS_INVALIDATE_CONTENT)(privateEvent, { name: SECRET }); + const text = loggedText(); + expect(text).toContain('content cache invalidated for '); + expect(text).not.toContain(SECRET); + }); + + test('a resolution that throws logs no name either', async () => { + // An invalid label throws out of ens_normalize, whose message quotes + // the offending name — the catch-all log line must not pass it through. + const result = await handlerFor(IPC.ENS_RESOLVE)(privateEvent, { + name: 'invalid_label.eth', + }); + + expect(result.reason).toBe('RESOLUTION_ERROR'); + expect(mockLog.error).toHaveBeenCalled(); + expect(loggedText()).not.toContain('invalid_label'); + }); + + // The policy loop and the reverse dispatcher log the name/address on every + // method hop, not just at the final line. Those sites are newer than the + // redaction guard, so they get their own assertions — a regression there + // would leak a private lookup into main.log while every other test passed. + test('a private reverse lookup keeps the address out of every policy line', async () => { + const SECRET_ADDR = '0x00000000000000000000000000000000000019a1'; + mockUrReverse.mockResolvedValue([SECRET, RESOLVER_ADDR, RESOLVER_ADDR]); + + const result = await handlerFor(IPC.ENS_RESOLVE_REVERSE)(privateEvent, { + address: SECRET_ADDR, + }); + + expect(result).toMatchObject({ success: true, name: SECRET }); + const text = loggedText(); + expect(text).not.toContain(SECRET_ADDR); + expect(text).not.toContain(SECRET); + // The per-method reverse lines are still emitted, just redacted. + const reverseLines = mockLog.info.mock.calls + .map((call) => call.join(' ')) + .filter((line) => line.includes('[ens] reverse ')); + expect(reverseLines.length).toBeGreaterThan(0); + for (const line of reverseLines) { + expect(line).toContain('address='); + } + }); + + test('a private myotis-served resolve redacts the per-method policy lines', async () => { + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => true); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + try { + const result = await handlerFor(IPC.ENS_RESOLVE)(privateEvent, { name: SECRET }); + + expect(result).toMatchObject({ + type: 'ok', + trust: { level: 'verified', method: 'myotis' }, + }); + const text = loggedText(); + expect(text).not.toContain(SECRET); + expect(text).not.toContain(IPFS_V0); + // The myotis hop is still accounted for in the log, name redacted. + expect(text).toContain('[ens] policy name='); + expect(text).toContain('[ens] method=myotis name='); + } finally { + mockMyotisIsEnabled.mockImplementation(() => false); + mockMyotisIsReady.mockImplementation(() => false); + } + }); + + test('a private resolve does not redact a later normal resolve', async () => { + await handlerFor(IPC.ENS_RESOLVE)(privateEvent, { name: SECRET }); + clearEnsResolutionCaches(); + mockLog.info.mockClear(); + + await handlerFor(IPC.ENS_RESOLVE)(normalEvent, { name: 'public-site.eth' }); + expect(loggedText()).toContain('[ens] Resolved: public-site.eth'); + }); +}); diff --git a/src/main/ens/colibri-resolver.js b/src/main/ens/colibri-resolver.js index 0ab0aad6..080119bf 100644 --- a/src/main/ens/colibri-resolver.js +++ b/src/main/ens/colibri-resolver.js @@ -12,16 +12,15 @@ const { universalResolverCall, universalResolverReverse, hostOf } = require('../ // to the prover); pinning rather than exposing as a toggle keeps the // threat model legible. const PRIVACY_MODE = 'basic'; -const CHAIN_ID = 1; const MAX_LATEST_AGE_SECONDS = 60; -let cachedClient = null; -let cachedClientKey = null; -let cachedProvider = null; -let inFlightBuild = null; +const clients = new Map(); +const inFlightBuilds = new Map(); +const clientReferences = new Map(); +const retiredClients = new Set(); let storageRegistration = null; let storageRegistered = false; -let buildGeneration = 0; +const buildGenerations = new Map(); // Disk-backed storage adapter for Colibri's verifier state (sync committee // pubkeys, current head witness, etc — keys like "states_1" / "sync_1_"). @@ -66,14 +65,141 @@ function destroyClient(client) { } } -async function buildClient({ key, proverUrl, zkProof, generation }) { +function retainClient(client) { + clientReferences.set(client, (clientReferences.get(client) || 0) + 1); +} + +function releaseClient(client) { + const remaining = (clientReferences.get(client) || 1) - 1; + if (remaining > 0) { + clientReferences.set(client, remaining); + return; + } + clientReferences.delete(client); + if (retiredClients.delete(client)) destroyClient(client); +} + +function retireClient(client) { + if (!client) return; + if ((clientReferences.get(client) || 0) > 0) { + retiredClients.add(client); + return; + } + destroyClient(client); +} + +// Obtain a live client + its provider with a reference already held, closing +// the use-after-destroy window: retainClient only ran inside the old +// useClient, i.e. after `await getClient` resolved, and in that microtask gap a +// concurrent request's failure path could release the last ref and destroy the +// very client this caller was about to use. Here we retain optimistically and +// then re-validate that the retained client is still the cached one (and not +// retired); if it was swapped/evicted during the gap, release and re-acquire. +// The caller releases in a finally. +async function acquireClient(chainId) { + const id = Number(chainId); + for (;;) { + const client = await getClient(id); + retainClient(client); + const cached = clients.get(id); + if (cached?.client === client && !retiredClients.has(client)) { + return { client, provider: cached.provider }; + } + // Evicted or rebuilt during the acquire gap — drop our ref and retry. + releaseClient(client); + } +} + +function colibriRevertData(err) { + const data = err?.data || err?.info?.error?.data || ''; + return typeof data === 'string' && data.length >= 10 ? data : null; +} + +function retryableColibriError(err) { + if (!err) return false; + if (err.code === 'CALL_EXCEPTION') return colibriRevertData(err) === null; + if (['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT'].includes(err.code)) return true; + if (err.info?.error?.code === -32603) return true; + return /ECONN|ENOTFOUND|ETIMEDOUT|fetch failed|network|no response|timeout/i + .test(err.shortMessage || err.message || ''); +} + +function sanitizeColibriErrorDetail(value, maxLength = 200) { + if (value == null || value === '') return ''; + const cleaned = String(value) + .replace(/https?:\/\/[^\s"'<>]+/gi, '') + .replace(/0x[0-9a-fA-F]{66,}/g, (hex) => + `${hex.slice(0, 10)}…(${Math.floor((hex.length - 2) / 2)} bytes)` + ) + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length <= maxLength + ? cleaned + : `${cleaned.slice(0, maxLength - 1)}…`; +} + +function colibriErrorForLog(err) { + const nested = err?.info?.error; + const message = sanitizeColibriErrorDetail( + err?.shortMessage || err?.reason || err?.message || String(err) + ); + const fields = [`error=${JSON.stringify(message || 'unknown error')}`]; + if (err?.code != null) fields.push(`code=${sanitizeColibriErrorDetail(err.code, 40)}`); + if (nested?.code != null) fields.push(`rpcCode=${sanitizeColibriErrorDetail(nested.code, 40)}`); + if (nested?.message) { + fields.push(`rpcMessage=${JSON.stringify(sanitizeColibriErrorDetail(nested.message))}`); + } + fields.push(`revert=${colibriRevertData(err)?.slice(0, 10) || 'none'}`); + return fields.join(' '); +} + +// Evict only the client that actually failed. If another concurrent request +// or a settings change has already installed a replacement, leave it intact. +function evictFailedClient(chainId, failedClient) { + const cached = clients.get(chainId); + if (cached?.client !== failedClient) return; + clients.delete(chainId); + retireClient(failedClient); +} + +// Retry exactly once after rebuilding the in-memory verifier. This recovers +// from a stale runtime after sleep or a transient prover/network failure while +// preserving fail-closed behavior: proof failures and reverts carrying actual +// EVM revert data are never retried or reclassified. +async function withColibriClientRetry(chainId, operation) { + const id = Number(chainId); + const first = await acquireClient(id); + try { + return await operation(first); + } catch (err) { + if (!retryableColibriError(err)) throw err; + log.warn( + `[colibri] chain ${id} request failed; rebuilding client and retrying once ` + + colibriErrorForLog(err) + ); + evictFailedClient(id, first.client); + const retry = await acquireClient(id); + try { + return await operation(retry); + } finally { + releaseClient(retry.client); + } + } finally { + // Always releases first's acquire ref — on success, on a rethrown + // non-retryable error, and after the retry path above. Dropping the last + // ref on an evicted/retired client is what finally destroys it. + releaseClient(first.client); + } +} + +async function buildClient({ chainId, key, proverUrl, zkProof, generation }) { // Storage adapter is registered exactly once per process: on the very // first construction. Later settings-change rebuilds reuse it — the // adapter is keyless and the Colibri runtime expects a single global. await ensureStorageRegistered(); const client = new Colibri({ - chainId: CHAIN_ID, + chainId, prover: [proverUrl], zk_proof: zkProof, privacy_mode: PRIVACY_MODE, @@ -81,17 +207,19 @@ async function buildClient({ key, proverUrl, zkProof, generation }) { max_latest_age_seconds: MAX_LATEST_AGE_SECONDS, }); - if (generation !== buildGeneration) { + if (generation !== buildGenerations.get(chainId)) { destroyClient(client); - return getClient(); + return getClient(chainId); } - const previousClient = cachedClient; - cachedClient = client; - cachedClientKey = key; - cachedProvider = new ethers.BrowserProvider(client); - destroyClient(previousClient); - log.info(`[ens-colibri] client ready (prover=${hostOf(proverUrl)}, zk=${zkProof})`); + const previousClient = clients.get(chainId)?.client; + // Co-locate the provider with its client in one entry so acquireClient + // captures the {client, provider} pair atomically — a separate providers + // map can return undefined mid-rebuild or a provider from another + // generation. + clients.set(chainId, { client, key, provider: new ethers.BrowserProvider(client) }); + retireClient(previousClient); + log.info(`[colibri] chain ${chainId} client ready (prover=${hostOf(proverUrl)}, zk=${zkProof})`); return client; } @@ -101,25 +229,32 @@ async function buildClient({ key, proverUrl, zkProof, generation }) { // on first use, not module load. `inFlightBuild` collapses concurrent // first-call lookups onto a single construction. The generation counter // prevents a slower old-settings build from replacing a newer client. -async function getClient() { - const [proverUrl] = registry.getEndpoints(CHAIN_ID, 'prover'); +async function getClient(chainId = 1) { + const id = Number(chainId); + const [proverUrl] = registry.getEndpoints(id, 'prover'); if (!proverUrl) { - throw new Error(`No Colibri prover configured for chain ${CHAIN_ID}`); + throw new Error(`No Colibri prover configured for chain ${id}`); } - const zkProof = registry.getNetwork(CHAIN_ID).zkProof !== false; + const zkProof = registry.getNetwork(id)?.zkProof !== false; const key = `${proverUrl}|${zkProof}`; - if (cachedClient && cachedClientKey === key) { - if (inFlightBuild && inFlightBuild.key !== key) buildGeneration += 1; - return cachedClient; + const cached = clients.get(id); + const inFlight = inFlightBuilds.get(id); + if (cached?.client && cached.key === key) { + if (inFlight && inFlight.key !== key) { + buildGenerations.set(id, (buildGenerations.get(id) || 0) + 1); + } + return cached.client; } - if (inFlightBuild && inFlightBuild.key === key) return inFlightBuild.promise; + if (inFlight && inFlight.key === key) return inFlight.promise; - const generation = buildGeneration + 1; - buildGeneration = generation; - const promise = buildClient({ key, proverUrl, zkProof, generation }); - inFlightBuild = { key, promise, generation }; + const generation = (buildGenerations.get(id) || 0) + 1; + buildGenerations.set(id, generation); + const promise = buildClient({ chainId: id, key, proverUrl, zkProof, generation }); + inFlightBuilds.set(id, { key, promise, generation }); try { return await promise; } - finally { if (inFlightBuild && inFlightBuild.promise === promise) inFlightBuild = null; } + finally { + if (inFlightBuilds.get(id)?.promise === promise) inFlightBuilds.delete(id); + } } // Drop-in for what a single `consensusResolve` leg does today, but the @@ -128,8 +263,9 @@ async function getClient() { // pins to head − 1 by construction (sync committee signatures for block N // live in block N+1). async function resolveCallViaColibri(name, callData, callResolver = universalResolverCall) { - await getClient(); - return callResolver(cachedProvider, name, callData); + return withColibriClientRetry(1, ({ provider }) => + callResolver(provider, name, callData) + ); } async function resolveViaColibri(name, callData) { @@ -141,24 +277,39 @@ async function resolveViaColibri(name, callData) { // Throws on revert (UR's ResolverNotFound / ReverseAddressMismatch) or // network/verification failure — the orchestrator classifies. async function resolveReverseViaColibri(addressBytes) { - await getClient(); - return universalResolverReverse(cachedProvider, addressBytes); + return withColibriClientRetry(1, ({ provider }) => + universalResolverReverse(provider, addressBytes) + ); +} + +async function requestViaColibri(chainId, method, params = []) { + return withColibriClientRetry(chainId, ({ client }) => + client.request({ method, params }) + ); } function clearColibriClientForTest() { - destroyClient(cachedClient); - cachedClient = null; - cachedClientKey = null; - cachedProvider = null; - inFlightBuild = null; + for (const { client } of clients.values()) retireClient(client); + for (const chainId of new Set([ + ...clients.keys(), + ...inFlightBuilds.keys(), + ...buildGenerations.keys(), + ])) { + buildGenerations.set(chainId, (buildGenerations.get(chainId) || 0) + 1); + } + clients.clear(); + inFlightBuilds.clear(); + for (const client of retiredClients) destroyClient(client); + clientReferences.clear(); + retiredClients.clear(); storageRegistration = null; storageRegistered = false; - buildGeneration += 1; } module.exports = { resolveCallViaColibri, resolveViaColibri, resolveReverseViaColibri, + requestViaColibri, clearColibriClientForTest, }; diff --git a/src/main/ens/colibri-resolver.test.js b/src/main/ens/colibri-resolver.test.js index e5a09347..1196f61d 100644 --- a/src/main/ens/colibri-resolver.test.js +++ b/src/main/ens/colibri-resolver.test.js @@ -3,6 +3,13 @@ jest.mock('electron', () => ({ app: { getPath: (...args) => mockGetPath(...args) }, })); +const mockLogInfo = jest.fn(); +const mockLogWarn = jest.fn(); +jest.mock('../logger', () => ({ + info: (...args) => mockLogInfo(...args), + warn: (...args) => mockLogWarn(...args), +})); + const mockMkdirSync = jest.fn(); const mockReadFileSync = jest.fn(); const mockWriteFileSync = jest.fn(); @@ -25,6 +32,7 @@ jest.mock('@corpus-core/colibri-stateless', () => { mockColibriCtor(config); this.config = config; this.destroy = jest.fn(); + this.request = jest.fn().mockResolvedValue('0x2a'); mockClientInstances.push(this); } static register_storage(storage) { return mockRegisterStorage(storage); } @@ -68,6 +76,7 @@ jest.mock('../ens-resolver', () => ({ const { resolveViaColibri, resolveReverseViaColibri, + requestViaColibri, clearColibriClientForTest, } = require('./colibri-resolver'); @@ -226,6 +235,75 @@ describe('resolveViaColibri', () => { const err = new Error('proof verification failed'); mockUniversalResolverCall.mockRejectedValue(err); await expect(resolveViaColibri('a.eth', '0x')).rejects.toBe(err); + expect(mockColibriCtor).toHaveBeenCalledTimes(1); + }); + + test('rebuilds once and retries a CALL_EXCEPTION without revert data', async () => { + const err = Object.assign(new Error('full ethers message with 0x' + 'ab'.repeat(200)), { + code: 'CALL_EXCEPTION', + shortMessage: 'missing revert data', + info: { error: { code: -32603, message: 'prover returned no response' } }, + }); + const recovered = { resolvedData: '0xfeed', resolverAddress: '0x1234' }; + mockUniversalResolverCall + .mockRejectedValueOnce(err) + .mockResolvedValueOnce(recovered); + + await expect(resolveViaColibri('retry.eth', '0x')).resolves.toEqual(recovered); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(2); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + expect(mockClientInstances[0].destroy).toHaveBeenCalledTimes(1); + expect(mockLogWarn).toHaveBeenCalledWith( + '[colibri] chain 1 request failed; rebuilding client and retrying once ' + + 'error="missing revert data" code=CALL_EXCEPTION rpcCode=-32603 ' + + 'rpcMessage="prover returned no response" revert=none' + ); + }); + + test('bounds a retryable failure to one rebuild', async () => { + const err = Object.assign(new Error('request timed out'), { code: 'TIMEOUT' }); + mockUniversalResolverCall.mockRejectedValue(err); + + await expect(resolveViaColibri('still-down.eth', '0x')).rejects.toBe(err); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(2); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + }); + + test('does not destroy a failed shared client while a sibling request still uses it', async () => { + let rejectFirst; + let resolveSibling; + mockUniversalResolverCall + .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject; })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSibling = resolve; })) + .mockResolvedValueOnce({ resolvedData: '0xrecovered' }); + + const first = resolveViaColibri('first.eth', '0x'); + const sibling = resolveViaColibri('sibling.eth', '0x'); + while (mockUniversalResolverCall.mock.calls.length < 2) await Promise.resolve(); + const sharedClient = mockClientInstances[0]; + + rejectFirst(Object.assign(new Error('network unavailable'), { code: 'NETWORK_ERROR' })); + await expect(first).resolves.toEqual({ resolvedData: '0xrecovered' }); + expect(sharedClient.destroy).not.toHaveBeenCalled(); + + resolveSibling({ resolvedData: '0xsibling' }); + await expect(sibling).resolves.toEqual({ resolvedData: '0xsibling' }); + expect(sharedClient.destroy).toHaveBeenCalledTimes(1); + }); + + test('does not retry an EVM revert carrying verified revert data', async () => { + const err = Object.assign(new Error('execution reverted'), { + code: 'CALL_EXCEPTION', + data: '0xdeadbeef', + }); + mockUniversalResolverCall.mockRejectedValue(err); + + await expect(resolveViaColibri('reverted.eth', '0x')).rejects.toBe(err); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(1); + expect(mockColibriCtor).toHaveBeenCalledTimes(1); }); }); @@ -256,6 +334,39 @@ describe('resolveReverseViaColibri', () => { }); }); +describe('requestViaColibri', () => { + test('creates and reuses an independent Gnosis client', async () => { + mockLoadSettings.mockReturnValue({ ...DEFAULTS }); + await expect( + requestViaColibri(100, 'eth_getBalance', ['0xabc', 'latest']) + ).resolves.toBe('0x2a'); + const gnosisClient = mockClientInstances[0]; + expect(mockColibriCtor).toHaveBeenCalledWith(expect.objectContaining({ chainId: 100 })); + expect(gnosisClient.request).toHaveBeenCalledWith({ + method: 'eth_getBalance', + params: ['0xabc', 'latest'], + }); + + await requestViaColibri(1, 'eth_blockNumber'); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + }); + + test('rebuilds the affected chain client once after a network failure', async () => { + await requestViaColibri(100, 'eth_blockNumber'); + const firstClient = mockClientInstances[0]; + firstClient.request + .mockRejectedValueOnce(Object.assign(new Error('network unavailable'), { + code: 'NETWORK_ERROR', + })); + + await expect(requestViaColibri(100, 'eth_blockNumber')).resolves.toBe('0x2a'); + + expect(firstClient.destroy).toHaveBeenCalledTimes(1); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + expect(mockClientInstances[1].request).toHaveBeenCalledTimes(1); + }); +}); + describe('disk storage adapter', () => { // Captured from the register_storage call after triggering construction. // No public export — the integration assertion (passed to register_storage) diff --git a/src/main/favicons.js b/src/main/favicons.js index a8365931..10b24c0a 100644 --- a/src/main/favicons.js +++ b/src/main/favicons.js @@ -9,6 +9,7 @@ const log = require('./logger'); const { ipcMain, net } = require('electron'); const { getDb } = require('./history'); const IPC = require('../shared/ipc-channels'); +const { isPrivateWebContents } = require('./private/private-windows'); // Prepared statements (lazily initialized) let statements = null; @@ -344,8 +345,20 @@ async function getFavicon(url) { * Register IPC handlers */ function registerFaviconsIpc() { + // PRIVATE MODE GUARD (favicons): private windows never write to the + // favicon cache. Reads of already-cached icons are fine (they reveal + // nothing about private browsing); fetch-and-cache is what would leave + // a trace, so fetching from a private sender degrades to a cache read. + // The private window's renderer already skips the fetch calls + // (src/renderer/lib/navigation.js); this is the main-process + // belt-and-braces. + const isPrivateSender = (event) => isPrivateWebContents(event?.sender); + // Get favicon (returns cached or fetches) - ipcMain.handle(IPC.FAVICON_GET, async (_event, url) => { + ipcMain.handle(IPC.FAVICON_GET, async (event, url) => { + if (isPrivateSender(event)) { + return getCachedFavicon(url); + } return await getFavicon(url); }); @@ -355,12 +368,20 @@ function registerFaviconsIpc() { }); // Fetch and cache favicon (called after page load) - ipcMain.handle(IPC.FAVICON_FETCH, async (_event, url) => { + ipcMain.handle(IPC.FAVICON_FETCH, async (event, url) => { + if (isPrivateSender(event)) { + log.info('[Favicons] Ignoring favicon:fetch from private window'); + return getCachedFavicon(url); + } return await fetchFavicon(url); }); // Fetch favicon with custom cache key (for bzz://, ipfs:// URLs) - ipcMain.handle(IPC.FAVICON_FETCH_WITH_KEY, async (_event, fetchUrl, cacheKey) => { + ipcMain.handle(IPC.FAVICON_FETCH_WITH_KEY, async (event, fetchUrl, cacheKey) => { + if (isPrivateSender(event)) { + log.info('[Favicons] Ignoring favicon:fetch-with-key from private window'); + return getCachedFavicon(cacheKey || fetchUrl); + } return await fetchFavicon(fetchUrl, cacheKey); }); diff --git a/src/main/favicons.test.js b/src/main/favicons.test.js new file mode 100644 index 00000000..372a0088 --- /dev/null +++ b/src/main/favicons.test.js @@ -0,0 +1,159 @@ +const IPC = require('../shared/ipc-channels'); +const { createIpcMainMock, loadMainModule } = require('../../test/helpers/main-process-test-utils'); + +// Minimal favicons-table fake: enough of the better-sqlite3 surface for +// getStatements() (migration is skipped by reporting user_version = 2). +function makeFakeFaviconsDb() { + const rows = new Map(); + return { + rows, + pragma: (statement, options = {}) => { + if (statement === 'user_version' && options.simple) return 2; + return null; + }, + exec: () => {}, + prepare: (sql) => { + if (/^\s*SELECT/i.test(sql)) { + return { get: (domain) => rows.get(domain) }; + } + if (/^\s*INSERT/i.test(sql)) { + return { + run: (domain, iconData, contentType, fetchedAt) => { + rows.set(domain, { + domain, + icon_data: iconData, + content_type: contentType, + fetched_at: fetchedAt, + }); + return { changes: 1 }; + }, + }; + } + return { + run: (domain) => { + rows.delete(domain); + return { changes: 1 }; + }, + }; + }, + }; +} + +// net.request stub whose requests immediately error out — asserting on +// whether a network fetch was *attempted* is all these tests need. +function makeNetMock() { + return { + request: jest.fn(() => { + const handlers = {}; + return { + on: (event, cb) => { + handlers[event] = cb; + }, + abort: jest.fn(), + end: () => { + setImmediate(() => handlers.error?.(new Error('offline (test stub)'))); + }, + }; + }), + }; +} + +function loadFavicons() { + const ipcMain = createIpcMainMock(); + const net = makeNetMock(); + const fakeDb = makeFakeFaviconsDb(); + + const ctx = loadMainModule(require.resolve('./favicons'), { + ipcMain, + electronOverrides: { net }, + extraMocks: { + [require.resolve('./history')]: () => ({ getDb: () => fakeDb }), + [require.resolve('./private/private-windows')]: () => ({ + isPrivateWebContents: (wc) => wc?.isPrivate === true, + }), + }, + }); + ctx.mod.registerFaviconsIpc(); + return { mod: ctx.mod, ipcMain, net, fakeDb }; +} + +const PRIVATE_EVENT = { sender: { isPrivate: true } }; +const NORMAL_EVENT = { sender: { isPrivate: false } }; + +describe('favicons private-window guard', () => { + test('favicon:fetch from a private sender never fetches or caches', async () => { + const { ipcMain, net, fakeDb } = loadFavicons(); + const handler = ipcMain.handlers.get(IPC.FAVICON_FETCH); + + const result = await handler(PRIVATE_EVENT, 'https://secret.example/page'); + + expect(result).toBeNull(); + expect(net.request).not.toHaveBeenCalled(); + expect(fakeDb.rows.size).toBe(0); + }); + + test('favicon:fetch from a private sender may return an already-cached icon', async () => { + const { ipcMain, net, fakeDb } = loadFavicons(); + fakeDb.rows.set('secret.example', { + domain: 'secret.example', + icon_data: 'data:image/png;base64,AAAA', + }); + + const handler = ipcMain.handlers.get(IPC.FAVICON_FETCH); + const result = await handler(PRIVATE_EVENT, 'https://secret.example/page'); + + expect(result).toBe('data:image/png;base64,AAAA'); + expect(net.request).not.toHaveBeenCalled(); + }); + + test('favicon:fetch-with-key from a private sender never fetches or caches', async () => { + const { ipcMain, net, fakeDb } = loadFavicons(); + const handler = ipcMain.handlers.get(IPC.FAVICON_FETCH_WITH_KEY); + + const result = await handler( + PRIVATE_EVENT, + 'http://127.0.0.1:1633/bzz/abc/', + 'bzz://secret.eth' + ); + + expect(result).toBeNull(); + expect(net.request).not.toHaveBeenCalled(); + expect(fakeDb.rows.size).toBe(0); + }); + + test('favicon:get from a private sender degrades to a cache read', async () => { + const { ipcMain, net } = loadFavicons(); + const handler = ipcMain.handlers.get(IPC.FAVICON_GET); + + const result = await handler(PRIVATE_EVENT, 'https://secret.example/page'); + + expect(result).toBeNull(); + expect(net.request).not.toHaveBeenCalled(); + }); + + test('favicon:fetch from a normal sender still attempts the network fetch', async () => { + const { ipcMain, net } = loadFavicons(); + const handler = ipcMain.handlers.get(IPC.FAVICON_FETCH); + + // The stubbed network errors out, so the fetch resolves null — the + // point is that the fetch was attempted at all. + const result = await handler(NORMAL_EVENT, 'https://public.example/page'); + + expect(net.request).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('favicon:get-cached stays available to private senders', async () => { + const { ipcMain, net, fakeDb } = loadFavicons(); + fakeDb.rows.set('public.example', { + domain: 'public.example', + icon_data: 'data:image/png;base64,BBBB', + }); + + const handler = ipcMain.handlers.get(IPC.FAVICON_GET_CACHED); + const result = handler(PRIVATE_EVENT, 'https://public.example/'); + + expect(result).toBe('data:image/png;base64,BBBB'); + expect(net.request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/history.js b/src/main/history.js index 3a7e0a6c..1afc0673 100644 --- a/src/main/history.js +++ b/src/main/history.js @@ -3,6 +3,7 @@ const { app, ipcMain } = require('electron'); const path = require('path'); const Database = require('better-sqlite3'); const IPC = require('../shared/ipc-channels'); +const { isPrivateWebContents } = require('./private/private-windows'); // Database instance (singleton) let db = null; @@ -225,7 +226,15 @@ function registerHistoryIpc() { }); // Add history entry - ipcMain.handle(IPC.HISTORY_ADD, (_event, entry) => { + ipcMain.handle(IPC.HISTORY_ADD, (event, entry) => { + // PRIVATE MODE GUARD (history): navigations in private windows are + // never recorded. The private window's renderer already skips the + // call (src/renderer/lib/navigation.js); this is the main-process + // belt-and-braces so no code path can write private history. + if (isPrivateWebContents(event?.sender)) { + log.info('[History] Ignoring history:add from private window'); + return null; + } if (!entry?.url) { log.warn('[History] Attempted to add entry without URL'); return null; diff --git a/src/main/history.test.js b/src/main/history.test.js index a8c196cf..27874157 100644 --- a/src/main/history.test.js +++ b/src/main/history.test.js @@ -140,3 +140,68 @@ describe('history', () => { await expect(ipcMain.invoke(IPC.HISTORY_CLEAR)).resolves.toBe(0); }); }); + +// PRIVATE MODE GUARD coverage: history:add from a private window's +// webContents is rejected in the main process, regardless of what the +// renderer sends. +describe('history private-window guard', () => { + let userDataDir; + let historyModule; + + beforeEach(() => { + userDataDir = createTempUserDataDir(); + historyModule = null; + }); + + afterEach(() => { + if (historyModule?.closeDb) { + historyModule.closeDb(); + } + removeTempUserDataDir(userDataDir); + }); + + const loadWithPrivateMock = () => { + const ipcMain = createIpcMainMock(); + // loadHistoryModule pins its own extraMocks, so go through + // loadMainModule directly to also stub the private-window registry. + const ctx = loadMainModule(require.resolve('./history'), { + userDataDir, + ipcMain, + extraMocks: { + 'better-sqlite3': () => FakeBetterSqlite3Database, + [require.resolve('./private/private-windows')]: () => ({ + isPrivateWebContents: (wc) => wc?.isPrivate === true, + }), + }, + }); + historyModule = ctx.mod; + ctx.mod.registerHistoryIpc(); + return { ctx, ipcMain }; + }; + + test('history:add from a private sender writes nothing', () => { + const { ipcMain } = loadWithPrivateMock(); + const handler = ipcMain.handlers.get(IPC.HISTORY_ADD); + + const result = handler( + { sender: { isPrivate: true } }, + { url: 'https://secret.example', title: 'Secret', protocol: 'https' } + ); + + expect(result).toBeNull(); + expect(historyModule.getHistoryCount()).toBe(0); + }); + + test('history:add from a normal sender still records', () => { + const { ipcMain } = loadWithPrivateMock(); + const handler = ipcMain.handlers.get(IPC.HISTORY_ADD); + + const result = handler( + { sender: { isPrivate: false } }, + { url: 'https://public.example', title: 'Public', protocol: 'https' } + ); + + expect(result).toEqual(expect.objectContaining({ url: 'https://public.example' })); + expect(historyModule.getHistoryCount()).toBe(1); + }); +}); diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index 5b56dd4f..fbf76b16 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -286,7 +286,7 @@ async function getUserWalletKey(walletIndex) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } if (record.type !== WALLET_TYPES.MNEMONIC) { - throw new Error('Hardware wallet accounts have no derivable private key'); + throw new Error('This account has no derivable private key — the key never leaves its device'); } const identity = await loadIdentityModule(); @@ -793,6 +793,13 @@ async function exportMnemonic() { const WALLET_TYPES = { MNEMONIC: 'mnemonic', LEDGER: 'ledger', + REMOTE: 'remote', // phone / other device signing over openlv +}; + +/** User-facing labels for device account types (auto-names, error text). */ +const DEVICE_LABELS = { + [WALLET_TYPES.LEDGER]: 'Ledger', + [WALLET_TYPES.REMOTE]: 'Phone', }; /** @@ -931,9 +938,9 @@ async function getDerivedWallets() { const type = wallet.type || WALLET_TYPES.MNEMONIC; let address = null; - if (type === WALLET_TYPES.LEDGER) { - // Hardware accounts: the address was read from the device when the - // account was added; there is nothing to derive locally. + if (type !== WALLET_TYPES.MNEMONIC) { + // Device accounts (Ledger, phone): the address was read from the + // device when the account was added; nothing to derive locally. address = wallet.address || null; } else if (mnemonic) { // Derive address from mnemonic @@ -961,25 +968,23 @@ async function getDerivedWallets() { } /** - * Add a Ledger hardware-wallet account to the wallet list. + * Add a device account (Ledger, phone) to the wallet list. * - * The address comes from the device during account discovery and is + * The address comes from the device when the account is added and is * persisted — it can never be re-derived locally. Does not require the * vault to be unlocked (no mnemonic involved), only that a vault exists * so there is a wallet list to add to. * - * @param {string} name - Display name ('' → auto "Ledger N") - * @param {string} address - Checksummed address read from the device - * @param {string} path - Derivation path in device format (e.g. "44'/60'/0'/0/0") - * @returns {Promise<{index: number, name: string, address: string, type: string, path: string}>} + * @param {string} type - WALLET_TYPES.LEDGER or WALLET_TYPES.REMOTE + * @param {string} name - Display name ('' → auto "