mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
6841d647c2
commit
6b48de6168
9 files changed
+191
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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", {});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user