mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Fix live-deployment bugs, add manual passwords, and improve integration support
Fixes reported from the live Vercel deployment:
- vercel.json: remove cleanUrls, which was breaking every internal .html
link (ERR_FAILED) since links already use explicit extensions
- Recovery export was silently broken after login or password rotation:
the crypto worker's session state only picked up the recovery capsule
event on register/recover, never on login/changePassword, even though
the decrypted payload always carries it. Surfaced recoveryCapsuleEvent
(and recoveryPublicKey for changePassword) through core's LoginResult /
ChangePasswordResult and wired them into the worker session; covered by
a new account.e2e.test.ts case
- New accounts never got a Nostr profile: publishProfileAndRelayLists is
now called with name: loginName after registration, so the npub's
profile defaults to the chosen login name
- Password manager never recognized the fields (shadow DOM + JS-driven
submission defeat the passive save-prompt heuristics, and the
generated-password screen never put the password in a real input at
all). Fixed with an explicit navigator.credentials.store() call after
every register/login/recover/changePassword; documented as
Chromium-only in docs.html
New: optional manual/custom password (packages/core/src/account/manualPassword.ts),
opt-in per spec §9.3's "future release may permit manual passwords behind
an advanced setting" allowance. Enforces a length floor, a common-password
blocklist, a login-name-substring check, a low-variety-pattern check, and
an entropy floor; the widget shows live non-blocking feedback as the user
types. The generated high-entropy passphrase remains the default,
recommended option.
Integration/interop support:
- window.nostr provider now carries a readonly _bitlogin marker
- window.bitlogin ({ version, isActiveSigner() }) is installed
unconditionally as soon as the script loads, independent of login
state or window.nostr's current occupant. Found and fixed a real bug
along the way: an unconditional assignment is required because browsers
auto-expose any element with a matching id/name as a same-named global,
so <bitlogin-auth id="bitlogin"> silently shadowed window.bitlogin
under a naive `if (!window.bitlogin)` guard. Renamed the demo's own
element id to avoid the same trap and documented the gotcha in docs.html
- docs.html: new section on supporting multiple signing methods
(extensions, NIP-46, BitLogin) alongside each other
- Added packages/demo/public/llms.txt for AI-assisted integration
Deployment: GitHub Pages requires a paid plan for private repos, so its
workflow and the .github directory are removed; README documents
Vercel-only deployment and the direct-from-CDN integration path.
Docs: added docs/future-recovery-options.md, laying out passkey-based
2FA/recovery options (pure 2FA, full independent recovery, and a
constrained middle ground) with pros/cons for future consideration —
not a development plan, nothing here is decided.
59/59 tests pass; core and widget typecheck clean; all fixes verified
live in Chromium via Playwright against an in-memory mock relay.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
This commit is contained in:
17 files changed
+655
-68
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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><bitlogin-auth></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><bitlogin-auth></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>
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
/** <bitlogin-auth> — the embeddable BitLogin login/create/recover widget (§3, §27). */
|
||||
import { generatePassphrase, isValidLoginName } from "@bitlogin/core/account";
|
||||
import { generatePassphrase, isValidLoginName, checkManualPassword, type ManualPasswordCheck } from "@bitlogin/core/account";
|
||||
import { encodeNpub } from "@bitlogin/core/nostr";
|
||||
import { WorkerClient } from "./worker/workerClient.js";
|
||||
import { createNip07Provider } from "./provider.js";
|
||||
@@ -40,6 +40,12 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private recoveryPhrase = "";
|
||||
private confirmSlots: ConfirmSlot[] = [];
|
||||
|
||||
// Manual password opt-in (§9.3). Off by default; the generated passphrase remains the
|
||||
// recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full
|
||||
// re-render (see onInput) so the field never loses focus while the user is typing.
|
||||
private manualPasswordMode = false;
|
||||
private manualPasswordFeedback: ManualPasswordCheck | null = null;
|
||||
|
||||
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
|
||||
private importKey = "";
|
||||
private importPreviewNpub = "";
|
||||
@@ -68,6 +74,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
this.root.addEventListener("click", (e) => this.onClick(e));
|
||||
this.root.addEventListener("submit", (e) => this.onSubmit(e));
|
||||
this.root.addEventListener("input", (e) => this.onInput(e));
|
||||
this.render();
|
||||
|
||||
if (!(window as unknown as { nostr?: unknown }).nostr) {
|
||||
@@ -114,6 +121,31 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return (this.root.querySelector(`[name="${name}"]`) as HTMLInputElement | null)?.value ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly asks the browser to offer saving this credential via the Credential
|
||||
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
|
||||
* `PasswordCredential`). This is the primary fix for password-manager integration here,
|
||||
* not just a nice-to-have: the generated-password screen never puts the password into a
|
||||
* real `<input>` for a browser to observe being filled (it's shown as text), and even on
|
||||
* the login screen — which does use real, correctly-`autocomplete`d inputs — save/autofill
|
||||
* heuristics are unreliable inside a shadow root, and this widget's JS-driven submit
|
||||
* (`preventDefault`, no real form POST) doesn't produce the passive signal most heuristics
|
||||
* expect anyway. An explicit `navigator.credentials.store()` call sidesteps all of that.
|
||||
* Best-effort only: never blocks or fails the surrounding flow.
|
||||
*/
|
||||
private async offerToSaveCredential(loginName: string, password: string): Promise<void> {
|
||||
try {
|
||||
const PasswordCredentialCtor = (
|
||||
window as unknown as { PasswordCredential?: new (data: { id: string; password: string; name?: string }) => Credential }
|
||||
).PasswordCredential;
|
||||
if (!PasswordCredentialCtor || !navigator.credentials?.store) return;
|
||||
const credential = new PasswordCredentialCtor({ id: loginName, password, name: loginName });
|
||||
await navigator.credentials.store(credential);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private async onClick(e: Event): Promise<void> {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-action]");
|
||||
if (!target) return;
|
||||
@@ -158,6 +190,12 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "toggle-manual-password":
|
||||
this.manualPasswordMode = !this.manualPasswordMode;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "copy-credential": {
|
||||
const box = this.root.querySelector<HTMLElement>("#credential-box");
|
||||
if (box) void navigator.clipboard?.writeText(box.textContent ?? "");
|
||||
@@ -206,6 +244,32 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live entropy/strength feedback for the manual-password field, updated by DIRECTLY
|
||||
* patching a feedback element's text — never calling this.render() — so the input never
|
||||
* loses focus or cursor position while the user is typing (§9.3).
|
||||
*/
|
||||
private onInput(e: Event): void {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!(target instanceof HTMLInputElement) || target.name !== "manualPassword") return;
|
||||
this.manualPasswordFeedback = target.value ? checkManualPassword(target.value, this.loginName) : null;
|
||||
const feedback = this.root.querySelector<HTMLElement>("#manual-password-feedback");
|
||||
if (!feedback) return;
|
||||
if (!this.manualPasswordFeedback) {
|
||||
feedback.textContent = "";
|
||||
feedback.className = "notice info";
|
||||
return;
|
||||
}
|
||||
const bits = this.manualPasswordFeedback.entropyBits.toFixed(0);
|
||||
if (this.manualPasswordFeedback.ok) {
|
||||
feedback.textContent = `Looks good (~${bits} bits estimated).`;
|
||||
feedback.className = "notice info";
|
||||
} else {
|
||||
feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`;
|
||||
feedback.className = "notice warn";
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePreviewImport(): Promise<void> {
|
||||
const pasted = this.field("importKey").trim();
|
||||
if (!pasted) {
|
||||
@@ -249,20 +313,35 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.loginName = name;
|
||||
this.generatedCredential = generatePassphrase().secret;
|
||||
this.savedCheckbox = false;
|
||||
this.manualPasswordMode = false;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.goto("create-credential");
|
||||
}
|
||||
|
||||
private async handleCreateCredentialSubmit(): Promise<void> {
|
||||
let password = this.generatedCredential;
|
||||
if (this.manualPasswordMode) {
|
||||
const typed = this.field("manualPassword");
|
||||
const check = checkManualPassword(typed, this.loginName);
|
||||
if (!check.ok) {
|
||||
this.errorMessage = `Password not accepted: ${check.reason}`;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
password = typed;
|
||||
}
|
||||
this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false;
|
||||
if (!this.savedCheckbox) {
|
||||
this.errorMessage = "Please confirm you saved your password before continuing.";
|
||||
this.errorMessage = this.manualPasswordMode
|
||||
? "Please confirm you've saved your password (or written it down) before continuing."
|
||||
: "Please confirm you saved your password before continuing.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.setBusy(true);
|
||||
const result = await this.worker.register({
|
||||
loginName: this.loginName,
|
||||
password: this.generatedCredential,
|
||||
password,
|
||||
importKey: this.importKey || undefined
|
||||
});
|
||||
// The pasted key is now wrapped in the capsules; drop the main-thread copy (§11.10).
|
||||
@@ -275,11 +354,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.busy = false;
|
||||
this.goto("confirm-phrase");
|
||||
// §15.8/§19.6 — publish relay preferences immediately after successful registration.
|
||||
// §15.8/§19.6 — publish relay preferences immediately after successful registration,
|
||||
// defaulting the public profile name to the chosen login name so the account isn't
|
||||
// just a bare npub in every other Nostr client.
|
||||
void this.worker.publishProfileAndRelayLists({
|
||||
name: this.loginName,
|
||||
generalRelays: this.vaultRelayUrls,
|
||||
dmRelays: this.vaultRelayUrls
|
||||
});
|
||||
void this.offerToSaveCredential(this.loginName, password);
|
||||
}
|
||||
|
||||
private handleConfirmPhraseSubmit(): void {
|
||||
@@ -307,6 +390,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy = false;
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(loginName, password);
|
||||
}
|
||||
|
||||
private async handleRecoverPhraseSubmit(): Promise<void> {
|
||||
@@ -338,6 +422,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy = false;
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } }));
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(newLoginName, this.newCredentialAfterRecovery);
|
||||
}
|
||||
|
||||
private async handleChangePasswordSubmit(): Promise<void> {
|
||||
@@ -350,6 +435,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
});
|
||||
this.busy = false;
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(this.loginName, this.changePasswordNewCredential);
|
||||
}
|
||||
|
||||
private async handleDownloadRecoveryExport(): Promise<void> {
|
||||
@@ -442,30 +528,51 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
|
||||
case "create-credential":
|
||||
case "create-credential": {
|
||||
const submitLabel = this.busy
|
||||
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
|
||||
: this.importKey
|
||||
? "Import account"
|
||||
: "Create account";
|
||||
|
||||
if (this.manualPasswordMode) {
|
||||
return `
|
||||
<h2>Choose your own password</h2>
|
||||
<p class="sub">Not recommended: a downloadable encrypted file can be guessed against forever, offline, with no rate limit. BitLogin can only run basic checks here — not a breach-database lookup — so weak or reused passwords are still your risk to carry.</p>
|
||||
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
|
||||
${this.renderError()}
|
||||
<form data-form="create-credential">
|
||||
<label for="manualPassword">Password</label>
|
||||
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" />
|
||||
<div class="notice info" id="manual-password-feedback"></div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||
I have saved this password somewhere safe.
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use a generated password instead (recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<h2>Your generated password</h2>
|
||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file. Manual passwords aren't allowed in this alpha.</p>
|
||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
|
||||
<div class="credential-box" id="credential-box">${escapeHtml(this.generatedCredential)}</div>
|
||||
<button class="secondary" type="button" data-action="copy-credential">Copy</button>
|
||||
<button class="secondary" type="button" data-action="regenerate-credential">Generate a different one</button>
|
||||
${this.renderError()}
|
||||
<form data-form="create-credential">
|
||||
<form data-form="create-credential" autocomplete="on">
|
||||
<input type="text" name="username" autocomplete="username" value="${escapeHtml(this.loginName)}" readonly hidden />
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||
I have saved this password somewhere safe.
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${
|
||||
this.busy
|
||||
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
|
||||
: this.importKey
|
||||
? "Import account"
|
||||
: "Create account"
|
||||
}
|
||||
</button>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use my own password instead (not recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
case "confirm-phrase":
|
||||
return `
|
||||
|
||||
@@ -5,6 +5,41 @@ if (!customElements.get("bitlogin-auth")) {
|
||||
customElements.define("bitlogin-auth", BitLoginAuthElement);
|
||||
}
|
||||
|
||||
export interface BitLoginGlobal {
|
||||
readonly version: string;
|
||||
/**
|
||||
* True when BitLogin currently occupies window.nostr. Only one provider can hold that
|
||||
* global at a time (a browser extension like Alby or nos2x may also implement it, or
|
||||
* install itself later and overwrite BitLogin's). This check survives that overwrite —
|
||||
* unlike checking `window.nostr._bitlogin` directly — since it's read fresh each call.
|
||||
*/
|
||||
isActiveSigner(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bitlogin?: BitLoginGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
// Installed once per page regardless of whether <bitlogin-auth> has been used yet, so a
|
||||
// host site can feature-detect BitLogin's presence and active-signer status independently
|
||||
// of window.nostr's current occupant (§26.2, §26.4 — "active signer" support for sites that
|
||||
// offer multiple signing methods: BitLogin, a NIP-07 extension, or a NIP-46 remote signer).
|
||||
//
|
||||
// This assignment is deliberately unconditional (not `if (!window.bitlogin)`): browsers
|
||||
// auto-expose any element with a matching `id` or `name` attribute as a same-named global
|
||||
// (e.g. `<div id="bitlogin">` becomes `window.bitlogin`). A guard would silently see that
|
||||
// element instead of ever installing this object on any page that happens to use that id —
|
||||
// including our own demo before it was renamed. A direct assignment creates a real own
|
||||
// property that takes priority over that fallback, on any page.
|
||||
window.bitlogin = {
|
||||
version: "0.1.0",
|
||||
isActiveSigner(): boolean {
|
||||
return (window as unknown as { nostr?: { _bitlogin?: boolean } }).nostr?._bitlogin === true;
|
||||
}
|
||||
};
|
||||
|
||||
export { BitLoginAuthElement } from "./element.js";
|
||||
export { WorkerClient } from "./worker/workerClient.js";
|
||||
export { createNip07Provider } from "./provider.js";
|
||||
|
||||
@@ -17,6 +17,15 @@ export interface Nip07Provider {
|
||||
encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
};
|
||||
/**
|
||||
* Identifies this provider as BitLogin's, distinct from a browser extension (Alby, nos2x,
|
||||
* …) that may also implement window.nostr. Only one provider can occupy window.nostr at a
|
||||
* time — this lets a host site that supports multiple signing methods tell which one is
|
||||
* actually active, e.g. `window.nostr?._bitlogin === true`. Prefer `window.bitlogin` (see
|
||||
* index.ts) for this check where available; it survives another provider later overwriting
|
||||
* window.nostr, which this flag on the object itself cannot.
|
||||
*/
|
||||
readonly _bitlogin: true;
|
||||
}
|
||||
|
||||
const NOT_UNLOCKED_MESSAGE =
|
||||
@@ -52,6 +61,7 @@ export function createNip07Provider(workerClient: WorkerClient, configuredRelays
|
||||
const { plaintext } = await wrapUnlockError(workerClient.nip44Decrypt({ peerPublicKey, payload }));
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
},
|
||||
_bitlogin: true
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,7 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
session.accountId = result.accountId;
|
||||
session.recoveryPublicKey = result.recoveryPublicKey;
|
||||
session.activeCredentialEvent = result.credentialEvent;
|
||||
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
|
||||
return {
|
||||
everydayPublicKey: result.everydayPublicKey,
|
||||
accountId: result.accountId,
|
||||
@@ -197,6 +198,12 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
vaultRelayUrls,
|
||||
store
|
||||
});
|
||||
// Keep the session's capsule references current so a recovery export or replica
|
||||
// repair requested right after rotation (without an intervening re-login) still
|
||||
// finds the NEW credential capsule and the (unchanged) recovery capsule.
|
||||
session.activeCredentialEvent = result.newCredentialEvent;
|
||||
session.recoveryPublicKey = result.recoveryPublicKey;
|
||||
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
|
||||
return {
|
||||
newLocatorPublicKey: result.newLocatorPublicKey,
|
||||
newGeneration: result.newGeneration,
|
||||
|
||||
+1
-2
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user