From 6b48de6168ff2cbf7164830f4185b978d70b5ce2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:06 -0400 Subject: [PATCH] Widget: offerNwcConnection() -- consent-gated offer-to-save The inverse of requestNwcConnection: an app that obtained an NWC URI by its own means (its own wallet chooser, its own paste box) offers the user a portable copy. The credential is already in the app's hands, so the only question on screen is whether a copy enters the user's vault -- and that write happens in BitLogin's own chrome or not at all, never silently, because a page that can pump records into someone's account without a tap is a spam vector wearing a feature's clothes. Dedupe by connection identity (wallet pubkey + secret; relays and labels may drift without making a new connection -- sameNwcCredential in core): a duplicate resolves 'already-saved' with a silent origin-binding refresh and no UI, which is what makes the call safe to fire after every successful connect. 'unavailable' (no session, no vault root) is a quiet no-op by contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1 --- CHANGELOG.md | 7 ++ packages/core/src/vault/nwc.ts | 7 ++ packages/core/src/vault/vault.test.ts | 10 +- packages/demo/public/docs.html | 9 ++ packages/demo/public/llms.txt | 6 ++ packages/widget/src/element.ts | 102 ++++++++++++++++++++- packages/widget/src/worker/cryptoWorker.ts | 34 +++++++ packages/widget/src/worker/protocol.ts | 13 +++ packages/widget/src/worker/workerClient.ts | 6 +- 9 files changed, 191 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9128f99..ecbe159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ would plausibly want. Newest first. 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. +- **Widget: `offerNwcConnection()` — offer-to-save for app-obtained + wallets.** The inverse of `requestNwcConnection`: an app that got an NWC + URI through its own chooser or paste box can offer the user a portable + copy. Consent-gated in the widget's own UI, never silent (the write goes + to the user's account); duplicates by wallet+secret resolve + `already-saved` with a silent origin-binding refresh and no UI, so apps + can fire it after every successful connect. - **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/packages/core/src/vault/nwc.ts b/packages/core/src/vault/nwc.ts index a88b33d..ab8f1eb 100644 --- a/packages/core/src/vault/nwc.ts +++ b/packages/core/src/vault/nwc.ts @@ -90,6 +90,13 @@ export function toNwcUri(credential: NwcCredential): string { return `nostr+walletconnect://${credential.wallet_pubkey}?${params.toString()}`; } +/** True when two credentials are the SAME connection: same wallet service, + * same client secret. Relays/labels/lud16 may drift without making a new + * connection — the secret is the identity of the grant. */ +export function sameNwcCredential(a: NwcCredential, b: NwcCredential): boolean { + return a.wallet_pubkey === b.wallet_pubkey && a.secret === b.secret; +} + export function validateNwcCredential(credential: unknown): asserts credential is NwcCredential { const c = credential as Partial | null; const fail = (message: string) => { diff --git a/packages/core/src/vault/vault.test.ts b/packages/core/src/vault/vault.test.ts index 5359af2..82ca976 100644 --- a/packages/core/src/vault/vault.test.ts +++ b/packages/core/src/vault/vault.test.ts @@ -18,7 +18,7 @@ import { deriveVaultPublicKey, newConnectionId } from "./derivation.js"; -import { parseNwcUri, toNwcUri, NwcParseError } from "./nwc.js"; +import { parseNwcUri, toNwcUri, sameNwcCredential, NwcParseError } from "./nwc.js"; import { buildConnectionRecordEvent, decryptConnectionRecordEvent, @@ -132,6 +132,14 @@ describe("NWC profile (nwc-connections.md §5-§6)", () => { expect(parseNwcUri(toNwcUri(credential))).toEqual(credential); }); + it("identity of a connection is wallet + secret, not relays or labels", () => { + const a = parseNwcUri(URI); + const b = { ...a, relays: ["wss://relay.other"], lud16: null }; + const c = { ...a, secret: "ef".repeat(32) }; + expect(sameNwcCredential(a, b)).toBe(true); + expect(sameNwcCredential(a, c)).toBe(false); + }); + it("refuses missing relay, missing secret, bad pubkey, and non-websocket relays", () => { const base = `nostr+walletconnect://${"ab".repeat(32)}`; expect(() => parseNwcUri(`${base}?secret=${"cd".repeat(32)}`)).toThrow(NwcParseError); diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index f4dd3cb..6e94037 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -125,6 +125,15 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declinedbitlogin-connection-granted event fires on the element after each share, and users manage or revoke grants from the widget dashboard's "Wallet connections" screen.

+

The inverse direction exists too: if your app obtained a connection by its own means (its own + wallet chooser, its own paste box), offer the user a portable copy —

+
const outcome = await bl.offerNwcConnection(uri, { appName: 'Satisfied' });
+// 'saved' | 'declined' | 'already-saved' | 'unavailable'
+

The widget shows its own consent screen ("Save this wallet to your BitLogin?") and never saves + silently — the write goes to the user's account, so it happens in BitLogin's chrome or not at all. + Duplicates (same wallet + secret) resolve already-saved with no UI, so it's safe to + fire after every successful connect. unavailable means nobody is signed in or the + account has no vault; treat it as a no-op.

This is reveal mode, and the consent screen says so. Your origin receives the full bearer credential and everything its wallet-side budget allows — an embedded same-origin widget is diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index bc0a154..56665f2 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -56,6 +56,12 @@ deployment above. mode: the origin receives the full bearer credential — wallet-side budgets are the real security boundary. Requires `style-src-attr 'unsafe-inline'` in the host CSP. Fires `bitlogin-connection-granted` after each share. +- `offerNwcConnection(uri, { appName, label })` on the element — the inverse: + the app obtained an NWC URI by its own means and offers the user a portable + copy. Consent screen in the widget's own UI, never a silent save; resolves + 'saved' | 'declined' | 'already-saved' (same wallet+secret exists; origin + binding refreshed, no UI) | 'unavailable' (no session/vault — treat as + no-op). Safe to fire after every successful connect. - Events on the element: `bitlogin-login` (`detail.publicKey`), `bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released` (the last also fires on `window` when triggered via diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index e4c180f..55414c6 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -32,7 +32,8 @@ type Screen = | "rollback-confirm" | "vault-consent" | "vault-import" - | "vault-manage"; + | "vault-manage" + | "vault-offer"; interface ConfirmSlot { index: number; @@ -120,6 +121,14 @@ export class BitLoginAuthElement extends HTMLElement { private vaultUnsaved = false; private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined; private vaultConnections: VaultConnectionSummary[] | null = null; + /** offerNwcConnection state: the app already holds this URI; the only + * question on screen is whether a copy enters the user's vault. */ + private vaultOffer: { + uri: string; + appName: string; + label: string; + resolve: (outcome: "saved" | "declined") => void; + } | null = null; constructor() { super(); @@ -246,6 +255,71 @@ export class BitLoginAuthElement extends HTMLElement { }); } + /** + * Offer-to-save (the inverse of requestNwcConnection): the app OBTAINED an + * NWC URI by its own means — its own wallet chooser, its own paste box — + * and offers the user a portable copy. Consent-gated in this widget's own + * UI, never silent: the write goes to the user's account (encrypted events + * under their vault identity), and nothing enters or leaves the vault + * without the user seeing it happen in BitLogin's chrome. + * + * Resolves "saved", "declined", "already-saved" (same wallet + secret + * exists; its origin binding was refreshed, no UI shown), or "unavailable" + * (no session or no vault root — the offer is quietly impossible, and an + * app should treat that as a no-op rather than an error). + */ + async offerNwcConnection( + uri: string, + options: { appName?: string; label?: string } = {} + ): Promise<"saved" | "declined" | "already-saved" | "unavailable"> { + if (this.vaultOffer || this.vaultRequest) return "unavailable"; + const status = await this.worker.getSessionStatus().catch(() => ({ unlocked: false })); + if (!status.unlocked) return "unavailable"; + const vaultStatus = await this.worker.vaultStatus(); + if (!vaultStatus.enabled) return "unavailable"; + const origin = window.location.origin; + const check = await this.worker.vaultOfferCheck({ uri, origin }); + if (check.duplicate) return "already-saved"; + + const appName = options.appName?.trim() || window.location.hostname || "This app"; + return new Promise((resolve) => { + this.vaultOffer = { + uri, + appName, + label: options.label?.trim() || `${appName} wallet`, + resolve + }; + this.goto("vault-offer"); + }); + } + + private finishVaultOffer(outcome: "saved" | "declined"): void { + const offer = this.vaultOffer; + if (!offer) return; + this.vaultOffer = null; + offer.resolve(outcome); + this.goto(this.session ? "dashboard" : "welcome"); + } + + private async acceptVaultOffer(): Promise { + const offer = this.vaultOffer; + if (!offer) return; + this.setBusy(true); + try { + const label = this.field("vaultOfferLabel").trim() || offer.label; + await this.worker.vaultSaveNwc({ uri: offer.uri, label, origin: window.location.origin }); + this.setBusy(false); + this.finishVaultOffer("saved"); + this.flashSuccess(this.screen, "Wallet saved"); + this.dispatchEvent( + new CustomEvent("bitlogin-connection-granted", { detail: { origin: window.location.origin } }) + ); + } catch (err) { + this.setBusy(false); + 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; @@ -463,6 +537,10 @@ export class BitLoginAuthElement extends HTMLElement { this.finishVaultRequest(null); return; } + if (screen === "welcome" && this.vaultOffer) { + this.finishVaultOffer("declined"); + return; + } this.screen = screen; this.errorMessage = undefined; this.render(); @@ -545,6 +623,11 @@ export class BitLoginAuthElement extends HTMLElement { if (!target) return; const action = target.dataset.action!; switch (action) { + case "vault-offer-save": + return this.acceptVaultOffer(); + case "vault-offer-decline": + this.finishVaultOffer("declined"); + return; case "vault-approve": return this.approveVaultCandidate(); case "vault-different": @@ -1414,6 +1497,23 @@ export class BitLoginAuthElement extends HTMLElement { `; } + case "vault-offer": { + const offer = this.vaultOffer; + if (!offer) return `
No wallet offer is in progress.
`; + return ` +

Save this wallet to your BitLogin?

+

${escapeHtml(offer.appName)} just connected a wallet on this device. Saved to your BitLogin, the connection follows you — on a new device it's one tap instead of another paste.

+ ${this.renderError()} + + + + +
Stored encrypted on your account; ${escapeHtml(offer.appName)} keeps working either way. Remove it any time from Wallet connections.
+ `; + } + case "vault-manage": { const list = this.vaultConnections; const rows = diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index 16e7a41..4b78eb6 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -24,6 +24,7 @@ import { VaultSession, parseNwcUri, toNwcUri, + sameNwcCredential, validateNwcCredential, type DecryptedConnectionRecord, type NwcCredential @@ -570,6 +571,39 @@ async function handle(action: string, payload: unknown): Promise { return {}; } + case "vaultOfferCheck": { + // The offer-to-save flow's dedupe: the same wallet + secret already + // stored means nothing NEW would enter the vault, so no consent screen + // is warranted. The origin binding is refreshed silently — the offering + // origin already HOLDS the credential (it supplied it), so binding is + // bookkeeping for later one-tap approvals, not a new grant. + const p = payload as { uri: string; origin: string | null }; + const credential = parseNwcUri(p.uri); + const { vault, connections } = await listLiveConnections(); + const existing = connections.find( + (c) => + c.record.connection_type === "nwc" && + sameNwcCredential(c.record.credential as unknown as NwcCredential, credential) + ); + if (!existing) return { duplicate: false }; + if (p.origin !== null && existing.record.application_binding.origin !== p.origin) { + const { record, event } = await vault.updateConnection(existing, { + application_binding: { origin: p.origin, app_pubkey: null } + }); + const publish = await vault.publish({ + event, + connectionId: record.connection_id, + relayUrls: vaultRelayUrls, + store + }); + if (publish.success) { + const decrypted = await vault.decryptEvent(event); + return { duplicate: true, connection: summarize(decrypted!) }; + } + } + return { duplicate: true, connection: summarize(existing) }; + } + 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 4b796f2..fce7956 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -220,6 +220,18 @@ export interface VaultDeletePayload { connectionId: string; } +export interface VaultOfferCheckPayload { + uri: string; + origin: string | null; +} +/** duplicate=true means the same wallet+secret is already stored; the worker + * has silently refreshed its origin binding, and no consent UI is needed — + * nothing new entered the vault. */ +export interface VaultOfferCheckResult { + duplicate: boolean; + connection?: VaultConnectionSummary; +} + export type WorkerActionMap = { configure: [ConfigurePayload, Record]; register: [RegisterPayload, RegisterResult]; @@ -248,6 +260,7 @@ export type WorkerActionMap = { vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult]; vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary]; vaultDelete: [VaultDeletePayload, Record]; + vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult]; }; export type WorkerAction = keyof WorkerActionMap; diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 6063e67..98a54e8 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -21,7 +21,8 @@ import type { VaultFindForOriginPayload, VaultRevealNwcPayload, VaultSetBindingPayload, - VaultDeletePayload + VaultDeletePayload, + VaultOfferCheckPayload } from "./protocol.js"; export class WorkerClient { @@ -141,6 +142,9 @@ export class WorkerClient { vaultDelete(payload: VaultDeletePayload) { return this.call("vaultDelete", payload); } + vaultOfferCheck(payload: VaultOfferCheckPayload) { + return this.call("vaultOfferCheck", payload); + } logout() { return this.call("logout", {}); }