Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
This commit is contained in:
parent
2fc428c220
commit
755e97b5bb
11 changed files with 906 additions and 14 deletions
450
DESIGN-integrated-wallet.md
Normal file
450
DESIGN-integrated-wallet.md
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
# Theseus integrated BCH wallet — design
|
||||
|
||||
Companion to [`ROADMAP-identity-wallet.md`](ROADMAP-identity-wallet.md) Strand B
|
||||
and [`DESIGN-password-manager.md`](DESIGN-password-manager.md) (same crypto
|
||||
discipline, distinct purpose subtree). Same shape as the password design doc:
|
||||
threat model → permission model → API → UI → sequencing → open questions.
|
||||
|
||||
The reference point is deliberate: **MetaMask in Brave.** A wallet the user
|
||||
already trusts, injected as `window.bcnr` into every page, permissioned per
|
||||
origin, and additionally exposed to *outside* dApps via WizardConnect so a
|
||||
mobile-only site can pair Theseus like a hardware wallet.
|
||||
|
||||
Everything below is chipnet only until explicitly directed otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope
|
||||
|
||||
**In:**
|
||||
- `window.bcnr` provider injected on every page (read-only immediately;
|
||||
permissioned methods gated per origin).
|
||||
- The user's wallet lives in Theseus. No external key material.
|
||||
- Modal-confirmed operations: `signMessage`, `sendPayment`, `registerName`,
|
||||
`updateName`, `transferName`.
|
||||
- WizardConnect **responder**: dApps outside Theseus (or inside it) can pair
|
||||
via `wiz://` URI + QR to sign transactions with the Theseus wallet.
|
||||
- Settings → Wallet section: connected sites, per-site/per-method revoke,
|
||||
transaction history.
|
||||
- Reuse of the already-shipped `BuiltInWallet`
|
||||
([Argus/src/lib/wallet-web.js:BuiltInWallet](../Argus/src/lib/wallet-web.js))
|
||||
as the signing core.
|
||||
- Reuse of the already-shipped registration/update flow
|
||||
([Argus/src/lib/register-tx.js](../Argus/src/lib/register-tx.js) +
|
||||
[Argus/src/lib/registrar.js](../Argus/src/lib/registrar.js)).
|
||||
|
||||
**Out (deliberate, deferred, or handled elsewhere):**
|
||||
- Ethereum / Solana / other-chain. BCH only in this strand.
|
||||
- Hardware-wallet abstraction (Ledger/Trezor). Phase 4 conversation.
|
||||
- WalletConnect v2. Explicitly dropped 2026-07-27 in favour of WizardConnect
|
||||
(see [`Decentralized.DNS/REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md)).
|
||||
We do not want a Reown project ID as a dependency.
|
||||
- CashConnect V0. Cannot mint name certificates by design
|
||||
([`REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md) §
|
||||
*CashConnect cannot mint*).
|
||||
- Nostr messenger (NIP-07 client for arbitrary Nostr apps). Separate purpose
|
||||
subtree, Strand B.5 in the roadmap, not this design.
|
||||
- Multi-account UX. One account per Theseus profile in v1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Threat model
|
||||
|
||||
The provider becomes malware infrastructure the moment its permission model
|
||||
fails. Three attack surfaces to lock down:
|
||||
|
||||
### 1.1 Malicious page reading the provider surface
|
||||
|
||||
Every loaded page sees `window.bcnr`. It must not be possible to enumerate
|
||||
addresses, balances, or names without an explicit user grant.
|
||||
|
||||
- **Read-only methods** (`resolveName`, `getBcnrTlds`, `isRegistered`) are
|
||||
chain queries — they leak nothing about the user. No permission.
|
||||
- **All identifying methods** (`getAccounts`, `signMessage`, `sendPayment`,
|
||||
`registerName`, `updateName`, `transferName`) require an origin permission
|
||||
that started with a modal.
|
||||
- **`getAccounts` returns `[]` for un-granted origins**, not a rejection.
|
||||
Rejection is a signal; empty is opaque.
|
||||
|
||||
### 1.2 Compromised page after permission granted
|
||||
|
||||
An origin can be captured (XSS, subdomain takeover, malicious ad on a trusted
|
||||
host) after the user granted a permission. Assume this.
|
||||
|
||||
- **Per-method scope**, not just per-origin. Granting "sign message" does not
|
||||
grant "send payment". A page has to earn each modal separately.
|
||||
- **`sendPayment` has a per-approval amount cap.** Approving `example.bch` for
|
||||
payments does not approve unlimited spending — the modal shows an amount, and
|
||||
a bigger amount re-prompts. "Approve unlimited" is not a UI option.
|
||||
- **Every mint / update / transfer is modal-confirmed every time.** On-chain
|
||||
writes are irrevocable; there is no "trust for the session" mode.
|
||||
|
||||
### 1.3 Origin confusion (phishing, homoglyph, subdomain)
|
||||
|
||||
- Origin is bound to the **eTLD+1 via the public suffix list snapshot** shipped
|
||||
with Theseus (same rule as password autofill, A.2 in the roadmap). No runtime
|
||||
fetch, no third-party dependency.
|
||||
- **BCNR names get a distinct origin tier** in the confirmation modal:
|
||||
`alice.bch` is a first-class origin (backed by an on-chain certificate),
|
||||
`alice.com` is a second-class origin (registrar-leased). The modal shows the
|
||||
distinction so the user knows what they're trusting.
|
||||
- **The recipient's cash address is shown verbatim in the confirmation.** If
|
||||
the address changes between two confirmations from the same origin during one
|
||||
session, a red warning banner appears — "the address this site is asking you
|
||||
to sign for has changed since your last approval."
|
||||
|
||||
### 1.4 What we are NOT defending against
|
||||
|
||||
- A user manually copying their recovery phrase into a phishing site. No wallet
|
||||
can defend against this and pretending otherwise misleads users.
|
||||
- A user who unlocks the wallet on a shared machine and walks away. Auto-lock
|
||||
helps but is not a substitute.
|
||||
- Compromise of Theseus itself (RCE, malicious extension, keylogger). The
|
||||
password vault and the wallet share the same threat model here — game over.
|
||||
|
||||
---
|
||||
|
||||
## 2. Permission model
|
||||
|
||||
### 2.1 Per-origin, per-method, per-limit
|
||||
|
||||
Stored in `<userData>/wallet-permissions.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"silentmode.bch": {
|
||||
"grantedAt": 1728000000000,
|
||||
"methods": {
|
||||
"signMessage": { "grants": 12 },
|
||||
"sendPayment": { "capSats": 100000, "used": 45000, "grants": 3 },
|
||||
"registerName": { "grants": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `capSats` is the **per-approval** cap the site was granted, not a cumulative
|
||||
budget. A payment above it re-prompts with a new modal.
|
||||
- `used` and `grants` are for the Settings audit trail. The user sees "this
|
||||
site has spent X and signed Y messages" — accountability.
|
||||
- **No wildcard origins.** Every subdomain is a separate grant.
|
||||
|
||||
### 2.2 First-use flow
|
||||
|
||||
1. Page calls `bcnr.requestAccount()` (or any permissioned method).
|
||||
2. Theseus renders a **connect modal** as a `WebContentsView` overlay (same
|
||||
pattern as popover, engine-picker, downloads).
|
||||
3. Modal shows:
|
||||
- Origin, with the BCNR-vs-ICANN badge.
|
||||
- What is being asked (specific method, specific parameters).
|
||||
- "Connect to this site with account `bchtest:qxxx…yyy`" (the wallet's
|
||||
receive address 0, truncated with copy-full option).
|
||||
- **Two buttons**: `Approve once` (single-shot, no permission stored) and
|
||||
`Approve for this site` (grant persisted).
|
||||
4. On approve, the permission is written and the promise resolves.
|
||||
5. On reject, the promise resolves with the same "empty result" pattern as an
|
||||
un-granted origin (no distinguishing signal to the page).
|
||||
|
||||
### 2.3 Revocation
|
||||
|
||||
Settings > Wallet > Connected sites. Table:
|
||||
`origin | granted | last used | scopes | [revoke]`. Per-scope revoke checkboxes,
|
||||
plus a nuclear "revoke all". Revocation clears `wallet-permissions.json` and
|
||||
takes effect on the origin's next call (no restart needed).
|
||||
|
||||
### 2.4 What the provider CAN'T do
|
||||
|
||||
- Read the vault, seed, or any other purpose subtree.
|
||||
- Sign anything without a modal.
|
||||
- Enumerate connected sites (that's a settings-scope API, not page-scope).
|
||||
- Watch the wallet balance across origins (each origin sees only its own
|
||||
granted accounts — usually one).
|
||||
|
||||
---
|
||||
|
||||
## 3. `window.bcnr` provider — the API
|
||||
|
||||
Injected by a preload extension registered via
|
||||
`session.defaultSession.setPreloads(...)` so it is present on every page. The
|
||||
preload uses `contextBridge.exposeInMainWorld("bcnr", …)` and forwards to main
|
||||
via IPC on a dedicated `bcnr:*` channel.
|
||||
|
||||
### 3.1 Read-only (no permission)
|
||||
|
||||
```ts
|
||||
bcnr.resolveName(name: string): Promise<Entry | null>
|
||||
bcnr.isRegistered(name: string): Promise<boolean>
|
||||
bcnr.getBcnrTlds(): Promise<string[]> // ['bch','p2p','nav',...]
|
||||
bcnr.getRecordVersion(name: string): Promise<number> // for cache invalidation
|
||||
```
|
||||
|
||||
### 3.2 Identity (permissioned; first call → modal)
|
||||
|
||||
```ts
|
||||
bcnr.requestAccount(): Promise<string | null> // returns cashaddr or null
|
||||
bcnr.getAccounts(): Promise<string[]> // [] if not granted
|
||||
bcnr.signMessage({ message: string, address?: string, userPrompt?: string }):
|
||||
Promise<{ signature: string, address: string }> // BIP-137
|
||||
```
|
||||
|
||||
### 3.3 Payments (permissioned; per-amount modal)
|
||||
|
||||
```ts
|
||||
bcnr.sendPayment({
|
||||
to: string, // cashaddr OR BCNR name resolvable to an `a` record
|
||||
amountSats: bigint,
|
||||
memo?: string,
|
||||
userPrompt?: string,
|
||||
}): Promise<{ txid: string }>
|
||||
```
|
||||
|
||||
### 3.4 Name operations (each modal-confirmed)
|
||||
|
||||
```ts
|
||||
bcnr.registerName({ name: string, records?: Record<string,string>, userPrompt?: string }):
|
||||
Promise<{ txid: string, category: string }>
|
||||
bcnr.updateName({ name: string, records: Record<string,string>, userPrompt?: string }):
|
||||
Promise<{ txid: string }>
|
||||
bcnr.transferName({ name: string, toAddress: string, userPrompt?: string }):
|
||||
Promise<{ txid: string }>
|
||||
```
|
||||
|
||||
### 3.5 Events
|
||||
|
||||
```ts
|
||||
bcnr.on('accountsChanged', (accounts: string[]) => void)
|
||||
bcnr.on('permissionsRevoked', (methods: string[]) => void)
|
||||
bcnr.on('lock', () => void) // wallet auto-locked
|
||||
```
|
||||
|
||||
### 3.6 Match with WizardConnect / WC2 shape where cheap
|
||||
|
||||
`signMessage` returns `{signature, address}` — same shape as
|
||||
`bch_signMessage` in the wc2-bch-bcr namespace (see
|
||||
[Parameters/WalletConnect/WalletConnect_integration_reference.md](../../Deviant/Parameters/WalletConnect/WalletConnect_integration_reference.md)
|
||||
§4.2). Pages that already speak WC2 can adapt with a two-line shim.
|
||||
|
||||
We do NOT re-expose the WC2 wire format (`stringify` with `<Uint8Array: 0x..>`
|
||||
markers) at the provider layer — that is a WC2 transport concern, not a
|
||||
provider API concern. The provider takes plain values; the WizardConnect
|
||||
responder (below) is where the wire format matters.
|
||||
|
||||
---
|
||||
|
||||
## 4. WizardConnect responder
|
||||
|
||||
The MetaMask analogue is "the wallet is here in the browser." The extra thing
|
||||
we get from WizardConnect is "a dApp on another device — a mobile site, a
|
||||
desktop app — can pair with the Theseus wallet by scanning a `wiz://` QR." This
|
||||
is exactly the responder side of what Deviant is finishing
|
||||
([`Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md`](../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md)),
|
||||
adapted to Theseus.
|
||||
|
||||
### 4.1 Wallet-side pieces
|
||||
|
||||
Same shape as Deviant:
|
||||
|
||||
- `@wizardconnect/wallet@0.2.2` for the SDK; already tested against the
|
||||
0.2.4-core wire protocol. `nostr-tools` and `ws` are already in Theseus's
|
||||
deps (verified 2026-08-13).
|
||||
- A **`WalletAdapter`** wrapping Theseus's `BuiltInWallet`. Theseus's wallet
|
||||
is sync-in-main-process behind the unlock modal, which is simpler than
|
||||
Deviant's async biometric-KeyStore case:
|
||||
- `walletName: "Theseus"`, `walletIcon: <shipped png>`
|
||||
- `getRelayPrivateKey(uri): Uint8Array` = `sha256(sha256(utf8(uri)))`
|
||||
(identical to Deviant's implementation — deterministic per URI so a
|
||||
reconnect keeps the same Nostr identity, different dApps cannot
|
||||
correlate; matches `@wizardconnect/wallet@0.2.2` types)
|
||||
- `getXpub(path): string` — reads pre-cached xpubs from `BuiltInWallet`
|
||||
- `getPublicKey(path, index)` — throws; the shipped SDK never calls it
|
||||
- `signTransaction(request): Promise<{ signedTransaction: string }>` —
|
||||
unwraps the `WcSignTransactionRequest` (the bug Deviant hit and fixed —
|
||||
see the integration guide §4), renders the same signing modal the
|
||||
`window.bcnr` provider uses, and signs via `coSignTransaction`-equivalent
|
||||
logic in `wallet-web.js`
|
||||
- Signing uses **SIGHASH_ALL | FORKID | UTXOS (0x61)**. Normative in the
|
||||
WizardConnect spec; Theseus's signer must accept this flag alongside 0x41.
|
||||
See Deviant `signer.ts:51` `WIZARDCONNECT_SIGHASH` for the reference.
|
||||
|
||||
### 4.2 Pairing UX
|
||||
|
||||
- Address-bar chip when a `wiz://` URI is present on a page (paste-detect).
|
||||
- Settings > Wallet > "Pair with a dApp" — QR scanner (uses the camera if
|
||||
available; otherwise paste), takes a `wiz://` URI, opens the wallet-side
|
||||
connection.
|
||||
- **Sign-request modal is the SAME modal** as the in-Theseus `bcnr` provider.
|
||||
A dApp asking for a signature via `window.bcnr` from a loaded page and a
|
||||
dApp asking via WizardConnect from a paired external site both hit the same
|
||||
confirmation surface. This is the property that makes the responder feel
|
||||
"already part of the browser" rather than a second wallet UX.
|
||||
|
||||
### 4.3 Session persistence
|
||||
|
||||
- Reuse `@wizardconnect/dapp`'s `session` mechanism on the wallet side: the
|
||||
Nostr identity per URI is deterministic, so a reconnect after a Theseus
|
||||
restart is transparent.
|
||||
- Persist active pairings in `<userData>/wallet-wz-sessions.json`.
|
||||
- Settings > Wallet > Paired dApps: list, revoke, "disconnect all".
|
||||
|
||||
### 4.4 The distinction to keep visible in code
|
||||
|
||||
**In-Theseus dApp using `window.bcnr` = local IPC.** No relay, no Nostr, no
|
||||
transport. Fast and private.
|
||||
|
||||
**External dApp using WizardConnect = Nostr relay.** Encrypted end-to-end (NIP-17
|
||||
gift-wrap), but the relay sees timing and message sizes. Users should know they
|
||||
picked the second one when they scanned a QR.
|
||||
|
||||
The wallet doesn't need to distinguish for *signing purposes* — same modal,
|
||||
same signer, same permission model — but the address-bar chip and the settings
|
||||
page should visibly separate `Connected sites` from `Paired dApps`.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI surfaces
|
||||
|
||||
New WebContentsView overlays, each needing a preload extension registered and
|
||||
listed in `TheseusNavigator/package.json` `build.files` (per `GOTCHAS.md`):
|
||||
|
||||
| Overlay | Preload | Purpose |
|
||||
|---|---|---|
|
||||
| `wallet-connect-modal.html` | `wallet-connect-preload.js` | first-use connect prompt |
|
||||
| `wallet-sign-modal.html` | `wallet-sign-preload.js` | signature/payment/name-op confirmation |
|
||||
| `wallet-pair-modal.html` | `wallet-pair-preload.js` | WizardConnect QR scan / URI paste |
|
||||
|
||||
The **address-bar chip** grows an icon when:
|
||||
- The current origin has an active permission (green key)
|
||||
- The current page contains a `wiz://` URI (purple wand)
|
||||
- The wallet is locked and the page is asking for something (grey with a lock)
|
||||
|
||||
Click the chip → shows a summary: which origin, which methods granted, spend
|
||||
so far, revoke options.
|
||||
|
||||
**Settings > Wallet** — a new pane alongside Passwords:
|
||||
- Account row: address, balance, "backup phrase" (behind master-password
|
||||
re-prompt)
|
||||
- Connected sites table
|
||||
- Paired dApps table (WizardConnect)
|
||||
- Transaction history (local; the on-chain source of truth is queryable via
|
||||
the chip-per-tx explorer link)
|
||||
- Auto-lock timeout
|
||||
- **Danger zone**: reset wallet (wipes local state, does not touch on-chain
|
||||
registrations — the certificates stay in the on-chain wallet the seed
|
||||
controls)
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage & unlock
|
||||
|
||||
- **Wallet key material lives in `<userData>/wallet.enc`**, encrypted the same
|
||||
way passwords are: PBKDF2 → AES-256-GCM under the master password (see
|
||||
[DESIGN-password-manager.md](DESIGN-password-manager.md) for the discipline).
|
||||
- **Same master password** unlocks both the vault and the wallet. Unlocking one
|
||||
unlocks the other for the session. The user picks the password once.
|
||||
- **Distinct purpose subtree** — the seed derives a wallet purpose root via
|
||||
HKDF with `info = "silentmode/wallet/0"`, independent of the password purpose
|
||||
root at `"silentmode/passwords/0"`. Extend `password-vault.js`'s
|
||||
`seedToPurposeRoot(...)` — it already takes a purpose string.
|
||||
- **The signing key is derived on demand** from the purpose root, not held in
|
||||
RAM long-term. Each `signMessage` / `sendPayment` reprises the derivation
|
||||
from the unlocked purpose root.
|
||||
- **Auto-lock** re-encrypts the purpose root under a fresh session key that is
|
||||
discarded on lock. Unlock re-derives from the master password.
|
||||
|
||||
Cross-strand rule: **never derive a wallet key from the password purpose root
|
||||
(or vice versa).** This is the discipline that keeps a website-XSS-driven
|
||||
password leak from also leaking wallet keys. Documented explicitly in
|
||||
`DESIGN-password-manager.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sequencing — the roadmap in order
|
||||
|
||||
| Phase | Roadmap ref | What ships | Depends on |
|
||||
|---|---|---|---|
|
||||
| B.1 | this doc | design doc, user aligned on permission model | — |
|
||||
| B.2a | roadmap B.2 | preload injection scaffold, read-only APIs only | — |
|
||||
| B.2b | roadmap B.2 | eTLD+1 origin binding (share PSL snapshot with A.2) | — |
|
||||
| B.3a | roadmap B.3 | wallet unlock flow reused from password vault | A.2 shipped |
|
||||
| B.3b | roadmap B.3 | `signMessage` + connect modal + settings-connected-sites | B.2 + B.3a |
|
||||
| B.3c | roadmap B.3 | `sendPayment` with per-amount cap | B.3b |
|
||||
| B.4 | roadmap B.4 | `registerName` / `updateName` / `transferName` | B.3b |
|
||||
| **W.1** | *(new)* | WizardConnect responder — WalletAdapter + pair modal | B.3b |
|
||||
| **W.2** | *(new)* | Settings-paired-dApps, session persistence, revoke | W.1 |
|
||||
|
||||
WizardConnect is new to the roadmap — the existing `B.6 — Advanced connect-
|
||||
wallet UX` bullet was vague and `B.5 — Nostr messenger` conflates transport
|
||||
with client. Suggest updating the roadmap: **replace `B.6` with `W.1`+`W.2`
|
||||
above**, keep `B.5` for a Nostr messenger client if that's still wanted, but
|
||||
they are independent projects that happen to share a transport.
|
||||
|
||||
W.1 depends on B.3b (the sign modal) so the same modal serves both surfaces.
|
||||
Do NOT ship W.1 before there is a working sign modal, or the WizardConnect
|
||||
responder will ship its own approval UI and the two will drift.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions — decide before writing B.2+ code
|
||||
|
||||
1. **Chain switching.** Chipnet only in v1 — but what does `window.bcnr` do on
|
||||
a mainnet dApp that expects mainnet? Options:
|
||||
(a) Error with a clear message,
|
||||
(b) present a "this is a chipnet wallet" modal,
|
||||
(c) support both networks with a per-origin chain preference.
|
||||
*Recommend (a) for v1; revisit at mainnet switch.*
|
||||
2. **`bcnr.sendPayment` recipient by name.** If `to: "alice.bch"`, do we
|
||||
resolve the `a` record (payment cashaddr) automatically, or force the caller
|
||||
to `bcnr.resolveName` first and pass a cashaddr? *Recommend auto-resolve;
|
||||
show the resolved address in the modal for the user to check.*
|
||||
3. **QR scanner for pairing.** Camera-based (Electron desktop can access
|
||||
camera, but macOS permission is friction) vs paste-only. *Recommend
|
||||
paste-first, camera as an enhancement in W.2 or later.*
|
||||
4. **BCNR-name provenance in the modal.** Show a "registered on-chain since
|
||||
height X, by category Y" line for BCNR-native origins? Helpful for advanced
|
||||
users, noise for others. *Recommend a collapsed disclosure that opens on
|
||||
click, not a default-visible field.*
|
||||
5. **Rate-limiting on read-only methods.** A page loop calling `resolveName`
|
||||
thousands of times could DoS the resolver pool. *Recommend a per-origin
|
||||
token bucket in the preload — 20 requests/second, drops the excess with a
|
||||
normal-looking error.*
|
||||
6. **Backup phrase display.** Behind the master password re-prompt, plain
|
||||
text or blur-until-hover? *Recommend blur-until-hover with a "why is this
|
||||
dangerous" panel that must be dismissed once before the phrase reveals.*
|
||||
7. **Transaction history storage.** Local JSON, encrypted like the vault, or
|
||||
just derived from chain queries on demand? *Recommend derived-on-demand for
|
||||
v1 — no new storage, no sync surface, and the chain is authoritative.*
|
||||
8. **`transferName` — does it need commit-reveal?** No, because a certificate
|
||||
transfer is just a token send; the commit-reveal spec applies to
|
||||
*registrations*. But the modal should still say "this sends the on-chain
|
||||
ownership token to a stranger and cannot be undone."
|
||||
|
||||
---
|
||||
|
||||
## 9. What this design does NOT decide
|
||||
|
||||
- The exact modal visual design. Ship a functional MVP; polish in a UX pass.
|
||||
- The wallet's home screen (balance, recent transactions). Settings > Wallet
|
||||
is sufficient in v1; a dedicated pane is a v2 conversation.
|
||||
- On-chain price display (fiat quotes). Deliberate — Silent Mode does not run
|
||||
a fiat oracle, and pulling from an external price API undoes half the point
|
||||
of the project.
|
||||
- Multi-account UX. Single account per profile; sub-accounts / vanity
|
||||
accounts are a future strand.
|
||||
- Whether the wallet ships to Ariadne mobile as a companion. That is a
|
||||
separate mobile project; the design here informs it but does not require it.
|
||||
|
||||
---
|
||||
|
||||
## 10. References (do not re-derive)
|
||||
|
||||
- Existing wallet:
|
||||
[`Argus/src/lib/wallet-web.js:BuiltInWallet`](../Argus/src/lib/wallet-web.js)
|
||||
- Existing registration flow: [`Argus/src/lib/registrar.js`](../Argus/src/lib/registrar.js),
|
||||
[`Argus/src/lib/register-tx.js`](../Argus/src/lib/register-tx.js)
|
||||
- Existing dApp-side WizardConnect (SilentMode registrar): [`Argus/src/lib/connect/wizardconnect.js`](../Argus/src/lib/connect/wizardconnect.js)
|
||||
- Existing wallet-side WizardConnect (Deviant, chipnet-validated):
|
||||
[`../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md`](../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md)
|
||||
- WizardConnect LGPL analysis: [`../Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md`](../Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md)
|
||||
- Crypto discipline (purpose subtrees, PBKDF2, HKDF):
|
||||
[`DESIGN-password-manager.md`](DESIGN-password-manager.md)
|
||||
- Registration flow status: [`../Decentralized.DNS/REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md)
|
||||
103
bcnr-origin.js
Normal file
103
bcnr-origin.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// origin.js — compute the "permission origin" for a page.
|
||||
//
|
||||
// Permissions are keyed off the eTLD+1 of the page's URL, not its full origin:
|
||||
// pay.merchant.com → permission origin merchant.com
|
||||
// blog.merchant.com → permission origin merchant.com (same as pay.)
|
||||
// evil.com → permission origin evil.com (different)
|
||||
// pay.silentmode.bch → permission origin silentmode.bch (BNS root)
|
||||
//
|
||||
// This matches how MetaMask, browser cookies (SameSite), and CORS all think of
|
||||
// origins — the boundary of trust is the registrable domain, not the specific
|
||||
// subdomain. A dApp that already got "always allow signMessage" on
|
||||
// checkout.merchant.com should NOT need to re-approve when it navigates to
|
||||
// account.merchant.com. But evil.com cannot piggyback on merchant.com's grant.
|
||||
//
|
||||
// For ICANN TLDs we use the Public Suffix List via the `psl` package — the
|
||||
// same list Chromium uses — which handles the multi-part cases (.co.uk,
|
||||
// .github.io, ...). For BNS we key off the passed-in bcnrTlds array so a
|
||||
// name like foo.wallet gets treated as a public suffix once the on-chain TLD
|
||||
// list includes "wallet".
|
||||
//
|
||||
// The function is pure: no I/O, no imports of Electron. Tested in isolation
|
||||
// by dev/origin-selftest.mjs.
|
||||
const psl = require("psl");
|
||||
|
||||
// Opaque or non-webby origins that never hold permissions. Returned as
|
||||
// literal strings so calling code can compare and treat them uniformly.
|
||||
const OPAQUE = new Set(["data:", "blob:", "javascript:"]);
|
||||
|
||||
function isIpLiteral(hostname) {
|
||||
// Bracketed IPv6 (URL.hostname strips brackets — the check on ':' inside
|
||||
// catches those) or a bare IPv4.
|
||||
if (!hostname) return false;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true;
|
||||
if (hostname.includes(":")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Given "pay.silentmode.bch" + bcnrTlds ["bch","wallet"] → "silentmode.bch".
|
||||
// Given "silentmode.bch" alone → "silentmode.bch". A bare TLD ("bch") returns
|
||||
// null — no registrable label to key permissions off.
|
||||
function bnsEtldPlusOne(hostname, bcnrTlds) {
|
||||
const labels = String(hostname).toLowerCase().split(".").filter(Boolean);
|
||||
if (labels.length < 2) return null;
|
||||
const tld = labels[labels.length - 1];
|
||||
if (!bcnrTlds.includes(tld)) {
|
||||
// Not a BCNR-native TLD — treat the whole hostname as the origin. Safer
|
||||
// than pretending we know the suffix; a name like foo.privateTld will
|
||||
// land here until the TLD list catches up.
|
||||
return labels.join(".");
|
||||
}
|
||||
// BCNR names are one label below the TLD: `{label}.{tld}` is the root
|
||||
// identity, subdomains extend it. Registrable = label + tld.
|
||||
return `${labels[labels.length - 2]}.${tld}`;
|
||||
}
|
||||
|
||||
// Main entry. Returns a string origin key, or null for un-resolvable input.
|
||||
// Strings intentionally include the scheme when it matters (`about:blank`,
|
||||
// `file://`) so an ICANN eTLD+1 can never collide with a special origin.
|
||||
function originOf(urlString, { bcnrTlds = ["bch"] } = {}) {
|
||||
if (!urlString || typeof urlString !== "string") return null;
|
||||
let u;
|
||||
try { u = new URL(urlString); } catch { return null; }
|
||||
|
||||
if (OPAQUE.has(u.protocol)) return null; // opaque — never gets permissions
|
||||
|
||||
if (u.protocol === "about:") {
|
||||
// about:blank, about:srcdoc — normalize to their canonical form.
|
||||
return `about:${u.pathname || "blank"}`;
|
||||
}
|
||||
if (u.protocol === "file:") return "file://"; // one bucket for all local files
|
||||
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (!host) return null;
|
||||
|
||||
// IP literals and localhost: key by scheme + host + port. Common for dev
|
||||
// servers. A permission granted to http://localhost:3000 does NOT extend
|
||||
// to :3001 or to another IP.
|
||||
if (host === "localhost" || isIpLiteral(host)) {
|
||||
const port = u.port ? `:${u.port}` : "";
|
||||
return `${u.protocol}//${host}${port}`;
|
||||
}
|
||||
|
||||
if (u.protocol === "bns:") {
|
||||
return bnsEtldPlusOne(host, bcnrTlds);
|
||||
}
|
||||
|
||||
if (u.protocol === "http:" || u.protocol === "https:") {
|
||||
const parsed = psl.parse(host);
|
||||
if (parsed.error || !parsed.domain) {
|
||||
// Bare TLD, invalid, or a listed public suffix with no registrable
|
||||
// label above it — no permission origin. Fall back to full host so
|
||||
// odd cases don't silently share a bucket.
|
||||
return host;
|
||||
}
|
||||
return parsed.domain; // e.g. merchant.com, foo.co.uk, user.github.io
|
||||
}
|
||||
|
||||
// Any other scheme (chrome://, chrome-extension://, ...) — key by scheme +
|
||||
// host so it doesn't collide with a real origin.
|
||||
return `${u.protocol}//${host}`;
|
||||
}
|
||||
|
||||
module.exports = { originOf, bnsEtldPlusOne };
|
||||
27
bcnr-preload.js
Normal file
27
bcnr-preload.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// bcnr-preload.js — session-wide preload that installs `window.bcnr` on every
|
||||
// page (regular tabs, popups, chrome/settings/etc). Reads only — no signing,
|
||||
// no wallet unlock, no permission prompts. These four methods query the same
|
||||
// resolver Theseus already runs for its address bar; nothing about the local
|
||||
// user leaks, so no origin gate is needed for this surface.
|
||||
//
|
||||
// Sequencing: registered via `session.defaultSession.setPreloads([...])` in
|
||||
// main.js at whenReady, which runs BEFORE per-WebContentsView preloads (home,
|
||||
// settings, popover, etc.), so those preloads still install their own bridges
|
||||
// on top of `window.bcnr`. See DESIGN-integrated-wallet.md §3 for the full
|
||||
// API surface.
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Every method returns a Promise; a name that fails to resolve or isn't
|
||||
// registered comes back as `null` (not an error) so page code can treat
|
||||
// "no such name" as data, not an exception. `getBcnrTlds` always returns
|
||||
// an array — even the seed ["bch"] before the on-chain list has landed.
|
||||
contextBridge.exposeInMainWorld("bcnr", {
|
||||
resolveName: (name) => ipcRenderer.invoke("bcnr:resolveName", name),
|
||||
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
|
||||
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
|
||||
getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name),
|
||||
// Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page.
|
||||
// dApp devs use this to see how their subdomains bucket under one grant.
|
||||
// Returns null for opaque origins (data:, blob:) which never hold grants.
|
||||
getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"),
|
||||
});
|
||||
10
chrome.html
10
chrome.html
|
|
@ -494,6 +494,16 @@
|
|||
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
|
||||
});
|
||||
|
||||
// Address-picker pick: the picker fires "address-pick" which routes to
|
||||
// navigateTab, but the tabs event's focus-guard would leave the typed
|
||||
// query in place if the URL input still had DOM focus. Force the full
|
||||
// picked URL into the bar and drop focus so subsequent tabs events
|
||||
// paint the loaded URL cleanly.
|
||||
T.onAddressPicked && T.onAddressPicked((url) => {
|
||||
try { $("url").blur(); } catch (e) {}
|
||||
$("url").value = String(url || "");
|
||||
});
|
||||
|
||||
// ---- tabs ----
|
||||
T.onTabs((d) => {
|
||||
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
|
||||
|
|
|
|||
68
dev/bcnr-selftest.js
Normal file
68
dev/bcnr-selftest.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// bcnr-selftest.js — end-to-end proof that `window.bcnr` from
|
||||
// bcnr-preload.js reaches the main-process handlers over IPC and returns
|
||||
// what the resolver sees. Mirrors dev/selftest.js style: sets
|
||||
// THESEUS_NO_AUTOSTART=1, imports main.js, then wires the minimum bits
|
||||
// (session preload + resolver warm-up) itself so the harness stays fast
|
||||
// and hermetic.
|
||||
//
|
||||
// Usage: npx electron dev/bcnr-selftest.js <name>
|
||||
// ^ optional; defaults to silentmode.bch
|
||||
process.env.THESEUS_NO_AUTOSTART = "1";
|
||||
const path = require("path");
|
||||
const { app, BrowserWindow, session } = require("electron");
|
||||
// main.js exports resolveHost etc. for tests; importing it also registers
|
||||
// the ipcMain.handle("bcnr:...") handlers at module load.
|
||||
require("../main.js");
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("disable-gpu");
|
||||
app.commandLine.appendSwitch("no-sandbox");
|
||||
|
||||
const name = (process.argv[2] || "silentmode.bch").toLowerCase();
|
||||
const watchdog = setTimeout(() => { console.log("WATCHDOG"); try { app.exit(3); } catch {} }, 60000);
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Install the same session-wide preload the shipped app installs.
|
||||
session.defaultSession.setPreloads([path.join(__dirname, "..", "bcnr-preload.js")]);
|
||||
|
||||
// No explicit warm-up: the first resolveName() call flows into
|
||||
// resolveHost() which lazily runs ensureIndex() itself. Cold start can
|
||||
// take a while (electrum handshake + full walk); the watchdog covers it.
|
||||
const win = new BrowserWindow({ width: 800, height: 600, show: false, webPreferences: { offscreen: true } });
|
||||
const wc = win.webContents;
|
||||
await wc.loadURL("about:blank");
|
||||
|
||||
const report = { name };
|
||||
try {
|
||||
report.hasBridge = await wc.executeJavaScript("typeof window.bcnr === 'object'");
|
||||
report.methods = await wc.executeJavaScript("Object.keys(window.bcnr || {}).sort()");
|
||||
report.getBcnrTlds = await wc.executeJavaScript("window.bcnr.getBcnrTlds()");
|
||||
report.isRegistered = await wc.executeJavaScript(`window.bcnr.isRegistered(${JSON.stringify(name)})`);
|
||||
report.getRecordVersion = await wc.executeJavaScript(`window.bcnr.getRecordVersion(${JSON.stringify(name)})`);
|
||||
report.resolveName = await wc.executeJavaScript(`window.bcnr.resolveName(${JSON.stringify(name)})`);
|
||||
// B.2b — the caller's eTLD+1 origin as Theseus sees it. From about:blank
|
||||
// this must be "about:blank"; a data: URL would be null; a real dApp
|
||||
// page would be its registrable domain. Full origin logic is unit-tested
|
||||
// in dev/origin-selftest.mjs; this proves the IPC wiring uses it.
|
||||
report.origin_aboutBlank = await wc.executeJavaScript("window.bcnr.getOrigin()");
|
||||
} catch (e) { report.error = e.message; }
|
||||
|
||||
console.log("\n===== BCNR SELFTEST =====");
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
// Verdict: bridge present, four methods, TLD list non-empty, and either
|
||||
// the name resolved OR it plausibly doesn't exist (isRegistered === false
|
||||
// + resolveName === null is a valid pass — resolver worked, name just
|
||||
// isn't on chain).
|
||||
const bridgeOk = report.hasBridge === true
|
||||
&& Array.isArray(report.methods)
|
||||
&& ["getBcnrTlds", "getOrigin", "getRecordVersion", "isRegistered", "resolveName"].every((m) => report.methods.includes(m));
|
||||
const tldsOk = Array.isArray(report.getBcnrTlds) && report.getBcnrTlds.length > 0;
|
||||
const shapeOk = (report.isRegistered === true && report.resolveName && report.resolveName.name === name)
|
||||
|| (report.isRegistered === false && report.resolveName === null);
|
||||
const originOk = report.origin_aboutBlank === "about:blank";
|
||||
report.verdict = bridgeOk && tldsOk && shapeOk && originOk ? "PASS" : "FAIL";
|
||||
console.log("verdict:", report.verdict);
|
||||
clearTimeout(watchdog);
|
||||
try { app.exit(report.verdict === "PASS" ? 0 : 1); } catch {}
|
||||
});
|
||||
72
dev/origin-selftest.mjs
Normal file
72
dev/origin-selftest.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// origin-selftest.mjs — verify the pure origin helper covers every case that
|
||||
// window.bcnr permissions (B.3) and the password-autofill eTLD+1 upgrade
|
||||
// (main.js:1257 stub) will lean on. No Electron needed.
|
||||
//
|
||||
// Usage: node dev/origin-selftest.mjs
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { originOf, bnsEtldPlusOne } = require("../bcnr-origin.js");
|
||||
|
||||
const BCNR = ["bch", "wallet"]; // simulate the on-chain TLD list after warm-up
|
||||
|
||||
const cases = [
|
||||
// ICANN — same permission origin for all subdomains of one registrable domain
|
||||
["https://checkout.merchant.com/pay", "merchant.com"],
|
||||
["https://blog.merchant.com/x", "merchant.com"],
|
||||
["https://merchant.com/", "merchant.com"],
|
||||
// Multi-part public suffixes — the reason we ship a real PSL, not a regex
|
||||
["https://alice.co.uk/", "alice.co.uk"],
|
||||
["https://pages.user.github.io/repo", "user.github.io"],
|
||||
// Different registrable domain → different bucket, no cross-grant
|
||||
["https://evil.com/", "evil.com"],
|
||||
// BNS — root name is the identity; subdomains inherit it
|
||||
["bns://silentmode.bch/", "silentmode.bch"],
|
||||
["bns://pay.silentmode.bch/", "silentmode.bch"],
|
||||
["bns://mail.silentmode.bch/x/y", "silentmode.bch"],
|
||||
["bns://foo.wallet/", "foo.wallet"],
|
||||
["bns://bar.foo.wallet/", "foo.wallet"],
|
||||
// BNS with an unknown-to-us TLD — safest is to key the full host so a
|
||||
// future TLD unlock doesn't silently expand old permissions.
|
||||
["bns://foo.privateTld/", "foo.privatetld"],
|
||||
// Dev origins — scheme+host+port matters, permissions don't cross ports
|
||||
["http://localhost:3000/", "http://localhost:3000"],
|
||||
["http://localhost:3001/", "http://localhost:3001"],
|
||||
["http://127.0.0.1:8080/x", "http://127.0.0.1:8080"],
|
||||
// Special / opaque
|
||||
["about:blank", "about:blank"],
|
||||
["file:///C:/Users/me/foo.html", "file://"],
|
||||
["data:text/html,<h1>hi</h1>", null],
|
||||
["blob:https://example.com/uuid", null],
|
||||
// Junk in → null out (no crash)
|
||||
["", null],
|
||||
["not a url", null],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const [input, expected] of cases) {
|
||||
const got = originOf(input, { bcnrTlds: BCNR });
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} originOf(${JSON.stringify(input)}) → ${JSON.stringify(got)}${ok ? "" : ` (expected ${JSON.stringify(expected)})`}`);
|
||||
ok ? pass++ : fail++;
|
||||
}
|
||||
|
||||
// Direct bnsEtldPlusOne coverage — the BNS-specific branch, since it's the
|
||||
// one PSL cannot help with.
|
||||
const bnsCases = [
|
||||
["silentmode.bch", BCNR, "silentmode.bch"],
|
||||
["pay.silentmode.bch", BCNR, "silentmode.bch"],
|
||||
["a.b.c.silentmode.bch", BCNR, "silentmode.bch"],
|
||||
["bch", BCNR, null], // bare TLD — no registrable label
|
||||
["single", BCNR, null],
|
||||
];
|
||||
for (const [host, tlds, expected] of bnsCases) {
|
||||
const got = bnsEtldPlusOne(host, tlds);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} bnsEtldPlusOne(${JSON.stringify(host)}) → ${JSON.stringify(got)}${ok ? "" : ` (expected ${JSON.stringify(expected)})`}`);
|
||||
ok ? pass++ : fail++;
|
||||
}
|
||||
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
124
main.js
124
main.js
|
|
@ -2060,7 +2060,20 @@ ipcMain.handle("address-picker-resize", (_e, h) => {
|
|||
apH = Math.max(40, Math.min(400, Math.round(h) || 60));
|
||||
if (apVisible) positionAddressPicker();
|
||||
});
|
||||
ipcMain.handle("address-pick", (_e, url) => { showAddressPicker(false); if (url) navigateTab(activeId, String(url)); });
|
||||
ipcMain.handle("address-pick", (_e, url) => {
|
||||
showAddressPicker(false);
|
||||
if (!url) return;
|
||||
const u = String(url);
|
||||
// Push the picked URL to the chrome renderer directly so the address bar
|
||||
// shows the full URL immediately. The tabs event's focus guard
|
||||
// (document.activeElement !== $("url"))
|
||||
// skips its value overwrite while the URL input still has DOM focus, and
|
||||
// clicking a WebContentsView sibling doesn't always deliver the blur to
|
||||
// the chrome renderer in time — user was left staring at their 3-letter
|
||||
// typed query while the picked URL loaded behind it.
|
||||
try { chrome?.webContents.send("address-picked", u); } catch {}
|
||||
navigateTab(activeId, u);
|
||||
});
|
||||
// Password fill — chip in the toolbar opens a picker of matching credentials
|
||||
// for the current site. Clicking a match injects the fill script into the
|
||||
// active tab. Whole flow is user-initiated; no page-load DOM watchers yet.
|
||||
|
|
@ -2432,6 +2445,104 @@ function openHermesWindow() {
|
|||
|
||||
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
|
||||
|
||||
// --- BCNR provider (window.bcnr) — read-only surface. See
|
||||
// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus
|
||||
// already runs; nothing about the user leaks, so no origin/permission gate.
|
||||
// A malicious page can call these; the worst it learns is what the chain
|
||||
// says publicly, which it could equally get through Argus. The preload that
|
||||
// exposes these lives at bcnr-preload.js and is installed session-wide
|
||||
// inside whenReady below.
|
||||
//
|
||||
// B.2b: eTLD+1 origin binding. The read methods below don't need the origin
|
||||
// — chain data is public — so they don't compute it. Instead pages that
|
||||
// want to see how Theseus will bucket their permissions can call
|
||||
// `window.bcnr.getOrigin()` (handler further down). B.3's write methods
|
||||
// (signMessage/sendPayment/registerName) will call `callerOrigin(event)` at
|
||||
// entry and check the result against wallet-permissions.json.
|
||||
const { originOf } = require("./bcnr-origin.js");
|
||||
function callerOrigin(event) {
|
||||
try { return originOf(event.sender.getURL(), { bcnrTlds }); }
|
||||
catch { return null; }
|
||||
}
|
||||
// wallet-permissions.json — per-origin (eTLD+1) grants for B.3's write
|
||||
// methods. Scaffolded in B.2b so B.3 doesn't have to touch main.js's
|
||||
// on-disk conventions. Shape is intentionally open — B.3 will define the
|
||||
// concrete decision values ("always" | "once" | "never", amount caps,
|
||||
// expiries) as each write method lands.
|
||||
let walletPermissions = {};
|
||||
const walletPermissionsFile = () => path.join(app.getPath("userData"), "wallet-permissions.json");
|
||||
function loadWalletPermissions() {
|
||||
try {
|
||||
if (fs.existsSync(walletPermissionsFile())) {
|
||||
const raw = JSON.parse(fs.readFileSync(walletPermissionsFile(), "utf8"));
|
||||
if (raw && typeof raw === "object") walletPermissions = raw;
|
||||
}
|
||||
} catch (e) { console.error("wallet-permissions load failed:", e.message); }
|
||||
}
|
||||
function saveWalletPermissions() {
|
||||
try { fs.writeFileSync(walletPermissionsFile(), JSON.stringify(walletPermissions, null, 2)); }
|
||||
catch (e) { console.error("wallet-permissions save failed:", e.message); }
|
||||
}
|
||||
// B.3 will use these. Kept here so the storage owner is one place.
|
||||
function getWalletPermission(origin, method) {
|
||||
if (!origin || !method) return null;
|
||||
return walletPermissions[origin]?.[method] ?? null;
|
||||
}
|
||||
function setWalletPermission(origin, method, value) {
|
||||
if (!origin || !method) return;
|
||||
if (!walletPermissions[origin]) walletPermissions[origin] = {};
|
||||
walletPermissions[origin][method] = value;
|
||||
saveWalletPermissions();
|
||||
}
|
||||
void getWalletPermission; void setWalletPermission; // silence unused-in-B.2b
|
||||
function serializeEntry(entry) {
|
||||
if (!entry) return null;
|
||||
// Explicit whitelist — records/category/txid/height are the on-chain facts
|
||||
// the design's `resolveName` promises. `updatedTxid` is included because it
|
||||
// shifts every UPD and lets `getRecordVersion` distinguish a REG-only entry
|
||||
// from one that has been updated in place.
|
||||
return {
|
||||
name: entry.name,
|
||||
category: entry.category,
|
||||
records: entry.records ?? {},
|
||||
txid: entry.txid,
|
||||
height: entry.height,
|
||||
updatedTxid: entry.updatedTxid ?? null,
|
||||
};
|
||||
}
|
||||
ipcMain.handle("bcnr:resolveName", async (_e, name) => {
|
||||
if (typeof name !== "string" || !name) return null;
|
||||
try { return serializeEntry(await resolveHost(name)); }
|
||||
catch { return null; }
|
||||
});
|
||||
ipcMain.handle("bcnr:isRegistered", async (_e, name) => {
|
||||
if (typeof name !== "string" || !name) return false;
|
||||
try { return (await resolveHost(name)) != null; }
|
||||
catch { return false; }
|
||||
});
|
||||
ipcMain.handle("bcnr:getBcnrTlds", () => bcnrTlds.slice());
|
||||
// Diagnostic — returns the eTLD+1 permission origin Theseus computes for the
|
||||
// caller. Same value B.3's write methods will gate on. No leak: a page can
|
||||
// already read its own location.href; this just tells it how Theseus buckets
|
||||
// its permissions (so a dApp dev can see that pay.foo.bch and blog.foo.bch
|
||||
// share one grant).
|
||||
ipcMain.handle("bcnr:getOrigin", (e) => callerOrigin(e));
|
||||
ipcMain.handle("bcnr:getRecordVersion", async (_e, name) => {
|
||||
if (typeof name !== "string" || !name) return null;
|
||||
try {
|
||||
const entry = await resolveHost(name);
|
||||
if (!entry) return null;
|
||||
// The pair (updatedTxid ?? txid, height) uniquely identifies which reveal
|
||||
// a dApp is looking at. dApps poll this cheaply and re-fetch records only
|
||||
// when it changes.
|
||||
return {
|
||||
txid: entry.updatedTxid ?? entry.txid,
|
||||
regTxid: entry.txid,
|
||||
height: entry.height,
|
||||
};
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
// Ctrl+Shift+M anywhere in Theseus opens (or focuses) the Messages panel.
|
||||
// One app-level hook: no per-window wiring, no per-tab plumbing, matches
|
||||
// however many WebContents Theseus ends up owning.
|
||||
|
|
@ -2455,9 +2566,20 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
|
|||
loadBookmarks();
|
||||
loadHistory();
|
||||
loadCollisions();
|
||||
loadWalletPermissions();
|
||||
applyPermissions();
|
||||
applyEmbedCookieShim();
|
||||
applyAcceptLanguage();
|
||||
// Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView
|
||||
// preloads (home/settings/popover/etc.), which stack on top of it. Must be
|
||||
// called before any tab is created; whenReady runs before createWindow().
|
||||
try {
|
||||
const bcnrPreload = path.join(__dirname, "bcnr-preload.js");
|
||||
const existing = session.defaultSession.getPreloads();
|
||||
if (!existing.includes(bcnrPreload)) {
|
||||
session.defaultSession.setPreloads([...existing, bcnrPreload]);
|
||||
}
|
||||
} catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); }
|
||||
protocol.handle("bns", serveBns);
|
||||
installDownloadTracker();
|
||||
createWindow();
|
||||
|
|
|
|||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -1,15 +1,16 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.7",
|
||||
"dependencies": {
|
||||
"fetch-socks": "^1.3.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"psl": "^1.15.0",
|
||||
"socks-proxy-agent": "^10.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
@ -4422,6 +4423,18 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
|
|
@ -4437,7 +4450,6 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
|
|||
45
package.json
45
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
"main": "main.js",
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"dependencies": {
|
||||
"fetch-socks": "^1.3.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"psl": "^1.15.0",
|
||||
"socks-proxy-agent": "^10.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
@ -24,7 +25,9 @@
|
|||
"appId": "st.silentmode.theseus",
|
||||
"productName": "Theseus Navigator",
|
||||
"artifactName": "TheseusNavigator-${version}-${arch}.${ext}",
|
||||
"directories": { "output": "dist-public" },
|
||||
"directories": {
|
||||
"output": "dist-public"
|
||||
},
|
||||
"asar": true,
|
||||
"files": [
|
||||
"main.js",
|
||||
|
|
@ -50,6 +53,8 @@
|
|||
"collision-preload.js",
|
||||
"messages.html",
|
||||
"messages-preload.js",
|
||||
"bcnr-preload.js",
|
||||
"bcnr-origin.js",
|
||||
"lib/**/*",
|
||||
"package.json",
|
||||
"node_modules/**/*",
|
||||
|
|
@ -61,19 +66,41 @@
|
|||
"!*PROMPT.md"
|
||||
],
|
||||
"extraResources": [
|
||||
{ "from": "tor", "to": "tor" },
|
||||
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" },
|
||||
{ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" },
|
||||
{ "from": "lib/hermes.js", "to": "lib/hermes.mjs" },
|
||||
{ "from": "snapshots/bns-name-snapshot.json", "to": "bns-name-snapshot.json" }
|
||||
{
|
||||
"from": "tor",
|
||||
"to": "tor"
|
||||
},
|
||||
{
|
||||
"from": "../Argus/src/lib/resolver-web.js",
|
||||
"to": "resolver-web.mjs"
|
||||
},
|
||||
{
|
||||
"from": "../Argus/src/lib/password-vault.js",
|
||||
"to": "password-vault.mjs"
|
||||
},
|
||||
{
|
||||
"from": "lib/hermes.js",
|
||||
"to": "lib/hermes.mjs"
|
||||
},
|
||||
{
|
||||
"from": "snapshots/bns-name-snapshot.json",
|
||||
"to": "bns-name-snapshot.json"
|
||||
}
|
||||
],
|
||||
"win": { "target": ["nsis", "portable"] },
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "TheseusNavigator-Setup-${version}.${ext}"
|
||||
},
|
||||
"portable": { "artifactName": "TheseusNavigator-${version}-portable.${ext}" }
|
||||
"portable": {
|
||||
"artifactName": "TheseusNavigator-${version}-portable.${ext}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld("theseus", {
|
|||
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
|
||||
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
|
||||
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
|
||||
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
|
||||
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
|
||||
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab
|
||||
collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg),
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue