Files
bitlogin/packages/core/src/account/manualPassword.ts
T
Claude 0801371566 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
2026-07-21 15:35:10 +00:00

89 lines
3.8 KiB
TypeScript

/**
* 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 };
}