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
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
+7
View File
@@ -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<NwcCredential> | null;
const fail = (message: string) => {
+9 -1
View File
@@ -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);
+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
fires on the element after each share, and users manage or revoke grants from the widget dashboard's
"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">
<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
+6
View File
@@ -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
+101 -1
View File
@@ -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<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. */
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 `<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": {
const list = this.vaultConnections;
const rows =
@@ -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<unknown> {
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}`);
}
+13
View File
@@ -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<string, never>];
register: [RegisterPayload, RegisterResult];
@@ -248,6 +260,7 @@ export type WorkerActionMap = {
vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult];
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
vaultDelete: [VaultDeletePayload, Record<string, never>];
vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
};
export type WorkerAction = keyof WorkerActionMap;
+5 -1
View File
@@ -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", {});
}