From c3e55add16d2e1347a169fa7548a7415562cf1ac Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:10:39 -0400 Subject: [PATCH] Widget: requestNwcConnection() -- vault authorization layer, reveal mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase D surface from vault-ux.md, scoped to NWC and honest about its boundary: an embedded same-origin widget cannot broker (§CV12.3), so the API reveals, says so in its consent copy, and leans on the wallet-side budget as the real security boundary. One public call owns the whole ceremony. If nobody is signed in, the sign-in flow runs first and the request resumes via the goto("dashboard") hook exactly once; if the account has a connection bound to the requesting origin, the user gets the one-tap approval screen the vault exists for; otherwise a guided import -- Bitcoin Connect's wallet chooser or a pasted URI. The BC integration lifts Satisfied's production discipline verbatim: chooser only, lazy chunks (never downloaded unless the flow opens), no numeric budget in authorizationUrlOptions (the unit is not interoperable), disconnect and clear bc:config so no plaintext copy outlives the ceremony. The worker holds the vault root (never the sudo key -- wiped on arrival) and gains vaultStatus/List/SaveNwc/FindForOrigin/RevealNwc/ SetBinding/Delete actions; the root rides the encrypted session cache so restored sessions keep working, with a vaultEnabled marker so a stale cache says "sign in again" instead of falsely claiming the account has no vault. Publishing failures are loud -- a connection that reached no relay is not saved anywhere and says so. The dashboard gains Wallet connections management with "revoke app access" and "remove from BitLogin" honestly separated from wallet-side revocation. VaultSession gains buildDeletionRequest so the NIP-09 courtesy ask is signed without exposing the signing key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1 --- CHANGELOG.md | 12 + README.md | 25 ++ docs/connection-vault.md | 8 +- package-lock.json | 245 ++++++++++++++- packages/core/package.json | 3 +- packages/core/src/vault/session.ts | 15 + packages/widget/package.json | 3 +- packages/widget/src/element.ts | 313 +++++++++++++++++++- packages/widget/src/vault/bitcoinConnect.ts | 108 +++++++ packages/widget/src/worker/cryptoWorker.ts | 217 +++++++++++++- packages/widget/src/worker/protocol.ts | 64 ++++ packages/widget/src/worker/sessionCache.ts | 31 +- packages/widget/src/worker/workerClient.ts | 28 +- 13 files changed, 1059 insertions(+), 13 deletions(-) create mode 100644 packages/widget/src/vault/bitcoinConnect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe6544..9128f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ would plausibly want. Newest first. ## 2026-07-30 +- **Widget: `requestNwcConnection()` — the Connection Vault authorization + layer (reveal mode).** Apps ask for a wallet; the widget owns sign-in, + one-tap approval for an origin-bound connection, or guided import via + Bitcoin Connect's chooser (lazy chunks — never downloaded unless the flow + opens) or a pasted URI, stored as an encrypted vault record. Dashboard + gains a "Wallet connections" management screen (revoke app access vs + remove-from-BitLogin, honestly distinguished). The vault root now rides + the encrypted session cache, so restored sessions can use connections + without re-login; the sudo key is never cached. New deploy note: the + widget now ships FIVE files (two extra lazy chunks) that must travel + together, and hosts using the wallet flow need `style-src-attr + 'unsafe-inline'` in their CSP. - **Connection Vault protocol layer in `@bitlogin/core`** (`src/vault/`): encrypted per-connection records signed by a derived vault identity, the finalized §CV6 key hierarchy with pinned test vectors, the NWC credential diff --git a/README.md b/README.md index 4ec1100..ab4a87c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,31 @@ element instance (`getPublicKey`, `signEvent`, `nip44Encrypt`, `nip44Decrypt`) its own widget instance, since `window.nostr` is a single global slot that whichever provider signed in last currently owns. +**Wallet connections (Connection Vault, reveal mode).** An app can ask the +user to share an NWC wallet connection with its origin: + +```javascript +const uri = await document.querySelector("bitlogin-auth") + .requestNwcConnection({ appName: "Satisfied", reason: "Pay for meal analysis" }); +if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined +``` + +The widget owns the whole ceremony: sign-in first if needed, a one-tap +approval when the account already has a connection bound to this origin +(that's the new-phone moment the vault exists for), or a guided import — +Bitcoin Connect's wallet chooser (loaded lazily; it never downloads unless +the flow opens) or a pasted URI — stored as an encrypted vault record and +restored on every device. Stated plainly: this is **reveal mode**. The +requesting origin receives the full bearer credential and everything its +wallet-side budget allows; an embedded same-origin widget cannot broker +(`docs/connection-vault.md` §12.3), so it does not pretend to. Encourage +users toward budget-capped connections — the wallet's own budget is the real +security boundary. Users manage and revoke grants from the dashboard's +"Wallet connections" screen. A `bitlogin-connection-granted` event fires on +the element after each share. Hosts using this flow need one extra CSP line, +`style-src-attr 'unsafe-inline'` (Bitcoin Connect's components set inline +style attributes; a hash cannot cover those). + **Integration gotcha:** the element's `disconnectedCallback` terminates its crypto Web Worker (that's the correct cleanup when the element is genuinely being removed). If your host app re-renders its own DOM by resetting an diff --git a/docs/connection-vault.md b/docs/connection-vault.md index 0bd06ed..66cb6c7 100644 --- a/docs/connection-vault.md +++ b/docs/connection-vault.md @@ -7,8 +7,12 @@ (`src/vault/`, plus the capsule fields in `src/capsules/` and the migration in `src/account/enableVault.ts`), with test vectors pinned in `src/vault/vault.test.ts` and protocol round trips in `vault.e2e.test.ts`. -The application-authorization layer (§12: consent UI, grants, Connected-apps -management) is **not yet implemented** — it is the widget generation's work. +The application-authorization layer (§12) is implemented in `@bitlogin/widget` +in **reveal mode** for NWC: `requestNwcConnection()` with consent and +one-tap origin-bound approval, Bitcoin Connect as the lazy-loaded wallet +chooser, and a "Wallet connections" management screen. Brokered mode waits +for a real execution boundary (§12.3). Personal-tier UI (sudo ceremony) is +not yet built. See §18 for the decisions finalized at implementation time, including the **sensitivity-tier model and the sudo key**, which supersede this document where they differ. The companion `vault-ux.md` holds the user-experience diff --git a/package-lock.json b/package-lock.json index 1a95edc..f263174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -473,6 +473,49 @@ "node": ">=18" } }, + "node_modules/@getalby/bitcoin-connect": { + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@getalby/bitcoin-connect/-/bitcoin-connect-3.12.3.tgz", + "integrity": "sha512-2d+tNHBCZBoImKJgZwTUOyK9ypXaTg/hwvtQ8EHbgSKKkuycOrks7SzlUc0iJHthD3JGFVMWZEgOgw4AQVVE4w==", + "license": "MIT", + "dependencies": { + "@getalby/lightning-tools": "^8.1.1", + "@getalby/sdk": "^8.0.3", + "@lightninglabs/lnc-web": "^0.3.6-alpha", + "qrcode-generator": "1.4.4", + "zustand": "^5.0.13" + } + }, + "node_modules/@getalby/lightning-tools": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-8.2.0.tgz", + "integrity": "sha512-eL+cnHyzeUARKVzNRuFBRBppAU5RBJOLjhFVzSWt8hDibRzL5+e4hq8I79+RGHfrWWMUDv382Jx8ewOazrBj7w==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-8.0.3.tgz", + "integrity": "sha512-vPEogAWwLHbL55COeXrRN7yRBXMDGDxX1M2A+o3Lqw60FFng6fa9FK9sw8ptdILMD1dQZq8FzPXuabQ7seoDBA==", + "license": "MIT", + "dependencies": { + "@getalby/lightning-tools": "^8.1.1", + "nostr-tools": "^2.23.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -480,6 +523,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@lightninglabs/lnc-core": { + "version": "0.3.6-alpha", + "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.6-alpha.tgz", + "integrity": "sha512-ObzY5wi+PS0GQduFHYDjGJpYZxKD27vtMf3fUVne2gGNMej1mvvJAOwATgef8YV/IZodAzy73Z/F2zfaQ+iE4Q==", + "license": "MIT" + }, + "node_modules/@lightninglabs/lnc-web": { + "version": "0.3.6-alpha", + "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-web/-/lnc-web-0.3.6-alpha.tgz", + "integrity": "sha512-oiL1mKlbEjb1lcmNAHA0Gn+mlx5oQOJ8YVONJR/vgo4QLmL/48EyA12QbZ71mP9li45hGOoGqpYMRwBYtIeFog==", + "license": "MIT", + "dependencies": { + "@lightninglabs/lnc-core": "^0.3.6-alpha", + "crypto-js": "4.2.0" + } + }, "node_modules/@noble/ciphers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", @@ -878,6 +937,56 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", @@ -1107,6 +1216,12 @@ "node": ">= 16" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1299,6 +1414,96 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nostr-tools": { + "version": "2.24.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.1.tgz", + "integrity": "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1365,6 +1570,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1519,7 +1730,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1746,6 +1957,35 @@ } } }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "packages/core": { "name": "@bitlogin/core", "version": "0.1.0", @@ -1778,7 +2018,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@bitlogin/core": "*" + "@bitlogin/core": "*", + "@getalby/bitcoin-connect": "^3.12.3" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/core/package.json b/packages/core/package.json index 54d7b2b..2896265 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,7 +13,8 @@ "./nostr": "./dist/nostr/index.js", "./capsules": "./dist/capsules/index.js", "./account": "./dist/account/index.js", - "./storage": "./dist/storage/index.js" + "./storage": "./dist/storage/index.js", + "./vault": "./dist/vault/index.js" }, "scripts": { "build": "tsc -b", diff --git a/packages/core/src/vault/session.ts b/packages/core/src/vault/session.ts index 1bdc208..da0a613 100644 --- a/packages/core/src/vault/session.ts +++ b/packages/core/src/vault/session.ts @@ -14,11 +14,14 @@ */ import { wipe } from "../crypto/memory.js"; import type { NostrEvent } from "../nostr/event.js"; +import { buildDeletionRequest } from "../nostr/nip09.js"; +import { KIND_APP_DATA } from "../nostr/kinds.js"; import type { KeyValueStore } from "../storage/interface.js"; import { derivePersonalPrk, deriveVaultPrk, deriveVaultPublicKey, + deriveVaultSigningKey, newConnectionId } from "./derivation.js"; import { @@ -155,6 +158,18 @@ export class VaultSession { return decryptConnectionRecordEvent(event, this.vaultPrk, this.personalPrk ?? undefined); } + /** §CV11 step 3: a NIP-09 deletion request for a replaced record event, + * signed by the vault identity. Best-effort by contract — the encrypted + * tombstone is the durable part of a deletion, this is the courtesy ask. */ + buildDeletionRequest(eventIdToDelete: string, now?: number): NostrEvent { + return buildDeletionRequest({ + privateKey: deriveVaultSigningKey(this.vaultPrk), + eventIdToDelete, + deletedEventKind: KIND_APP_DATA, + createdAt: now ?? Math.floor(Date.now() / 1000) + }); + } + publish(params: { event: NostrEvent; connectionId: string; diff --git a/packages/widget/package.json b/packages/widget/package.json index 0c8bb96..7d52e43 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -12,7 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bitlogin/core": "*" + "@bitlogin/core": "*", + "@getalby/bitcoin-connect": "^3.12.3" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 2a8e73d..e4c180f 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -13,6 +13,8 @@ import { WorkerClient } from "./worker/workerClient.js"; import { createNip07Provider, type Nip07Provider } from "./provider.js"; import { readConfigFromElement } from "./config.js"; import { WIDGET_STYLES } from "./styles.js"; +import { chooseWalletViaBitcoinConnect } from "./vault/bitcoinConnect.js"; +import type { VaultConnectionSummary } from "./worker/protocol.js"; type Screen = | "welcome" @@ -27,7 +29,10 @@ type Screen = | "dashboard" | "change-password" | "export" - | "rollback-confirm"; + | "rollback-confirm" + | "vault-consent" + | "vault-import" + | "vault-manage"; interface ConfirmSlot { index: number; @@ -98,6 +103,24 @@ export class BitLoginAuthElement extends HTMLElement { | null = null; private rollbackMessage = ""; + // ---- Connection Vault request flow (vault-ux.md §2-§4, reveal mode) ---- + // stage "auth": waiting for the user to sign in first; the goto("dashboard") + // hook resumes the request exactly once. stage "active": the vault screens + // own navigation until finishVaultRequest() settles the promise. + private vaultRequest: { + appName: string; + reason?: string; + origin: string; + stage: "auth" | "active"; + resolve: (uri: string | null) => void; + } | null = null; + private vaultCandidate: VaultConnectionSummary | null = null; + /** True when the account cannot store the connection (no vault root); the + * flow still hands the URI to the app, labeled as unsaved. */ + private vaultUnsaved = false; + private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined; + private vaultConnections: VaultConnectionSummary[] | null = null; + constructor() { super(); this.root = this.attachShadow({ mode: "open" }); @@ -186,6 +209,178 @@ export class BitLoginAuthElement extends HTMLElement { this.goto("welcome"); } + /** + * Connection Vault request API (connection-vault.md §12, vault-ux.md §2-§4). + * + * Asks the user to share an NWC wallet connection with THIS page's origin + * and resolves the raw `nostr+walletconnect://` URI, or null if the user + * declines or dismisses. REVEAL MODE, stated plainly: the caller receives + * the full bearer credential and everything its wallet-side budget allows — + * an embedded same-origin widget cannot broker (§CV12.3), so it does not + * pretend to. The consent copy tells the user the same thing. + * + * If nobody is signed in, the widget shows its sign-in flow first and + * resumes the request after. If the account already has a connection bound + * to this origin, the user sees a one-tap approval; otherwise a guided + * import (Bitcoin Connect chooser, or paste). + */ + async requestNwcConnection(options: { appName?: string; reason?: string } = {}): Promise { + if (this.vaultRequest) throw new Error("A wallet connection request is already in progress."); + const origin = window.location.origin; + const appName = options.appName?.trim() || window.location.hostname || "This app"; + return new Promise((resolve) => { + this.vaultRequest = { appName, reason: options.reason, origin, stage: "auth", resolve }; + void (async () => { + try { + const status = await this.worker.getSessionStatus(); + if (!status.unlocked) { + // Sign-in first; the goto("dashboard") hook resumes the request. + this.goto("login"); + return; + } + await this.continueVaultRequest(); + } catch (err) { + this.fail(err); + } + })(); + }); + } + + /** Settles the pending request exactly once and returns to a neutral screen. */ + private finishVaultRequest(uri: string | null): void { + const request = this.vaultRequest; + if (!request) return; + this.vaultRequest = null; + this.vaultCandidate = null; + this.vaultUnsaved = false; + this.vaultUnsavedReason = undefined; + request.resolve(uri); + if (uri !== null) { + this.dispatchEvent( + new CustomEvent("bitlogin-connection-granted", { detail: { origin: request.origin } }) + ); + } + this.goto(this.session ? "dashboard" : "welcome"); + } + + private async continueVaultRequest(): Promise { + const request = this.vaultRequest; + if (!request) return; + request.stage = "active"; + const status = await this.worker.vaultStatus(); + if (!status.enabled) { + this.vaultUnsaved = true; + this.vaultUnsavedReason = status.reason; + this.goto("vault-import"); + return; + } + this.vaultUnsaved = false; + const found = await this.worker.vaultFindForOrigin({ origin: request.origin }); + if (found.connection) { + this.vaultCandidate = found.connection; + this.goto("vault-consent"); + } else { + this.goto("vault-import"); + } + } + + private async approveVaultCandidate(): Promise { + const candidate = this.vaultCandidate; + if (!candidate || !this.vaultRequest) return; + this.setBusy(true); + try { + const { uri } = await this.worker.vaultRevealNwc({ connectionId: candidate.connectionId }); + this.setBusy(false); + this.finishVaultRequest(uri); + this.flashSuccess(this.screen, "Wallet shared"); + } catch (err) { + this.setBusy(false); + this.fail(err); + } + } + + private async runVaultBcChooser(): Promise { + const request = this.vaultRequest; + if (!request) return; + this.setBusy(true); + try { + const uri = await chooseWalletViaBitcoinConnect(request.appName); + if (uri === null) { + this.setBusy(false); + return; // modal dismissed — stay on the import screen + } + await this.saveAndShareVaultUri(uri, this.field("vaultLabel")); + } catch (err) { + this.setBusy(false); + this.fail(err); + } + } + + private async handleVaultImportSubmit(): Promise { + const uri = this.field("vaultUri").trim(); + if (!uri) return; + this.setBusy(true); + try { + await this.saveAndShareVaultUri(uri, this.field("vaultLabel")); + } catch (err) { + this.setBusy(false); + this.fail(err); + } + } + + private async saveAndShareVaultUri(uri: string, labelDraft: string): Promise { + const request = this.vaultRequest; + if (!request) return; + if (this.vaultUnsaved) { + // No vault root in this session: hand the URI to the app, honestly unsaved. + this.setBusy(false); + this.finishVaultRequest(uri); + return; + } + const label = labelDraft.trim() || `${request.appName} wallet`; + await this.worker.vaultSaveNwc({ uri, label, origin: request.origin }); + this.setBusy(false); + this.finishVaultRequest(uri); + this.flashSuccess(this.screen, "Wallet connected"); + } + + private async loadVaultManage(): Promise { + this.vaultConnections = null; + this.goto("vault-manage"); + try { + const listed = await this.worker.vaultList(); + this.vaultConnections = listed.connections; + } catch (err) { + this.errorMessage = err instanceof Error ? err.message : String(err); + this.vaultConnections = []; + } + this.render(); + } + + private async vaultUnbind(connectionId: string): Promise { + this.setBusy(true); + try { + await this.worker.vaultSetBinding({ connectionId, origin: null }); + this.busy = false; + await this.loadVaultManage(); + } catch (err) { + this.setBusy(false); + this.fail(err); + } + } + + private async vaultDeleteConnection(connectionId: string): Promise { + this.setBusy(true); + try { + await this.worker.vaultDelete({ connectionId }); + this.busy = false; + await this.loadVaultManage(); + } catch (err) { + this.setBusy(false); + this.fail(err); + } + } + /** * (Re)installs this element's own provider as window.nostr, taking over from whatever is * currently there (an extension, a different BitLogin instance, or nothing). Called @@ -257,6 +452,17 @@ export class BitLoginAuthElement extends HTMLElement { } private goto(screen: Screen): void { + // A pending wallet request hijacks the two navigation moments that decide + // its fate: landing on the dashboard after the sign-in it was waiting for + // (resume), and backing all the way out to welcome (decline). + if (screen === "dashboard" && this.vaultRequest?.stage === "auth") { + void this.continueVaultRequest().catch((err) => this.fail(err)); + return; + } + if (screen === "welcome" && this.vaultRequest) { + this.finishVaultRequest(null); + return; + } this.screen = screen; this.errorMessage = undefined; this.render(); @@ -339,6 +545,23 @@ export class BitLoginAuthElement extends HTMLElement { if (!target) return; const action = target.dataset.action!; switch (action) { + case "vault-approve": + return this.approveVaultCandidate(); + case "vault-different": + this.vaultCandidate = null; + this.goto("vault-import"); + return; + case "vault-bc": + return this.runVaultBcChooser(); + case "vault-cancel": + this.finishVaultRequest(null); + return; + case "goto-vault-manage": + return this.loadVaultManage(); + case "vault-unbind": + return this.vaultUnbind(target.dataset.id!); + case "vault-delete": + return this.vaultDeleteConnection(target.dataset.id!); case "goto-create": this.loginName = ""; this.importKey = ""; @@ -462,6 +685,8 @@ export class BitLoginAuthElement extends HTMLElement { return await this.handleRecoverNewCredentialsSubmit(); case "change-password": return await this.handleChangePasswordSubmit(); + case "vault-import": + return await this.handleVaultImportSubmit(); default: return; } @@ -1128,11 +1353,97 @@ export class BitLoginAuthElement extends HTMLElement { : "" }
+ `; + case "vault-consent": { + const request = this.vaultRequest; + const candidate = this.vaultCandidate; + if (!request || !candidate) return `
No wallet request is in progress.
`; + const connectedOn = new Date(candidate.createdAt * 1000).toLocaleDateString(); + return ` +

Share a wallet with ${escapeHtml(request.appName)}?

+ ${request.reason ? `

Reason given: ${escapeHtml(request.reason)}

` : ""} + ${this.renderError()} +
+ ${escapeHtml(candidate.label)}
+ Connected ${escapeHtml(connectedOn)} · wallet ${escapeHtml((candidate.walletPubkey ?? "").slice(0, 10))}… +
+
${escapeHtml(request.appName)} will receive this connection and can spend within the budget your wallet enforces, until you revoke it from Wallet connections.
+ + + + `; + } + + case "vault-import": { + const request = this.vaultRequest; + if (!request) return `
No wallet request is in progress.
`; + const unsavedNotice = this.vaultUnsaved + ? `
${ + this.vaultUnsavedReason === "no-vault" + ? "This account predates the Connection Vault, so the connection will be handed to the app but not saved to your account. Enable the vault from your account manager (recovery phrase required) to make wallets portable." + : "Sign in again to save connections to your account — until then the connection will be handed to the app but not saved." + }
` + : ""; + return ` +

Connect a wallet for ${escapeHtml(request.appName)}

+ ${request.reason ? `

Reason given: ${escapeHtml(request.reason)}

` : ""} + ${unsavedNotice} + ${this.renderError()} + +
+
+ + + + + +
+
Set a spending budget on the wallet's own authorization page — the wallet is the only place a budget is actually enforced.
+ + `; + } + + case "vault-manage": { + const list = this.vaultConnections; + const rows = + list === null + ? `

Loading…

` + : list.length === 0 + ? `

No connections stored yet. They're added when you connect a wallet inside an app.

` + : list + .map( + (c) => ` +
+ ${escapeHtml(c.label)} (${escapeHtml(c.connectionType)})
+ ${c.origin ? `Linked to ${escapeHtml(c.origin)}` : "Not linked to an app"} +
+ ${c.origin ? `` : ""} + +
+
` + ) + .join(""); + return ` +

Wallet connections

+

Connections your apps use, stored encrypted on your account and restored on any device you sign in to.

+ ${this.renderError()} + ${rows} +
"Remove from BitLogin" deletes the stored copy only — the connection itself keeps working for any app that already has it. To revoke spending authority, delete the connection inside your wallet app.
+ + `; + } + case "rollback-confirm": return `

This looks like a stale or revoked credential

diff --git a/packages/widget/src/vault/bitcoinConnect.ts b/packages/widget/src/vault/bitcoinConnect.ts new file mode 100644 index 0000000..528851a --- /dev/null +++ b/packages/widget/src/vault/bitcoinConnect.ts @@ -0,0 +1,108 @@ +/** + * Bitcoin Connect as a wallet CHOOSER for NWC import (vault-ux.md §2). + * + * The discipline here is lifted from Satisfied's production integration, + * which learned each rule on a real device: + * + * - Chooser only. BC's job ends the moment a wallet hands over the URI; the + * encrypted vault record is the sole owner afterward. BC is disconnected + * and its localStorage config cleared so no plaintext copy of the secret + * outlives the ceremony (§CV17). + * - Loaded lazily. BC pulls in @getalby/sdk, lightning-tools, zustand, and a + * QR renderer — heavier than the whole widget core. The dynamic import + * below builds as its own chunk, downloaded only when a user actually + * opens the wallet-connect flow. + * - NO numeric budget in authorizationUrlOptions. The field's unit is not + * interoperable (CoinOS reads millisats, Alby Hub reads sats); the budget + * belongs on the wallet's own authorization page, which is also the only + * place it is actually enforced. + * - persistConnection/autoConnect off, and the config key cleared even so: + * BC must never restore itself as a competing provider on a later load. + * + * Host CSP note: BC's Lit components set inline style ATTRIBUTES, so + * embedding pages need `style-src-attr 'unsafe-inline'` for this flow (the + * narrowest directive that covers it — hashes cannot). + */ + +const BC_STORAGE_KEY = "bc:config"; +const NWC_URI = /^nostr\+walletconnect:\/\//iu; + +type BitcoinConnectModule = typeof import("@getalby/bitcoin-connect"); + +let modulePromise: Promise | null = null; + +function loadBitcoinConnect(appName: string): Promise { + if (!modulePromise) { + // BC runs its restore logic at module evaluation, before init() can apply + // persistConnection:false — clear any stale key first so an old connector + // can never race this flow. + try { + localStorage.removeItem(BC_STORAGE_KEY); + } catch { + // Storage being blocked only disables BC's own persistence, which is + // what this flow wants anyway. + } + modulePromise = import("@getalby/bitcoin-connect") + .then((module) => { + module.init({ + appName, + filters: ["nwc"], + showBalance: false, + autoConnect: false, + persistConnection: false, + providerConfig: { + nwc: { + authorizationUrlOptions: { + name: appName, + requestMethods: ["pay_invoice"] + } + } + } + }); + return module; + }) + .catch((error: unknown) => { + modulePromise = null; // a transient chunk failure must be retryable + throw error; + }); + } + return modulePromise; +} + +function isCancellation(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /closed|cancell?ed|dismissed|aborted/iu.test(message); +} + +/** + * Opens Bitcoin Connect's wallet chooser and resolves the NWC URI, or null + * when the user dismisses the modal (an ordinary outcome, not an error). + */ +export async function chooseWalletViaBitcoinConnect(appName: string): Promise { + const bc = await loadBitcoinConnect(appName); + try { + const provider = await bc.requestProvider(); + const configUrl = bc.getConnectorConfig()?.nwcUrl; + const providerUrl = (provider as unknown as { client?: { nostrWalletConnectUrl?: unknown } }).client + ?.nostrWalletConnectUrl; + const uri = typeof configUrl === "string" ? configUrl : typeof providerUrl === "string" ? providerUrl : ""; + if (!NWC_URI.test(uri.trim())) { + throw new Error("The wallet did not return an NWC connection. Try again or paste one manually."); + } + return uri.trim(); + } catch (error) { + if (isCancellation(error)) return null; + throw error; + } finally { + try { + bc.disconnect(); + } catch { + // The URI (if any) is already captured; connector cleanup is best-effort. + } + try { + localStorage.removeItem(BC_STORAGE_KEY); + } catch { + // Storage can be blocked independently of connector cleanup. + } + } +} diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index b1911cf..16e7a41 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -19,9 +19,18 @@ import { type RecoveredIdentity } from "@bitlogin/core/account"; import { RelayPool, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr"; -import { getPublicKeyHex } from "@bitlogin/core/crypto"; +import { getPublicKeyHex, base64urlToBytes, wipe } from "@bitlogin/core/crypto"; +import { + VaultSession, + parseNwcUri, + toNwcUri, + validateNwcCredential, + type DecryptedConnectionRecord, + type NwcCredential +} from "@bitlogin/core/vault"; import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js"; import { saveCachedSession, loadCachedSession, clearCachedSession } from "./sessionCache.js"; +import type { VaultConnectionSummary } from "./protocol.js"; import type { WorkerRequest, WorkerResponse, @@ -48,6 +57,14 @@ interface SessionState { activeCredentialEvent: NostrEvent | null; activeRecoveryEvent: NostrEvent | null; pendingRecovery: RecoveredIdentity | null; + /** Connection Vault root (§CV5.2), or null when the account predates the + * vault OR the restored cache predates the field — vaultKnown tells the + * two apart. The SUDO key is deliberately NOT session state: personal-tier + * records are out of scope for the widget until a sudo ceremony exists, + * and the key is wiped the moment a login result delivers it. */ + connectionVaultRoot: Uint8Array | null; + vaultKnown: boolean; + vault: VaultSession | null; } const session: SessionState = { @@ -57,7 +74,10 @@ const session: SessionState = { recoveryPublicKey: null, activeCredentialEvent: null, activeRecoveryEvent: null, - pendingRecovery: null + pendingRecovery: null, + connectionVaultRoot: null, + vaultKnown: false, + vault: null }; let vaultRelayUrls: string[] = [...BUILTIN_VAULT_RELAYS]; @@ -81,6 +101,11 @@ function clearSession(): void { session.recoveryPublicKey = null; session.activeCredentialEvent = null; session.activeRecoveryEvent = null; + session.vault?.destroy(); + session.vault = null; + if (session.connectionVaultRoot) session.connectionVaultRoot.fill(0); + session.connectionVaultRoot = null; + session.vaultKnown = false; if (session.pendingRecovery) { session.pendingRecovery.recoveryPrivateKey.fill(0); session.pendingRecovery.everydayPrivateKey.fill(0); @@ -88,6 +113,15 @@ function clearSession(): void { } } +/** Adopts vault material from a login/register/recovery result: keeps the + * root, WIPES the sudo key immediately (see SessionState comment). */ +function adoptVaultMaterial(root: Uint8Array | undefined, sudoKey?: Uint8Array): void { + session.connectionVaultRoot = root ?? null; + session.vaultKnown = true; + session.vault = null; + if (sudoKey) wipe(sudoKey); +} + // Caches the current session (§21) so a later page load can restore it via // "restoreSession" instead of asking for the login name + password again. // Best-effort and silent on any missing field -- every call site here runs @@ -108,10 +142,69 @@ async function persistSession(): Promise { accountId: session.accountId, recoveryPublicKey: session.recoveryPublicKey, activeCredentialEvent: session.activeCredentialEvent, - activeRecoveryEvent: session.activeRecoveryEvent + activeRecoveryEvent: session.activeRecoveryEvent, + ...(session.connectionVaultRoot ? { connectionVaultRoot: session.connectionVaultRoot } : {}), + ...(session.vaultKnown ? { vaultEnabled: session.connectionVaultRoot !== null } : {}) }); } +function requireVault(): VaultSession { + if (!session.signer) throw new Error("No identity is unlocked in this session."); + if (!session.connectionVaultRoot) { + throw new Error( + session.vaultKnown + ? "This account predates the Connection Vault. Enable it from your BitLogin account manager (recovery phrase required)." + : "Sign in again to use wallet connections on this device." + ); + } + session.vault ??= new VaultSession(session.connectionVaultRoot.slice()); + return session.vault; +} + +function summarize(decrypted: DecryptedConnectionRecord): VaultConnectionSummary { + const { record } = decrypted; + const summary: VaultConnectionSummary = { + connectionId: record.connection_id, + connectionType: record.connection_type, + label: record.label, + state: record.state, + origin: record.application_binding.origin, + createdAt: record.created_at, + updatedAt: record.updated_at + }; + if (record.connection_type === "nwc" && record.state !== "deleted") { + const credential = record.credential as unknown as NwcCredential; + summary.walletPubkey = credential.wallet_pubkey; + summary.relayCount = credential.relays?.length; + } + return summary; +} + +/** Fetch + decrypt the live (non-deleted) connections. */ +async function listLiveConnections(): Promise<{ + vault: VaultSession; + connections: DecryptedConnectionRecord[]; + rollbackWarnings: string[]; +}> { + const vault = requireVault(); + const listed = await vault.listConnections({ relayUrls: vaultRelayUrls, store }); + return { + vault, + connections: listed.connections.filter((c) => c.record.state !== "deleted"), + rollbackWarnings: listed.rollbackWarnings + }; +} + +async function findConnection(connectionId: string): Promise<{ + vault: VaultSession; + connection: DecryptedConnectionRecord; +}> { + const { vault, connections } = await listLiveConnections(); + const connection = connections.find((c) => c.record.connection_id === connectionId); + if (!connection) throw new Error("That connection no longer exists."); + return { vault, connection }; +} + async function handle(action: string, payload: unknown): Promise { switch (action) { case "configure": { @@ -133,6 +226,7 @@ async function handle(action: string, payload: unknown): Promise { session.recoveryPublicKey = result.recoveryPublicKey; session.activeCredentialEvent = result.credentialEvent; session.activeRecoveryEvent = result.recoveryEvent; + adoptVaultMaterial(result.connectionVaultRoot, result.vaultSudoKey); await persistSession(); return { recoveryPhrase: result.recoveryPhrase, @@ -172,6 +266,7 @@ async function handle(action: string, payload: unknown): Promise { session.recoveryPublicKey = result.recoveryPublicKey; session.activeCredentialEvent = result.credentialEvent; session.activeRecoveryEvent = result.recoveryCapsuleEvent; + adoptVaultMaterial(result.connectionVaultRoot, result.vaultSudoKey); await persistSession(); return { everydayPublicKey: result.everydayPublicKey, @@ -197,6 +292,12 @@ async function handle(action: string, payload: unknown): Promise { session.accountId = recovered.accountId; session.recoveryPublicKey = recovered.recoveryPublicKey; session.activeRecoveryEvent = recovered.currentRecoveryEvent; + // §CV13.2 — the roots ride the recovery capsule; the sudo copy is not retained. + adoptVaultMaterial( + recovered.currentRecoveryPayload.connection_vault_root + ? base64urlToBytes(recovered.currentRecoveryPayload.connection_vault_root) + : undefined + ); return { everydayPublicKey: recovered.everydayPublicKey, accountId: recovered.accountId, @@ -350,6 +451,11 @@ async function handle(action: string, payload: unknown): Promise { session.recoveryPublicKey = cached.recoveryPublicKey; session.activeCredentialEvent = cached.activeCredentialEvent; session.activeRecoveryEvent = cached.activeRecoveryEvent; + session.connectionVaultRoot = cached.connectionVaultRoot ?? null; + // vaultKnown only when the cache recorded a definitive answer; an old + // cache entry leaves it false so requireVault says "sign in again" + // rather than falsely claiming the account has no vault. + session.vaultKnown = cached.vaultEnabled !== undefined; return { restored: true, everydayPublicKey: session.signer.getPublicKey(), accountId: cached.accountId }; } @@ -359,6 +465,111 @@ async function handle(action: string, payload: unknown): Promise { return {}; } + // ---- Connection Vault (connection-vault.md §12, reveal mode) ---- + + case "vaultStatus": { + if (!session.signer) return { enabled: false }; + if (session.connectionVaultRoot) { + return { enabled: true, vaultPublicKey: requireVault().vaultPublicKey }; + } + return { enabled: false, reason: session.vaultKnown ? "no-vault" : "stale-cache" }; + } + + case "vaultList": { + const { connections, rollbackWarnings } = await listLiveConnections(); + return { connections: connections.map(summarize), rollbackWarnings }; + } + + case "vaultSaveNwc": { + const p = payload as { uri: string; label: string; origin: string | null }; + const vault = requireVault(); + const credential = parseNwcUri(p.uri); + const label = p.label.trim().slice(0, 120) || "Wallet connection"; + const { record, event } = await vault.createConnection({ + connection_type: "nwc", + tier: "connectable", + label, + credential: credential as unknown as { schema: string } & Record, + application_binding: { origin: p.origin, app_pubkey: null } + }); + const publish = await vault.publish({ + event, + connectionId: record.connection_id, + relayUrls: vaultRelayUrls, + store + }); + if (!publish.success) { + // Nothing was stored anywhere durable -- say so instead of letting the + // user believe a connection exists that no device can ever restore. + throw new Error("The connection could not be saved to enough relays. Please try again."); + } + const decrypted = await vault.decryptEvent(event); + return summarize(decrypted!); + } + + case "vaultFindForOrigin": { + const p = payload as { origin: string }; + const { connections } = await listLiveConnections(); + const match = connections.find( + (c) => + c.record.connection_type === "nwc" && + c.record.state === "active" && + c.record.application_binding.origin === p.origin + ); + return { connection: match ? summarize(match) : null }; + } + + case "vaultRevealNwc": { + const p = payload as { connectionId: string }; + const { connection } = await findConnection(p.connectionId); + if (connection.record.connection_type !== "nwc" || connection.record.state !== "active") { + throw new Error("That connection is not an active wallet connection."); + } + const credential = connection.record.credential as unknown as NwcCredential; + validateNwcCredential(credential); + return { uri: toNwcUri(credential) }; + } + + case "vaultSetBinding": { + const p = payload as { connectionId: string; origin: string | null }; + const { vault, connection } = await findConnection(p.connectionId); + const { record, event } = await vault.updateConnection(connection, { + application_binding: { origin: p.origin, app_pubkey: null } + }); + const publish = await vault.publish({ + event, + connectionId: record.connection_id, + relayUrls: vaultRelayUrls, + store + }); + if (!publish.success) throw new Error("The change could not reach enough relays. Please try again."); + const decrypted = await vault.decryptEvent(event); + return summarize(decrypted!); + } + + case "vaultDelete": { + const p = payload as { connectionId: string }; + const { vault, connection } = await findConnection(p.connectionId); + const { record, event } = await vault.deleteConnection(connection); + const publish = await vault.publish({ + event, + connectionId: record.connection_id, + relayUrls: vaultRelayUrls, + store + }); + if (!publish.success) throw new Error("The deletion could not reach enough relays. Please try again."); + // §CV11 step 3: best-effort NIP-09 request for the replaced event id. + try { + const deletion = vault.buildDeletionRequest(connection.event.id); + const pool = new RelayPool(vaultRelayUrls); + await pool.publishAll(deletion); + pool.closeAll(); + } catch { + // The tombstone is the durable part; a failed NIP-09 broadcast is not. + } + return {}; + } + default: throw new Error(`Unknown worker action: ${action}`); } diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 05ef02c..4b796f2 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -163,6 +163,63 @@ export interface RestoreSessionResult { accountId?: string; } +// ---- Connection Vault (connection-vault.md §12, phase D; reveal mode) ---- + +export interface VaultStatusResult { + /** True when this session holds the vault root and can use connections. */ + enabled: boolean; + /** Set when enabled=false: the account predates the vault ("no-vault") or + * the root simply wasn't in the restored cache and a fresh sign-in would + * load it ("stale-cache"). */ + reason?: "no-vault" | "stale-cache"; + vaultPublicKey?: string; +} + +/** Everything the UI needs and nothing an app must not see: no secrets. */ +export interface VaultConnectionSummary { + connectionId: string; + connectionType: string; + label: string; + state: string; + origin: string | null; + createdAt: number; + updatedAt: number; + /** NWC display metadata (non-secret). */ + walletPubkey?: string; + relayCount?: number; +} + +export interface VaultListResult { + connections: VaultConnectionSummary[]; + rollbackWarnings: string[]; +} + +export interface VaultSaveNwcPayload { + uri: string; + label: string; + origin: string | null; +} + +export interface VaultFindForOriginPayload { + origin: string; +} + +export interface VaultRevealNwcPayload { + connectionId: string; +} +export interface VaultRevealNwcResult { + uri: string; +} + +export interface VaultSetBindingPayload { + connectionId: string; + origin: string | null; +} + +export interface VaultDeletePayload { + connectionId: string; +} + export type WorkerActionMap = { configure: [ConfigurePayload, Record]; register: [RegisterPayload, RegisterResult]; @@ -184,6 +241,13 @@ export type WorkerActionMap = { getSessionStatus: [Record, SessionStatusResult]; restoreSession: [Record, RestoreSessionResult]; logout: [Record, Record]; + vaultStatus: [Record, VaultStatusResult]; + vaultList: [Record, VaultListResult]; + vaultSaveNwc: [VaultSaveNwcPayload, VaultConnectionSummary]; + vaultFindForOrigin: [VaultFindForOriginPayload, { connection: VaultConnectionSummary | null }]; + vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult]; + vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary]; + vaultDelete: [VaultDeletePayload, Record]; }; export type WorkerAction = keyof WorkerActionMap; diff --git a/packages/widget/src/worker/sessionCache.ts b/packages/widget/src/worker/sessionCache.ts index fed3cd6..2a4ee9d 100644 --- a/packages/widget/src/worker/sessionCache.ts +++ b/packages/widget/src/worker/sessionCache.ts @@ -24,6 +24,14 @@ export interface CachedSessionData { recoveryPublicKey: string; activeCredentialEvent: NostrEvent; activeRecoveryEvent: NostrEvent; + /** Connection Vault root (§CV5.2). Same sensitivity class as the everyday + * key, cached under the same encrypted-at-rest tradeoff. The SUDO key is + * deliberately never cached: re-obtaining it is the sudo ceremony. */ + connectionVaultRoot?: Uint8Array; + /** False when the logged-in capsule definitively carried no vault root, so + * a restored session can tell "account predates the vault" apart from + * "root missing because this cache entry predates the field". */ + vaultEnabled?: boolean; } interface PlainSession { @@ -32,6 +40,8 @@ interface PlainSession { recoveryPublicKey: string; activeCredentialEvent: NostrEvent; activeRecoveryEvent: NostrEvent; + connectionVaultRootHex?: string; + vaultEnabled?: boolean; } interface EncryptedSession { @@ -80,7 +90,15 @@ export async function saveCachedSession(store: SessionStore, data: CachedSession accountId: data.accountId, recoveryPublicKey: data.recoveryPublicKey, activeCredentialEvent: data.activeCredentialEvent, - activeRecoveryEvent: data.activeRecoveryEvent + activeRecoveryEvent: data.activeRecoveryEvent, + ...(data.connectionVaultRoot + ? { + connectionVaultRootHex: Array.from(data.connectionVaultRoot, (byte) => + byte.toString(16).padStart(2, "0") + ).join("") + } + : {}), + ...(data.vaultEnabled !== undefined ? { vaultEnabled: data.vaultEnabled } : {}) }; // Best-effort: a full disk or a browser blocking IndexedDB (some private- // browsing modes) shouldn't break the login/register/rotate call that @@ -148,7 +166,16 @@ export async function loadCachedSession(store: SessionStore): Promise Number.parseInt(pair, 16) + ) + } + : {}), + ...(typeof parsed.vaultEnabled === "boolean" ? { vaultEnabled: parsed.vaultEnabled } : {}) }; } catch { // Corrupt or unrecognized cache entry -- treat as "no session," not an error. diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 7752433..6063e67 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -16,7 +16,12 @@ import type { Nip44EncryptPayload, Nip44DecryptPayload, Nip04EncryptPayload, - Nip04DecryptPayload + Nip04DecryptPayload, + VaultSaveNwcPayload, + VaultFindForOriginPayload, + VaultRevealNwcPayload, + VaultSetBindingPayload, + VaultDeletePayload } from "./protocol.js"; export class WorkerClient { @@ -115,6 +120,27 @@ export class WorkerClient { restoreSession() { return this.call("restoreSession", {}); } + vaultStatus() { + return this.call("vaultStatus", {}); + } + vaultList() { + return this.call("vaultList", {}); + } + vaultSaveNwc(payload: VaultSaveNwcPayload) { + return this.call("vaultSaveNwc", payload); + } + vaultFindForOrigin(payload: VaultFindForOriginPayload) { + return this.call("vaultFindForOrigin", payload); + } + vaultRevealNwc(payload: VaultRevealNwcPayload) { + return this.call("vaultRevealNwc", payload); + } + vaultSetBinding(payload: VaultSetBindingPayload) { + return this.call("vaultSetBinding", payload); + } + vaultDelete(payload: VaultDeletePayload) { + return this.call("vaultDelete", payload); + } logout() { return this.call("logout", {}); }