mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Centralized on-ramps, widget side: 'Use an account you already have'
Implements centralized-onramps.md §CO5. A host opts in via onramp-url / onramp-name / onramp-providers attributes -- nothing centralized ships by default. The options menu gains the owner-chosen group heading 'Use an account you already have' covering extension, remote signer, key import, and 'Continue with <Provider>' rows whose sub-label IS the custody statement (§CO7): '<Service> manages your sign-in'. The handshake (onramp.ts, pure and unit-tested): popup to the configured URL with a CSPRNG state nonce; exactly one unlock or register message accepted, only from the configured origin with the exact nonce. Returning users flow into the ordinary password login. New users choose a fresh identity or §SF10-import an existing nsec; registration runs client-side and the password is POSTed to the service -- if the store fails, the phrase ceremony is forced immediately rather than deferred, so no account exists that nobody can unlock. Tier B2 otherwise: the dashboard carries a 'Secure your account' card into the existing phrase screens while the phrase is still in session memory, plus a standing '<Service> manages your sign-in' line. Policy pins: on-ramp rows render only inside the menu with the custody label; group heading wording pinned. 12 new tests; verified visually with a configured element. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
dc6490e46a
commit
ff1470a849
9 files changed
+657
-8
No files matched your search
@@ -1,8 +1,27 @@
|
||||
# Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody
|
||||
|
||||
**Document version:** 0.1 (design proposal)
|
||||
**Status:** Proposal only — nothing here is implemented, and the reference
|
||||
services it describes live outside this repository by definition. Depends on
|
||||
**Document version:** 0.2
|
||||
**Status:** The **widget side of §CO5 is implemented** (2026-08-04):
|
||||
`onramp-url` / `onramp-name` / `onramp-providers` attributes, the popup
|
||||
handshake below, "Continue with <Provider>" rows under the "Use an account
|
||||
you already have" group (§CO11.1 resolved: that heading covers the whole
|
||||
existing-account group — extension, remote signer, key import, and
|
||||
centralized rails — with the custody statement carried by each row's
|
||||
sub-label), client-side registration with optional §SF10 import, and the
|
||||
Tier B2 "Secure your account" dashboard card. Reference services live
|
||||
outside this repository by definition and do not yet exist.
|
||||
|
||||
**Handshake contract as implemented (v1):** the widget opens
|
||||
`onramp-url?provider=<id>&state=<nonce>&origin=<page origin>` in a popup;
|
||||
the popup posts to its opener exactly one
|
||||
`{ type: "bitlogin:onramp:unlock", state, unlock: { loginName, password } }`
|
||||
(returning user) or `{ …, register: { registrationToken,
|
||||
suggestedLoginName? } }` (new user), accepted only from the configured
|
||||
origin with the exact state nonce. For new users the widget registers
|
||||
client-side, then `POST onramp-url` with
|
||||
`{ type: "bitlogin:onramp:store", registrationToken, loginName, password }`;
|
||||
a non-OK response forces the phrase ceremony immediately instead of
|
||||
deferring it. Hosts must add the service origin to their CSP `connect-src`. Depends on
|
||||
`docs/spec.md`, `docs/login-methods.md` (the one-login-surface model), and
|
||||
`docs/second-factor.md` §SF10 (imported identities, implemented), which turns
|
||||
out to be the graduation mechanism.
|
||||
@@ -256,6 +275,8 @@ A service a BitLogin deployment points at must:
|
||||
`{ provider, service, tier }`.
|
||||
3. Whether Tier B2's first-session phrase window should survive a reload
|
||||
within some time budget, or end strictly with the worker.
|
||||
4. Whether the option-menu group heading "Centralized options" is the right
|
||||
label, or something warmer ("Use an account you already have") with the
|
||||
custody statement carried entirely by the sub-label.
|
||||
4. **Resolved 2026-08-04 (owner decision):** the group heading is "Use an
|
||||
account you already have," covering every existing-account path — Nostr
|
||||
extension, remote signer, key import, and centralized rails alike — with
|
||||
the custody statement carried entirely by each centralized row's
|
||||
sub-label ("<Service> manages your sign-in").
|
||||
@@ -162,6 +162,12 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
|
||||
already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one
|
||||
surface, and routes every signing call to whichever the user picked. You don't need your own method picker,
|
||||
and your app code doesn't branch by method.</p>
|
||||
<p>Sites that want a "Continue with Google/GitHub" on-ramp can additionally point the widget at an OAuth
|
||||
unlock service (<code>onramp-url</code>, <code>onramp-name</code>, <code>onramp-providers</code> attributes —
|
||||
nothing centralized ships unless you configure it, and your CSP's <code>connect-src</code> must include the
|
||||
service's origin). The service manages the password of a real, portable Nostr account; users can bring an
|
||||
existing key at signup and are nudged toward claiming their recovery phrase over time. See
|
||||
<code>docs/centralized-onramps.md</code> in the repo for the handshake contract and threat model.</p>
|
||||
<p>Two different questions have two different APIs, and conflating them is the classic integration bug:</p>
|
||||
<p><strong>"Is someone signed in through BitLogin?"</strong> — use <code>window.bitlogin.activeMethod()</code>
|
||||
(or the richer <code>activeSession()</code>), or listen for <code>bitlogin-login</code>. This answers correctly
|
||||
|
||||
@@ -28,6 +28,17 @@ deployment above.
|
||||
- A `<bitlogin-auth>` custom element (Shadow DOM, no CSS collisions) handling
|
||||
create account, sign in, forgot-password recovery via a 12-word phrase, and
|
||||
password rotation.
|
||||
- Optional centralized on-ramps ("Continue with Google/GitHub/..."): a host
|
||||
may point the widget at an OAuth unlock service via `onramp-url`,
|
||||
`onramp-name`, and `onramp-providers` attributes -- nothing centralized
|
||||
ships by default. The service acts as the password manager for a REAL
|
||||
capsule account (it holds the unlock secret, never the key); rows render
|
||||
under "Use an account you already have" with the custody statement
|
||||
("<Service> manages your sign-in") as the sub-label, new users can create
|
||||
a fresh identity or import an existing nsec, and the dashboard nudges
|
||||
toward claiming the recovery phrase ("Secure your account"). Hosts must
|
||||
add the service origin to CSP connect-src. Contract and threat model:
|
||||
docs/centralized-onramps.md.
|
||||
- One login surface, several methods: if the visitor's browser has a NIP-07
|
||||
signer extension, the welcome screen offers "Use your Nostr extension
|
||||
instead" below the primary username/password path, and every visitor gets
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** Host-page-configurable options for <bitlogin-auth> (§19.1, §19.6). */
|
||||
import { parseOnrampConfig, type OnrampConfig } from "./onramp.js";
|
||||
|
||||
export interface BitLoginConfig {
|
||||
vaultRelayUrls?: string[];
|
||||
discoveryRelayUrls?: string[];
|
||||
/** Centralized on-ramp (centralized-onramps.md §CO5). Absent unless the
|
||||
* host explicitly configures one -- BitLogin ships no centralized
|
||||
* dependency by default. */
|
||||
onramp?: OnrampConfig;
|
||||
}
|
||||
|
||||
export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
|
||||
@@ -9,6 +15,11 @@ export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
|
||||
const discoveryAttr = el.getAttribute("discovery-relays");
|
||||
return {
|
||||
vaultRelayUrls: vaultAttr ? vaultAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||
discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined
|
||||
discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||
onramp: parseOnrampConfig({
|
||||
url: el.getAttribute("onramp-url"),
|
||||
name: el.getAttribute("onramp-name"),
|
||||
providers: el.getAttribute("onramp-providers")
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -20,12 +20,22 @@ import { Nip46Signer } from "./signers/nip46.js";
|
||||
import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js";
|
||||
import { renderQrSvg } from "./qr.js";
|
||||
import { setActiveSession, clearActiveSession } from "./globalSession.js";
|
||||
import {
|
||||
buildOnrampAuthUrl,
|
||||
newOnrampState,
|
||||
onrampProviderLabel,
|
||||
storeOnrampCredential,
|
||||
validateOnrampMessage,
|
||||
type OnrampConfig
|
||||
} from "./onramp.js";
|
||||
import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
|
||||
|
||||
type Screen =
|
||||
| "welcome"
|
||||
| "extension-confirm"
|
||||
| "bunker-connect"
|
||||
| "bunker-confirm"
|
||||
| "onramp-create"
|
||||
| "import-key"
|
||||
| "create-name"
|
||||
| "create-credential"
|
||||
@@ -54,7 +64,8 @@ const OPTION_ICONS = {
|
||||
extension: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M6.5 6.7h.01"/><path d="M9.3 6.7h.01"/></svg>`,
|
||||
remote: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3"/><path d="M21 14v0.01"/><path d="M14 21h0.01"/><path d="M17.5 17.5L21 21"/></svg>`,
|
||||
importKey: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21"/><path d="M18 12v3.2"/><path d="M14.8 12v2.2"/></svg>`,
|
||||
recover: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 5v5h5"/><path d="M4.2 14a8 8 0 1 0 1.9-8.3L2.5 10"/></svg>`
|
||||
recover: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 5v5h5"/><path d="M4.2 14a8 8 0 1 0 1.9-8.3L2.5 10"/></svg>`,
|
||||
onramp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="10" r="3"/><path d="M6.2 18.5a6.5 6.5 0 0 1 11.6 0"/></svg>`
|
||||
} as const;
|
||||
|
||||
const CHEVRON_DOWN = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>`;
|
||||
@@ -112,6 +123,47 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private bunkerConnectUri = "";
|
||||
private bunkerAuthUrl = "";
|
||||
private pendingBunker: { userPubkey: string; npub: string } | null = null;
|
||||
|
||||
// ---- Centralized on-ramp (centralized-onramps.md §CO5, Architecture B) ----
|
||||
// Configured by the host or absent entirely. `attempt` spans one popup round
|
||||
// trip (state nonce + popup handle); `registration` spans a new user's
|
||||
// choice between a fresh identity and importing one (§SF10); `session` marks
|
||||
// the standing for dashboard labeling (the session itself is an ordinary
|
||||
// BitLogin account session). `securePhrasePending` is Tier B2: the phrase is
|
||||
// in memory this session, the ceremony deferred behind a dashboard card.
|
||||
private onramp: OnrampConfig | undefined;
|
||||
private onrampAttempt: { state: string; provider: string; popup: Window | null } | null = null;
|
||||
private onrampRegistration: { registrationToken: string; provider: string; suggestedLoginName?: string } | null = null;
|
||||
private onrampSession = false;
|
||||
private securePhrasePending = false;
|
||||
private readonly onWindowMessage = (event: MessageEvent): void => {
|
||||
const attempt = this.onrampAttempt;
|
||||
if (!attempt || !this.onramp) return;
|
||||
const result = validateOnrampMessage(
|
||||
{ origin: event.origin, data: event.data },
|
||||
{ origin: this.onramp.origin, state: attempt.state }
|
||||
);
|
||||
if (!result) return;
|
||||
this.onrampAttempt = null;
|
||||
try {
|
||||
attempt.popup?.close();
|
||||
} catch {
|
||||
// Cross-origin popup handles can refuse; it closes itself regardless.
|
||||
}
|
||||
if (result.kind === "unlock") {
|
||||
// Returning user: the service is simply the password manager -- this is
|
||||
// the ordinary password login from here on.
|
||||
this.onrampSession = true;
|
||||
void this.attemptLogin(result.loginName, result.password);
|
||||
return;
|
||||
}
|
||||
this.onrampRegistration = {
|
||||
registrationToken: result.registrationToken,
|
||||
provider: attempt.provider,
|
||||
suggestedLoginName: result.suggestedLoginName
|
||||
};
|
||||
this.goto("onramp-create");
|
||||
};
|
||||
private sessionWarnings: string[] = [];
|
||||
private lastSignedEventJson = "";
|
||||
private exportedNsec = "";
|
||||
@@ -190,6 +242,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
const config = readConfigFromElement(this);
|
||||
this.vaultRelayUrls = config.vaultRelayUrls ?? [];
|
||||
this.discoveryRelayUrls = config.discoveryRelayUrls ?? [];
|
||||
this.onramp = config.onramp;
|
||||
window.addEventListener("message", this.onWindowMessage);
|
||||
void this.worker
|
||||
.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls })
|
||||
.then(() => this.tryRestoreSession());
|
||||
@@ -216,6 +270,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer);
|
||||
window.removeEventListener("message", this.onWindowMessage);
|
||||
clearActiveSession(this);
|
||||
this.releaseSigner();
|
||||
this.worker.terminate();
|
||||
@@ -271,6 +326,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect();
|
||||
this.altSigner = null;
|
||||
this.session = null;
|
||||
this.onrampSession = false;
|
||||
this.securePhrasePending = false;
|
||||
clearActiveSession(this);
|
||||
this.releaseSigner();
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
|
||||
@@ -279,6 +336,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
await this.worker.logout();
|
||||
this.session = null;
|
||||
this.onrampSession = false;
|
||||
this.securePhrasePending = false;
|
||||
clearActiveSession(this);
|
||||
this.releaseSigner();
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
|
||||
@@ -770,6 +829,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
case "goto-welcome":
|
||||
this.pendingRollback = null;
|
||||
this.pendingExtensionSigner = null;
|
||||
this.onrampAttempt = null;
|
||||
this.onrampRegistration = null;
|
||||
// Backing out of an unfinished bunker connect abandons the worker-side
|
||||
// attempt -- but never a live session (altSigner) that's merely
|
||||
// navigating around.
|
||||
@@ -785,6 +846,18 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.moreOptionsOpen = !this.moreOptionsOpen;
|
||||
this.render();
|
||||
return;
|
||||
case "onramp-signin":
|
||||
this.startOnramp(target.dataset.provider ?? "");
|
||||
return;
|
||||
case "onramp-new":
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
return void this.runOnrampRegistration();
|
||||
case "onramp-import":
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.goto("import-key");
|
||||
return;
|
||||
case "extension-signin":
|
||||
return this.handleExtensionSignIn();
|
||||
case "extension-continue":
|
||||
@@ -976,10 +1049,103 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (this.onrampRegistration) {
|
||||
// On-ramp signup chose "use my existing key": same §SF10 wrap, with the
|
||||
// service holding the password instead of the user (§CO5).
|
||||
void this.runOnrampRegistration();
|
||||
return;
|
||||
}
|
||||
this.loginName = "";
|
||||
this.goto("create-name");
|
||||
}
|
||||
|
||||
/** Opens the on-ramp's OAuth popup for one attempt (§CO5). */
|
||||
private startOnramp(provider: string): void {
|
||||
if (!this.onramp) return;
|
||||
const state = newOnrampState();
|
||||
const url = buildOnrampAuthUrl(this.onramp, provider, state, window.location.origin);
|
||||
const popup = window.open(url, "bitlogin-onramp", "popup,width=480,height=640");
|
||||
if (!popup) {
|
||||
this.errorMessage = "Your browser blocked the sign-in window. Allow pop-ups for this site and try again.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.onrampAttempt = { state, provider, popup };
|
||||
this.errorMessage = undefined;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* New-user registration through the on-ramp (§CO5): standard client-side
|
||||
* registration (§15) -- optionally wrapping an imported key (§SF10) -- with
|
||||
* the generated password handed to the service for safekeeping afterward.
|
||||
* Tier B2 phrase handling: the ceremony is deferred behind the dashboard's
|
||||
* "Secure your account" card, EXCEPT when the store call fails -- an account
|
||||
* whose credential nobody is holding forces the ceremony immediately.
|
||||
*/
|
||||
private async runOnrampRegistration(): Promise<void> {
|
||||
const registration = this.onrampRegistration;
|
||||
const onramp = this.onramp;
|
||||
if (!registration || !onramp) return;
|
||||
this.setBusy(true);
|
||||
try {
|
||||
const suggested = registration.suggestedLoginName?.trim().toLowerCase() ?? "";
|
||||
const loginName = isValidLoginName(suggested) ? suggested : `nostr-${bytesToHex(randomBytes(4))}`;
|
||||
const password = generatePassphrase().secret;
|
||||
const result = await this.worker.register({
|
||||
loginName,
|
||||
password,
|
||||
importKey: this.importKey || undefined
|
||||
});
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.onrampRegistration = null;
|
||||
this.loginName = loginName;
|
||||
this.recoveryPhrase = result.recoveryPhrase;
|
||||
const words = this.recoveryPhrase.split(" ");
|
||||
const indices = pickRandomIndices(words.length, 3);
|
||||
this.confirmSlots = indices.map((index) => ({ index, value: "" }));
|
||||
this.session = {
|
||||
publicKey: result.everydayPublicKey,
|
||||
npub: encodeNpub(result.everydayPublicKey),
|
||||
accountId: result.accountId,
|
||||
method: "bitlogin"
|
||||
};
|
||||
this.sessionWarnings = [];
|
||||
void this.worker.publishProfileAndRelayLists({
|
||||
name: this.loginName,
|
||||
generalRelays: this.vaultRelayUrls,
|
||||
dmRelays: this.vaultRelayUrls
|
||||
});
|
||||
try {
|
||||
await storeOnrampCredential(onramp, {
|
||||
registrationToken: registration.registrationToken,
|
||||
loginName,
|
||||
password
|
||||
});
|
||||
} catch (storeErr) {
|
||||
// The account is real and live on relays, but the service is not
|
||||
// holding its credential -- the phrase ceremony happens NOW.
|
||||
this.busy = false;
|
||||
this.onrampSession = false;
|
||||
this.securePhrasePending = false;
|
||||
this.errorMessage = storeErr instanceof Error ? storeErr.message : String(storeErr);
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchLogin();
|
||||
this.goto("confirm-phrase");
|
||||
return;
|
||||
}
|
||||
this.busy = false;
|
||||
this.onrampSession = true;
|
||||
this.securePhrasePending = true;
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Account created");
|
||||
} catch (err) {
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCreateNameSubmit(): void {
|
||||
const name = this.field("loginName").trim().toLowerCase();
|
||||
if (!isValidLoginName(name)) {
|
||||
@@ -1042,6 +1208,14 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.securePhrasePending) {
|
||||
// Tier B2 deferred ceremony (§CO4): the session already exists and
|
||||
// bitlogin-login already fired at registration -- this just retires
|
||||
// the "Secure your account" card.
|
||||
this.securePhrasePending = false;
|
||||
this.flashSuccess("dashboard", "Recovery phrase secured");
|
||||
return;
|
||||
}
|
||||
this.sessionWarnings = [];
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchLogin();
|
||||
@@ -1051,6 +1225,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private async handleLoginSubmit(): Promise<void> {
|
||||
const loginName = this.field("loginName").trim().toLowerCase();
|
||||
const password = this.field("password");
|
||||
this.onrampSession = false; // typed credentials: the user is their own password manager here
|
||||
await this.attemptLogin(loginName, password);
|
||||
}
|
||||
|
||||
@@ -1442,11 +1617,28 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy
|
||||
)
|
||||
: "";
|
||||
// Centralized rows exist only when the host configured an on-ramp;
|
||||
// the sub-label IS the custody statement (§CO7): who manages access.
|
||||
const onrampRows = (this.onramp?.providers ?? [])
|
||||
.map((provider) => {
|
||||
const label = onrampProviderLabel(provider);
|
||||
return `
|
||||
<button class="option-row" type="button" data-action="onramp-signin" data-provider="${escapeHtml(provider)}">
|
||||
<span class="option-icon">${OPTION_ICONS.onramp}</span>
|
||||
<span class="option-text"><span>Continue with ${escapeHtml(label)}</span><span class="option-sub">${escapeHtml(this.onramp!.name)} manages your sign-in</span></span>
|
||||
</button>`;
|
||||
})
|
||||
.join("");
|
||||
const onrampWaiting = this.onrampAttempt
|
||||
? `<div class="notice info">Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.</div>`
|
||||
: "";
|
||||
const optionsMenu = this.moreOptionsOpen
|
||||
? `<div class="option-menu">
|
||||
<div class="option-group-label">Use an account you already have</div>
|
||||
${extensionRow}
|
||||
${optionRow("goto-bunker", OPTION_ICONS.remote, "Remote signer", "Scan a code with Amber or another signer app")}
|
||||
${optionRow("goto-import", OPTION_ICONS.importKey, "Import a Nostr key", "Wrap an existing identity in a login name and password")}
|
||||
${onrampRows}
|
||||
${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
|
||||
</div>`
|
||||
: "";
|
||||
@@ -1459,10 +1651,29 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="options-toggle ${this.moreOptionsOpen ? "open" : ""}" type="button" data-action="toggle-more" aria-expanded="${this.moreOptionsOpen}">
|
||||
More sign-in options ${CHEVRON_DOWN}
|
||||
</button>
|
||||
${onrampWaiting}
|
||||
${optionsMenu}
|
||||
`;
|
||||
}
|
||||
|
||||
case "onramp-create": {
|
||||
const providerLabel = this.onrampRegistration
|
||||
? onrampProviderLabel(this.onrampRegistration.provider)
|
||||
: "";
|
||||
return `
|
||||
${this.renderScreenHead("Almost there")}
|
||||
<p class="sub">${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${
|
||||
providerLabel ? ` through ${escapeHtml(providerLabel)}` : ""
|
||||
}. Your account itself lives on the open Nostr network — choose how to set up its identity:</p>
|
||||
${this.renderError()}
|
||||
<button class="primary" type="button" data-action="onramp-new" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Creating your account…' : "Create a fresh identity"}
|
||||
</button>
|
||||
<button class="secondary" type="button" data-action="onramp-import" ${this.busy ? "disabled" : ""}>I already have a Nostr key</button>
|
||||
<p class="small">Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
case "extension-confirm":
|
||||
return `
|
||||
${this.renderScreenHead("Sign in with your extension")}
|
||||
@@ -1692,10 +1903,21 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<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>`;
|
||||
// Tier B2 (§CO4): the phrase exists only in this session's memory
|
||||
// until the user claims it -- the one nudge that stays until acted on.
|
||||
const securePhraseCard = this.securePhrasePending
|
||||
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even without ${escapeHtml(this.onramp?.name ?? "the sign-in service")}.
|
||||
<button class="link-inline" type="button" data-action="goto-confirm-phrase">View and save it</button></div>`
|
||||
: "";
|
||||
const onrampStanding = this.onrampSession
|
||||
? `<p class="small">Signed in via ${escapeHtml(this.onramp?.name ?? "a sign-in service")} — they manage your sign-in; your identity lives on the open Nostr network.</p>`
|
||||
: "";
|
||||
return `
|
||||
<h2>Signed in</h2>
|
||||
${securePhraseCard}
|
||||
${this.renderWarnings()}
|
||||
<p class="pubkey">${escapeHtml(this.session?.npub ?? "")}</p>
|
||||
${onrampStanding}
|
||||
${this.renderError()}
|
||||
<button class="secondary" type="button" data-action="sign-test-event">Sign a test event</button>
|
||||
${
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildOnrampAuthUrl,
|
||||
newOnrampState,
|
||||
onrampProviderLabel,
|
||||
parseOnrampConfig,
|
||||
storeOnrampCredential,
|
||||
validateOnrampMessage,
|
||||
type OnrampConfig
|
||||
} from "./onramp.js";
|
||||
|
||||
const CONFIG: OnrampConfig = {
|
||||
url: "https://onramp.example/bitlogin",
|
||||
origin: "https://onramp.example",
|
||||
name: "AcmeSigner",
|
||||
providers: ["google", "github"]
|
||||
};
|
||||
|
||||
describe("parseOnrampConfig", () => {
|
||||
it("returns undefined when unconfigured, without warning", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(parseOnrampConfig({ url: null, name: null, providers: null })).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("parses a full configuration and derives the origin", () => {
|
||||
const config = parseOnrampConfig({
|
||||
url: "https://onramp.example/bitlogin",
|
||||
name: " AcmeSigner ",
|
||||
providers: "Google, github ,"
|
||||
})!;
|
||||
expect(config.origin).toBe("https://onramp.example");
|
||||
expect(config.name).toBe("AcmeSigner");
|
||||
expect(config.providers).toEqual(["google", "github"]);
|
||||
});
|
||||
|
||||
it("rejects non-https URLs (except loopback), empty providers, and a missing name -- loudly", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
parseOnrampConfig({ url: "http://onramp.example/x", name: "A", providers: "google" })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseOnrampConfig({ url: "https://onramp.example/x", name: "A", providers: " , " })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseOnrampConfig({ url: "https://onramp.example/x", name: " ", providers: "google" })
|
||||
).toBeUndefined();
|
||||
expect(parseOnrampConfig({ url: "not a url", name: "A", providers: "google" })).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(4);
|
||||
expect(
|
||||
parseOnrampConfig({ url: "http://localhost:8787/x", name: "Dev", providers: "google" })
|
||||
).toBeDefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth URL and state", () => {
|
||||
it("appends provider, state, and page origin to the configured URL", () => {
|
||||
const url = new URL(buildOnrampAuthUrl(CONFIG, "google", "abc123", "https://site.example"));
|
||||
expect(url.origin).toBe("https://onramp.example");
|
||||
expect(url.pathname).toBe("/bitlogin");
|
||||
expect(url.searchParams.get("provider")).toBe("google");
|
||||
expect(url.searchParams.get("state")).toBe("abc123");
|
||||
expect(url.searchParams.get("origin")).toBe("https://site.example");
|
||||
});
|
||||
|
||||
it("mints distinct high-entropy states", () => {
|
||||
const a = newOnrampState();
|
||||
const b = newOnrampState();
|
||||
expect(a).toMatch(/^[0-9a-f]{32}$/u);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("labels known providers and capitalizes unknown ones", () => {
|
||||
expect(onrampProviderLabel("google")).toBe("Google");
|
||||
expect(onrampProviderLabel("github")).toBe("GitHub");
|
||||
expect(onrampProviderLabel("keycloak")).toBe("Keycloak");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOnrampMessage", () => {
|
||||
const expected = { origin: CONFIG.origin, state: "state1" };
|
||||
const unlockMsg = {
|
||||
type: "bitlogin:onramp:unlock",
|
||||
state: "state1",
|
||||
unlock: { loginName: "user1", password: "pw-pw-pw-pw" }
|
||||
};
|
||||
|
||||
it("accepts a well-formed unlock from the exact origin and state", () => {
|
||||
expect(validateOnrampMessage({ origin: CONFIG.origin, data: unlockMsg }, expected)).toEqual({
|
||||
kind: "unlock",
|
||||
loginName: "user1",
|
||||
password: "pw-pw-pw-pw"
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a well-formed register branch", () => {
|
||||
const result = validateOnrampMessage(
|
||||
{
|
||||
origin: CONFIG.origin,
|
||||
data: {
|
||||
type: "bitlogin:onramp:unlock",
|
||||
state: "state1",
|
||||
register: { registrationToken: "tok", suggestedLoginName: "sunny-otter" }
|
||||
}
|
||||
},
|
||||
expected
|
||||
);
|
||||
expect(result).toEqual({ kind: "register", registrationToken: "tok", suggestedLoginName: "sunny-otter" });
|
||||
});
|
||||
|
||||
it("rejects wrong origin, wrong state, wrong type, both branches, and malformed shapes", () => {
|
||||
expect(
|
||||
validateOnrampMessage({ origin: "https://evil.example", data: unlockMsg }, expected)
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, state: "other" } }, expected)
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, type: "hello" } }, expected)
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateOnrampMessage(
|
||||
{
|
||||
origin: CONFIG.origin,
|
||||
data: { ...unlockMsg, register: { registrationToken: "tok" } }
|
||||
},
|
||||
expected
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateOnrampMessage(
|
||||
{
|
||||
origin: CONFIG.origin,
|
||||
data: { type: "bitlogin:onramp:unlock", state: "state1", unlock: { loginName: "u" } }
|
||||
},
|
||||
expected
|
||||
)
|
||||
).toBeNull();
|
||||
expect(validateOnrampMessage({ origin: CONFIG.origin, data: null }, expected)).toBeNull();
|
||||
expect(validateOnrampMessage({ origin: CONFIG.origin, data: "string" }, expected)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeOnrampCredential", () => {
|
||||
const payload = { registrationToken: "tok", loginName: "user1", password: "pw" };
|
||||
|
||||
it("POSTs the credential to the configured URL only, and resolves on ok", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
await storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0]!;
|
||||
expect(url).toBe(CONFIG.url);
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||
type: "bitlogin:onramp:store",
|
||||
...payload
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a phrase-preserving error when the service refuses", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
await expect(
|
||||
storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch)
|
||||
).rejects.toThrow(/recovery phrase/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Centralized on-ramp handshake (centralized-onramps.md §CO5) — pure logic.
|
||||
*
|
||||
* The widget's side of the "Sign in with Google/GitHub/…" rail. A host opts in
|
||||
* via element attributes; nothing here activates otherwise. The flow:
|
||||
*
|
||||
* 1. The option row opens `authUrl(provider, state)` in a popup. The service
|
||||
* runs its OAuth dance there.
|
||||
* 2. The popup posts ONE message back to the opener:
|
||||
* { type: "bitlogin:onramp:unlock", state,
|
||||
* unlock: { loginName, password } } // returning user
|
||||
* or
|
||||
* { type: "bitlogin:onramp:unlock", state,
|
||||
* register: { registrationToken, suggestedLoginName? } } // new user
|
||||
* 3. Returning users go straight into the ordinary password login. New
|
||||
* users register CLIENT-SIDE (standard §15 flow, optionally wrapping an
|
||||
* imported key, §SF10), then the widget POSTs the credential to the
|
||||
* service for safekeeping: { type: "bitlogin:onramp:store",
|
||||
* registrationToken, loginName, password }.
|
||||
*
|
||||
* Trust boundaries enforced here: messages are accepted only from the
|
||||
* configured service's exact origin with the exact per-attempt state nonce
|
||||
* (CSPRNG); the store call goes only to the configured URL. Configuring an
|
||||
* on-ramp is the HOST'S declaration of trust in that service -- the widget's
|
||||
* job is to make sure nobody else can ride that trust.
|
||||
*/
|
||||
import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
|
||||
|
||||
export interface OnrampConfig {
|
||||
/** The service's auth/store endpoint, as configured. */
|
||||
url: string;
|
||||
/** Exact origin messages must come from (derived from url). */
|
||||
origin: string;
|
||||
/** Human name shown in the custody label ("AcmeSigner manages your sign-in"). */
|
||||
name: string;
|
||||
/** Provider ids to render as rows, e.g. ["google", "github"]. */
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
/** Providers we can label nicely; anything else renders capitalized as-is. */
|
||||
const KNOWN_PROVIDER_LABELS: Record<string, string> = {
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
facebook: "Facebook",
|
||||
apple: "Apple",
|
||||
microsoft: "Microsoft"
|
||||
};
|
||||
|
||||
export function onrampProviderLabel(provider: string): string {
|
||||
return (
|
||||
KNOWN_PROVIDER_LABELS[provider.toLowerCase()] ??
|
||||
provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the element attributes; returns undefined when no on-ramp is
|
||||
* configured, and undefined WITH a console warning when one is configured
|
||||
* wrong -- a broken opt-in must fail visibly to the developer, not silently
|
||||
* ship a half-working row to users.
|
||||
*/
|
||||
export function parseOnrampConfig(attrs: {
|
||||
url: string | null;
|
||||
name: string | null;
|
||||
providers: string | null;
|
||||
}): OnrampConfig | undefined {
|
||||
if (!attrs.url) return undefined;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(attrs.url);
|
||||
} catch {
|
||||
console.warn("BitLogin: onramp-url is not a valid URL; ignoring the on-ramp configuration.");
|
||||
return undefined;
|
||||
}
|
||||
const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
|
||||
console.warn("BitLogin: onramp-url must be https (or loopback for development); ignoring the on-ramp configuration.");
|
||||
return undefined;
|
||||
}
|
||||
const providers = (attrs.providers ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (providers.length === 0) {
|
||||
console.warn("BitLogin: onramp-providers is empty; ignoring the on-ramp configuration.");
|
||||
return undefined;
|
||||
}
|
||||
if (!attrs.name?.trim()) {
|
||||
console.warn("BitLogin: onramp-name is required (it is the custody label users see); ignoring the on-ramp configuration.");
|
||||
return undefined;
|
||||
}
|
||||
return { url: parsed.toString(), origin: parsed.origin, name: attrs.name.trim(), providers };
|
||||
}
|
||||
|
||||
/** Per-attempt CSPRNG nonce; the popup must echo it exactly. */
|
||||
export function newOnrampState(): string {
|
||||
return bytesToHex(randomBytes(16));
|
||||
}
|
||||
|
||||
export function buildOnrampAuthUrl(config: OnrampConfig, provider: string, state: string, pageOrigin: string): string {
|
||||
const url = new URL(config.url);
|
||||
url.searchParams.set("provider", provider);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("origin", pageOrigin);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export type OnrampUnlockResult =
|
||||
| { kind: "unlock"; loginName: string; password: string }
|
||||
| { kind: "register"; registrationToken: string; suggestedLoginName?: string };
|
||||
|
||||
function nonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a message event against the configured service and the attempt's
|
||||
* state nonce. Returns null for anything that doesn't match EXACTLY -- other
|
||||
* frames post messages all the time, and none of them get to mint a session.
|
||||
*/
|
||||
export function validateOnrampMessage(
|
||||
event: { origin: string; data: unknown },
|
||||
expected: { origin: string; state: string }
|
||||
): OnrampUnlockResult | null {
|
||||
if (event.origin !== expected.origin) return null;
|
||||
const data = event.data as
|
||||
| { type?: unknown; state?: unknown; unlock?: unknown; register?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
if (data.type !== "bitlogin:onramp:unlock") return null;
|
||||
if (data.state !== expected.state) return null;
|
||||
|
||||
const unlock = data.unlock as { loginName?: unknown; password?: unknown } | undefined;
|
||||
const register = data.register as
|
||||
| { registrationToken?: unknown; suggestedLoginName?: unknown }
|
||||
| undefined;
|
||||
// Exactly one branch, fully well-formed.
|
||||
if (unlock && !register) {
|
||||
if (!nonEmptyString(unlock.loginName) || !nonEmptyString(unlock.password)) return null;
|
||||
return { kind: "unlock", loginName: unlock.loginName, password: unlock.password };
|
||||
}
|
||||
if (register && !unlock) {
|
||||
if (!nonEmptyString(register.registrationToken)) return null;
|
||||
return {
|
||||
kind: "register",
|
||||
registrationToken: register.registrationToken,
|
||||
suggestedLoginName: nonEmptyString(register.suggestedLoginName)
|
||||
? register.suggestedLoginName
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a freshly registered credential to the service for safekeeping
|
||||
* (Architecture B: the service is the password manager). Only ever called
|
||||
* with the host-configured URL. Throws on any non-OK response -- the caller
|
||||
* must NOT treat the account as onramp-managed unless the service confirmed
|
||||
* it stored the credential.
|
||||
*/
|
||||
export async function storeOnrampCredential(
|
||||
config: OnrampConfig,
|
||||
payload: { registrationToken: string; loginName: string; password: string },
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<void> {
|
||||
const response = await fetchImpl(config.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "bitlogin:onramp:store", ...payload })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${config.name} could not save your sign-in (HTTP ${response.status}). Your account was created -- do not lose the recovery phrase, and try connecting again later.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,20 @@ describe("widget security policy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("centralized on-ramp rows carry the custody label and live inside the menu", () => {
|
||||
// §CO7: the sub-label IS the custody statement, rendered from the
|
||||
// host-configured service name -- and the rows exist nowhere else.
|
||||
const menuStart = widgetSource.indexOf('`<div class="option-menu">');
|
||||
const menuEnd = widgetSource.indexOf("</div>`", menuStart);
|
||||
const menu = widgetSource.slice(menuStart, menuEnd);
|
||||
expect(menu).toContain("${onrampRows}");
|
||||
expect(widgetSource).toContain("manages your sign-in</span>");
|
||||
// One rendering site (the onrampRows template) + one onClick case.
|
||||
expect(widgetSource.split('"onramp-signin"').length - 1).toBe(2);
|
||||
// The group heading is the owner-chosen wording.
|
||||
expect(menu).toContain(">Use an account you already have</div>");
|
||||
});
|
||||
|
||||
it("treats the bunker URI as a secret and keeps the element storage-free", () => {
|
||||
// A bunker:// URI carries a connection token; it gets a password input
|
||||
// (no shoulder-surfing, no autofill heuristics), and the element itself
|
||||
|
||||
@@ -267,6 +267,26 @@ button:focus-visible { outline: 2px solid var(--bl-accent); outline-offset: 2px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.option-group-label {
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bl-muted);
|
||||
}
|
||||
.option-group-label + .option-row { border-top: none; }
|
||||
.notice .link-inline {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user