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

-47
View File
@@ -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
+22
View File
@@ -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
<script type="module" src="https://bitlogin.vercel.app/vendor/bitlogin/bitlogin.js"></script>
<bitlogin-auth></bitlogin-auth>
```
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)
+170
View File
@@ -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.
@@ -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();
@@ -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<Chan
oldLocatorPublicKey,
newLocatorPublicKey,
newGeneration,
recoveryPublicKey: oldPayload.recovery_public_key,
recoveryCapsuleEvent: oldPayload.recovery_capsule_event,
newCredentialEvent,
tombstoneEvent,
deletionRequestEvent,
+1
View File
@@ -1,6 +1,7 @@
export * from "./errors.js";
export * from "./wordlist.js";
export * from "./passphrase.js";
export * from "./manualPassword.js";
export * from "./normalize.js";
export * from "./timestamp.js";
export * from "./highWaterMark.js";
+3
View File
@@ -24,6 +24,8 @@ export interface LoginResult {
accountId: string;
generation: number;
credentialEvent: NostrEvent;
/** The signed recovery capsule event embedded in the credential capsule (§12.1, §24.4) — needed to build a recovery export or rebroadcast replicas without re-deriving anything from the phrase. */
recoveryCapsuleEvent: NostrEvent;
/** Set when the accepted generation is lower than one this device has previously seen (§16.2 step 6). */
rollbackWarning?: string;
/** Set when responsive relays disagree about which capsule is newest (§16.2 step 8). */
@@ -71,6 +73,7 @@ export async function loginWithPassword(params: LoginParams): Promise<LoginResul
accountId: payload.account_id,
generation: payload.generation,
credentialEvent: result.best.event,
recoveryCapsuleEvent: payload.recovery_capsule_event,
rollbackWarning,
relayDisagreementWarning
};
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { checkManualPassword, estimatePasswordEntropyBits } from "./manualPassword.js";
describe("checkManualPassword (§9.3)", () => {
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);
});
});
@@ -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 };
}
+2 -2
View File
@@ -37,7 +37,7 @@
<code>discovery-relays</code> attributes at your own relays for production use.
</p>
</div>
<bitlogin-auth id="bitlogin"></bitlogin-auth>
<bitlogin-auth id="bitlogin-widget"></bitlogin-auth>
</div>
<footer>
@@ -46,7 +46,7 @@
<script type="module" src="./vendor/bitlogin/bitlogin.js"></script>
<script type="module">
const el = document.getElementById('bitlogin');
const el = document.getElementById('bitlogin-widget');
el.addEventListener('bitlogin-login', (e) => console.log('[bitlogin] signed in', e.detail.publicKey));
el.addEventListener('bitlogin-logout', () => console.log('[bitlogin] signed out'));
</script>
+42
View File
@@ -73,6 +73,36 @@ const plain = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);</code></
and back.
</div>
</li>
<li>
<h3>Supporting multiple signing methods (extensions, NIP-46, BitLogin)</h3>
<p><code>window.nostr</code> is a single global slot — only one provider can occupy it at a time. If a user also
has a NIP-07 extension (Alby, nos2x, …) installed, BitLogin will not overwrite it: it only installs
<code>window.nostr</code> when nothing is already there. The reverse isn't in BitLogin's control — an extension
that loads after BitLogin can still overwrite it, same as any two NIP-07 providers would collide.</p>
<p>To know specifically whether BitLogin is the active signer (as opposed to an extension), check
<code>window.bitlogin?.isActiveSigner()</code> rather than just <code>typeof window.nostr !== 'undefined'</code> —
the latter only tells you <em>a</em> signer is present, not which one. <code>window.bitlogin</code> is installed
as soon as the script loads, independent of whether <code>&lt;bitlogin-auth&gt;</code> has signed anyone in yet,
so it's safe to feature-detect immediately.</p>
<pre><code>if (window.bitlogin) {
console.log('BitLogin is available, version', window.bitlogin.version);
}
if (window.bitlogin?.isActiveSigner()) {
// window.nostr is currently backed by BitLogin specifically
}</code></pre>
<p>NIP-46 (remote "bunker" signers) is a different transport — it doesn't typically hook
<code>window.nostr</code> directly, so it isn't something BitLogin needs to avoid colliding with. A site can
offer "Sign in with BitLogin," "Connect an extension," and "Connect a bunker" as separate options; whichever
the user picks becomes the active signer, and your app code should call whatever's currently active rather
than assume a specific one.</p>
<div class="callout">
<strong>Don't give your <code>&lt;bitlogin-auth&gt;</code> element <code>id="bitlogin"</code>.</strong>
Browsers automatically expose any element with a matching <code>id</code> or <code>name</code> attribute as
a same-named global — an element with <code>id="bitlogin"</code> becomes <code>window.bitlogin</code>,
shadowing the API object described above. Use something like <code>id="bitlogin-widget"</code> instead
(as this demo does).
</div>
</li>
<li>
<h3>Choose your relays</h3>
<p>BitLogin ships with a small built-in bootstrap relay list, but you should point <code>vault-relays</code> and
@@ -85,6 +115,18 @@ const plain = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);</code></
shows the full loop: create an account, sign a test event, rotate the password, and export a recovery file.</p>
</li>
</ol>
<div class="callout">
<strong>Password-manager save prompts.</strong> Because the widget's login form lives inside
a shadow root and submits via JavaScript (never a real form POST), browsers' passive
"offer to save this password" heuristics don't reliably fire — and the generated-password
screen never puts the password into a real input at all, since it's only shown as text.
The widget works around this by explicitly calling the Credential Management API
(<code>navigator.credentials.store()</code>) after every successful registration, login,
password rotation, and recovery. This is Chromium-only (Chrome, Edge, Brave, Opera) —
Firefox and Safari never implemented <code>PasswordCredential</code>, so users on those
browsers will need to save their generated password manually.
</div>
</section>
<footer>
+79
View File
@@ -0,0 +1,79 @@
# BitLogin
> A static, relay-backed portable Nostr account protocol. A familiar login
> name and password (or an existing `nsec`) unlocks a portable Nostr identity
> stored as encrypted "capsules" on public Nostr relays — no account server,
> no password-reset email, no database. Ships as an embeddable custom element
> plus a NIP-07-compatible `window.nostr` provider that any static site can
> drop in with one script tag.
## Integrate in one snippet
```html
<script type="module" src="https://bitlogin.vercel.app/vendor/bitlogin/bitlogin.js"></script>
<bitlogin-auth
vault-relays="wss://relay-one.example,wss://relay-two.example,wss://relay-three.example"
discovery-relays="wss://discovery-one.example,wss://discovery-two.example">
</bitlogin-auth>
```
`bitlogin.js` and `cryptoWorker.js` (plus the shared chunk they both import)
must be deployed side by side — the main script resolves the worker's URL at
runtime relative to its own location, so this works from any path. For
production, self-host these files rather than depending on the demo
deployment above.
## What it gives your app
- A `<bitlogin-auth>` custom element (Shadow DOM, no CSS collisions) handling
create account, sign in, forgot-password recovery via a 12-word phrase, and
password rotation.
- A `window.nostr` provider shaped like the de facto NIP-07 interface
(`getPublicKey`, `signEvent`, `getRelays`, `nip44.encrypt`/`decrypt`) —
existing Nostr app code that already calls `window.nostr` works unmodified,
same as it would against a browser extension (Alby, nos2x, …).
- `window.bitlogin` — installed as soon as the script loads, independent of
login state: `{ version, isActiveSigner() }`. Use
`window.bitlogin.isActiveSigner()` to tell whether BitLogin specifically
(not another extension) currently backs `window.nostr`.
- Events on the element: `bitlogin-login` (`detail.publicKey`) and
`bitlogin-logout`.
- Private keys never leave a dedicated Web Worker; the main thread only ever
sees public keys, signed events, and ciphertext.
## Key facts for integration
- No build step required to consume it — plain `<script type="module">`.
- Don't give the element `id="bitlogin"` — browsers auto-expose elements with
a matching `id`/`name` as a same-named global, which shadows
`window.bitlogin`. Use `id="bitlogin-widget"` or similar.
- Configure relays via the `vault-relays` and `discovery-relays` attributes
(comma-separated `wss://` URLs); omit to use BitLogin's small built-in
bootstrap list.
- Manual (user-chosen) passwords are opt-in and discouraged by default — the
widget generates a high-entropy passphrase and recommends it, since a
downloadable encrypted capsule can be guessed against offline forever.
- Recovery is a 12-word BIP-39-style phrase, independent of the password;
losing the password never locks the user out if they still have the
phrase.
## Reference docs
- [Integration guide](/docs.html) — full walkthrough with code samples.
- [Live account manager demo](/account.html) — create/login/recover, sign a
test event, rotate password, export identity.
- [Protocol specification](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/spec.md) —
the full BitLogin v0.3 protocol (crypto construction, capsule formats,
relay architecture, threat model).
- [Second-factor / recovery options design notes](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/second-factor.md)
and [future recovery-path options](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/future-recovery-options.md) —
proposals, not all implemented; each notes its own status.
- [Source repository](https://github.com/PR0M3TH3AN/bitlogin) — monorepo:
`packages/core` (protocol implementation), `packages/widget` (the embeddable
element + provider), `packages/demo` (this site).
## Status
Experimental alpha. Verified with an in-memory mock-relay test suite and
real-browser end-to-end runs, but not independently audited. See the README
in the source repository for the full implemented/deferred feature list.
+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,
+1 -2
View File
@@ -1,5 +1,4 @@
{
"buildCommand": "npm run build -w @bitlogin/core && npm run build -w @bitlogin/widget && npm run build -w @bitlogin/demo",
"outputDirectory": "packages/demo/dist",
"cleanUrls": true
"outputDirectory": "packages/demo/dist"
}