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.

- +