From 991c2ee6396eaf69a132015c5cd71e34737b6815 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 10:30:19 +0000 Subject: [PATCH 1/3] Add draft spec addendum: second factor, backup codes, imported identities Design proposal (not implemented) for three opt-in, serverless additions layered on the existing capsule/relay/NIP-44 primitives: - Phone-approval sign-in via a 2-of-2 split credential-capsule key - Backup codes (each an independent high-entropy recovery path) - Importing an existing nsec as the everyday identity Includes a "why not literal TOTP" section and per-feature threat notes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6 --- docs/second-factor.md | 393 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/second-factor.md diff --git a/docs/second-factor.md b/docs/second-factor.md new file mode 100644 index 0000000..dfac628 --- /dev/null +++ b/docs/second-factor.md @@ -0,0 +1,393 @@ +# BitLogin Second Factor, Backup Codes, and Imported Identities + +**Document version:** 0.1 (draft addendum to the v0.3 core specification) +**Status:** Design proposal — not implemented, not final +**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 + +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. +``` From a76c8484f7a973435c492b97f0d2aa421400dc72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 10:59:01 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Add=20nsec=20import:=20use=20an=20existing?= =?UTF-8?q?=20Nostr=20identity=20as=20the=20everyday=20key=20(=C2=A7SF10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core: - registerAccount gains an optional everydayPrivateKey (validated scalar, used instead of generating); result carries an `imported` flag - importAccount() + decodeEverydayPrivateKey() convenience helpers accept nsec (bech32) or 64-char hex, wiring in the existing decodeNsec primitive - e2e tests: imported nsec round-trips to the same npub through both clean-device login and phrase recovery; malformed keys rejected widget: - worker gains a `previewImportKey` action (validate + preview npub without publishing) and passes importKey through `register` - gets an "Import an existing Nostr key" screen: paste nsec/hex, check-key preview shows the resulting npub for confirmation, then the normal name -> password -> phrase flow, with the pasted key dropped from the main thread after registration (§11.10) and a risky-paste warning shown up front docs: mark §SF10 implemented; note backup codes are redundant with the phrase and unlikely to ship; README implemented-list updated. Verified end-to-end in real Chromium (preview npub, import, clean-device login all reproduce the imported identity). 51/51 tests pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6 --- README.md | 9 +- docs/second-factor.md | 8 +- packages/core/src/account/account.e2e.test.ts | 49 ++++++++- packages/core/src/account/create.ts | 60 ++++++++++- packages/widget/src/element.ts | 102 +++++++++++++++++- packages/widget/src/worker/cryptoWorker.ts | 22 +++- packages/widget/src/worker/protocol.ts | 12 +++ packages/widget/src/worker/workerClient.ts | 4 + 8 files changed, 249 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6738777..f253db1 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,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/second-factor.md b/docs/second-factor.md index dfac628..4357a76 100644 --- a/docs/second-factor.md +++ b/docs/second-factor.md @@ -1,7 +1,11 @@ # BitLogin Second Factor, Backup Codes, and Imported Identities **Document version:** 0.1 (draft addendum to the v0.3 core specification) -**Status:** Design proposal — not implemented, not final +**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) --- @@ -307,7 +311,7 @@ Lost everything incl. phrase → unrecoverable (unchanged, §19.4) --- -# SF10. Imported identities +# 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 diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts index 0aef20f..74808af 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,50 @@ 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("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/create.ts b/packages/core/src/account/create.ts index 12d0634..abe4911 100644 --- a/packages/core/src/account/create.ts +++ b/packages/core/src/account/create.ts @@ -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 & { 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/widget/src/element.ts b/packages/widget/src/element.ts index 57b77b8..497dddd 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -8,6 +8,7 @@ import { WIDGET_STYLES } from "./styles.js"; type Screen = | "welcome" + | "import-key" | "create-name" | "create-credential" | "confirm-phrase" @@ -39,6 +40,10 @@ export class BitLoginAuthElement extends HTMLElement { private recoveryPhrase = ""; private confirmSlots: ConfirmSlot[] = []; + // 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 = ""; @@ -116,8 +121,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; @@ -166,6 +182,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 +206,39 @@ export class BitLoginAuthElement extends HTMLElement { } } + private async handlePreviewImport(): Promise { + 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)) { @@ -209,7 +260,14 @@ export class BitLoginAuthElement extends HTMLElement { 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: this.generatedCredential, + 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); @@ -338,13 +396,43 @@ export class BitLoginAuthElement extends HTMLElement { ${this.renderError()} + `; + case "import-key": { + const previewed = !!this.importPreviewNpub; + return ` +

Import an existing Nostr key

+

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.

+
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.
+ ${this.renderError()} +
+ + + +
+ ${ + previewed + ? `
This key's public identity:
+
${escapeHtml(this.importPreviewNpub)}
+ ` + : "" + } + + `; + } + case "create-name": return ` -

Create your BitLogin

-

Choose a login name. It's a convenience, not a secret (it contributes no security).

+

${this.importKey ? "Set up your login" : "Create your BitLogin"}

+

${ + 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)." + }

${this.renderError()}
@@ -368,7 +456,13 @@ export class BitLoginAuthElement extends HTMLElement { I have saved this password somewhere safe.
`; diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index f06cb37..f21bd10 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -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 { 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 { 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 }); diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 45a5877..87d048e 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -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]; register: [RegisterPayload, RegisterResult]; + previewImportKey: [PreviewImportKeyPayload, PreviewImportKeyResult]; login: [LoginPayload, LoginResult]; recover: [RecoverPayload, RecoverResult]; completeRecovery: [CompleteRecoveryPayload, CompleteRecoveryResult]; diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 8f0f0fb..b6c4997 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -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); } From 08013715666a6493dc8fabf23e9bd0c096bb76d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 15:35:10 +0000 Subject: [PATCH 3/3] Fix live-deployment bugs, add manual passwords, and improve integration support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes reported from the live Vercel deployment: - vercel.json: remove cleanUrls, which was breaking every internal .html link (ERR_FAILED) since links already use explicit extensions - Recovery export was silently broken after login or password rotation: the crypto worker's session state only picked up the recovery capsule event on register/recover, never on login/changePassword, even though the decrypted payload always carries it. Surfaced recoveryCapsuleEvent (and recoveryPublicKey for changePassword) through core's LoginResult / ChangePasswordResult and wired them into the worker session; covered by a new account.e2e.test.ts case - New accounts never got a Nostr profile: publishProfileAndRelayLists is now called with name: loginName after registration, so the npub's profile defaults to the chosen login name - Password manager never recognized the fields (shadow DOM + JS-driven submission defeat the passive save-prompt heuristics, and the generated-password screen never put the password in a real input at all). Fixed with an explicit navigator.credentials.store() call after every register/login/recover/changePassword; documented as Chromium-only in docs.html New: optional manual/custom password (packages/core/src/account/manualPassword.ts), opt-in per spec §9.3's "future release may permit manual passwords behind an advanced setting" allowance. Enforces a length floor, a common-password blocklist, a login-name-substring check, a low-variety-pattern check, and an entropy floor; the widget shows live non-blocking feedback as the user types. The generated high-entropy passphrase remains the default, recommended option. Integration/interop support: - window.nostr provider now carries a readonly _bitlogin marker - window.bitlogin ({ version, isActiveSigner() }) is installed unconditionally as soon as the script loads, independent of login state or window.nostr's current occupant. Found and fixed a real bug along the way: an unconditional assignment is required because browsers auto-expose any element with a matching id/name as a same-named global, so silently shadowed window.bitlogin under a naive `if (!window.bitlogin)` guard. Renamed the demo's own element id to avoid the same trap and documented the gotcha in docs.html - docs.html: new section on supporting multiple signing methods (extensions, NIP-46, BitLogin) alongside each other - Added packages/demo/public/llms.txt for AI-assisted integration Deployment: GitHub Pages requires a paid plan for private repos, so its workflow and the .github directory are removed; README documents Vercel-only deployment and the direct-from-CDN integration path. Docs: added docs/future-recovery-options.md, laying out passkey-based 2FA/recovery options (pure 2FA, full independent recovery, and a constrained middle ground) with pros/cons for future consideration — not a development plan, nothing here is decided. 59/59 tests pass; core and widget typecheck clean; all fixes verified live in Chromium via Playwright against an in-memory mock relay. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6 --- .github/workflows/pages.yml | 47 ----- README.md | 22 +++ docs/future-recovery-options.md | 170 ++++++++++++++++++ packages/core/src/account/account.e2e.test.ts | 26 +++ packages/core/src/account/changePassword.ts | 5 + packages/core/src/account/index.ts | 1 + packages/core/src/account/login.ts | 3 + .../core/src/account/manualPassword.test.ts | 40 +++++ packages/core/src/account/manualPassword.ts | 88 +++++++++ packages/demo/public/account.html | 4 +- packages/demo/public/docs.html | 42 +++++ packages/demo/public/llms.txt | 79 ++++++++ packages/widget/src/element.ts | 139 ++++++++++++-- packages/widget/src/index.ts | 35 ++++ packages/widget/src/provider.ts | 12 +- packages/widget/src/worker/cryptoWorker.ts | 7 + vercel.json | 3 +- 17 files changed, 655 insertions(+), 68 deletions(-) delete mode 100644 .github/workflows/pages.yml create mode 100644 docs/future-recovery-options.md create mode 100644 packages/core/src/account/manualPassword.test.ts create mode 100644 packages/core/src/account/manualPassword.ts create mode 100644 packages/demo/public/llms.txt 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 f253db1..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) 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/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts index 74808af..5af4203 100644 --- a/packages/core/src/account/account.e2e.test.ts +++ b/packages/core/src/account/account.e2e.test.ts @@ -90,6 +90,32 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { ARGON2_TIMEOUT ); + it( + "login and password change both surface the embedded recovery capsule event (recovery export must work without a fresh phrase-recovery)", + async () => { + const loginName = "exportbug"; + const oldPassword = generatePassphrase().secret; + const newPassword = generatePassphrase().secret; + const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + + // A clean-device login (no prior registration/recovery in this "session") must still + // return the embedded recovery event -- this is what a recovery-export button needs. + const login = await loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls }); + expect(login.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); + + // After a password rotation, the (unchanged) recovery capsule event must still be + // surfaced -- previously this was silently dropped, breaking "download export" after + // a rotation even across a logout/login. + const changed = await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls }); + expect(changed.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); + expect(changed.recoveryPublicKey).toBe(registration.recoveryPublicKey); + + const loginAfterRotation = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls }); + expect(loginAfterRotation.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); + }, + ARGON2_TIMEOUT * 2 + ); + it("rejects malformed keys on import (§SF10)", () => { expect(() => decodeEverydayPrivateKey("not-a-key")).toThrow(); expect(() => decodeEverydayPrivateKey("nsec1invalid")).toThrow(); 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 { + 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.

- +