mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-08 06:21:25 +00:00
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:
17 files changed
+655
-68
No files matched your search
+123
-16
@@ -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 `
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user