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.

- +