Merge pull request #2 from PR0M3TH3AN/claude/bitlogin-protocol-spec-q18ust

Fix live-deployment bugs, add manual passwords, and improve integration support
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-07-21 11:58:34 -04:00
commit 4a8dfb8cde
21 files changed
+1287 -75

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
+27 -4
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)
@@ -88,10 +110,11 @@ Implemented: registration (§15), any-device password login with rollback and
relay-disagreement detection (§16), phrase recovery with recovery-capsule
refresh (§17), known-password change with mandatory tombstone + NIP-09
deletion (§18), replica repair (§24.4), recovery export (§19.5), NIP-19
nsec/npub encoding, a NIP-44 v2 implementation for the `window.nostr.nip44`
surface, bootstrap/discovery relay list plumbing (§19), and the full
crypto stack from §11 (Argon2id, HKDF, ScalarExpand, AES-256-GCM, JCS,
fixed padding buckets).
nsec/npub encoding, importing an existing Nostr `nsec` as the everyday
identity (§28.1, §SF10 — with a preview-before-commit UI step), a NIP-44 v2
implementation for the `window.nostr.nip44` surface, bootstrap/discovery
relay list plumbing (§19), and the full crypto stack from §11 (Argon2id,
HKDF, ScalarExpand, AES-256-GCM, JCS, fixed padding buckets).
**Deferred / not in this pass:**
- Phase 2 messaging (NIP-17 inbox/compose) — the NIP-44 primitive is built
+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.
+397
View File
@@ -0,0 +1,397 @@
# BitLogin Second Factor, Backup Codes, and Imported Identities
**Document version:** 0.1 (draft addendum to the v0.3 core specification)
**Status:** Mixed. §SF10 (imported identities) is **implemented**. Phone-approval
sign-in (§SF5–SF7) and backup codes (§SF8) are **design proposals only** — and
backup codes are unlikely to ship, since the recovery phrase already covers the
same ground (see §SF8 note). The "why not TOTP" analysis (§SF2) is a settled
rejection, kept on the record.
**Depends on:** `docs/spec.md` (§ references below point there unless marked §SF)
---
# SF1. Purpose and scope
Mainstream users understand one second-factor ceremony: *scan a QR code, confirm
a six-digit code, save your backup codes, and approve sign-ins on your phone.*
This addendum specifies how BitLogin reproduces that exact ceremony — opt-in,
enable/disable at any time after account creation — without a server, using
only the primitives the core protocol already ships: capsules on relays,
NIP-44 encryption, and self-custodied Nostr keys.
It defines three opt-in features:
1. **Phone-approval sign-in** (§SF5–SF7): a real cryptographic second factor.
The credential capsule key is split 2-of-2 between the password and a
secret held on an enrolled phone; sign-in requires both.
2. **Backup codes** (§SF8): one-time high-entropy codes, each an independent
recovery path, presented in the familiar "save these codes" format.
3. **Imported identities** (§SF10): using an existing Nostr `nsec` as the
everyday identity under all of the above.
The recovery phrase remains the master path and is never gated by any of
these features (§SF9).
---
# SF2. Why literal TOTP is excluded
TOTP works because a server holds the shared secret and enforces attempt
limits. BitLogin has no server, and its capsules are downloadable by anyone
(§9.1, §23.3). The consequences are not fixable by implementation care:
1. A 6-digit code carries ~20 bits of entropy. Anything decryptable "given
the right code" is brute-forced offline in milliseconds — 10⁶ candidate
codes against a downloaded capsule is not an attack, it is a for-loop.
2. A code the client merely *asks for* in its UI, without the code being
cryptographically necessary for decryption, is cosmetic. Any client that
skips the UI decrypts with the password alone. UI-enforced 2FA in an open
protocol protects nobody and trains users to trust theater.
Therefore: **no stored or login-gating secret in this design may have less
entropy than a generated credential (§9.2).** Short codes appear in exactly
one place — the interactive pairing confirmation of §SF6, where the code is
one-shot, ephemeral, human-compared between two live devices, and never
stored or transmitted as a secret.
The familiar *ceremony* survives; the mechanism underneath must differ. The
mapping:
```text
User sees Actually happening
───────────────────────────────────── ─────────────────────────────────────
"Scan this QR with your phone" One-time full-entropy enrollment
secret transferred to the phone
"Enter the 6-digit code shown" Interactive pairing confirmation
(one-shot, never stored)
"Approve this sign-in on your phone" Phone releases its 2-of-2 key share
over an encrypted relay message
"Save these backup codes" Each code is an independent
high-entropy recovery credential
backing its own capsule
```
---
# SF3. New identities and secrets
This addendum adds, per account (all optional):
* **Device factor keypair** — a secp256k1 keypair generated on the enrolled
phone at enrollment time. Its private key never leaves the phone. Its
public key ("device pubkey") is the address for approval requests. It is
a fourth identity class alongside the recovery, everyday, and locator
identities (§7), and like the locator it is never shown to the user as an
account.
* **Device share** — 32 random bytes generated at enrollment, held only on
the phone (wrapped at rest per §21.2: PIN, platform keystore, or passkey),
and released only inside an approved sign-in (§SF7). Never published at
rest, on any relay, in any capsule.
* **Backup-code secrets** — generated codes, each ≥ 77 bits of entropy
(§SF8). Shown once, then existing only as the user's saved copy.
The enrollment QR payload contains a one-time secret from which the phone
derives nothing durable except its own locally-generated keys — the QR is a
transport for bootstrapping the pairing session (§SF6), not a long-term
secret. A photographed QR that is never used within its pairing window
confers nothing.
---
# SF4. Envelope changes: the gated envelope
Core envelopes (§13) carry one AEAD payload. A second-factor-protected
credential capsule uses a two-layer **gated envelope**:
```json
{
"version": 2,
"algorithm": "aes-256-gcm",
"gate": {
"nonce": "<base64url>",
"ciphertext": "<base64url>"
},
"nonce": "<base64url>",
"ciphertext": "<base64url>"
}
```
* The **gate** is encrypted with the password-derived key alone
(`password_capsule_key`, §11.4). Its plaintext names the device pubkey,
the approval-relay hints, and the split parameters:
```json
{
"second_factor": "device-approval-v1",
"device_public_key": "lowercase-hex",
"approval_relays": ["wss://..."],
"split": "hkdf-2of2-v1"
}
```
* The **payload** is the ordinary credential payload (§12.1), encrypted with
the split key of §SF5, which requires the device share.
Properties this buys:
1. **Trial decryption stays honest.** A client that knows only the password
opens the gate, learns that a second factor is required and who to ask,
and cannot read the payload. Wrong-password attempts fail at the gate
exactly as they fail today — §16.3's indistinguishability is preserved.
2. **The device pubkey is not public.** Relays see a version-2 envelope
(which does reveal *that* the account uses a second factor — accepted and
disclosed, as with the `d`-tag decision in §13) but learn the device
pubkey only if they crack the password. Correlating approval traffic to a
specific capsule therefore requires the password.
3. **Version-1 envelopes remain valid.** Accounts without the feature are
byte-for-byte unchanged.
---
# SF5. The 2-of-2 split key
With the second factor enabled, the credential payload key is:
```text
split_prk = HKDF-Extract(
salt = SHA256("bitlogin/second-factor-split/v1"),
IKM = password_prk || device_share
)
split_capsule_key = HKDF-Expand(
split_prk,
"bitlogin/2fa-capsule-encryption/v1",
32
)
```
where `password_prk` is the existing extract of §11.4 and `device_share` is
the phone-held 32-byte secret. Both inputs are required; neither alone
yields anything. The locator derivation (§11.4) is **unchanged** — the
capsule is *found* with the password alone and *opened* only with both.
Enabling or disabling the second factor is a **credential event** (§12.2):
the client republishes the credential capsule at the same locator with an
incremented generation and `created_at` per §24.6, switching between the
plain and gated envelope forms. No other machinery changes.
**Honest limitation (mirrors §18.3):** enabling the second factor does not
protect capsule generations an attacker downloaded *before* it was enabled.
Those old copies remain decryptable with the password alone, forever. The
enable screen must disclose this; users who suspect prior password
compromise need rotation (§18.4), not 2FA.
---
# SF6. Enrollment ceremony
Preconditions: user is signed in on a primary device; has the BitLogin PWA
(or a compatible client) on the phone to be enrolled; has confirmed saved
backup codes (§SF8) — **enrollment shall refuse to complete before backup
codes are confirmed**, because 2-of-2 with a lost phone would otherwise be a
lockout (§SF9).
1. Primary device generates an ephemeral pairing keypair and a pairing
session id, and renders a QR: `{pairing_pubkey, session_id, relay_hints,
expiry}` (expiry ≤ 5 minutes).
2. Phone scans the QR, generates its device factor keypair and device share,
and opens an encrypted channel to the pairing pubkey over the hinted
relays (NIP-44, ephemeral event kinds — relays are transport only and
store nothing).
3. Both screens display a **short confirmation code** derived from the
channel transcript hash (e.g. 6 digits of
`SHA256(transcript)`). The user visually confirms they match and types
the phone's code into the primary device. This is the familiar "enter the
6-digit code" moment; it authenticates the pairing against a
machine-in-the-middle on the relay path, is one-shot, and is never a
stored secret (§SF2).
4. Phone sends its device pubkey (not the share) over the confirmed channel.
5. Primary device rebuilds the credential capsule in gated-envelope form:
it derives `split_capsule_key` — which requires the device share, so the
phone performs the HKDF locally and returns only the resulting
`split_capsule_key` over the confirmed channel; the share itself still
never leaves the phone — encrypts, publishes, verifies readback (§15.6).
6. Both sides wipe the pairing keys. The phone stores its device private key
and share wrapped per §21.2 and registers a relay subscription for
approval requests addressed to its device pubkey.
---
# SF7. Sign-in approval flow
With the second factor enabled, any-device login (§16) gains one step:
1. Client derives the locator from login name + password, fetches the
capsule (unchanged, §16.1–16.2).
2. Client decrypts the **gate** with the password-derived key. On success it
learns the device pubkey and approval relays.
3. Client generates an ephemeral session keypair and publishes an
**approval request**: an ephemeral-kind event, authored by the session
key, p-tagged to the device pubkey, NIP-44-encrypted to it. Contents:
session pubkey, a random challenge, requesting-client context strings
(origin/user-agent as claimed, displayed to the user as *unverified*),
and an expiry ≤ 2 minutes.
4. The phone displays: *"Approve sign-in? (details) — Approve / Deny."* On
approve, it computes `split_capsule_key` locally (§SF5) and returns it
NIP-44-encrypted to the session pubkey, then wipes the derived key.
The device share itself is never transmitted.
5. Client decrypts the payload, validates per §12.4, proceeds as §16.4. All
session-key material is wiped.
Failure behavior: timeout, denial, and offline phone are indistinguishable
to the requesting client ("approval not received") and surface the fallback
paths: backup code or recovery phrase. Relay unavailability degrades to the
same fallback — approval relays are listed in the gate and should overlap
the vault relays for availability.
Connection hygiene (§23.4) extends naturally: approval traffic uses the
session/device identities and shall not share connections with locator,
recovery, or everyday-identity operations.
---
# SF8. Backup codes
Backup codes reuse the password path's machinery with a different label —
each code is, mechanically, a generated single-use password with its own
locator.
* **Format:** `XXXXX-XXXXX-XXXXX-XXXXX`, Crockford base32, ≥ 80 bits of
entropy per code. Default: 8 codes per issuance.
* **Derivation:** exactly §11.3–11.4 with the salt label
`"bitlogin/backup-code-salt/v1"` and the code (uppercased, hyphens
stripped) in place of the password; the login name is *not* an input, so a
code alone suffices during recovery. Distinct HKDF info labels
(`bitlogin/backup-locator-signing/v1`,
`bitlogin/backup-capsule-encryption/v1`).
* **Capsule:** one kind-30078 event per code, `d` tag
`bitlogin:backup:v1`, authored by the code's locator, containing a
standard credential payload (§12.1) in a **plain** (version-1) envelope —
backup codes are the escape hatch and are deliberately not gated by the
second factor.
* **Issuance** requires only a signed-in session (everyday key in memory).
Codes are displayed once. An encrypted registry of issued-code locator
pubkeys (a NIP-78 record under the everyday identity) lets honest clients
show "6 of 8 codes remaining" and support "regenerate all codes," which
tombstones (§18.1) every outstanding code capsule and issues fresh ones.
* **Use:** "Sign in with a backup code" derives the locator, fetches,
decrypts, restores the identity, forces establishment of a new login
name/password (as §17.4), and then tombstones the used code's capsule and
updates the registry.
* **Honest limitation:** as everywhere in this protocol (§18.3), tombstoning
cannot revoke copies already downloaded. A backup-code capsule remains
decryptable by its code for anyone who saved the ciphertext. Codes are
high-entropy, so this is the same accepted risk as the password path —
but "single-use" is a relay-layer promise, not a cryptographic one, and
the interface must not claim otherwise.
---
# SF9. Interaction with the recovery phrase
The recovery phrase remains the root of last resort and is **never** gated
by the second factor:
* Phrase recovery (§17) proceeds identically whether or not 2FA is enabled,
and — because it establishes a fresh credential capsule — recovery is also
the *disable* path when the phone is lost: recover with the phrase (or a
backup code), and the newly published credential capsule is ungated.
* Corollary, stated plainly: **the second factor does not protect against
phrase theft** (§29.6 is unchanged), nor against theft of an unused backup
code. It protects one thing well: a stolen or cracked *password* alone no
longer opens the current account.
* Consequently the lockout matrix is:
```text
Lost phone → sign in with backup code or phrase; re-enroll
Lost phone + forgot password → backup code or phrase
Lost everything except phrase → phrase (unchanged, §17)
Lost everything incl. phrase → unrecoverable (unchanged, §19.4)
```
---
# SF10. Imported identities — IMPLEMENTED
An existing Nostr identity may be used as the everyday identity under all
of the above (§28.1 anticipated this; the everyday key is by design never
derived from any BitLogin secret, §7.2, §10.4).
**Flow:** the import screen accepts an `nsec` or 32-byte hex key. The client
validates the scalar, derives the public key, and displays it for the user
to confirm against their known `npub`. Registration then proceeds exactly as
§15 with key generation skipped: a **new** BitLogin recovery phrase is
generated, both capsules are built around the imported key, and the
second factor and backup codes layer on with no differences whatsoever.
The public key is the only pre-existing material; the wrapper is identical.
**Required disclosures:**
1. *Prior exposure is out of scope.* BitLogin secures its own storage of the
key. Copies that already exist — browser extensions, other signers, old
clipboard managers, backups — remain live attack surface, and the
security of the imported account is the minimum of BitLogin's guarantees
and the key's prior handling. If the key may already be compromised,
the correct move is rotation to a fresh identity (§18.4), not import.
2. *The paste is the risk.* Entering an `nsec` into any web page is the most
dangerous single action in Nostr use. The import field follows §11.10
(read once, straight to the worker, cleared), the client warns about
clipboard history, and a malicious build (§22.1) captures the key
regardless — import on a build you trust.
3. The `npub` alone cannot be imported; the private key is what the capsules
wrap.
---
# SF11. Threat notes
```text
Adversary holds… Outcome with this addendum
────────────────────────────────────── ──────────────────────────────────────
Password only (current capsule gated) Locates capsule; opens gate; cannot
decrypt payload; phone shows an
approval prompt the user can deny —
which is also a breach alarm
Password + pre-2FA capsule copy Full compromise (unchanged, §18.3);
disclosed at enable time
Password + stolen phone Full compromise if the phone's §21.2
wrapping (PIN/passkey) is defeated;
software-only PIN wrapping is brute-
forceable by a thief — platform
keystore/passkey wrapping recommended
Stolen phone only Nothing (share is not a path by
itself; no capsule decrypts with it)
One unused backup code Full account recovery — codes are
credentials; store them like the
phrase
Recovery phrase Full compromise (unchanged, §29.6)
Relay operator Sees version-2 envelope (2FA in use),
approval-traffic timing between
pseudonymous keys; content and
linkage to the everyday identity
remain encrypted (§SF4, §23.4)
```
Availability note: phone-approval sign-in adds a live relay round-trip to
login. The phone being offline, or approval relays being unreachable, must
degrade to backup-code/phrase fallback with a clear message — never to an
indefinite spinner.
---
# SF12. Open questions
1. Should the approval response return the derived `split_capsule_key`
(as specified — simplest) or a blinded share via an OPRF-style exchange
so the phone learns nothing about login timing content? (Current design
already reveals only timing to the phone; likely fine.)
2. Passkey/WebAuthn-PRF as the device factor on platforms that support it —
same protocol, share held by the authenticator, removes the
PIN-brute-force row from §SF11. Target for the first revision.
3. Multiple enrolled devices (any-of-N approval)? Requires per-device gates
in the envelope; deferred.
4. Should backup-code capsules pad to the smallest bucket always (they are
uniform), and should issuance count be user-configurable?
5. Ephemeral-kind number registration for the pairing/approval events, and
whether approval relays should be required to support NIP-42 AUTH.
```
+74 -1
View File
@@ -1,7 +1,10 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { MockRelay } from "../test-support/mockRelay.js";
import { generatePassphrase } from "./passphrase.js";
import { registerAccount } from "./create.js";
import { registerAccount, importAccount, decodeEverydayPrivateKey } from "./create.js";
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
import { encodeNsec, encodeNpub } from "../nostr/nip19.js";
import { bytesToHex } from "../crypto/encoding.js";
import { loginWithPassword } from "./login.js";
import { recoverWithPhrase, completeRecoveryWithNewCredentials } from "./recover.js";
import { changePassword } from "./changePassword.js";
@@ -50,6 +53,76 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
ARGON2_TIMEOUT
);
it(
"import an existing nsec -> clean-device login and phrase recovery both reproduce the imported npub (§SF10)",
async () => {
// A pre-existing Nostr identity the user already controls.
const existingKey = generatePrivateKey();
const existingPubkey = getPublicKeyHex(existingKey);
const nsec = encodeNsec(existingKey);
const npub = encodeNpub(existingPubkey);
const loginName = "importer";
const password = generatePassphrase().secret;
// decodeEverydayPrivateKey accepts both nsec and 64-char hex forms.
const existingKeyHex = bytesToHex(existingKey);
expect(getPublicKeyHex(decodeEverydayPrivateKey(nsec))).toBe(existingPubkey);
expect(getPublicKeyHex(decodeEverydayPrivateKey(existingKeyHex))).toBe(existingPubkey);
const registration = await importAccount({ nsecOrHex: nsec, loginName, password, vaultRelayUrls });
expect(registration.imported).toBe(true);
expect(registration.everydayPublicKey).toBe(existingPubkey);
// Clean-device password login yields the imported identity, unchanged.
const login = await loginWithPassword({ loginName, password, vaultRelayUrls });
expect(login.everydayPublicKey).toBe(existingPubkey);
expect(encodeNpub(login.everydayPublicKey)).toBe(npub);
// The freshly generated BitLogin phrase recovers the SAME imported identity.
const recovered = await recoverWithPhrase({
phrase: registration.recoveryPhrase,
vaultRelayUrls,
discoveryRelayUrls: vaultRelayUrls
});
expect(recovered.everydayPublicKey).toBe(existingPubkey);
},
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();
expect(() => decodeEverydayPrivateKey("00".repeat(32))).toThrow(); // zero scalar is invalid
expect(() => decodeEverydayPrivateKey("ab".repeat(20))).toThrow(); // wrong length hex
});
it(
"wrong password on a real account is indistinguishable from a nonexistent account (§16.3)",
async () => {
@@ -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,
+56 -4
View File
@@ -1,7 +1,8 @@
/** Registration flow (§15). Recovery capsule is built and published before the credential capsule. */
import { randomEntropy128, randomAccountId } from "../crypto/random.js";
import { bytesToBase64url } from "../crypto/encoding.js";
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
import { bytesToBase64url, hexToBytes } from "../crypto/encoding.js";
import { generatePrivateKey, getPublicKeyHex, isValidScalar } from "../crypto/secp256k1.js";
import { decodeNsec } from "../nostr/nip19.js";
import { entropyToRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js";
import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "./normalize.js";
import { RelayPool } from "../nostr/pool.js";
@@ -19,6 +20,13 @@ export interface RegisterAccountParams {
/** A client-generated credential (§9.2); manual passwords are prohibited in the alpha (§9.3). */
password: string;
vaultRelayUrls: string[];
/**
* Optional existing everyday identity to wrap instead of generating a fresh one (§28.1, §SF10).
* The everyday key is never derived from any BitLogin secret (§7.2), so an imported key is
* indistinguishable to the rest of the protocol — only the origin differs. Must be a valid
* 32-byte secp256k1 scalar; the caller is responsible for decoding an `nsec` first.
*/
everydayPrivateKey?: Uint8Array;
minAcknowledgements?: number;
timeoutMs?: number;
/** Testing hook; defaults to the current time. */
@@ -33,6 +41,8 @@ export interface RegisterAccountResult {
recoveryPublicKey: string;
locatorPublicKey: string;
accountId: string;
/** True when the everyday identity was imported (§SF10) rather than freshly generated. */
imported: boolean;
credentialEvent: NostrEvent;
recoveryEvent: NostrEvent;
credentialPublish: PublishVerificationResult;
@@ -49,8 +59,12 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
const { recoveryPrivateKey, capsuleKey: recoveryCapsuleKey } = deriveRecoveryKeys(bip39Seed);
const recoveryPublicKey = getPublicKeyHex(recoveryPrivateKey);
// §15.3 — everyday identity
const everydayPrivateKey = generatePrivateKey();
// §15.3 — everyday identity: generated fresh, or an imported existing key (§28.1, §SF10).
const imported = params.everydayPrivateKey !== undefined;
if (imported && !isValidScalar(params.everydayPrivateKey!)) {
throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key.");
}
const everydayPrivateKey = imported ? params.everydayPrivateKey! : generatePrivateKey();
const everydayPublicKey = getPublicKeyHex(everydayPrivateKey);
const accountId = bytesToBase64url(randomAccountId());
@@ -129,9 +143,47 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
recoveryPublicKey,
locatorPublicKey,
accountId,
imported,
credentialEvent,
recoveryEvent,
credentialPublish,
recoveryPublish
};
}
/**
* Convenience wrapper for importing an existing Nostr identity (§28.1, §SF10).
* Accepts an `nsec` (bech32) or 64-char lowercase-hex private key, decodes it,
* and registers a new BitLogin account (fresh recovery phrase + password)
* around that key. Everything downstream is identical to a generated account —
* only the everyday key's origin differs.
*
* Honest limitation (§SF10): BitLogin secures its own storage of the key, but
* copies that already exist elsewhere (extensions, other signers, clipboard
* history, backups) remain live attack surface outside its reach. If the key
* may already be compromised, rotate to a fresh identity instead of importing.
*/
export async function importAccount(
params: Omit<RegisterAccountParams, "everydayPrivateKey"> & { nsecOrHex: string }
): Promise<RegisterAccountResult> {
const { nsecOrHex, ...rest } = params;
const everydayPrivateKey = decodeEverydayPrivateKey(nsecOrHex);
return registerAccount({ ...rest, everydayPrivateKey });
}
/** Decodes an `nsec` or 64-char hex string to a validated 32-byte scalar (§SF10). */
export function decodeEverydayPrivateKey(nsecOrHex: string): Uint8Array {
const trimmed = nsecOrHex.trim();
let key: Uint8Array;
if (trimmed.startsWith("nsec1")) {
key = decodeNsec(trimmed);
} else if (/^[0-9a-fA-F]{64}$/u.test(trimmed)) {
key = hexToBytes(trimmed.toLowerCase());
} else {
throw new RegistrationFailedError("Enter a valid nsec (nsec1…) or a 64-character hex private key.");
}
if (!isValidScalar(key)) {
throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key.");
}
return key;
}
+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.
+213 -12
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";
@@ -8,6 +8,7 @@ import { WIDGET_STYLES } from "./styles.js";
type Screen =
| "welcome"
| "import-key"
| "create-name"
| "create-credential"
| "confirm-phrase"
@@ -39,6 +40,16 @@ 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 = "";
private recoverPhraseInput = "";
private recoveredPreview: { generalRelaysCount: number; dmRelaysCount: number; chainWarning?: string } | null = null;
private newCredentialAfterRecovery = "";
@@ -63,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) {
@@ -109,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;
@@ -116,8 +153,19 @@ export class BitLoginAuthElement extends HTMLElement {
switch (action) {
case "goto-create":
this.loginName = "";
this.importKey = "";
this.importPreviewNpub = "";
this.goto("create-name");
return;
case "goto-import":
this.importKey = "";
this.importPreviewNpub = "";
this.goto("import-key");
return;
case "preview-import":
return this.handlePreviewImport();
case "import-continue":
return this.handleImportContinue();
case "goto-login":
this.goto("login");
return;
@@ -142,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 ?? "");
@@ -166,6 +220,8 @@ export class BitLoginAuthElement extends HTMLElement {
const formName = form.dataset.form;
try {
switch (formName) {
case "import-key":
return await this.handlePreviewImport();
case "create-name":
return this.handleCreateNameSubmit();
case "create-credential":
@@ -188,6 +244,65 @@ 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) {
this.errorMessage = "Paste your nsec or hex private key first.";
this.render();
return;
}
this.setBusy(true);
try {
const preview = await this.worker.previewImportKey({ nsecOrHex: pasted });
this.importKey = pasted;
this.importPreviewNpub = preview.npub;
this.busy = false;
this.render();
} catch (err) {
this.importKey = "";
this.importPreviewNpub = "";
this.fail(err);
}
}
private handleImportContinue(): void {
// Reached only after a successful preview; move into the normal name -> credential flow,
// which will register with importKey set (§SF10).
if (!this.importKey || !this.importPreviewNpub) {
this.errorMessage = "Check your key before continuing.";
this.render();
return;
}
this.loginName = "";
this.goto("create-name");
}
private handleCreateNameSubmit(): void {
const name = this.field("loginName").trim().toLowerCase();
if (!isValidLoginName(name)) {
@@ -198,18 +313,40 @@ 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 });
const result = await this.worker.register({
loginName: this.loginName,
password,
importKey: this.importKey || undefined
});
// The pasted key is now wrapped in the capsules; drop the main-thread copy (§11.10).
this.importKey = "";
this.importPreviewNpub = "";
this.recoveryPhrase = result.recoveryPhrase;
const words = this.recoveryPhrase.split(" ");
const indices = pickRandomIndices(words.length, 3);
@@ -217,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 {
@@ -249,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> {
@@ -280,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> {
@@ -292,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> {
@@ -338,13 +482,43 @@ export class BitLoginAuthElement extends HTMLElement {
${this.renderError()}
<button class="primary" data-action="goto-login">Sign in</button>
<button class="secondary" data-action="goto-create">Create account</button>
<button class="link" data-action="goto-import">Import an existing Nostr key</button>
<button class="link" data-action="goto-recover">Forgot password? Recover with phrase</button>
`;
case "import-key": {
const previewed = !!this.importPreviewNpub;
return `
<h2>Import an existing Nostr key</h2>
<p class="sub">Wrap a Nostr identity you already control in a BitLogin login name and password. Your key never changes — you just get a friendlier way in.</p>
<div class="notice warn">Pasting a private key into any web page is risky. Only do this on a BitLogin build you trust, and clear your clipboard afterward. BitLogin can't secure copies of this key that already exist elsewhere.</div>
${this.renderError()}
<form data-form="import-key">
<label for="importKey">Your nsec or hex private key</label>
<input type="password" name="importKey" id="importKey" autocomplete="off" placeholder="nsec1… or 64-character hex" required />
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Checking…' : "Check key"}
</button>
</form>
${
previewed
? `<div class="notice info">This key's public identity:</div>
<div class="credential-box">${escapeHtml(this.importPreviewNpub)}</div>
<button class="primary" type="button" data-action="import-continue">This is my identity — continue</button>`
: ""
}
<button class="link" data-action="goto-welcome">Back</button>
`;
}
case "create-name":
return `
<h2>Create your BitLogin</h2>
<p class="sub">Choose a login name. It's a convenience, not a secret (it contributes no security).</p>
<h2>${this.importKey ? "Set up your login" : "Create your BitLogin"}</h2>
<p class="sub">${
this.importKey
? "Choose a login name for your imported identity. It's a convenience, not a secret."
: "Choose a login name. It's a convenience, not a secret (it contributes no security)."
}</p>
${this.renderError()}
<form data-form="create-name">
<label for="loginName">Login name</label>
@@ -354,24 +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>Creating 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
};
}
+27 -2
View File
@@ -15,10 +15,13 @@ import {
buildProfileEvent,
buildRecoveryExport,
repairReplicas,
importAccount,
decodeEverydayPrivateKey,
NostrSigner,
type RecoveredIdentity
} from "@bitlogin/core/account";
import { RelayPool, countAcknowledgements, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr";
import { getPublicKeyHex } from "@bitlogin/core/crypto";
import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js";
import type {
WorkerRequest,
@@ -32,7 +35,8 @@ import type {
PublishProfilePayload,
SignEventPayload,
Nip44EncryptPayload,
Nip44DecryptPayload
Nip44DecryptPayload,
PreviewImportKeyPayload
} from "./protocol.js";
interface SessionState {
@@ -94,7 +98,9 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
case "register": {
const p = payload as RegisterPayload;
const result = await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls });
const result = p.importKey
? await importAccount({ nsecOrHex: p.importKey, loginName: p.loginName, password: p.password, vaultRelayUrls })
: await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls });
clearSession();
session.signer = new NostrSigner(result.everydayPrivateKey);
session.everydayPrivateKey = result.everydayPrivateKey;
@@ -107,11 +113,23 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
everydayPublicKey: result.everydayPublicKey,
recoveryPublicKey: result.recoveryPublicKey,
accountId: result.accountId,
imported: result.imported,
credentialEventId: result.credentialEvent.id,
recoveryEventId: result.recoveryEvent.id
};
}
case "previewImportKey": {
// Validates and previews the npub WITHOUT publishing anything, so the user can
// confirm they pasted the right key before committing to registration (§SF10).
const p = payload as PreviewImportKeyPayload;
const key = decodeEverydayPrivateKey(p.nsecOrHex);
const everydayPublicKey = getPublicKeyHex(key);
const preview = { everydayPublicKey, npub: encodeNpub(everydayPublicKey) };
key.fill(0); // §11.10 — the previewed key is not retained
return preview;
}
case "login": {
const p = payload as LoginPayload;
const result = await loginWithPassword({ loginName: p.loginName, password: p.password, vaultRelayUrls, store });
@@ -121,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,
@@ -179,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,
+12
View File
@@ -19,16 +19,27 @@ export interface ConfigurePayload {
export interface RegisterPayload {
loginName: string;
password: string;
/** When present, import this existing identity (nsec or 64-char hex) instead of generating one (§SF10). */
importKey?: string;
}
export interface RegisterResult {
recoveryPhrase: string;
everydayPublicKey: string;
recoveryPublicKey: string;
accountId: string;
imported: boolean;
credentialEventId: string;
recoveryEventId: string;
}
export interface PreviewImportKeyPayload {
nsecOrHex: string;
}
export interface PreviewImportKeyResult {
everydayPublicKey: string;
npub: string;
}
export interface LoginPayload {
loginName: string;
password: string;
@@ -122,6 +133,7 @@ export interface SessionStatusResult {
export type WorkerActionMap = {
configure: [ConfigurePayload, Record<string, never>];
register: [RegisterPayload, RegisterResult];
previewImportKey: [PreviewImportKeyPayload, PreviewImportKeyResult];
login: [LoginPayload, LoginResult];
recover: [RecoverPayload, RecoverResult];
completeRecovery: [CompleteRecoveryPayload, CompleteRecoveryResult];
@@ -6,6 +6,7 @@ import type {
WorkerResponse,
ConfigurePayload,
RegisterPayload,
PreviewImportKeyPayload,
LoginPayload,
RecoverPayload,
CompleteRecoveryPayload,
@@ -61,6 +62,9 @@ export class WorkerClient {
register(payload: RegisterPayload) {
return this.call("register", payload);
}
previewImportKey(payload: PreviewImportKeyPayload) {
return this.call("previewImportKey", payload);
}
login(payload: LoginPayload) {
return this.call("login", payload);
}
+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"
}