Widget: requestNwcConnection() -- vault authorization layer, reveal mode

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-30 13:10:39 -04:00
1 parent a465e1d8fa
commit c3e55add16
13 files changed
+1059 -13

No files matched your search

+12
View File
@@ -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
+25
View File
@@ -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
+6 -2
View File
@@ -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
+243 -2
View File
@@ -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:[email protected]"
}
},
"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:[email protected]"
}
},
"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",
+2 -1
View File
@@ -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",
+15
View File
@@ -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;
+2 -1
View File
@@ -12,7 +12,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bitlogin/core": "*"
"@bitlogin/core": "*",
"@getalby/bitcoin-connect": "^3.12.3"
},
"devDependencies": {
"typescript": "^5.7.3",
+312 -1
View File
@@ -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<string | null> {
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<string | null>((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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 {
: ""
}
<div class="divider"></div>
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
<button class="secondary" type="button" data-action="goto-export">Export identity</button>
<button class="secondary" type="button" data-action="logout">Log out</button>
`;
case "vault-consent": {
const request = this.vaultRequest;
const candidate = this.vaultCandidate;
if (!request || !candidate) return `<div class="notice error">No wallet request is in progress.</div>`;
const connectedOn = new Date(candidate.createdAt * 1000).toLocaleDateString();
return `
<h2>Share a wallet with ${escapeHtml(request.appName)}?</h2>
${request.reason ? `<p class="sub">Reason given: ${escapeHtml(request.reason)}</p>` : ""}
${this.renderError()}
<div class="credential-box">
<strong>${escapeHtml(candidate.label)}</strong><br />
Connected ${escapeHtml(connectedOn)} · wallet ${escapeHtml((candidate.walletPubkey ?? "").slice(0, 10))}…
</div>
<div class="notice info">${escapeHtml(request.appName)} will receive this connection and can spend within the budget your wallet enforces, until you revoke it from Wallet connections.</div>
<button class="primary" type="button" data-action="vault-approve" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Sharing…' : "Use this wallet"}
</button>
<button class="secondary" type="button" data-action="vault-different">Use a different wallet</button>
<button class="link" type="button" data-action="vault-cancel">Cancel</button>
`;
}
case "vault-import": {
const request = this.vaultRequest;
if (!request) return `<div class="notice error">No wallet request is in progress.</div>`;
const unsavedNotice = this.vaultUnsaved
? `<div class="notice warn">${
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."
}</div>`
: "";
return `
<h2>Connect a wallet for ${escapeHtml(request.appName)}</h2>
${request.reason ? `<p class="sub">Reason given: ${escapeHtml(request.reason)}</p>` : ""}
${unsavedNotice}
${this.renderError()}
<button class="primary" type="button" data-action="vault-bc" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Waiting for your wallet…' : "Connect a wallet"}
</button>
<div class="divider"></div>
<form data-form="vault-import">
<label for="vaultUri">Or paste an NWC connection</label>
<input type="password" name="vaultUri" id="vaultUri" autocomplete="off" placeholder="nostr+walletconnect://…" />
<label for="vaultLabel">Name this connection</label>
<input type="text" name="vaultLabel" id="vaultLabel" autocomplete="off" maxlength="120" value="${escapeHtml(`${request.appName} wallet`)}" />
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
${this.vaultUnsaved ? "Share without saving" : "Save and share"}
</button>
</form>
<div class="notice info">Set a spending budget on the wallet's own authorization page — the wallet is the only place a budget is actually enforced.</div>
<button class="link" type="button" data-action="vault-cancel">Cancel</button>
`;
}
case "vault-manage": {
const list = this.vaultConnections;
const rows =
list === null
? `<p class="sub">Loading…</p>`
: list.length === 0
? `<p class="sub">No connections stored yet. They're added when you connect a wallet inside an app.</p>`
: list
.map(
(c) => `
<div class="credential-box">
<strong>${escapeHtml(c.label)}</strong> <span class="sub">(${escapeHtml(c.connectionType)})</span><br />
${c.origin ? `Linked to ${escapeHtml(c.origin)}` : "Not linked to an app"}
<div>
${c.origin ? `<button class="link" type="button" data-action="vault-unbind" data-id="${escapeHtml(c.connectionId)}" ${this.busy ? "disabled" : ""}>Revoke app access</button>` : ""}
<button class="link" type="button" data-action="vault-delete" data-id="${escapeHtml(c.connectionId)}" ${this.busy ? "disabled" : ""}>Remove from BitLogin</button>
</div>
</div>`
)
.join("");
return `
<h2>Wallet connections</h2>
<p class="sub">Connections your apps use, stored encrypted on your account and restored on any device you sign in to.</p>
${this.renderError()}
${rows}
<div class="notice info">"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.</div>
<button class="link" data-action="goto-dashboard">Back</button>
`;
}
case "rollback-confirm":
return `
<h2>This looks like a stale or revoked credential</h2>
+108
View File
@@ -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<BitcoinConnectModule> | null = null;
function loadBitcoinConnect(appName: string): Promise<BitcoinConnectModule> {
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<string | null> {
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.
}
}
}
+214 -3
View File
@@ -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<void> {
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<unknown> {
switch (action) {
case "configure": {
@@ -133,6 +226,7 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<string, unknown>,
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}`);
}
+64
View File
@@ -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<string, never>];
register: [RegisterPayload, RegisterResult];
@@ -184,6 +241,13 @@ export type WorkerActionMap = {
getSessionStatus: [Record<string, never>, SessionStatusResult];
restoreSession: [Record<string, never>, RestoreSessionResult];
logout: [Record<string, never>, Record<string, never>];
vaultStatus: [Record<string, never>, VaultStatusResult];
vaultList: [Record<string, never>, VaultListResult];
vaultSaveNwc: [VaultSaveNwcPayload, VaultConnectionSummary];
vaultFindForOrigin: [VaultFindForOriginPayload, { connection: VaultConnectionSummary | null }];
vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult];
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
vaultDelete: [VaultDeletePayload, Record<string, never>];
};
export type WorkerAction = keyof WorkerActionMap;
+29 -2
View File
@@ -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<CachedSess
accountId: parsed.accountId,
recoveryPublicKey: parsed.recoveryPublicKey,
activeCredentialEvent: parsed.activeCredentialEvent,
activeRecoveryEvent: parsed.activeRecoveryEvent
activeRecoveryEvent: parsed.activeRecoveryEvent,
...(typeof parsed.connectionVaultRootHex === "string"
? {
connectionVaultRoot: Uint8Array.from(
parsed.connectionVaultRootHex.match(/.{1,2}/g) || [],
(pair) => Number.parseInt(pair, 16)
)
}
: {}),
...(typeof parsed.vaultEnabled === "boolean" ? { vaultEnabled: parsed.vaultEnabled } : {})
};
} catch {
// Corrupt or unrecognized cache entry -- treat as "no session," not an error.
+27 -1
View File
@@ -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", {});
}