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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-30 21:17:06 -04:00
1 parent 6841d647c2
commit 6b48de6168
9 files changed
+191 -3

No files matched your search

+7
View File
@@ -20,6 +20,13 @@ would plausibly want. Newest first.
widget now ships FIVE files (two extra lazy chunks) that must travel widget now ships FIVE files (two extra lazy chunks) that must travel
together, and hosts using the wallet flow need `style-src-attr together, and hosts using the wallet flow need `style-src-attr
'unsafe-inline'` in their CSP. '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/`): - **Connection Vault protocol layer in `@bitlogin/core`** (`src/vault/`):
encrypted per-connection records signed by a derived vault identity, the encrypted per-connection records signed by a derived vault identity, the
finalized §CV6 key hierarchy with pinned test vectors, the NWC credential finalized §CV6 key hierarchy with pinned test vectors, the NWC credential
+7
View File
@@ -90,6 +90,13 @@ export function toNwcUri(credential: NwcCredential): string {
return `nostr+walletconnect://${credential.wallet_pubkey}?${params.toString()}`; 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 { export function validateNwcCredential(credential: unknown): asserts credential is NwcCredential {
const c = credential as Partial<NwcCredential> | null; const c = credential as Partial<NwcCredential> | null;
const fail = (message: string) => { const fail = (message: string) => {
+9 -1
View File
@@ -18,7 +18,7 @@ import {
deriveVaultPublicKey, deriveVaultPublicKey,
newConnectionId newConnectionId
} from "./derivation.js"; } from "./derivation.js";
import { parseNwcUri, toNwcUri, NwcParseError } from "./nwc.js"; import { parseNwcUri, toNwcUri, sameNwcCredential, NwcParseError } from "./nwc.js";
import { import {
buildConnectionRecordEvent, buildConnectionRecordEvent,
decryptConnectionRecordEvent, decryptConnectionRecordEvent,
@@ -132,6 +132,14 @@ describe("NWC profile (nwc-connections.md §5-§6)", () => {
expect(parseNwcUri(toNwcUri(credential))).toEqual(credential); 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", () => { it("refuses missing relay, missing secret, bad pubkey, and non-websocket relays", () => {
const base = `nostr+walletconnect://${"ab".repeat(32)}`; const base = `nostr+walletconnect://${"ab".repeat(32)}`;
expect(() => parseNwcUri(`${base}?secret=${"cd".repeat(32)}`)).toThrow(NwcParseError); expect(() => parseNwcUri(`${base}?secret=${"cd".repeat(32)}`)).toThrow(NwcParseError);
+9
View File
@@ -125,6 +125,15 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
stored as an encrypted record on the user's account. A <code>bitlogin-connection-granted</code> event stored as an encrypted record on the user's account. A <code>bitlogin-connection-granted</code> event
fires on the element after each share, and users manage or revoke grants from the widget dashboard's fires on the element after each share, and users manage or revoke grants from the widget dashboard's
"Wallet connections" screen.</p> "Wallet connections" screen.</p>
<p>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 —</p>
<pre><code>const outcome = await bl.offerNwcConnection(uri, { appName: 'Satisfied' });
// 'saved' | 'declined' | 'already-saved' | 'unavailable'</code></pre>
<p>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 <code>already-saved</code> with no UI, so it's safe to
fire after every successful connect. <code>unavailable</code> means nobody is signed in or the
account has no vault; treat it as a no-op.</p>
<div class="callout"> <div class="callout">
<strong>This is reveal mode, and the consent screen says so.</strong> Your origin receives the full <strong>This is reveal mode, and the consent screen says so.</strong> Your origin receives the full
bearer credential and everything its wallet-side budget allows — an embedded same-origin widget is bearer credential and everything its wallet-side budget allows — an embedded same-origin widget is
+6
View File
@@ -56,6 +56,12 @@ deployment above.
mode: the origin receives the full bearer credential — wallet-side budgets mode: the origin receives the full bearer credential — wallet-side budgets
are the real security boundary. Requires `style-src-attr 'unsafe-inline'` are the real security boundary. Requires `style-src-attr 'unsafe-inline'`
in the host CSP. Fires `bitlogin-connection-granted` after each share. 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`), - Events on the element: `bitlogin-login` (`detail.publicKey`),
`bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released` `bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released`
(the last also fires on `window` when triggered via (the last also fires on `window` when triggered via
+101 -1
View File
@@ -32,7 +32,8 @@ type Screen =
| "rollback-confirm" | "rollback-confirm"
| "vault-consent" | "vault-consent"
| "vault-import" | "vault-import"
| "vault-manage"; | "vault-manage"
| "vault-offer";
interface ConfirmSlot { interface ConfirmSlot {
index: number; index: number;
@@ -120,6 +121,14 @@ export class BitLoginAuthElement extends HTMLElement {
private vaultUnsaved = false; private vaultUnsaved = false;
private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined; private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined;
private vaultConnections: VaultConnectionSummary[] | null = null; 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() { constructor() {
super(); 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<void> {
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. */ /** Settles the pending request exactly once and returns to a neutral screen. */
private finishVaultRequest(uri: string | null): void { private finishVaultRequest(uri: string | null): void {
const request = this.vaultRequest; const request = this.vaultRequest;
@@ -463,6 +537,10 @@ export class BitLoginAuthElement extends HTMLElement {
this.finishVaultRequest(null); this.finishVaultRequest(null);
return; return;
} }
if (screen === "welcome" && this.vaultOffer) {
this.finishVaultOffer("declined");
return;
}
this.screen = screen; this.screen = screen;
this.errorMessage = undefined; this.errorMessage = undefined;
this.render(); this.render();
@@ -545,6 +623,11 @@ export class BitLoginAuthElement extends HTMLElement {
if (!target) return; if (!target) return;
const action = target.dataset.action!; const action = target.dataset.action!;
switch (action) { switch (action) {
case "vault-offer-save":
return this.acceptVaultOffer();
case "vault-offer-decline":
this.finishVaultOffer("declined");
return;
case "vault-approve": case "vault-approve":
return this.approveVaultCandidate(); return this.approveVaultCandidate();
case "vault-different": case "vault-different":
@@ -1414,6 +1497,23 @@ export class BitLoginAuthElement extends HTMLElement {
`; `;
} }
case "vault-offer": {
const offer = this.vaultOffer;
if (!offer) return `<div class="notice error">No wallet offer is in progress.</div>`;
return `
<h2>Save this wallet to your BitLogin?</h2>
<p class="sub">${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.</p>
${this.renderError()}
<label for="vaultOfferLabel">Name this connection</label>
<input type="text" name="vaultOfferLabel" id="vaultOfferLabel" autocomplete="off" maxlength="120" value="${escapeHtml(offer.label)}" />
<button class="primary" type="button" data-action="vault-offer-save" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Saving…' : "Save to BitLogin"}
</button>
<button class="link" type="button" data-action="vault-offer-decline" ${this.busy ? "disabled" : ""}>No thanks — keep it on this device only</button>
<div class="notice info">Stored encrypted on your account; ${escapeHtml(offer.appName)} keeps working either way. Remove it any time from Wallet connections.</div>
`;
}
case "vault-manage": { case "vault-manage": {
const list = this.vaultConnections; const list = this.vaultConnections;
const rows = const rows =
@@ -24,6 +24,7 @@ import {
VaultSession, VaultSession,
parseNwcUri, parseNwcUri,
toNwcUri, toNwcUri,
sameNwcCredential,
validateNwcCredential, validateNwcCredential,
type DecryptedConnectionRecord, type DecryptedConnectionRecord,
type NwcCredential type NwcCredential
@@ -570,6 +571,39 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
return {}; 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: default:
throw new Error(`Unknown worker action: ${action}`); throw new Error(`Unknown worker action: ${action}`);
} }
+13
View File
@@ -220,6 +220,18 @@ export interface VaultDeletePayload {
connectionId: string; 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 = { export type WorkerActionMap = {
configure: [ConfigurePayload, Record<string, never>]; configure: [ConfigurePayload, Record<string, never>];
register: [RegisterPayload, RegisterResult]; register: [RegisterPayload, RegisterResult];
@@ -248,6 +260,7 @@ export type WorkerActionMap = {
vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult]; vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult];
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary]; vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
vaultDelete: [VaultDeletePayload, Record<string, never>]; vaultDelete: [VaultDeletePayload, Record<string, never>];
vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
}; };
export type WorkerAction = keyof WorkerActionMap; export type WorkerAction = keyof WorkerActionMap;
+5 -1
View File
@@ -21,7 +21,8 @@ import type {
VaultFindForOriginPayload, VaultFindForOriginPayload,
VaultRevealNwcPayload, VaultRevealNwcPayload,
VaultSetBindingPayload, VaultSetBindingPayload,
VaultDeletePayload VaultDeletePayload,
VaultOfferCheckPayload
} from "./protocol.js"; } from "./protocol.js";
export class WorkerClient { export class WorkerClient {
@@ -141,6 +142,9 @@ export class WorkerClient {
vaultDelete(payload: VaultDeletePayload) { vaultDelete(payload: VaultDeletePayload) {
return this.call("vaultDelete", payload); return this.call("vaultDelete", payload);
} }
vaultOfferCheck(payload: VaultOfferCheckPayload) {
return this.call("vaultOfferCheck", payload);
}
logout() { logout() {
return this.call("logout", {}); return this.call("logout", {});
} }