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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 11:34:52 -04:00
1 parent dc6490e46a
commit ff1470a849
9 files changed
+657 -8

No files matched your search

+27 -6
View File
@@ -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").
+6
View File
@@ -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
+11
View File
@@ -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
+12 -1
View File
@@ -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")
})
};
}
+223 -1
View File
@@ -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>
${
+167
View File
@@ -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/);
});
});
+177
View File
@@ -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
+20
View File
@@ -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;