Fix live-deployment bugs, add manual passwords, and improve integration support

Fixes reported from the live Vercel deployment:
- vercel.json: remove cleanUrls, which was breaking every internal .html
  link (ERR_FAILED) since links already use explicit extensions
- Recovery export was silently broken after login or password rotation:
  the crypto worker's session state only picked up the recovery capsule
  event on register/recover, never on login/changePassword, even though
  the decrypted payload always carries it. Surfaced recoveryCapsuleEvent
  (and recoveryPublicKey for changePassword) through core's LoginResult /
  ChangePasswordResult and wired them into the worker session; covered by
  a new account.e2e.test.ts case
- New accounts never got a Nostr profile: publishProfileAndRelayLists is
  now called with name: loginName after registration, so the npub's
  profile defaults to the chosen login name
- Password manager never recognized the fields (shadow DOM + JS-driven
  submission defeat the passive save-prompt heuristics, and the
  generated-password screen never put the password in a real input at
  all). Fixed with an explicit navigator.credentials.store() call after
  every register/login/recover/changePassword; documented as
  Chromium-only in docs.html

New: optional manual/custom password (packages/core/src/account/manualPassword.ts),
opt-in per spec §9.3's "future release may permit manual passwords behind
an advanced setting" allowance. Enforces a length floor, a common-password
blocklist, a login-name-substring check, a low-variety-pattern check, and
an entropy floor; the widget shows live non-blocking feedback as the user
types. The generated high-entropy passphrase remains the default,
recommended option.

Integration/interop support:
- window.nostr provider now carries a readonly _bitlogin marker
- window.bitlogin ({ version, isActiveSigner() }) is installed
  unconditionally as soon as the script loads, independent of login
  state or window.nostr's current occupant. Found and fixed a real bug
  along the way: an unconditional assignment is required because browsers
  auto-expose any element with a matching id/name as a same-named global,
  so <bitlogin-auth id="bitlogin"> silently shadowed window.bitlogin
  under a naive `if (!window.bitlogin)` guard. Renamed the demo's own
  element id to avoid the same trap and documented the gotcha in docs.html
- docs.html: new section on supporting multiple signing methods
  (extensions, NIP-46, BitLogin) alongside each other
- Added packages/demo/public/llms.txt for AI-assisted integration

Deployment: GitHub Pages requires a paid plan for private repos, so its
workflow and the .github directory are removed; README documents
Vercel-only deployment and the direct-from-CDN integration path.

Docs: added docs/future-recovery-options.md, laying out passkey-based
2FA/recovery options (pure 2FA, full independent recovery, and a
constrained middle ground) with pros/cons for future consideration —
not a development plan, nothing here is decided.

59/59 tests pass; core and widget typecheck clean; all fixes verified
live in Chromium via Playwright against an in-memory mock relay.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
This commit is contained in:
Claude committed 2026-07-21 15:35:10 +00:00
1 parent a76c8484f7
commit 0801371566
17 files changed
+655 -68

No files matched your search

+123 -16
View File
@@ -1,5 +1,5 @@
/** <bitlogin-auth> — the embeddable BitLogin login/create/recover widget (§3, §27). */
import { generatePassphrase, isValidLoginName } from "@bitlogin/core/account";
import { generatePassphrase, isValidLoginName, checkManualPassword, type ManualPasswordCheck } from "@bitlogin/core/account";
import { encodeNpub } from "@bitlogin/core/nostr";
import { WorkerClient } from "./worker/workerClient.js";
import { createNip07Provider } from "./provider.js";
@@ -40,6 +40,12 @@ export class BitLoginAuthElement extends HTMLElement {
private recoveryPhrase = "";
private confirmSlots: ConfirmSlot[] = [];
// Manual password opt-in (§9.3). Off by default; the generated passphrase remains the
// recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full
// re-render (see onInput) so the field never loses focus while the user is typing.
private manualPasswordMode = false;
private manualPasswordFeedback: ManualPasswordCheck | null = null;
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
private importKey = "";
private importPreviewNpub = "";
@@ -68,6 +74,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.root.addEventListener("click", (e) => this.onClick(e));
this.root.addEventListener("submit", (e) => this.onSubmit(e));
this.root.addEventListener("input", (e) => this.onInput(e));
this.render();
if (!(window as unknown as { nostr?: unknown }).nostr) {
@@ -114,6 +121,31 @@ export class BitLoginAuthElement extends HTMLElement {
return (this.root.querySelector(`[name="${name}"]`) as HTMLInputElement | null)?.value ?? "";
}
/**
* Explicitly asks the browser to offer saving this credential via the Credential
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
* `PasswordCredential`). This is the primary fix for password-manager integration here,
* not just a nice-to-have: the generated-password screen never puts the password into a
* real `<input>` for a browser to observe being filled (it's shown as text), and even on
* the login screen — which does use real, correctly-`autocomplete`d inputs — save/autofill
* heuristics are unreliable inside a shadow root, and this widget's JS-driven submit
* (`preventDefault`, no real form POST) doesn't produce the passive signal most heuristics
* expect anyway. An explicit `navigator.credentials.store()` call sidesteps all of that.
* Best-effort only: never blocks or fails the surrounding flow.
*/
private async offerToSaveCredential(loginName: string, password: string): Promise<void> {
try {
const PasswordCredentialCtor = (
window as unknown as { PasswordCredential?: new (data: { id: string; password: string; name?: string }) => Credential }
).PasswordCredential;
if (!PasswordCredentialCtor || !navigator.credentials?.store) return;
const credential = new PasswordCredentialCtor({ id: loginName, password, name: loginName });
await navigator.credentials.store(credential);
} catch {
// Best-effort only.
}
}
private async onClick(e: Event): Promise<void> {
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-action]");
if (!target) return;
@@ -158,6 +190,12 @@ export class BitLoginAuthElement extends HTMLElement {
this.savedCheckbox = false;
this.render();
return;
case "toggle-manual-password":
this.manualPasswordMode = !this.manualPasswordMode;
this.manualPasswordFeedback = null;
this.savedCheckbox = false;
this.render();
return;
case "copy-credential": {
const box = this.root.querySelector<HTMLElement>("#credential-box");
if (box) void navigator.clipboard?.writeText(box.textContent ?? "");
@@ -206,6 +244,32 @@ export class BitLoginAuthElement extends HTMLElement {
}
}
/**
* Live entropy/strength feedback for the manual-password field, updated by DIRECTLY
* patching a feedback element's text — never calling this.render() — so the input never
* loses focus or cursor position while the user is typing (§9.3).
*/
private onInput(e: Event): void {
const target = e.target as HTMLElement;
if (!(target instanceof HTMLInputElement) || target.name !== "manualPassword") return;
this.manualPasswordFeedback = target.value ? checkManualPassword(target.value, this.loginName) : null;
const feedback = this.root.querySelector<HTMLElement>("#manual-password-feedback");
if (!feedback) return;
if (!this.manualPasswordFeedback) {
feedback.textContent = "";
feedback.className = "notice info";
return;
}
const bits = this.manualPasswordFeedback.entropyBits.toFixed(0);
if (this.manualPasswordFeedback.ok) {
feedback.textContent = `Looks good (~${bits} bits estimated).`;
feedback.className = "notice info";
} else {
feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`;
feedback.className = "notice warn";
}
}
private async handlePreviewImport(): Promise<void> {
const pasted = this.field("importKey").trim();
if (!pasted) {
@@ -249,20 +313,35 @@ export class BitLoginAuthElement extends HTMLElement {
this.loginName = name;
this.generatedCredential = generatePassphrase().secret;
this.savedCheckbox = false;
this.manualPasswordMode = false;
this.manualPasswordFeedback = null;
this.goto("create-credential");
}
private async handleCreateCredentialSubmit(): Promise<void> {
let password = this.generatedCredential;
if (this.manualPasswordMode) {
const typed = this.field("manualPassword");
const check = checkManualPassword(typed, this.loginName);
if (!check.ok) {
this.errorMessage = `Password not accepted: ${check.reason}`;
this.render();
return;
}
password = typed;
}
this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false;
if (!this.savedCheckbox) {
this.errorMessage = "Please confirm you saved your password before continuing.";
this.errorMessage = this.manualPasswordMode
? "Please confirm you've saved your password (or written it down) before continuing."
: "Please confirm you saved your password before continuing.";
this.render();
return;
}
this.setBusy(true);
const result = await this.worker.register({
loginName: this.loginName,
password: this.generatedCredential,
password,
importKey: this.importKey || undefined
});
// The pasted key is now wrapped in the capsules; drop the main-thread copy (§11.10).
@@ -275,11 +354,15 @@ export class BitLoginAuthElement extends HTMLElement {
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
this.busy = false;
this.goto("confirm-phrase");
// §15.8/§19.6 — publish relay preferences immediately after successful registration.
// §15.8/§19.6 — publish relay preferences immediately after successful registration,
// defaulting the public profile name to the chosen login name so the account isn't
// just a bare npub in every other Nostr client.
void this.worker.publishProfileAndRelayLists({
name: this.loginName,
generalRelays: this.vaultRelayUrls,
dmRelays: this.vaultRelayUrls
});
void this.offerToSaveCredential(this.loginName, password);
}
private handleConfirmPhraseSubmit(): void {
@@ -307,6 +390,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.busy = false;
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
this.goto("dashboard");
void this.offerToSaveCredential(loginName, password);
}
private async handleRecoverPhraseSubmit(): Promise<void> {
@@ -338,6 +422,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.busy = false;
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } }));
this.goto("dashboard");
void this.offerToSaveCredential(newLoginName, this.newCredentialAfterRecovery);
}
private async handleChangePasswordSubmit(): Promise<void> {
@@ -350,6 +435,7 @@ export class BitLoginAuthElement extends HTMLElement {
});
this.busy = false;
this.goto("dashboard");
void this.offerToSaveCredential(this.loginName, this.changePasswordNewCredential);
}
private async handleDownloadRecoveryExport(): Promise<void> {
@@ -442,30 +528,51 @@ export class BitLoginAuthElement extends HTMLElement {
<button class="link" data-action="goto-welcome">Back</button>
`;
case "create-credential":
case "create-credential": {
const submitLabel = this.busy
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
: this.importKey
? "Import account"
: "Create account";
if (this.manualPasswordMode) {
return `
<h2>Choose your own password</h2>
<p class="sub">Not recommended: a downloadable encrypted file can be guessed against forever, offline, with no rate limit. BitLogin can only run basic checks here — not a breach-database lookup — so weak or reused passwords are still your risk to carry.</p>
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
${this.renderError()}
<form data-form="create-credential">
<label for="manualPassword">Password</label>
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" />
<div class="notice info" id="manual-password-feedback"></div>
<label class="checkbox-row">
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
I have saved this password somewhere safe.
</label>
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
</form>
<button class="link" data-action="toggle-manual-password">Use a generated password instead (recommended)</button>
`;
}
return `
<h2>Your generated password</h2>
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file. Manual passwords aren't allowed in this alpha.</p>
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
<div class="credential-box" id="credential-box">${escapeHtml(this.generatedCredential)}</div>
<button class="secondary" type="button" data-action="copy-credential">Copy</button>
<button class="secondary" type="button" data-action="regenerate-credential">Generate a different one</button>
${this.renderError()}
<form data-form="create-credential">
<form data-form="create-credential" autocomplete="on">
<input type="text" name="username" autocomplete="username" value="${escapeHtml(this.loginName)}" readonly hidden />
<label class="checkbox-row">
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
I have saved this password somewhere safe.
</label>
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
${
this.busy
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
: this.importKey
? "Import account"
: "Create account"
}
</button>
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
</form>
<button class="link" data-action="toggle-manual-password">Use my own password instead (not recommended)</button>
`;
}
case "confirm-phrase":
return `
+35
View File
@@ -5,6 +5,41 @@ if (!customElements.get("bitlogin-auth")) {
customElements.define("bitlogin-auth", BitLoginAuthElement);
}
export interface BitLoginGlobal {
readonly version: string;
/**
* True when BitLogin currently occupies window.nostr. Only one provider can hold that
* global at a time (a browser extension like Alby or nos2x may also implement it, or
* install itself later and overwrite BitLogin's). This check survives that overwrite —
* unlike checking `window.nostr._bitlogin` directly — since it's read fresh each call.
*/
isActiveSigner(): boolean;
}
declare global {
interface Window {
bitlogin?: BitLoginGlobal;
}
}
// Installed once per page regardless of whether <bitlogin-auth> has been used yet, so a
// host site can feature-detect BitLogin's presence and active-signer status independently
// of window.nostr's current occupant (§26.2, §26.4 — "active signer" support for sites that
// offer multiple signing methods: BitLogin, a NIP-07 extension, or a NIP-46 remote signer).
//
// This assignment is deliberately unconditional (not `if (!window.bitlogin)`): browsers
// auto-expose any element with a matching `id` or `name` attribute as a same-named global
// (e.g. `<div id="bitlogin">` becomes `window.bitlogin`). A guard would silently see that
// element instead of ever installing this object on any page that happens to use that id —
// including our own demo before it was renamed. A direct assignment creates a real own
// property that takes priority over that fallback, on any page.
window.bitlogin = {
version: "0.1.0",
isActiveSigner(): boolean {
return (window as unknown as { nostr?: { _bitlogin?: boolean } }).nostr?._bitlogin === true;
}
};
export { BitLoginAuthElement } from "./element.js";
export { WorkerClient } from "./worker/workerClient.js";
export { createNip07Provider } from "./provider.js";
+11 -1
View File
@@ -17,6 +17,15 @@ export interface Nip07Provider {
encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
decrypt(peerPublicKey: string, payload: string): Promise<string>;
};
/**
* Identifies this provider as BitLogin's, distinct from a browser extension (Alby, nos2x,
* …) that may also implement window.nostr. Only one provider can occupy window.nostr at a
* time — this lets a host site that supports multiple signing methods tell which one is
* actually active, e.g. `window.nostr?._bitlogin === true`. Prefer `window.bitlogin` (see
* index.ts) for this check where available; it survives another provider later overwriting
* window.nostr, which this flag on the object itself cannot.
*/
readonly _bitlogin: true;
}
const NOT_UNLOCKED_MESSAGE =
@@ -52,6 +61,7 @@ export function createNip07Provider(workerClient: WorkerClient, configuredRelays
const { plaintext } = await wrapUnlockError(workerClient.nip44Decrypt({ peerPublicKey, payload }));
return plaintext;
}
}
},
_bitlogin: true
};
}
@@ -139,6 +139,7 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
session.accountId = result.accountId;
session.recoveryPublicKey = result.recoveryPublicKey;
session.activeCredentialEvent = result.credentialEvent;
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
return {
everydayPublicKey: result.everydayPublicKey,
accountId: result.accountId,
@@ -197,6 +198,12 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
vaultRelayUrls,
store
});
// Keep the session's capsule references current so a recovery export or replica
// repair requested right after rotation (without an intervening re-login) still
// finds the NEW credential capsule and the (unchanged) recovery capsule.
session.activeCredentialEvent = result.newCredentialEvent;
session.recoveryPublicKey = result.recoveryPublicKey;
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
return {
newLocatorPublicKey: result.newLocatorPublicKey,
newGeneration: result.newGeneration,