diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
deleted file mode 100644
index dc65616..0000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Deploy demo to GitHub Pages
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - run: npm ci
-
- - run: npm run build -w @bitlogin/core
- - run: npm run build -w @bitlogin/widget
- - run: npm run build -w @bitlogin/demo
-
- - uses: actions/configure-pages@v5
-
- - uses: actions/upload-pages-artifact@v3
- with:
- path: packages/demo/dist
-
- deploy:
- needs: build
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - id: deployment
- uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 6738777..2bd3ffa 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,28 @@ guide, and `packages/demo/public/account.html` for a working example
(create → confirm recovery phrase → sign an event → rotate password →
export identity).
+## Deployment
+
+The demo site deploys to Vercel: `vercel.json` at the repo root sets the
+build command (core → widget → demo) and output directory, so importing this
+repo at [vercel.com/new](https://vercel.com/new) needs no manual
+configuration. GitHub Pages was evaluated but dropped — Pages requires a paid
+plan for private repositories, whereas Vercel's free tier deploys a private
+repo without that restriction.
+
+Because the deployed demo serves the built widget files as plain static
+assets at `/vendor/bitlogin/`, **any other site can point directly at the
+Vercel deployment today** without installing anything locally:
+
+```html
+
+
+```
+
+This is fine for prototyping across multiple projects; for production use in
+someone else's app, self-hosting the built files (or a future published npm
+package / CDN release) avoids depending on this demo deployment's uptime.
+
## What's implemented
This build covers the protocol's Phase 0 (cryptographic core + test vectors)
@@ -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
diff --git a/docs/future-recovery-options.md b/docs/future-recovery-options.md
new file mode 100644
index 0000000..74307a7
--- /dev/null
+++ b/docs/future-recovery-options.md
@@ -0,0 +1,170 @@
+# BitLogin: Future Recovery-Path Options
+
+**Document version:** 0.1
+**Status:** Options for future consideration only. Nothing here is decided,
+scheduled, or implemented, and this is not a development plan — it's a
+record of the tradeoffs so the decision can be revisited with the reasoning
+intact, rather than re-litigated from scratch.
+**Depends on:** `docs/spec.md` and `docs/second-factor.md` (§SF5 describes the
+2-of-2 split-key mechanism referenced throughout).
+
+---
+
+## The core distinction: a second factor vs. a recovery path
+
+These are different shapes of feature, and conflating them is the easiest
+way to accidentally weaken the account:
+
+- **A second factor (AND-gate).** Something *additional* required alongside
+ the password. It can only make login harder to complete without it — it
+ can never, by itself, grant access. If you lose the second factor, you
+ fall back to whatever recovery paths already existed (today: the recovery
+ phrase).
+- **A recovery path (OR-gate).** An *independent* way to reach the account
+ without the password (or without anything else). BitLogin already has
+ exactly one of these: the 12-word recovery phrase. Every recovery path is
+ a full-power master key — anyone who has it can take over the account
+ completely, with nothing else needed.
+
+The question that prompted this document: could a WebAuthn passkey (Face
+ID / Touch ID / Windows Hello, via the PRF extension) serve as **both** — a
+2FA hardening layer on the password path, **and** a way to sign back in and
+reset the password without the phrase, the way "reset via authenticator"
+flows work on many consumer services today?
+
+Mechanically, yes. Whether to build it is a real tradeoff, laid out below.
+
+---
+
+## Option A — Passkey as a pure second factor (AND-gate only)
+
+**How it works:** the credential-capsule payload key becomes a 2-of-2 split
+between the password and a device-held secret (see `second-factor.md` §SF5
+for the exact derivation). The passkey's WebAuthn PRF output unlocks the
+device's share of that split. Without the password, the capsule can't even
+be located — the locator itself is password-derived — so the passkey alone
+grants nothing. The recovery phrase remains the *only* way to recover
+without the password.
+
+**Pros**
+- Matches the "bank 2FA" mental model most users already have: forgot your
+ password still means "use your recovery phrase," full stop. No new
+ master key exists.
+- Smallest attack-surface increase of any option here — it can only ever
+ make an account *harder* to break into, never easier.
+- Fully backward-compatible and additive to the shipped protocol (see
+ `second-factor.md` §SF4 for why the envelope-versioning approach doesn't
+ disturb existing accounts).
+- No dependency on a third party's account security (Apple ID, Google
+ Account) for anything account-critical. If a synced passkey is lost or
+ its sync account is compromised, the attacker still needs the password —
+ the phrase alone remains the recovery answer either way.
+
+**Cons**
+- Doesn't solve "I forgot my password and can't find my 12 words" — the
+ exact moment a lot of real users hit. The convenience win from a
+ Face-ID-driven "reset" flow doesn't materialize under this option.
+- One more thing enrolled per account (a device/credential registry) for a
+ security property that's arguably nice-to-have rather than essential —
+ BitLogin's generated high-entropy passwords already carry most of the
+ defensive load against the threat 2FA typically addresses (credential
+ stuffing / reused passwords), which matters less here since manual
+ passwords are opt-in and discouraged.
+
+---
+
+## Option B — Passkey as an independent recovery path (OR-gate, full parity with the phrase)
+
+**How it works:** using WebAuthn's *discoverable credentials*, a browser can
+present "sign in with a passkey" and hand back an identity without the site
+supplying a username first. The passkey would derive its own locator and
+capsule — structurally parallel to how the recovery phrase works today —
+letting a user tap the passkey, get logged in, and set a brand-new login
+name and password. No phrase required.
+
+**Pros**
+- Solves the actual "forgot password" moment with the lowest possible
+ friction: Face ID, done. This is the flow users already expect from
+ "Sign in with passkey" buttons elsewhere on the web.
+- No paper to lose, no drawer to search.
+
+**Cons — the significant one first**
+- **Most real users will have a *synced* passkey** (iCloud Keychain, Google
+ Password Manager) — that's the default, low-friction path on every
+ mainstream device, and there's no realistic way to force people onto
+ non-syncing hardware keys. A synced passkey lives inside the user's
+ Apple ID / Google account. If this option grants full account recovery
+ with no phrase required, **the account's real security floor becomes
+ whatever protects that Apple/Google account** — not BitLogin's own
+ cryptography. Account takeovers via compromised iCloud/Google
+ credentials (phishing, SIM-swap) are a common, well-documented attack
+ pattern, not a hypothetical.
+- That directly reopens the exact dependency BitLogin's entire design
+ exists to avoid: no operator, no support desk, no third party that can be
+ compelled, breached, or phished into handing over the account. This
+ option quietly relocates that dependency to Apple or Google instead of
+ removing it.
+- It's a second full-power master key, full stop — doubling the number of
+ ways to fully take over an account, independent of how carefully it's
+ implemented.
+- It is not a small add-on. Matching the recovery capsule's existing
+ guarantees (its own address, its own hash chain for gap/replay detection,
+ its own tombstone/vandalism-resistance story per §14.2) means building an
+ entire second, parallel recovery-capsule system — real protocol surface,
+ not a UI feature.
+- Two "I forgot everything" buttons that do fundamentally different things
+ is a support and mental-model problem. A user may stop safeguarding their
+ phrase ("I have Face ID") and only discover it was the one real backup
+ after a new phone or a changed Apple ID quietly drops the synced passkey.
+
+---
+
+## Option C — Constrained passkey recovery (same-credential-only)
+
+**How it works:** a middle path on Option B — recovery is permitted only
+through the *specific* passkey credential ID already enrolled for 2FA
+(Option A), never a freshly minted one. Enrolling a new passkey requires
+already being logged in; recovery never accepts an unfamiliar credential.
+
+**Pros**
+- Closes off the specific case of "attacker takes over my iCloud account,
+ mints a brand-new passkey there, uses it to recover" — a fresh credential
+ can never be used for recovery, only ones already on file.
+- Somewhat lower engineering cost than a fully independent Option B, since
+ it reuses the same enrolled-credential registry as the 2FA design.
+
+**Cons**
+- Does **not** close the main hole: if the *already-enrolled* passkey is
+ itself synced, an attacker who compromises the Apple/Google account gets
+ a working copy of that exact credential too — "same credential only"
+ and "any new credential" become equally reachable once the sync account
+ is owned. This mitigation only fully works for non-syncing, single-device
+ passkeys, which most users won't have.
+- Inherits most of Option B's structural cost (a second recovery-capsule
+ system) for a partial fix.
+- A serverless design has no good way to add the mitigations centralized
+ services normally layer onto "recover without your password" flows —
+ a delay window with a chance to cancel, an out-of-band notification to
+ the real owner, a manual review step. There's no server to hold that
+ state and no reliable channel to notify anyone (BitLogin has no email,
+ and a signed Nostr DM only reaches a device that happens to be online and
+ watching). That gap is a property of the architecture, not something this
+ option can design around — it's worth naming plainly rather than
+ pretending a technical constraint could close it.
+
+---
+
+## Summary comparison
+
+```text
+ Forgot-password New master-key Depends on
+ UX improvement? risk added? Apple/Google?
+Option A (2FA only) no minimal no
+Option B (full recovery) yes full yes (synced case)
+Option C (constrained) yes partial‑reduced yes (synced case)
+```
+
+None of these are mutually exclusive with each other or with the existing
+password + phrase design — Option A could ship on its own, or as a
+foundation that C or B build on top of later. The point of this document is
+only to make the tradeoff explicit before any of them is chosen.
diff --git a/docs/second-factor.md b/docs/second-factor.md
new file mode 100644
index 0000000..4357a76
--- /dev/null
+++ b/docs/second-factor.md
@@ -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": "",
+ "ciphertext": ""
+ },
+ "nonce": "",
+ "ciphertext": ""
+}
+```
+
+* 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.
+```
diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts
index 0aef20f..5af4203 100644
--- a/packages/core/src/account/account.e2e.test.ts
+++ b/packages/core/src/account/account.e2e.test.ts
@@ -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 () => {
diff --git a/packages/core/src/account/changePassword.ts b/packages/core/src/account/changePassword.ts
index 8b6a8c9..8eedf18 100644
--- a/packages/core/src/account/changePassword.ts
+++ b/packages/core/src/account/changePassword.ts
@@ -31,6 +31,9 @@ export interface ChangePasswordResult {
oldLocatorPublicKey: string;
newLocatorPublicKey: string;
newGeneration: number;
+ /** Carried forward unchanged from the old capsule (§14.1: rotation never touches the recovery capsule). */
+ recoveryPublicKey: string;
+ recoveryCapsuleEvent: NostrEvent;
newCredentialEvent: NostrEvent;
tombstoneEvent: NostrEvent;
deletionRequestEvent: NostrEvent;
@@ -127,6 +130,8 @@ export async function changePassword(params: ChangePasswordParams): Promise & { nsecOrHex: string }
+): Promise {
+ 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;
+}
diff --git a/packages/core/src/account/index.ts b/packages/core/src/account/index.ts
index 5bc4a38..2fff6bc 100644
--- a/packages/core/src/account/index.ts
+++ b/packages/core/src/account/index.ts
@@ -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";
diff --git a/packages/core/src/account/login.ts b/packages/core/src/account/login.ts
index b653a1d..fb44942 100644
--- a/packages/core/src/account/login.ts
+++ b/packages/core/src/account/login.ts
@@ -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 {
+ it("rejects passwords shorter than the minimum length", () => {
+ expect(checkManualPassword("Sh0rt!", "adam").ok).toBe(false);
+ });
+
+ it("rejects common weak passwords", () => {
+ expect(checkManualPassword("password123", "adam").ok).toBe(false);
+ expect(checkManualPassword("correcthorsebatterystaple", "adam").ok).toBe(false);
+ });
+
+ it("rejects a password containing the login name", () => {
+ expect(checkManualPassword("adamsSuperSecret1!", "adam").ok).toBe(false);
+ });
+
+ it("rejects repeated-character and sequence patterns", () => {
+ expect(checkManualPassword("aaaaaaaaaaaaaaaa", "user").ok).toBe(false);
+ expect(checkManualPassword("abcdabcdabcdabcd", "user").ok).toBe(false);
+ expect(checkManualPassword("12345678901234", "user").ok).toBe(false);
+ });
+
+ it("rejects passwords below the entropy floor even if structurally fine", () => {
+ // 12 lowercase letters ~= 12 * log2(26) ~= 56 bits, below the 64-bit floor.
+ expect(checkManualPassword("qwertyuiopzx", "user").ok).toBe(false);
+ });
+
+ it("accepts a sufficiently long, varied, non-blocklisted password", () => {
+ const result = checkManualPassword("Tr0mbone$Xylophone#42", "user");
+ expect(result.ok).toBe(true);
+ expect(result.entropyBits).toBeGreaterThanOrEqual(64);
+ });
+
+ it("estimates higher entropy for a longer, more varied password", () => {
+ const short = estimatePasswordEntropyBits("abcdefgh");
+ const long = estimatePasswordEntropyBits("abcdefghijklmnop123!@#");
+ expect(long).toBeGreaterThan(short);
+ });
+});
diff --git a/packages/core/src/account/manualPassword.ts b/packages/core/src/account/manualPassword.ts
new file mode 100644
index 0000000..8b39468
--- /dev/null
+++ b/packages/core/src/account/manualPassword.ts
@@ -0,0 +1,88 @@
+/**
+ * Optional manual password support (§9.3). The alpha's default and
+ * recommendation is a generated credential (§9.2); a user may opt into
+ * typing their own, gated by a hard minimum-entropy estimate, a small
+ * known-weak-pattern check, and an explicit warning that offline guessing
+ * against a downloaded capsule cannot be prevented by any client-side check
+ * (§9.1). This is an alpha-grade heuristic, not a breach-database lookup —
+ * BitLogin is static and makes no third-party network calls, so it cannot
+ * check against HaveIBeenPwned-style corpora. A production release should
+ * use a proper strength estimator (e.g. zxcvbn) and/or a bundled breach list.
+ */
+
+export const MANUAL_PASSWORD_MIN_ENTROPY_BITS = 64;
+export const MANUAL_PASSWORD_MIN_LENGTH = 12;
+
+const COMMON_WEAK_PASSWORDS = new Set([
+ "password", "password1", "password123", "123456", "123456789", "12345678",
+ "qwerty", "qwerty123", "letmein", "welcome", "monkey", "dragon", "master",
+ "abc123", "iloveyou", "admin", "login", "starwars", "sunshine", "princess",
+ "football", "baseball", "trustno1", "000000", "111111", "123123", "1234567890",
+ "changeme", "passw0rd", "correcthorsebatterystaple"
+]);
+
+export interface ManualPasswordCheck {
+ ok: boolean;
+ entropyBits: number;
+ reason?: string;
+}
+
+function characterClassAlphabetSize(password: string): number {
+ let size = 0;
+ if (/[a-z]/u.test(password)) size += 26;
+ if (/[A-Z]/u.test(password)) size += 26;
+ if (/[0-9]/u.test(password)) size += 10;
+ if (/[^a-zA-Z0-9]/u.test(password)) size += 33; // common printable symbols
+ return size || 1;
+}
+
+/**
+ * A conservative, dependency-free entropy estimate: character-class alphabet
+ * size times length. This deliberately under-credits patterned passwords
+ * rather than over-trusts them, but it cannot detect every weak pattern —
+ * it is combined with the blocklist and structural checks below.
+ */
+export function estimatePasswordEntropyBits(password: string): number {
+ return password.length * Math.log2(characterClassAlphabetSize(password));
+}
+
+function hasLowVarietyPattern(password: string): boolean {
+ if (/^(.)\1*$/u.test(password)) return true; // all one character
+ if (/(.)\1{2,}/u.test(password)) return true; // 3+ repeated character run
+ const lower = password.toLowerCase();
+ const sequences = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
+ for (const seq of sequences) {
+ for (let i = 0; i + 4 <= seq.length; i++) {
+ const forward = seq.slice(i, i + 4);
+ const backward = [...forward].reverse().join("");
+ if (lower.includes(forward) || lower.includes(backward)) return true;
+ }
+ }
+ return false;
+}
+
+/** Validates a user-chosen password against the alpha's manual-password floor (§9.3). */
+export function checkManualPassword(password: string, normalizedLoginName: string): ManualPasswordCheck {
+ const entropyBits = estimatePasswordEntropyBits(password);
+
+ if (password.length < MANUAL_PASSWORD_MIN_LENGTH) {
+ return { ok: false, entropyBits, reason: `Must be at least ${MANUAL_PASSWORD_MIN_LENGTH} characters.` };
+ }
+ if (COMMON_WEAK_PASSWORDS.has(password.toLowerCase())) {
+ return { ok: false, entropyBits, reason: "This is on a list of extremely common passwords." };
+ }
+ if (normalizedLoginName.length >= 3 && password.toLowerCase().includes(normalizedLoginName)) {
+ return { ok: false, entropyBits, reason: "Must not contain your login name." };
+ }
+ if (hasLowVarietyPattern(password)) {
+ return { ok: false, entropyBits, reason: "Too predictable (repeated characters or a simple sequence)." };
+ }
+ if (entropyBits < MANUAL_PASSWORD_MIN_ENTROPY_BITS) {
+ return {
+ ok: false,
+ entropyBits,
+ reason: `Estimated entropy (~${entropyBits.toFixed(0)} bits) is below the required ${MANUAL_PASSWORD_MIN_ENTROPY_BITS}. Use a longer password or mix character types.`
+ };
+ }
+ return { ok: true, entropyBits };
+}
diff --git a/packages/demo/public/account.html b/packages/demo/public/account.html
index 599d4a1..a8f8276 100644
--- a/packages/demo/public/account.html
+++ b/packages/demo/public/account.html
@@ -37,7 +37,7 @@
discovery-relays attributes at your own relays for production use.