From 08013715666a6493dc8fabf23e9bd0c096bb76d7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 21 Jul 2026 15:35:10 +0000
Subject: [PATCH] Fix live-deployment bugs, add manual passwords, and improve
integration support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 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
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
---
.github/workflows/pages.yml | 47 -----
README.md | 22 +++
docs/future-recovery-options.md | 170 ++++++++++++++++++
packages/core/src/account/account.e2e.test.ts | 26 +++
packages/core/src/account/changePassword.ts | 5 +
packages/core/src/account/index.ts | 1 +
packages/core/src/account/login.ts | 3 +
.../core/src/account/manualPassword.test.ts | 40 +++++
packages/core/src/account/manualPassword.ts | 88 +++++++++
packages/demo/public/account.html | 4 +-
packages/demo/public/docs.html | 42 +++++
packages/demo/public/llms.txt | 79 ++++++++
packages/widget/src/element.ts | 139 ++++++++++++--
packages/widget/src/index.ts | 35 ++++
packages/widget/src/provider.ts | 12 +-
packages/widget/src/worker/cryptoWorker.ts | 7 +
vercel.json | 3 +-
17 files changed, 655 insertions(+), 68 deletions(-)
delete mode 100644 .github/workflows/pages.yml
create mode 100644 docs/future-recovery-options.md
create mode 100644 packages/core/src/account/manualPassword.test.ts
create mode 100644 packages/core/src/account/manualPassword.ts
create mode 100644 packages/demo/public/llms.txt
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
deleted file mode 100644
index dc65616..0000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Deploy demo to GitHub Pages
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - run: npm ci
-
- - run: npm run build -w @bitlogin/core
- - run: npm run build -w @bitlogin/widget
- - run: npm run build -w @bitlogin/demo
-
- - uses: actions/configure-pages@v5
-
- - uses: actions/upload-pages-artifact@v3
- with:
- path: packages/demo/dist
-
- deploy:
- needs: build
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - id: deployment
- uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index f253db1..2bd3ffa 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,28 @@ guide, and `packages/demo/public/account.html` for a working example
(create → confirm recovery phrase → sign an event → rotate password →
export identity).
+## Deployment
+
+The demo site deploys to Vercel: `vercel.json` at the repo root sets the
+build command (core → widget → demo) and output directory, so importing this
+repo at [vercel.com/new](https://vercel.com/new) needs no manual
+configuration. GitHub Pages was evaluated but dropped — Pages requires a paid
+plan for private repositories, whereas Vercel's free tier deploys a private
+repo without that restriction.
+
+Because the deployed demo serves the built widget files as plain static
+assets at `/vendor/bitlogin/`, **any other site can point directly at the
+Vercel deployment today** without installing anything locally:
+
+```html
+
+
+```
+
+This is fine for prototyping across multiple projects; for production use in
+someone else's app, self-hosting the built files (or a future published npm
+package / CDN release) avoids depending on this demo deployment's uptime.
+
## What's implemented
This build covers the protocol's Phase 0 (cryptographic core + test vectors)
diff --git a/docs/future-recovery-options.md b/docs/future-recovery-options.md
new file mode 100644
index 0000000..74307a7
--- /dev/null
+++ b/docs/future-recovery-options.md
@@ -0,0 +1,170 @@
+# BitLogin: Future Recovery-Path Options
+
+**Document version:** 0.1
+**Status:** Options for future consideration only. Nothing here is decided,
+scheduled, or implemented, and this is not a development plan — it's a
+record of the tradeoffs so the decision can be revisited with the reasoning
+intact, rather than re-litigated from scratch.
+**Depends on:** `docs/spec.md` and `docs/second-factor.md` (§SF5 describes the
+2-of-2 split-key mechanism referenced throughout).
+
+---
+
+## The core distinction: a second factor vs. a recovery path
+
+These are different shapes of feature, and conflating them is the easiest
+way to accidentally weaken the account:
+
+- **A second factor (AND-gate).** Something *additional* required alongside
+ the password. It can only make login harder to complete without it — it
+ can never, by itself, grant access. If you lose the second factor, you
+ fall back to whatever recovery paths already existed (today: the recovery
+ phrase).
+- **A recovery path (OR-gate).** An *independent* way to reach the account
+ without the password (or without anything else). BitLogin already has
+ exactly one of these: the 12-word recovery phrase. Every recovery path is
+ a full-power master key — anyone who has it can take over the account
+ completely, with nothing else needed.
+
+The question that prompted this document: could a WebAuthn passkey (Face
+ID / Touch ID / Windows Hello, via the PRF extension) serve as **both** — a
+2FA hardening layer on the password path, **and** a way to sign back in and
+reset the password without the phrase, the way "reset via authenticator"
+flows work on many consumer services today?
+
+Mechanically, yes. Whether to build it is a real tradeoff, laid out below.
+
+---
+
+## Option A — Passkey as a pure second factor (AND-gate only)
+
+**How it works:** the credential-capsule payload key becomes a 2-of-2 split
+between the password and a device-held secret (see `second-factor.md` §SF5
+for the exact derivation). The passkey's WebAuthn PRF output unlocks the
+device's share of that split. Without the password, the capsule can't even
+be located — the locator itself is password-derived — so the passkey alone
+grants nothing. The recovery phrase remains the *only* way to recover
+without the password.
+
+**Pros**
+- Matches the "bank 2FA" mental model most users already have: forgot your
+ password still means "use your recovery phrase," full stop. No new
+ master key exists.
+- Smallest attack-surface increase of any option here — it can only ever
+ make an account *harder* to break into, never easier.
+- Fully backward-compatible and additive to the shipped protocol (see
+ `second-factor.md` §SF4 for why the envelope-versioning approach doesn't
+ disturb existing accounts).
+- No dependency on a third party's account security (Apple ID, Google
+ Account) for anything account-critical. If a synced passkey is lost or
+ its sync account is compromised, the attacker still needs the password —
+ the phrase alone remains the recovery answer either way.
+
+**Cons**
+- Doesn't solve "I forgot my password and can't find my 12 words" — the
+ exact moment a lot of real users hit. The convenience win from a
+ Face-ID-driven "reset" flow doesn't materialize under this option.
+- One more thing enrolled per account (a device/credential registry) for a
+ security property that's arguably nice-to-have rather than essential —
+ BitLogin's generated high-entropy passwords already carry most of the
+ defensive load against the threat 2FA typically addresses (credential
+ stuffing / reused passwords), which matters less here since manual
+ passwords are opt-in and discouraged.
+
+---
+
+## Option B — Passkey as an independent recovery path (OR-gate, full parity with the phrase)
+
+**How it works:** using WebAuthn's *discoverable credentials*, a browser can
+present "sign in with a passkey" and hand back an identity without the site
+supplying a username first. The passkey would derive its own locator and
+capsule — structurally parallel to how the recovery phrase works today —
+letting a user tap the passkey, get logged in, and set a brand-new login
+name and password. No phrase required.
+
+**Pros**
+- Solves the actual "forgot password" moment with the lowest possible
+ friction: Face ID, done. This is the flow users already expect from
+ "Sign in with passkey" buttons elsewhere on the web.
+- No paper to lose, no drawer to search.
+
+**Cons — the significant one first**
+- **Most real users will have a *synced* passkey** (iCloud Keychain, Google
+ Password Manager) — that's the default, low-friction path on every
+ mainstream device, and there's no realistic way to force people onto
+ non-syncing hardware keys. A synced passkey lives inside the user's
+ Apple ID / Google account. If this option grants full account recovery
+ with no phrase required, **the account's real security floor becomes
+ whatever protects that Apple/Google account** — not BitLogin's own
+ cryptography. Account takeovers via compromised iCloud/Google
+ credentials (phishing, SIM-swap) are a common, well-documented attack
+ pattern, not a hypothetical.
+- That directly reopens the exact dependency BitLogin's entire design
+ exists to avoid: no operator, no support desk, no third party that can be
+ compelled, breached, or phished into handing over the account. This
+ option quietly relocates that dependency to Apple or Google instead of
+ removing it.
+- It's a second full-power master key, full stop — doubling the number of
+ ways to fully take over an account, independent of how carefully it's
+ implemented.
+- It is not a small add-on. Matching the recovery capsule's existing
+ guarantees (its own address, its own hash chain for gap/replay detection,
+ its own tombstone/vandalism-resistance story per §14.2) means building an
+ entire second, parallel recovery-capsule system — real protocol surface,
+ not a UI feature.
+- Two "I forgot everything" buttons that do fundamentally different things
+ is a support and mental-model problem. A user may stop safeguarding their
+ phrase ("I have Face ID") and only discover it was the one real backup
+ after a new phone or a changed Apple ID quietly drops the synced passkey.
+
+---
+
+## Option C — Constrained passkey recovery (same-credential-only)
+
+**How it works:** a middle path on Option B — recovery is permitted only
+through the *specific* passkey credential ID already enrolled for 2FA
+(Option A), never a freshly minted one. Enrolling a new passkey requires
+already being logged in; recovery never accepts an unfamiliar credential.
+
+**Pros**
+- Closes off the specific case of "attacker takes over my iCloud account,
+ mints a brand-new passkey there, uses it to recover" — a fresh credential
+ can never be used for recovery, only ones already on file.
+- Somewhat lower engineering cost than a fully independent Option B, since
+ it reuses the same enrolled-credential registry as the 2FA design.
+
+**Cons**
+- Does **not** close the main hole: if the *already-enrolled* passkey is
+ itself synced, an attacker who compromises the Apple/Google account gets
+ a working copy of that exact credential too — "same credential only"
+ and "any new credential" become equally reachable once the sync account
+ is owned. This mitigation only fully works for non-syncing, single-device
+ passkeys, which most users won't have.
+- Inherits most of Option B's structural cost (a second recovery-capsule
+ system) for a partial fix.
+- A serverless design has no good way to add the mitigations centralized
+ services normally layer onto "recover without your password" flows —
+ a delay window with a chance to cancel, an out-of-band notification to
+ the real owner, a manual review step. There's no server to hold that
+ state and no reliable channel to notify anyone (BitLogin has no email,
+ and a signed Nostr DM only reaches a device that happens to be online and
+ watching). That gap is a property of the architecture, not something this
+ option can design around — it's worth naming plainly rather than
+ pretending a technical constraint could close it.
+
+---
+
+## Summary comparison
+
+```text
+ Forgot-password New master-key Depends on
+ UX improvement? risk added? Apple/Google?
+Option A (2FA only) no minimal no
+Option B (full recovery) yes full yes (synced case)
+Option C (constrained) yes partial‑reduced yes (synced case)
+```
+
+None of these are mutually exclusive with each other or with the existing
+password + phrase design — Option A could ship on its own, or as a
+foundation that C or B build on top of later. The point of this document is
+only to make the tradeoff explicit before any of them is chosen.
diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts
index 74808af..5af4203 100644
--- a/packages/core/src/account/account.e2e.test.ts
+++ b/packages/core/src/account/account.e2e.test.ts
@@ -90,6 +90,32 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
ARGON2_TIMEOUT
);
+ it(
+ "login and password change both surface the embedded recovery capsule event (recovery export must work without a fresh phrase-recovery)",
+ async () => {
+ const loginName = "exportbug";
+ const oldPassword = generatePassphrase().secret;
+ const newPassword = generatePassphrase().secret;
+ const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls });
+
+ // A clean-device login (no prior registration/recovery in this "session") must still
+ // return the embedded recovery event -- this is what a recovery-export button needs.
+ const login = await loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls });
+ expect(login.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
+
+ // After a password rotation, the (unchanged) recovery capsule event must still be
+ // surfaced -- previously this was silently dropped, breaking "download export" after
+ // a rotation even across a logout/login.
+ const changed = await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls });
+ expect(changed.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
+ expect(changed.recoveryPublicKey).toBe(registration.recoveryPublicKey);
+
+ const loginAfterRotation = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls });
+ expect(loginAfterRotation.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
+ },
+ ARGON2_TIMEOUT * 2
+ );
+
it("rejects malformed keys on import (§SF10)", () => {
expect(() => decodeEverydayPrivateKey("not-a-key")).toThrow();
expect(() => decodeEverydayPrivateKey("nsec1invalid")).toThrow();
diff --git a/packages/core/src/account/changePassword.ts b/packages/core/src/account/changePassword.ts
index 8b6a8c9..8eedf18 100644
--- a/packages/core/src/account/changePassword.ts
+++ b/packages/core/src/account/changePassword.ts
@@ -31,6 +31,9 @@ export interface ChangePasswordResult {
oldLocatorPublicKey: string;
newLocatorPublicKey: string;
newGeneration: number;
+ /** Carried forward unchanged from the old capsule (§14.1: rotation never touches the recovery capsule). */
+ recoveryPublicKey: string;
+ recoveryCapsuleEvent: NostrEvent;
newCredentialEvent: NostrEvent;
tombstoneEvent: NostrEvent;
deletionRequestEvent: NostrEvent;
@@ -127,6 +130,8 @@ export async function changePassword(params: ChangePasswordParams): Promise {
+ it("rejects passwords shorter than the minimum length", () => {
+ expect(checkManualPassword("Sh0rt!", "adam").ok).toBe(false);
+ });
+
+ it("rejects common weak passwords", () => {
+ expect(checkManualPassword("password123", "adam").ok).toBe(false);
+ expect(checkManualPassword("correcthorsebatterystaple", "adam").ok).toBe(false);
+ });
+
+ it("rejects a password containing the login name", () => {
+ expect(checkManualPassword("adamsSuperSecret1!", "adam").ok).toBe(false);
+ });
+
+ it("rejects repeated-character and sequence patterns", () => {
+ expect(checkManualPassword("aaaaaaaaaaaaaaaa", "user").ok).toBe(false);
+ expect(checkManualPassword("abcdabcdabcdabcd", "user").ok).toBe(false);
+ expect(checkManualPassword("12345678901234", "user").ok).toBe(false);
+ });
+
+ it("rejects passwords below the entropy floor even if structurally fine", () => {
+ // 12 lowercase letters ~= 12 * log2(26) ~= 56 bits, below the 64-bit floor.
+ expect(checkManualPassword("qwertyuiopzx", "user").ok).toBe(false);
+ });
+
+ it("accepts a sufficiently long, varied, non-blocklisted password", () => {
+ const result = checkManualPassword("Tr0mbone$Xylophone#42", "user");
+ expect(result.ok).toBe(true);
+ expect(result.entropyBits).toBeGreaterThanOrEqual(64);
+ });
+
+ it("estimates higher entropy for a longer, more varied password", () => {
+ const short = estimatePasswordEntropyBits("abcdefgh");
+ const long = estimatePasswordEntropyBits("abcdefghijklmnop123!@#");
+ expect(long).toBeGreaterThan(short);
+ });
+});
diff --git a/packages/core/src/account/manualPassword.ts b/packages/core/src/account/manualPassword.ts
new file mode 100644
index 0000000..8b39468
--- /dev/null
+++ b/packages/core/src/account/manualPassword.ts
@@ -0,0 +1,88 @@
+/**
+ * Optional manual password support (§9.3). The alpha's default and
+ * recommendation is a generated credential (§9.2); a user may opt into
+ * typing their own, gated by a hard minimum-entropy estimate, a small
+ * known-weak-pattern check, and an explicit warning that offline guessing
+ * against a downloaded capsule cannot be prevented by any client-side check
+ * (§9.1). This is an alpha-grade heuristic, not a breach-database lookup —
+ * BitLogin is static and makes no third-party network calls, so it cannot
+ * check against HaveIBeenPwned-style corpora. A production release should
+ * use a proper strength estimator (e.g. zxcvbn) and/or a bundled breach list.
+ */
+
+export const MANUAL_PASSWORD_MIN_ENTROPY_BITS = 64;
+export const MANUAL_PASSWORD_MIN_LENGTH = 12;
+
+const COMMON_WEAK_PASSWORDS = new Set([
+ "password", "password1", "password123", "123456", "123456789", "12345678",
+ "qwerty", "qwerty123", "letmein", "welcome", "monkey", "dragon", "master",
+ "abc123", "iloveyou", "admin", "login", "starwars", "sunshine", "princess",
+ "football", "baseball", "trustno1", "000000", "111111", "123123", "1234567890",
+ "changeme", "passw0rd", "correcthorsebatterystaple"
+]);
+
+export interface ManualPasswordCheck {
+ ok: boolean;
+ entropyBits: number;
+ reason?: string;
+}
+
+function characterClassAlphabetSize(password: string): number {
+ let size = 0;
+ if (/[a-z]/u.test(password)) size += 26;
+ if (/[A-Z]/u.test(password)) size += 26;
+ if (/[0-9]/u.test(password)) size += 10;
+ if (/[^a-zA-Z0-9]/u.test(password)) size += 33; // common printable symbols
+ return size || 1;
+}
+
+/**
+ * A conservative, dependency-free entropy estimate: character-class alphabet
+ * size times length. This deliberately under-credits patterned passwords
+ * rather than over-trusts them, but it cannot detect every weak pattern —
+ * it is combined with the blocklist and structural checks below.
+ */
+export function estimatePasswordEntropyBits(password: string): number {
+ return password.length * Math.log2(characterClassAlphabetSize(password));
+}
+
+function hasLowVarietyPattern(password: string): boolean {
+ if (/^(.)\1*$/u.test(password)) return true; // all one character
+ if (/(.)\1{2,}/u.test(password)) return true; // 3+ repeated character run
+ const lower = password.toLowerCase();
+ const sequences = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
+ for (const seq of sequences) {
+ for (let i = 0; i + 4 <= seq.length; i++) {
+ const forward = seq.slice(i, i + 4);
+ const backward = [...forward].reverse().join("");
+ if (lower.includes(forward) || lower.includes(backward)) return true;
+ }
+ }
+ return false;
+}
+
+/** Validates a user-chosen password against the alpha's manual-password floor (§9.3). */
+export function checkManualPassword(password: string, normalizedLoginName: string): ManualPasswordCheck {
+ const entropyBits = estimatePasswordEntropyBits(password);
+
+ if (password.length < MANUAL_PASSWORD_MIN_LENGTH) {
+ return { ok: false, entropyBits, reason: `Must be at least ${MANUAL_PASSWORD_MIN_LENGTH} characters.` };
+ }
+ if (COMMON_WEAK_PASSWORDS.has(password.toLowerCase())) {
+ return { ok: false, entropyBits, reason: "This is on a list of extremely common passwords." };
+ }
+ if (normalizedLoginName.length >= 3 && password.toLowerCase().includes(normalizedLoginName)) {
+ return { ok: false, entropyBits, reason: "Must not contain your login name." };
+ }
+ if (hasLowVarietyPattern(password)) {
+ return { ok: false, entropyBits, reason: "Too predictable (repeated characters or a simple sequence)." };
+ }
+ if (entropyBits < MANUAL_PASSWORD_MIN_ENTROPY_BITS) {
+ return {
+ ok: false,
+ entropyBits,
+ reason: `Estimated entropy (~${entropyBits.toFixed(0)} bits) is below the required ${MANUAL_PASSWORD_MIN_ENTROPY_BITS}. Use a longer password or mix character types.`
+ };
+ }
+ return { ok: true, entropyBits };
+}
diff --git a/packages/demo/public/account.html b/packages/demo/public/account.html
index 599d4a1..a8f8276 100644
--- a/packages/demo/public/account.html
+++ b/packages/demo/public/account.html
@@ -37,7 +37,7 @@
discovery-relays attributes at your own relays for production use.