mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Merge pull request #2 from PR0M3TH3AN/claude/bitlogin-protocol-spec-q18ust
Fix live-deployment bugs, add manual passwords, and improve integration support
This commit is contained in:
21 files changed
+1287
-75
No files matched your search
@@ -1,47 +0,0 @@
|
||||
name: Deploy demo to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm run build -w @bitlogin/core
|
||||
- run: npm run build -w @bitlogin/widget
|
||||
- run: npm run build -w @bitlogin/demo
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: packages/demo/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -65,6 +65,28 @@ guide, and `packages/demo/public/account.html` for a working example
|
||||
(create → confirm recovery phrase → sign an event → rotate password →
|
||||
export identity).
|
||||
|
||||
## Deployment
|
||||
|
||||
The demo site deploys to Vercel: `vercel.json` at the repo root sets the
|
||||
build command (core → widget → demo) and output directory, so importing this
|
||||
repo at [vercel.com/new](https://vercel.com/new) needs no manual
|
||||
configuration. GitHub Pages was evaluated but dropped — Pages requires a paid
|
||||
plan for private repositories, whereas Vercel's free tier deploys a private
|
||||
repo without that restriction.
|
||||
|
||||
Because the deployed demo serves the built widget files as plain static
|
||||
assets at `/vendor/bitlogin/`, **any other site can point directly at the
|
||||
Vercel deployment today** without installing anything locally:
|
||||
|
||||
```html
|
||||
<script type="module" src="https://bitlogin.vercel.app/vendor/bitlogin/bitlogin.js"></script>
|
||||
<bitlogin-auth></bitlogin-auth>
|
||||
```
|
||||
|
||||
This is fine for prototyping across multiple projects; for production use in
|
||||
someone else's app, self-hosting the built files (or a future published npm
|
||||
package / CDN release) avoids depending on this demo deployment's uptime.
|
||||
|
||||
## What's implemented
|
||||
|
||||
This build covers the protocol's Phase 0 (cryptographic core + test vectors)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,397 @@
|
||||
# BitLogin Second Factor, Backup Codes, and Imported Identities
|
||||
|
||||
**Document version:** 0.1 (draft addendum to the v0.3 core specification)
|
||||
**Status:** Mixed. §SF10 (imported identities) is **implemented**. Phone-approval
|
||||
sign-in (§SF5–SF7) and backup codes (§SF8) are **design proposals only** — and
|
||||
backup codes are unlikely to ship, since the recovery phrase already covers the
|
||||
same ground (see §SF8 note). The "why not TOTP" analysis (§SF2) is a settled
|
||||
rejection, kept on the record.
|
||||
**Depends on:** `docs/spec.md` (§ references below point there unless marked §SF)
|
||||
|
||||
---
|
||||
|
||||
# SF1. Purpose and scope
|
||||
|
||||
Mainstream users understand one second-factor ceremony: *scan a QR code, confirm
|
||||
a six-digit code, save your backup codes, and approve sign-ins on your phone.*
|
||||
This addendum specifies how BitLogin reproduces that exact ceremony — opt-in,
|
||||
enable/disable at any time after account creation — without a server, using
|
||||
only the primitives the core protocol already ships: capsules on relays,
|
||||
NIP-44 encryption, and self-custodied Nostr keys.
|
||||
|
||||
It defines three opt-in features:
|
||||
|
||||
1. **Phone-approval sign-in** (§SF5–SF7): a real cryptographic second factor.
|
||||
The credential capsule key is split 2-of-2 between the password and a
|
||||
secret held on an enrolled phone; sign-in requires both.
|
||||
2. **Backup codes** (§SF8): one-time high-entropy codes, each an independent
|
||||
recovery path, presented in the familiar "save these codes" format.
|
||||
3. **Imported identities** (§SF10): using an existing Nostr `nsec` as the
|
||||
everyday identity under all of the above.
|
||||
|
||||
The recovery phrase remains the master path and is never gated by any of
|
||||
these features (§SF9).
|
||||
|
||||
---
|
||||
|
||||
# SF2. Why literal TOTP is excluded
|
||||
|
||||
TOTP works because a server holds the shared secret and enforces attempt
|
||||
limits. BitLogin has no server, and its capsules are downloadable by anyone
|
||||
(§9.1, §23.3). The consequences are not fixable by implementation care:
|
||||
|
||||
1. A 6-digit code carries ~20 bits of entropy. Anything decryptable "given
|
||||
the right code" is brute-forced offline in milliseconds — 10⁶ candidate
|
||||
codes against a downloaded capsule is not an attack, it is a for-loop.
|
||||
2. A code the client merely *asks for* in its UI, without the code being
|
||||
cryptographically necessary for decryption, is cosmetic. Any client that
|
||||
skips the UI decrypts with the password alone. UI-enforced 2FA in an open
|
||||
protocol protects nobody and trains users to trust theater.
|
||||
|
||||
Therefore: **no stored or login-gating secret in this design may have less
|
||||
entropy than a generated credential (§9.2).** Short codes appear in exactly
|
||||
one place — the interactive pairing confirmation of §SF6, where the code is
|
||||
one-shot, ephemeral, human-compared between two live devices, and never
|
||||
stored or transmitted as a secret.
|
||||
|
||||
The familiar *ceremony* survives; the mechanism underneath must differ. The
|
||||
mapping:
|
||||
|
||||
```text
|
||||
User sees Actually happening
|
||||
───────────────────────────────────── ─────────────────────────────────────
|
||||
"Scan this QR with your phone" One-time full-entropy enrollment
|
||||
secret transferred to the phone
|
||||
"Enter the 6-digit code shown" Interactive pairing confirmation
|
||||
(one-shot, never stored)
|
||||
"Approve this sign-in on your phone" Phone releases its 2-of-2 key share
|
||||
over an encrypted relay message
|
||||
"Save these backup codes" Each code is an independent
|
||||
high-entropy recovery credential
|
||||
backing its own capsule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# SF3. New identities and secrets
|
||||
|
||||
This addendum adds, per account (all optional):
|
||||
|
||||
* **Device factor keypair** — a secp256k1 keypair generated on the enrolled
|
||||
phone at enrollment time. Its private key never leaves the phone. Its
|
||||
public key ("device pubkey") is the address for approval requests. It is
|
||||
a fourth identity class alongside the recovery, everyday, and locator
|
||||
identities (§7), and like the locator it is never shown to the user as an
|
||||
account.
|
||||
* **Device share** — 32 random bytes generated at enrollment, held only on
|
||||
the phone (wrapped at rest per §21.2: PIN, platform keystore, or passkey),
|
||||
and released only inside an approved sign-in (§SF7). Never published at
|
||||
rest, on any relay, in any capsule.
|
||||
* **Backup-code secrets** — generated codes, each ≥ 77 bits of entropy
|
||||
(§SF8). Shown once, then existing only as the user's saved copy.
|
||||
|
||||
The enrollment QR payload contains a one-time secret from which the phone
|
||||
derives nothing durable except its own locally-generated keys — the QR is a
|
||||
transport for bootstrapping the pairing session (§SF6), not a long-term
|
||||
secret. A photographed QR that is never used within its pairing window
|
||||
confers nothing.
|
||||
|
||||
---
|
||||
|
||||
# SF4. Envelope changes: the gated envelope
|
||||
|
||||
Core envelopes (§13) carry one AEAD payload. A second-factor-protected
|
||||
credential capsule uses a two-layer **gated envelope**:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"algorithm": "aes-256-gcm",
|
||||
"gate": {
|
||||
"nonce": "<base64url>",
|
||||
"ciphertext": "<base64url>"
|
||||
},
|
||||
"nonce": "<base64url>",
|
||||
"ciphertext": "<base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
* The **gate** is encrypted with the password-derived key alone
|
||||
(`password_capsule_key`, §11.4). Its plaintext names the device pubkey,
|
||||
the approval-relay hints, and the split parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"second_factor": "device-approval-v1",
|
||||
"device_public_key": "lowercase-hex",
|
||||
"approval_relays": ["wss://..."],
|
||||
"split": "hkdf-2of2-v1"
|
||||
}
|
||||
```
|
||||
|
||||
* The **payload** is the ordinary credential payload (§12.1), encrypted with
|
||||
the split key of §SF5, which requires the device share.
|
||||
|
||||
Properties this buys:
|
||||
|
||||
1. **Trial decryption stays honest.** A client that knows only the password
|
||||
opens the gate, learns that a second factor is required and who to ask,
|
||||
and cannot read the payload. Wrong-password attempts fail at the gate
|
||||
exactly as they fail today — §16.3's indistinguishability is preserved.
|
||||
2. **The device pubkey is not public.** Relays see a version-2 envelope
|
||||
(which does reveal *that* the account uses a second factor — accepted and
|
||||
disclosed, as with the `d`-tag decision in §13) but learn the device
|
||||
pubkey only if they crack the password. Correlating approval traffic to a
|
||||
specific capsule therefore requires the password.
|
||||
3. **Version-1 envelopes remain valid.** Accounts without the feature are
|
||||
byte-for-byte unchanged.
|
||||
|
||||
---
|
||||
|
||||
# SF5. The 2-of-2 split key
|
||||
|
||||
With the second factor enabled, the credential payload key is:
|
||||
|
||||
```text
|
||||
split_prk = HKDF-Extract(
|
||||
salt = SHA256("bitlogin/second-factor-split/v1"),
|
||||
IKM = password_prk || device_share
|
||||
)
|
||||
|
||||
split_capsule_key = HKDF-Expand(
|
||||
split_prk,
|
||||
"bitlogin/2fa-capsule-encryption/v1",
|
||||
32
|
||||
)
|
||||
```
|
||||
|
||||
where `password_prk` is the existing extract of §11.4 and `device_share` is
|
||||
the phone-held 32-byte secret. Both inputs are required; neither alone
|
||||
yields anything. The locator derivation (§11.4) is **unchanged** — the
|
||||
capsule is *found* with the password alone and *opened* only with both.
|
||||
|
||||
Enabling or disabling the second factor is a **credential event** (§12.2):
|
||||
the client republishes the credential capsule at the same locator with an
|
||||
incremented generation and `created_at` per §24.6, switching between the
|
||||
plain and gated envelope forms. No other machinery changes.
|
||||
|
||||
**Honest limitation (mirrors §18.3):** enabling the second factor does not
|
||||
protect capsule generations an attacker downloaded *before* it was enabled.
|
||||
Those old copies remain decryptable with the password alone, forever. The
|
||||
enable screen must disclose this; users who suspect prior password
|
||||
compromise need rotation (§18.4), not 2FA.
|
||||
|
||||
---
|
||||
|
||||
# SF6. Enrollment ceremony
|
||||
|
||||
Preconditions: user is signed in on a primary device; has the BitLogin PWA
|
||||
(or a compatible client) on the phone to be enrolled; has confirmed saved
|
||||
backup codes (§SF8) — **enrollment shall refuse to complete before backup
|
||||
codes are confirmed**, because 2-of-2 with a lost phone would otherwise be a
|
||||
lockout (§SF9).
|
||||
|
||||
1. Primary device generates an ephemeral pairing keypair and a pairing
|
||||
session id, and renders a QR: `{pairing_pubkey, session_id, relay_hints,
|
||||
expiry}` (expiry ≤ 5 minutes).
|
||||
2. Phone scans the QR, generates its device factor keypair and device share,
|
||||
and opens an encrypted channel to the pairing pubkey over the hinted
|
||||
relays (NIP-44, ephemeral event kinds — relays are transport only and
|
||||
store nothing).
|
||||
3. Both screens display a **short confirmation code** derived from the
|
||||
channel transcript hash (e.g. 6 digits of
|
||||
`SHA256(transcript)`). The user visually confirms they match and types
|
||||
the phone's code into the primary device. This is the familiar "enter the
|
||||
6-digit code" moment; it authenticates the pairing against a
|
||||
machine-in-the-middle on the relay path, is one-shot, and is never a
|
||||
stored secret (§SF2).
|
||||
4. Phone sends its device pubkey (not the share) over the confirmed channel.
|
||||
5. Primary device rebuilds the credential capsule in gated-envelope form:
|
||||
it derives `split_capsule_key` — which requires the device share, so the
|
||||
phone performs the HKDF locally and returns only the resulting
|
||||
`split_capsule_key` over the confirmed channel; the share itself still
|
||||
never leaves the phone — encrypts, publishes, verifies readback (§15.6).
|
||||
6. Both sides wipe the pairing keys. The phone stores its device private key
|
||||
and share wrapped per §21.2 and registers a relay subscription for
|
||||
approval requests addressed to its device pubkey.
|
||||
|
||||
---
|
||||
|
||||
# SF7. Sign-in approval flow
|
||||
|
||||
With the second factor enabled, any-device login (§16) gains one step:
|
||||
|
||||
1. Client derives the locator from login name + password, fetches the
|
||||
capsule (unchanged, §16.1–16.2).
|
||||
2. Client decrypts the **gate** with the password-derived key. On success it
|
||||
learns the device pubkey and approval relays.
|
||||
3. Client generates an ephemeral session keypair and publishes an
|
||||
**approval request**: an ephemeral-kind event, authored by the session
|
||||
key, p-tagged to the device pubkey, NIP-44-encrypted to it. Contents:
|
||||
session pubkey, a random challenge, requesting-client context strings
|
||||
(origin/user-agent as claimed, displayed to the user as *unverified*),
|
||||
and an expiry ≤ 2 minutes.
|
||||
4. The phone displays: *"Approve sign-in? (details) — Approve / Deny."* On
|
||||
approve, it computes `split_capsule_key` locally (§SF5) and returns it
|
||||
NIP-44-encrypted to the session pubkey, then wipes the derived key.
|
||||
The device share itself is never transmitted.
|
||||
5. Client decrypts the payload, validates per §12.4, proceeds as §16.4. All
|
||||
session-key material is wiped.
|
||||
|
||||
Failure behavior: timeout, denial, and offline phone are indistinguishable
|
||||
to the requesting client ("approval not received") and surface the fallback
|
||||
paths: backup code or recovery phrase. Relay unavailability degrades to the
|
||||
same fallback — approval relays are listed in the gate and should overlap
|
||||
the vault relays for availability.
|
||||
|
||||
Connection hygiene (§23.4) extends naturally: approval traffic uses the
|
||||
session/device identities and shall not share connections with locator,
|
||||
recovery, or everyday-identity operations.
|
||||
|
||||
---
|
||||
|
||||
# SF8. Backup codes
|
||||
|
||||
Backup codes reuse the password path's machinery with a different label —
|
||||
each code is, mechanically, a generated single-use password with its own
|
||||
locator.
|
||||
|
||||
* **Format:** `XXXXX-XXXXX-XXXXX-XXXXX`, Crockford base32, ≥ 80 bits of
|
||||
entropy per code. Default: 8 codes per issuance.
|
||||
* **Derivation:** exactly §11.3–11.4 with the salt label
|
||||
`"bitlogin/backup-code-salt/v1"` and the code (uppercased, hyphens
|
||||
stripped) in place of the password; the login name is *not* an input, so a
|
||||
code alone suffices during recovery. Distinct HKDF info labels
|
||||
(`bitlogin/backup-locator-signing/v1`,
|
||||
`bitlogin/backup-capsule-encryption/v1`).
|
||||
* **Capsule:** one kind-30078 event per code, `d` tag
|
||||
`bitlogin:backup:v1`, authored by the code's locator, containing a
|
||||
standard credential payload (§12.1) in a **plain** (version-1) envelope —
|
||||
backup codes are the escape hatch and are deliberately not gated by the
|
||||
second factor.
|
||||
* **Issuance** requires only a signed-in session (everyday key in memory).
|
||||
Codes are displayed once. An encrypted registry of issued-code locator
|
||||
pubkeys (a NIP-78 record under the everyday identity) lets honest clients
|
||||
show "6 of 8 codes remaining" and support "regenerate all codes," which
|
||||
tombstones (§18.1) every outstanding code capsule and issues fresh ones.
|
||||
* **Use:** "Sign in with a backup code" derives the locator, fetches,
|
||||
decrypts, restores the identity, forces establishment of a new login
|
||||
name/password (as §17.4), and then tombstones the used code's capsule and
|
||||
updates the registry.
|
||||
* **Honest limitation:** as everywhere in this protocol (§18.3), tombstoning
|
||||
cannot revoke copies already downloaded. A backup-code capsule remains
|
||||
decryptable by its code for anyone who saved the ciphertext. Codes are
|
||||
high-entropy, so this is the same accepted risk as the password path —
|
||||
but "single-use" is a relay-layer promise, not a cryptographic one, and
|
||||
the interface must not claim otherwise.
|
||||
|
||||
---
|
||||
|
||||
# SF9. Interaction with the recovery phrase
|
||||
|
||||
The recovery phrase remains the root of last resort and is **never** gated
|
||||
by the second factor:
|
||||
|
||||
* Phrase recovery (§17) proceeds identically whether or not 2FA is enabled,
|
||||
and — because it establishes a fresh credential capsule — recovery is also
|
||||
the *disable* path when the phone is lost: recover with the phrase (or a
|
||||
backup code), and the newly published credential capsule is ungated.
|
||||
* Corollary, stated plainly: **the second factor does not protect against
|
||||
phrase theft** (§29.6 is unchanged), nor against theft of an unused backup
|
||||
code. It protects one thing well: a stolen or cracked *password* alone no
|
||||
longer opens the current account.
|
||||
* Consequently the lockout matrix is:
|
||||
|
||||
```text
|
||||
Lost phone → sign in with backup code or phrase; re-enroll
|
||||
Lost phone + forgot password → backup code or phrase
|
||||
Lost everything except phrase → phrase (unchanged, §17)
|
||||
Lost everything incl. phrase → unrecoverable (unchanged, §19.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# SF10. Imported identities — IMPLEMENTED
|
||||
|
||||
An existing Nostr identity may be used as the everyday identity under all
|
||||
of the above (§28.1 anticipated this; the everyday key is by design never
|
||||
derived from any BitLogin secret, §7.2, §10.4).
|
||||
|
||||
**Flow:** the import screen accepts an `nsec` or 32-byte hex key. The client
|
||||
validates the scalar, derives the public key, and displays it for the user
|
||||
to confirm against their known `npub`. Registration then proceeds exactly as
|
||||
§15 with key generation skipped: a **new** BitLogin recovery phrase is
|
||||
generated, both capsules are built around the imported key, and the
|
||||
second factor and backup codes layer on with no differences whatsoever.
|
||||
The public key is the only pre-existing material; the wrapper is identical.
|
||||
|
||||
**Required disclosures:**
|
||||
|
||||
1. *Prior exposure is out of scope.* BitLogin secures its own storage of the
|
||||
key. Copies that already exist — browser extensions, other signers, old
|
||||
clipboard managers, backups — remain live attack surface, and the
|
||||
security of the imported account is the minimum of BitLogin's guarantees
|
||||
and the key's prior handling. If the key may already be compromised,
|
||||
the correct move is rotation to a fresh identity (§18.4), not import.
|
||||
2. *The paste is the risk.* Entering an `nsec` into any web page is the most
|
||||
dangerous single action in Nostr use. The import field follows §11.10
|
||||
(read once, straight to the worker, cleared), the client warns about
|
||||
clipboard history, and a malicious build (§22.1) captures the key
|
||||
regardless — import on a build you trust.
|
||||
3. The `npub` alone cannot be imported; the private key is what the capsules
|
||||
wrap.
|
||||
|
||||
---
|
||||
|
||||
# SF11. Threat notes
|
||||
|
||||
```text
|
||||
Adversary holds… Outcome with this addendum
|
||||
────────────────────────────────────── ──────────────────────────────────────
|
||||
Password only (current capsule gated) Locates capsule; opens gate; cannot
|
||||
decrypt payload; phone shows an
|
||||
approval prompt the user can deny —
|
||||
which is also a breach alarm
|
||||
Password + pre-2FA capsule copy Full compromise (unchanged, §18.3);
|
||||
disclosed at enable time
|
||||
Password + stolen phone Full compromise if the phone's §21.2
|
||||
wrapping (PIN/passkey) is defeated;
|
||||
software-only PIN wrapping is brute-
|
||||
forceable by a thief — platform
|
||||
keystore/passkey wrapping recommended
|
||||
Stolen phone only Nothing (share is not a path by
|
||||
itself; no capsule decrypts with it)
|
||||
One unused backup code Full account recovery — codes are
|
||||
credentials; store them like the
|
||||
phrase
|
||||
Recovery phrase Full compromise (unchanged, §29.6)
|
||||
Relay operator Sees version-2 envelope (2FA in use),
|
||||
approval-traffic timing between
|
||||
pseudonymous keys; content and
|
||||
linkage to the everyday identity
|
||||
remain encrypted (§SF4, §23.4)
|
||||
```
|
||||
|
||||
Availability note: phone-approval sign-in adds a live relay round-trip to
|
||||
login. The phone being offline, or approval relays being unreachable, must
|
||||
degrade to backup-code/phrase fallback with a clear message — never to an
|
||||
indefinite spinner.
|
||||
|
||||
---
|
||||
|
||||
# SF12. Open questions
|
||||
|
||||
1. Should the approval response return the derived `split_capsule_key`
|
||||
(as specified — simplest) or a blinded share via an OPRF-style exchange
|
||||
so the phone learns nothing about login timing content? (Current design
|
||||
already reveals only timing to the phone; likely fine.)
|
||||
2. Passkey/WebAuthn-PRF as the device factor on platforms that support it —
|
||||
same protocol, share held by the authenticator, removes the
|
||||
PIN-brute-force row from §SF11. Target for the first revision.
|
||||
3. Multiple enrolled devices (any-of-N approval)? Requires per-device gates
|
||||
in the envelope; deferred.
|
||||
4. Should backup-code capsules pad to the smallest bucket always (they are
|
||||
uniform), and should issuance count be user-configurable?
|
||||
5. Ephemeral-kind number registration for the pairing/approval events, and
|
||||
whether approval relays should be required to support NIP-42 AUTH.
|
||||
```
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { MockRelay } from "../test-support/mockRelay.js";
|
||||
import { generatePassphrase } from "./passphrase.js";
|
||||
import { registerAccount } from "./create.js";
|
||||
import { registerAccount, importAccount, decodeEverydayPrivateKey } from "./create.js";
|
||||
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||
import { encodeNsec, encodeNpub } from "../nostr/nip19.js";
|
||||
import { bytesToHex } from "../crypto/encoding.js";
|
||||
import { loginWithPassword } from "./login.js";
|
||||
import { recoverWithPhrase, completeRecoveryWithNewCredentials } from "./recover.js";
|
||||
import { changePassword } from "./changePassword.js";
|
||||
@@ -50,6 +53,76 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
|
||||
ARGON2_TIMEOUT
|
||||
);
|
||||
|
||||
it(
|
||||
"import an existing nsec -> clean-device login and phrase recovery both reproduce the imported npub (§SF10)",
|
||||
async () => {
|
||||
// A pre-existing Nostr identity the user already controls.
|
||||
const existingKey = generatePrivateKey();
|
||||
const existingPubkey = getPublicKeyHex(existingKey);
|
||||
const nsec = encodeNsec(existingKey);
|
||||
const npub = encodeNpub(existingPubkey);
|
||||
|
||||
const loginName = "importer";
|
||||
const password = generatePassphrase().secret;
|
||||
|
||||
// decodeEverydayPrivateKey accepts both nsec and 64-char hex forms.
|
||||
const existingKeyHex = bytesToHex(existingKey);
|
||||
expect(getPublicKeyHex(decodeEverydayPrivateKey(nsec))).toBe(existingPubkey);
|
||||
expect(getPublicKeyHex(decodeEverydayPrivateKey(existingKeyHex))).toBe(existingPubkey);
|
||||
|
||||
const registration = await importAccount({ nsecOrHex: nsec, loginName, password, vaultRelayUrls });
|
||||
expect(registration.imported).toBe(true);
|
||||
expect(registration.everydayPublicKey).toBe(existingPubkey);
|
||||
|
||||
// Clean-device password login yields the imported identity, unchanged.
|
||||
const login = await loginWithPassword({ loginName, password, vaultRelayUrls });
|
||||
expect(login.everydayPublicKey).toBe(existingPubkey);
|
||||
expect(encodeNpub(login.everydayPublicKey)).toBe(npub);
|
||||
|
||||
// The freshly generated BitLogin phrase recovers the SAME imported identity.
|
||||
const recovered = await recoverWithPhrase({
|
||||
phrase: registration.recoveryPhrase,
|
||||
vaultRelayUrls,
|
||||
discoveryRelayUrls: vaultRelayUrls
|
||||
});
|
||||
expect(recovered.everydayPublicKey).toBe(existingPubkey);
|
||||
},
|
||||
ARGON2_TIMEOUT
|
||||
);
|
||||
|
||||
it(
|
||||
"login and password change both surface the embedded recovery capsule event (recovery export must work without a fresh phrase-recovery)",
|
||||
async () => {
|
||||
const loginName = "exportbug";
|
||||
const oldPassword = generatePassphrase().secret;
|
||||
const newPassword = generatePassphrase().secret;
|
||||
const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls });
|
||||
|
||||
// A clean-device login (no prior registration/recovery in this "session") must still
|
||||
// return the embedded recovery event -- this is what a recovery-export button needs.
|
||||
const login = await loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls });
|
||||
expect(login.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
|
||||
|
||||
// After a password rotation, the (unchanged) recovery capsule event must still be
|
||||
// surfaced -- previously this was silently dropped, breaking "download export" after
|
||||
// a rotation even across a logout/login.
|
||||
const changed = await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls });
|
||||
expect(changed.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
|
||||
expect(changed.recoveryPublicKey).toBe(registration.recoveryPublicKey);
|
||||
|
||||
const loginAfterRotation = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls });
|
||||
expect(loginAfterRotation.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id);
|
||||
},
|
||||
ARGON2_TIMEOUT * 2
|
||||
);
|
||||
|
||||
it("rejects malformed keys on import (§SF10)", () => {
|
||||
expect(() => decodeEverydayPrivateKey("not-a-key")).toThrow();
|
||||
expect(() => decodeEverydayPrivateKey("nsec1invalid")).toThrow();
|
||||
expect(() => decodeEverydayPrivateKey("00".repeat(32))).toThrow(); // zero scalar is invalid
|
||||
expect(() => decodeEverydayPrivateKey("ab".repeat(20))).toThrow(); // wrong length hex
|
||||
});
|
||||
|
||||
it(
|
||||
"wrong password on a real account is indistinguishable from a nonexistent account (§16.3)",
|
||||
async () => {
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface ChangePasswordResult {
|
||||
oldLocatorPublicKey: string;
|
||||
newLocatorPublicKey: string;
|
||||
newGeneration: number;
|
||||
/** Carried forward unchanged from the old capsule (§14.1: rotation never touches the recovery capsule). */
|
||||
recoveryPublicKey: string;
|
||||
recoveryCapsuleEvent: NostrEvent;
|
||||
newCredentialEvent: NostrEvent;
|
||||
tombstoneEvent: NostrEvent;
|
||||
deletionRequestEvent: NostrEvent;
|
||||
@@ -127,6 +130,8 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
|
||||
oldLocatorPublicKey,
|
||||
newLocatorPublicKey,
|
||||
newGeneration,
|
||||
recoveryPublicKey: oldPayload.recovery_public_key,
|
||||
recoveryCapsuleEvent: oldPayload.recovery_capsule_event,
|
||||
newCredentialEvent,
|
||||
tombstoneEvent,
|
||||
deletionRequestEvent,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Registration flow (§15). Recovery capsule is built and published before the credential capsule. */
|
||||
import { randomEntropy128, randomAccountId } from "../crypto/random.js";
|
||||
import { bytesToBase64url } from "../crypto/encoding.js";
|
||||
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||
import { bytesToBase64url, hexToBytes } from "../crypto/encoding.js";
|
||||
import { generatePrivateKey, getPublicKeyHex, isValidScalar } from "../crypto/secp256k1.js";
|
||||
import { decodeNsec } from "../nostr/nip19.js";
|
||||
import { entropyToRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js";
|
||||
import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "./normalize.js";
|
||||
import { RelayPool } from "../nostr/pool.js";
|
||||
@@ -19,6 +20,13 @@ export interface RegisterAccountParams {
|
||||
/** A client-generated credential (§9.2); manual passwords are prohibited in the alpha (§9.3). */
|
||||
password: string;
|
||||
vaultRelayUrls: string[];
|
||||
/**
|
||||
* Optional existing everyday identity to wrap instead of generating a fresh one (§28.1, §SF10).
|
||||
* The everyday key is never derived from any BitLogin secret (§7.2), so an imported key is
|
||||
* indistinguishable to the rest of the protocol — only the origin differs. Must be a valid
|
||||
* 32-byte secp256k1 scalar; the caller is responsible for decoding an `nsec` first.
|
||||
*/
|
||||
everydayPrivateKey?: Uint8Array;
|
||||
minAcknowledgements?: number;
|
||||
timeoutMs?: number;
|
||||
/** Testing hook; defaults to the current time. */
|
||||
@@ -33,6 +41,8 @@ export interface RegisterAccountResult {
|
||||
recoveryPublicKey: string;
|
||||
locatorPublicKey: string;
|
||||
accountId: string;
|
||||
/** True when the everyday identity was imported (§SF10) rather than freshly generated. */
|
||||
imported: boolean;
|
||||
credentialEvent: NostrEvent;
|
||||
recoveryEvent: NostrEvent;
|
||||
credentialPublish: PublishVerificationResult;
|
||||
@@ -49,8 +59,12 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
|
||||
const { recoveryPrivateKey, capsuleKey: recoveryCapsuleKey } = deriveRecoveryKeys(bip39Seed);
|
||||
const recoveryPublicKey = getPublicKeyHex(recoveryPrivateKey);
|
||||
|
||||
// §15.3 — everyday identity
|
||||
const everydayPrivateKey = generatePrivateKey();
|
||||
// §15.3 — everyday identity: generated fresh, or an imported existing key (§28.1, §SF10).
|
||||
const imported = params.everydayPrivateKey !== undefined;
|
||||
if (imported && !isValidScalar(params.everydayPrivateKey!)) {
|
||||
throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key.");
|
||||
}
|
||||
const everydayPrivateKey = imported ? params.everydayPrivateKey! : generatePrivateKey();
|
||||
const everydayPublicKey = getPublicKeyHex(everydayPrivateKey);
|
||||
const accountId = bytesToBase64url(randomAccountId());
|
||||
|
||||
@@ -129,9 +143,47 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
|
||||
recoveryPublicKey,
|
||||
locatorPublicKey,
|
||||
accountId,
|
||||
imported,
|
||||
credentialEvent,
|
||||
recoveryEvent,
|
||||
credentialPublish,
|
||||
recoveryPublish
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for importing an existing Nostr identity (§28.1, §SF10).
|
||||
* Accepts an `nsec` (bech32) or 64-char lowercase-hex private key, decodes it,
|
||||
* and registers a new BitLogin account (fresh recovery phrase + password)
|
||||
* around that key. Everything downstream is identical to a generated account —
|
||||
* only the everyday key's origin differs.
|
||||
*
|
||||
* Honest limitation (§SF10): BitLogin secures its own storage of the key, but
|
||||
* copies that already exist elsewhere (extensions, other signers, clipboard
|
||||
* history, backups) remain live attack surface outside its reach. If the key
|
||||
* may already be compromised, rotate to a fresh identity instead of importing.
|
||||
*/
|
||||
export async function importAccount(
|
||||
params: Omit<RegisterAccountParams, "everydayPrivateKey"> & { nsecOrHex: string }
|
||||
): Promise<RegisterAccountResult> {
|
||||
const { nsecOrHex, ...rest } = params;
|
||||
const everydayPrivateKey = decodeEverydayPrivateKey(nsecOrHex);
|
||||
return registerAccount({ ...rest, everydayPrivateKey });
|
||||
}
|
||||
|
||||
/** Decodes an `nsec` or 64-char hex string to a validated 32-byte scalar (§SF10). */
|
||||
export function decodeEverydayPrivateKey(nsecOrHex: string): Uint8Array {
|
||||
const trimmed = nsecOrHex.trim();
|
||||
let key: Uint8Array;
|
||||
if (trimmed.startsWith("nsec1")) {
|
||||
key = decodeNsec(trimmed);
|
||||
} else if (/^[0-9a-fA-F]{64}$/u.test(trimmed)) {
|
||||
key = hexToBytes(trimmed.toLowerCase());
|
||||
} else {
|
||||
throw new RegistrationFailedError("Enter a valid nsec (nsec1…) or a 64-character hex private key.");
|
||||
}
|
||||
if (!isValidScalar(key)) {
|
||||
throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key.");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./errors.js";
|
||||
export * from "./wordlist.js";
|
||||
export * from "./passphrase.js";
|
||||
export * from "./manualPassword.js";
|
||||
export * from "./normalize.js";
|
||||
export * from "./timestamp.js";
|
||||
export * from "./highWaterMark.js";
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface LoginResult {
|
||||
accountId: string;
|
||||
generation: number;
|
||||
credentialEvent: NostrEvent;
|
||||
/** The signed recovery capsule event embedded in the credential capsule (§12.1, §24.4) — needed to build a recovery export or rebroadcast replicas without re-deriving anything from the phrase. */
|
||||
recoveryCapsuleEvent: NostrEvent;
|
||||
/** Set when the accepted generation is lower than one this device has previously seen (§16.2 step 6). */
|
||||
rollbackWarning?: string;
|
||||
/** Set when responsive relays disagree about which capsule is newest (§16.2 step 8). */
|
||||
@@ -71,6 +73,7 @@ export async function loginWithPassword(params: LoginParams): Promise<LoginResul
|
||||
accountId: payload.account_id,
|
||||
generation: payload.generation,
|
||||
credentialEvent: result.best.event,
|
||||
recoveryCapsuleEvent: payload.recovery_capsule_event,
|
||||
rollbackWarning,
|
||||
relayDisagreementWarning
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkManualPassword, estimatePasswordEntropyBits } from "./manualPassword.js";
|
||||
|
||||
describe("checkManualPassword (§9.3)", () => {
|
||||
it("rejects passwords shorter than the minimum length", () => {
|
||||
expect(checkManualPassword("Sh0rt!", "adam").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects common weak passwords", () => {
|
||||
expect(checkManualPassword("password123", "adam").ok).toBe(false);
|
||||
expect(checkManualPassword("correcthorsebatterystaple", "adam").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a password containing the login name", () => {
|
||||
expect(checkManualPassword("adamsSuperSecret1!", "adam").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects repeated-character and sequence patterns", () => {
|
||||
expect(checkManualPassword("aaaaaaaaaaaaaaaa", "user").ok).toBe(false);
|
||||
expect(checkManualPassword("abcdabcdabcdabcd", "user").ok).toBe(false);
|
||||
expect(checkManualPassword("12345678901234", "user").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects passwords below the entropy floor even if structurally fine", () => {
|
||||
// 12 lowercase letters ~= 12 * log2(26) ~= 56 bits, below the 64-bit floor.
|
||||
expect(checkManualPassword("qwertyuiopzx", "user").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a sufficiently long, varied, non-blocklisted password", () => {
|
||||
const result = checkManualPassword("Tr0mbone$Xylophone#42", "user");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.entropyBits).toBeGreaterThanOrEqual(64);
|
||||
});
|
||||
|
||||
it("estimates higher entropy for a longer, more varied password", () => {
|
||||
const short = estimatePasswordEntropyBits("abcdefgh");
|
||||
const long = estimatePasswordEntropyBits("abcdefghijklmnop123!@#");
|
||||
expect(long).toBeGreaterThan(short);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Optional manual password support (§9.3). The alpha's default and
|
||||
* recommendation is a generated credential (§9.2); a user may opt into
|
||||
* typing their own, gated by a hard minimum-entropy estimate, a small
|
||||
* known-weak-pattern check, and an explicit warning that offline guessing
|
||||
* against a downloaded capsule cannot be prevented by any client-side check
|
||||
* (§9.1). This is an alpha-grade heuristic, not a breach-database lookup —
|
||||
* BitLogin is static and makes no third-party network calls, so it cannot
|
||||
* check against HaveIBeenPwned-style corpora. A production release should
|
||||
* use a proper strength estimator (e.g. zxcvbn) and/or a bundled breach list.
|
||||
*/
|
||||
|
||||
export const MANUAL_PASSWORD_MIN_ENTROPY_BITS = 64;
|
||||
export const MANUAL_PASSWORD_MIN_LENGTH = 12;
|
||||
|
||||
const COMMON_WEAK_PASSWORDS = new Set([
|
||||
"password", "password1", "password123", "123456", "123456789", "12345678",
|
||||
"qwerty", "qwerty123", "letmein", "welcome", "monkey", "dragon", "master",
|
||||
"abc123", "iloveyou", "admin", "login", "starwars", "sunshine", "princess",
|
||||
"football", "baseball", "trustno1", "000000", "111111", "123123", "1234567890",
|
||||
"changeme", "passw0rd", "correcthorsebatterystaple"
|
||||
]);
|
||||
|
||||
export interface ManualPasswordCheck {
|
||||
ok: boolean;
|
||||
entropyBits: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function characterClassAlphabetSize(password: string): number {
|
||||
let size = 0;
|
||||
if (/[a-z]/u.test(password)) size += 26;
|
||||
if (/[A-Z]/u.test(password)) size += 26;
|
||||
if (/[0-9]/u.test(password)) size += 10;
|
||||
if (/[^a-zA-Z0-9]/u.test(password)) size += 33; // common printable symbols
|
||||
return size || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A conservative, dependency-free entropy estimate: character-class alphabet
|
||||
* size times length. This deliberately under-credits patterned passwords
|
||||
* rather than over-trusts them, but it cannot detect every weak pattern —
|
||||
* it is combined with the blocklist and structural checks below.
|
||||
*/
|
||||
export function estimatePasswordEntropyBits(password: string): number {
|
||||
return password.length * Math.log2(characterClassAlphabetSize(password));
|
||||
}
|
||||
|
||||
function hasLowVarietyPattern(password: string): boolean {
|
||||
if (/^(.)\1*$/u.test(password)) return true; // all one character
|
||||
if (/(.)\1{2,}/u.test(password)) return true; // 3+ repeated character run
|
||||
const lower = password.toLowerCase();
|
||||
const sequences = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
|
||||
for (const seq of sequences) {
|
||||
for (let i = 0; i + 4 <= seq.length; i++) {
|
||||
const forward = seq.slice(i, i + 4);
|
||||
const backward = [...forward].reverse().join("");
|
||||
if (lower.includes(forward) || lower.includes(backward)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validates a user-chosen password against the alpha's manual-password floor (§9.3). */
|
||||
export function checkManualPassword(password: string, normalizedLoginName: string): ManualPasswordCheck {
|
||||
const entropyBits = estimatePasswordEntropyBits(password);
|
||||
|
||||
if (password.length < MANUAL_PASSWORD_MIN_LENGTH) {
|
||||
return { ok: false, entropyBits, reason: `Must be at least ${MANUAL_PASSWORD_MIN_LENGTH} characters.` };
|
||||
}
|
||||
if (COMMON_WEAK_PASSWORDS.has(password.toLowerCase())) {
|
||||
return { ok: false, entropyBits, reason: "This is on a list of extremely common passwords." };
|
||||
}
|
||||
if (normalizedLoginName.length >= 3 && password.toLowerCase().includes(normalizedLoginName)) {
|
||||
return { ok: false, entropyBits, reason: "Must not contain your login name." };
|
||||
}
|
||||
if (hasLowVarietyPattern(password)) {
|
||||
return { ok: false, entropyBits, reason: "Too predictable (repeated characters or a simple sequence)." };
|
||||
}
|
||||
if (entropyBits < MANUAL_PASSWORD_MIN_ENTROPY_BITS) {
|
||||
return {
|
||||
ok: false,
|
||||
entropyBits,
|
||||
reason: `Estimated entropy (~${entropyBits.toFixed(0)} bits) is below the required ${MANUAL_PASSWORD_MIN_ENTROPY_BITS}. Use a longer password or mix character types.`
|
||||
};
|
||||
}
|
||||
return { ok: true, entropyBits };
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
<code>discovery-relays</code> attributes at your own relays for production use.
|
||||
</p>
|
||||
</div>
|
||||
<bitlogin-auth id="bitlogin"></bitlogin-auth>
|
||||
<bitlogin-auth id="bitlogin-widget"></bitlogin-auth>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<script type="module" src="./vendor/bitlogin/bitlogin.js"></script>
|
||||
<script type="module">
|
||||
const el = document.getElementById('bitlogin');
|
||||
const el = document.getElementById('bitlogin-widget');
|
||||
el.addEventListener('bitlogin-login', (e) => console.log('[bitlogin] signed in', e.detail.publicKey));
|
||||
el.addEventListener('bitlogin-logout', () => console.log('[bitlogin] signed out'));
|
||||
</script>
|
||||
|
||||
@@ -73,6 +73,36 @@ const plain = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);</code></
|
||||
and back.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Supporting multiple signing methods (extensions, NIP-46, BitLogin)</h3>
|
||||
<p><code>window.nostr</code> is a single global slot — only one provider can occupy it at a time. If a user also
|
||||
has a NIP-07 extension (Alby, nos2x, …) installed, BitLogin will not overwrite it: it only installs
|
||||
<code>window.nostr</code> when nothing is already there. The reverse isn't in BitLogin's control — an extension
|
||||
that loads after BitLogin can still overwrite it, same as any two NIP-07 providers would collide.</p>
|
||||
<p>To know specifically whether BitLogin is the active signer (as opposed to an extension), check
|
||||
<code>window.bitlogin?.isActiveSigner()</code> rather than just <code>typeof window.nostr !== 'undefined'</code> —
|
||||
the latter only tells you <em>a</em> signer is present, not which one. <code>window.bitlogin</code> is installed
|
||||
as soon as the script loads, independent of whether <code><bitlogin-auth></code> has signed anyone in yet,
|
||||
so it's safe to feature-detect immediately.</p>
|
||||
<pre><code>if (window.bitlogin) {
|
||||
console.log('BitLogin is available, version', window.bitlogin.version);
|
||||
}
|
||||
if (window.bitlogin?.isActiveSigner()) {
|
||||
// window.nostr is currently backed by BitLogin specifically
|
||||
}</code></pre>
|
||||
<p>NIP-46 (remote "bunker" signers) is a different transport — it doesn't typically hook
|
||||
<code>window.nostr</code> directly, so it isn't something BitLogin needs to avoid colliding with. A site can
|
||||
offer "Sign in with BitLogin," "Connect an extension," and "Connect a bunker" as separate options; whichever
|
||||
the user picks becomes the active signer, and your app code should call whatever's currently active rather
|
||||
than assume a specific one.</p>
|
||||
<div class="callout">
|
||||
<strong>Don't give your <code><bitlogin-auth></code> element <code>id="bitlogin"</code>.</strong>
|
||||
Browsers automatically expose any element with a matching <code>id</code> or <code>name</code> attribute as
|
||||
a same-named global — an element with <code>id="bitlogin"</code> becomes <code>window.bitlogin</code>,
|
||||
shadowing the API object described above. Use something like <code>id="bitlogin-widget"</code> instead
|
||||
(as this demo does).
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Choose your relays</h3>
|
||||
<p>BitLogin ships with a small built-in bootstrap relay list, but you should point <code>vault-relays</code> and
|
||||
@@ -85,6 +115,18 @@ const plain = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);</code></
|
||||
shows the full loop: create an account, sign a test event, rotate the password, and export a recovery file.</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="callout">
|
||||
<strong>Password-manager save prompts.</strong> Because the widget's login form lives inside
|
||||
a shadow root and submits via JavaScript (never a real form POST), browsers' passive
|
||||
"offer to save this password" heuristics don't reliably fire — and the generated-password
|
||||
screen never puts the password into a real input at all, since it's only shown as text.
|
||||
The widget works around this by explicitly calling the Credential Management API
|
||||
(<code>navigator.credentials.store()</code>) after every successful registration, login,
|
||||
password rotation, and recovery. This is Chromium-only (Chrome, Edge, Brave, Opera) —
|
||||
Firefox and Safari never implemented <code>PasswordCredential</code>, so users on those
|
||||
browsers will need to save their generated password manually.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# BitLogin
|
||||
|
||||
> A static, relay-backed portable Nostr account protocol. A familiar login
|
||||
> name and password (or an existing `nsec`) unlocks a portable Nostr identity
|
||||
> stored as encrypted "capsules" on public Nostr relays — no account server,
|
||||
> no password-reset email, no database. Ships as an embeddable custom element
|
||||
> plus a NIP-07-compatible `window.nostr` provider that any static site can
|
||||
> drop in with one script tag.
|
||||
|
||||
## Integrate in one snippet
|
||||
|
||||
```html
|
||||
<script type="module" src="https://bitlogin.vercel.app/vendor/bitlogin/bitlogin.js"></script>
|
||||
<bitlogin-auth
|
||||
vault-relays="wss://relay-one.example,wss://relay-two.example,wss://relay-three.example"
|
||||
discovery-relays="wss://discovery-one.example,wss://discovery-two.example">
|
||||
</bitlogin-auth>
|
||||
```
|
||||
|
||||
`bitlogin.js` and `cryptoWorker.js` (plus the shared chunk they both import)
|
||||
must be deployed side by side — the main script resolves the worker's URL at
|
||||
runtime relative to its own location, so this works from any path. For
|
||||
production, self-host these files rather than depending on the demo
|
||||
deployment above.
|
||||
|
||||
## What it gives your app
|
||||
|
||||
- A `<bitlogin-auth>` custom element (Shadow DOM, no CSS collisions) handling
|
||||
create account, sign in, forgot-password recovery via a 12-word phrase, and
|
||||
password rotation.
|
||||
- A `window.nostr` provider shaped like the de facto NIP-07 interface
|
||||
(`getPublicKey`, `signEvent`, `getRelays`, `nip44.encrypt`/`decrypt`) —
|
||||
existing Nostr app code that already calls `window.nostr` works unmodified,
|
||||
same as it would against a browser extension (Alby, nos2x, …).
|
||||
- `window.bitlogin` — installed as soon as the script loads, independent of
|
||||
login state: `{ version, isActiveSigner() }`. Use
|
||||
`window.bitlogin.isActiveSigner()` to tell whether BitLogin specifically
|
||||
(not another extension) currently backs `window.nostr`.
|
||||
- Events on the element: `bitlogin-login` (`detail.publicKey`) and
|
||||
`bitlogin-logout`.
|
||||
- Private keys never leave a dedicated Web Worker; the main thread only ever
|
||||
sees public keys, signed events, and ciphertext.
|
||||
|
||||
## Key facts for integration
|
||||
|
||||
- No build step required to consume it — plain `<script type="module">`.
|
||||
- Don't give the element `id="bitlogin"` — browsers auto-expose elements with
|
||||
a matching `id`/`name` as a same-named global, which shadows
|
||||
`window.bitlogin`. Use `id="bitlogin-widget"` or similar.
|
||||
- Configure relays via the `vault-relays` and `discovery-relays` attributes
|
||||
(comma-separated `wss://` URLs); omit to use BitLogin's small built-in
|
||||
bootstrap list.
|
||||
- Manual (user-chosen) passwords are opt-in and discouraged by default — the
|
||||
widget generates a high-entropy passphrase and recommends it, since a
|
||||
downloadable encrypted capsule can be guessed against offline forever.
|
||||
- Recovery is a 12-word BIP-39-style phrase, independent of the password;
|
||||
losing the password never locks the user out if they still have the
|
||||
phrase.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- [Integration guide](/docs.html) — full walkthrough with code samples.
|
||||
- [Live account manager demo](/account.html) — create/login/recover, sign a
|
||||
test event, rotate password, export identity.
|
||||
- [Protocol specification](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/spec.md) —
|
||||
the full BitLogin v0.3 protocol (crypto construction, capsule formats,
|
||||
relay architecture, threat model).
|
||||
- [Second-factor / recovery options design notes](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/second-factor.md)
|
||||
and [future recovery-path options](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/future-recovery-options.md) —
|
||||
proposals, not all implemented; each notes its own status.
|
||||
- [Source repository](https://github.com/PR0M3TH3AN/bitlogin) — monorepo:
|
||||
`packages/core` (protocol implementation), `packages/widget` (the embeddable
|
||||
element + provider), `packages/demo` (this site).
|
||||
|
||||
## Status
|
||||
|
||||
Experimental alpha. Verified with an in-memory mock-relay test suite and
|
||||
real-browser end-to-end runs, but not independently audited. See the README
|
||||
in the source repository for the full implemented/deferred feature list.
|
||||
+213
-12
@@ -1,5 +1,5 @@
|
||||
/** <bitlogin-auth> — the embeddable BitLogin login/create/recover widget (§3, §27). */
|
||||
import { generatePassphrase, isValidLoginName } from "@bitlogin/core/account";
|
||||
import { generatePassphrase, isValidLoginName, checkManualPassword, type ManualPasswordCheck } from "@bitlogin/core/account";
|
||||
import { encodeNpub } from "@bitlogin/core/nostr";
|
||||
import { WorkerClient } from "./worker/workerClient.js";
|
||||
import { createNip07Provider } from "./provider.js";
|
||||
@@ -8,6 +8,7 @@ import { WIDGET_STYLES } from "./styles.js";
|
||||
|
||||
type Screen =
|
||||
| "welcome"
|
||||
| "import-key"
|
||||
| "create-name"
|
||||
| "create-credential"
|
||||
| "confirm-phrase"
|
||||
@@ -39,6 +40,16 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private recoveryPhrase = "";
|
||||
private confirmSlots: ConfirmSlot[] = [];
|
||||
|
||||
// Manual password opt-in (§9.3). Off by default; the generated passphrase remains the
|
||||
// recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full
|
||||
// re-render (see onInput) so the field never loses focus while the user is typing.
|
||||
private manualPasswordMode = false;
|
||||
private manualPasswordFeedback: ManualPasswordCheck | null = null;
|
||||
|
||||
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
|
||||
private importKey = "";
|
||||
private importPreviewNpub = "";
|
||||
|
||||
private recoverPhraseInput = "";
|
||||
private recoveredPreview: { generalRelaysCount: number; dmRelaysCount: number; chainWarning?: string } | null = null;
|
||||
private newCredentialAfterRecovery = "";
|
||||
@@ -63,6 +74,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
this.root.addEventListener("click", (e) => this.onClick(e));
|
||||
this.root.addEventListener("submit", (e) => this.onSubmit(e));
|
||||
this.root.addEventListener("input", (e) => this.onInput(e));
|
||||
this.render();
|
||||
|
||||
if (!(window as unknown as { nostr?: unknown }).nostr) {
|
||||
@@ -109,6 +121,31 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return (this.root.querySelector(`[name="${name}"]`) as HTMLInputElement | null)?.value ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly asks the browser to offer saving this credential via the Credential
|
||||
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
|
||||
* `PasswordCredential`). This is the primary fix for password-manager integration here,
|
||||
* not just a nice-to-have: the generated-password screen never puts the password into a
|
||||
* real `<input>` for a browser to observe being filled (it's shown as text), and even on
|
||||
* the login screen — which does use real, correctly-`autocomplete`d inputs — save/autofill
|
||||
* heuristics are unreliable inside a shadow root, and this widget's JS-driven submit
|
||||
* (`preventDefault`, no real form POST) doesn't produce the passive signal most heuristics
|
||||
* expect anyway. An explicit `navigator.credentials.store()` call sidesteps all of that.
|
||||
* Best-effort only: never blocks or fails the surrounding flow.
|
||||
*/
|
||||
private async offerToSaveCredential(loginName: string, password: string): Promise<void> {
|
||||
try {
|
||||
const PasswordCredentialCtor = (
|
||||
window as unknown as { PasswordCredential?: new (data: { id: string; password: string; name?: string }) => Credential }
|
||||
).PasswordCredential;
|
||||
if (!PasswordCredentialCtor || !navigator.credentials?.store) return;
|
||||
const credential = new PasswordCredentialCtor({ id: loginName, password, name: loginName });
|
||||
await navigator.credentials.store(credential);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private async onClick(e: Event): Promise<void> {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-action]");
|
||||
if (!target) return;
|
||||
@@ -116,8 +153,19 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
switch (action) {
|
||||
case "goto-create":
|
||||
this.loginName = "";
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.goto("create-name");
|
||||
return;
|
||||
case "goto-import":
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.goto("import-key");
|
||||
return;
|
||||
case "preview-import":
|
||||
return this.handlePreviewImport();
|
||||
case "import-continue":
|
||||
return this.handleImportContinue();
|
||||
case "goto-login":
|
||||
this.goto("login");
|
||||
return;
|
||||
@@ -142,6 +190,12 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "toggle-manual-password":
|
||||
this.manualPasswordMode = !this.manualPasswordMode;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "copy-credential": {
|
||||
const box = this.root.querySelector<HTMLElement>("#credential-box");
|
||||
if (box) void navigator.clipboard?.writeText(box.textContent ?? "");
|
||||
@@ -166,6 +220,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
const formName = form.dataset.form;
|
||||
try {
|
||||
switch (formName) {
|
||||
case "import-key":
|
||||
return await this.handlePreviewImport();
|
||||
case "create-name":
|
||||
return this.handleCreateNameSubmit();
|
||||
case "create-credential":
|
||||
@@ -188,6 +244,65 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live entropy/strength feedback for the manual-password field, updated by DIRECTLY
|
||||
* patching a feedback element's text — never calling this.render() — so the input never
|
||||
* loses focus or cursor position while the user is typing (§9.3).
|
||||
*/
|
||||
private onInput(e: Event): void {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!(target instanceof HTMLInputElement) || target.name !== "manualPassword") return;
|
||||
this.manualPasswordFeedback = target.value ? checkManualPassword(target.value, this.loginName) : null;
|
||||
const feedback = this.root.querySelector<HTMLElement>("#manual-password-feedback");
|
||||
if (!feedback) return;
|
||||
if (!this.manualPasswordFeedback) {
|
||||
feedback.textContent = "";
|
||||
feedback.className = "notice info";
|
||||
return;
|
||||
}
|
||||
const bits = this.manualPasswordFeedback.entropyBits.toFixed(0);
|
||||
if (this.manualPasswordFeedback.ok) {
|
||||
feedback.textContent = `Looks good (~${bits} bits estimated).`;
|
||||
feedback.className = "notice info";
|
||||
} else {
|
||||
feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`;
|
||||
feedback.className = "notice warn";
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePreviewImport(): Promise<void> {
|
||||
const pasted = this.field("importKey").trim();
|
||||
if (!pasted) {
|
||||
this.errorMessage = "Paste your nsec or hex private key first.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.setBusy(true);
|
||||
try {
|
||||
const preview = await this.worker.previewImportKey({ nsecOrHex: pasted });
|
||||
this.importKey = pasted;
|
||||
this.importPreviewNpub = preview.npub;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
} catch (err) {
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleImportContinue(): void {
|
||||
// Reached only after a successful preview; move into the normal name -> credential flow,
|
||||
// which will register with importKey set (§SF10).
|
||||
if (!this.importKey || !this.importPreviewNpub) {
|
||||
this.errorMessage = "Check your key before continuing.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.loginName = "";
|
||||
this.goto("create-name");
|
||||
}
|
||||
|
||||
private handleCreateNameSubmit(): void {
|
||||
const name = this.field("loginName").trim().toLowerCase();
|
||||
if (!isValidLoginName(name)) {
|
||||
@@ -198,18 +313,40 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.loginName = name;
|
||||
this.generatedCredential = generatePassphrase().secret;
|
||||
this.savedCheckbox = false;
|
||||
this.manualPasswordMode = false;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.goto("create-credential");
|
||||
}
|
||||
|
||||
private async handleCreateCredentialSubmit(): Promise<void> {
|
||||
let password = this.generatedCredential;
|
||||
if (this.manualPasswordMode) {
|
||||
const typed = this.field("manualPassword");
|
||||
const check = checkManualPassword(typed, this.loginName);
|
||||
if (!check.ok) {
|
||||
this.errorMessage = `Password not accepted: ${check.reason}`;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
password = typed;
|
||||
}
|
||||
this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false;
|
||||
if (!this.savedCheckbox) {
|
||||
this.errorMessage = "Please confirm you saved your password before continuing.";
|
||||
this.errorMessage = this.manualPasswordMode
|
||||
? "Please confirm you've saved your password (or written it down) before continuing."
|
||||
: "Please confirm you saved your password before continuing.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.setBusy(true);
|
||||
const result = await this.worker.register({ loginName: this.loginName, password: this.generatedCredential });
|
||||
const result = await this.worker.register({
|
||||
loginName: this.loginName,
|
||||
password,
|
||||
importKey: this.importKey || undefined
|
||||
});
|
||||
// The pasted key is now wrapped in the capsules; drop the main-thread copy (§11.10).
|
||||
this.importKey = "";
|
||||
this.importPreviewNpub = "";
|
||||
this.recoveryPhrase = result.recoveryPhrase;
|
||||
const words = this.recoveryPhrase.split(" ");
|
||||
const indices = pickRandomIndices(words.length, 3);
|
||||
@@ -217,11 +354,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.busy = false;
|
||||
this.goto("confirm-phrase");
|
||||
// §15.8/§19.6 — publish relay preferences immediately after successful registration.
|
||||
// §15.8/§19.6 — publish relay preferences immediately after successful registration,
|
||||
// defaulting the public profile name to the chosen login name so the account isn't
|
||||
// just a bare npub in every other Nostr client.
|
||||
void this.worker.publishProfileAndRelayLists({
|
||||
name: this.loginName,
|
||||
generalRelays: this.vaultRelayUrls,
|
||||
dmRelays: this.vaultRelayUrls
|
||||
});
|
||||
void this.offerToSaveCredential(this.loginName, password);
|
||||
}
|
||||
|
||||
private handleConfirmPhraseSubmit(): void {
|
||||
@@ -249,6 +390,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy = false;
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(loginName, password);
|
||||
}
|
||||
|
||||
private async handleRecoverPhraseSubmit(): Promise<void> {
|
||||
@@ -280,6 +422,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy = false;
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } }));
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(newLoginName, this.newCredentialAfterRecovery);
|
||||
}
|
||||
|
||||
private async handleChangePasswordSubmit(): Promise<void> {
|
||||
@@ -292,6 +435,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
});
|
||||
this.busy = false;
|
||||
this.goto("dashboard");
|
||||
void this.offerToSaveCredential(this.loginName, this.changePasswordNewCredential);
|
||||
}
|
||||
|
||||
private async handleDownloadRecoveryExport(): Promise<void> {
|
||||
@@ -338,13 +482,43 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
${this.renderError()}
|
||||
<button class="primary" data-action="goto-login">Sign in</button>
|
||||
<button class="secondary" data-action="goto-create">Create account</button>
|
||||
<button class="link" data-action="goto-import">Import an existing Nostr key</button>
|
||||
<button class="link" data-action="goto-recover">Forgot password? Recover with phrase</button>
|
||||
`;
|
||||
|
||||
case "import-key": {
|
||||
const previewed = !!this.importPreviewNpub;
|
||||
return `
|
||||
<h2>Import an existing Nostr key</h2>
|
||||
<p class="sub">Wrap a Nostr identity you already control in a BitLogin login name and password. Your key never changes — you just get a friendlier way in.</p>
|
||||
<div class="notice warn">Pasting a private key into any web page is risky. Only do this on a BitLogin build you trust, and clear your clipboard afterward. BitLogin can't secure copies of this key that already exist elsewhere.</div>
|
||||
${this.renderError()}
|
||||
<form data-form="import-key">
|
||||
<label for="importKey">Your nsec or hex private key</label>
|
||||
<input type="password" name="importKey" id="importKey" autocomplete="off" placeholder="nsec1… or 64-character hex" required />
|
||||
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Checking…' : "Check key"}
|
||||
</button>
|
||||
</form>
|
||||
${
|
||||
previewed
|
||||
? `<div class="notice info">This key's public identity:</div>
|
||||
<div class="credential-box">${escapeHtml(this.importPreviewNpub)}</div>
|
||||
<button class="primary" type="button" data-action="import-continue">This is my identity — continue</button>`
|
||||
: ""
|
||||
}
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
}
|
||||
|
||||
case "create-name":
|
||||
return `
|
||||
<h2>Create your BitLogin</h2>
|
||||
<p class="sub">Choose a login name. It's a convenience, not a secret (it contributes no security).</p>
|
||||
<h2>${this.importKey ? "Set up your login" : "Create your BitLogin"}</h2>
|
||||
<p class="sub">${
|
||||
this.importKey
|
||||
? "Choose a login name for your imported identity. It's a convenience, not a secret."
|
||||
: "Choose a login name. It's a convenience, not a secret (it contributes no security)."
|
||||
}</p>
|
||||
${this.renderError()}
|
||||
<form data-form="create-name">
|
||||
<label for="loginName">Login name</label>
|
||||
@@ -354,24 +528,51 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
|
||||
case "create-credential":
|
||||
case "create-credential": {
|
||||
const submitLabel = this.busy
|
||||
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
|
||||
: this.importKey
|
||||
? "Import account"
|
||||
: "Create account";
|
||||
|
||||
if (this.manualPasswordMode) {
|
||||
return `
|
||||
<h2>Choose your own password</h2>
|
||||
<p class="sub">Not recommended: a downloadable encrypted file can be guessed against forever, offline, with no rate limit. BitLogin can only run basic checks here — not a breach-database lookup — so weak or reused passwords are still your risk to carry.</p>
|
||||
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
|
||||
${this.renderError()}
|
||||
<form data-form="create-credential">
|
||||
<label for="manualPassword">Password</label>
|
||||
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" />
|
||||
<div class="notice info" id="manual-password-feedback"></div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||
I have saved this password somewhere safe.
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use a generated password instead (recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<h2>Your generated password</h2>
|
||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file. Manual passwords aren't allowed in this alpha.</p>
|
||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
|
||||
<div class="credential-box" id="credential-box">${escapeHtml(this.generatedCredential)}</div>
|
||||
<button class="secondary" type="button" data-action="copy-credential">Copy</button>
|
||||
<button class="secondary" type="button" data-action="regenerate-credential">Generate a different one</button>
|
||||
${this.renderError()}
|
||||
<form data-form="create-credential">
|
||||
<form data-form="create-credential" autocomplete="on">
|
||||
<input type="text" name="username" autocomplete="username" value="${escapeHtml(this.loginName)}" readonly hidden />
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||
I have saved this password somewhere safe.
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Creating account…' : "Create account"}
|
||||
</button>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use my own password instead (not recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
case "confirm-phrase":
|
||||
return `
|
||||
|
||||
@@ -5,6 +5,41 @@ if (!customElements.get("bitlogin-auth")) {
|
||||
customElements.define("bitlogin-auth", BitLoginAuthElement);
|
||||
}
|
||||
|
||||
export interface BitLoginGlobal {
|
||||
readonly version: string;
|
||||
/**
|
||||
* True when BitLogin currently occupies window.nostr. Only one provider can hold that
|
||||
* global at a time (a browser extension like Alby or nos2x may also implement it, or
|
||||
* install itself later and overwrite BitLogin's). This check survives that overwrite —
|
||||
* unlike checking `window.nostr._bitlogin` directly — since it's read fresh each call.
|
||||
*/
|
||||
isActiveSigner(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bitlogin?: BitLoginGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
// Installed once per page regardless of whether <bitlogin-auth> has been used yet, so a
|
||||
// host site can feature-detect BitLogin's presence and active-signer status independently
|
||||
// of window.nostr's current occupant (§26.2, §26.4 — "active signer" support for sites that
|
||||
// offer multiple signing methods: BitLogin, a NIP-07 extension, or a NIP-46 remote signer).
|
||||
//
|
||||
// This assignment is deliberately unconditional (not `if (!window.bitlogin)`): browsers
|
||||
// auto-expose any element with a matching `id` or `name` attribute as a same-named global
|
||||
// (e.g. `<div id="bitlogin">` becomes `window.bitlogin`). A guard would silently see that
|
||||
// element instead of ever installing this object on any page that happens to use that id —
|
||||
// including our own demo before it was renamed. A direct assignment creates a real own
|
||||
// property that takes priority over that fallback, on any page.
|
||||
window.bitlogin = {
|
||||
version: "0.1.0",
|
||||
isActiveSigner(): boolean {
|
||||
return (window as unknown as { nostr?: { _bitlogin?: boolean } }).nostr?._bitlogin === true;
|
||||
}
|
||||
};
|
||||
|
||||
export { BitLoginAuthElement } from "./element.js";
|
||||
export { WorkerClient } from "./worker/workerClient.js";
|
||||
export { createNip07Provider } from "./provider.js";
|
||||
|
||||
@@ -17,6 +17,15 @@ export interface Nip07Provider {
|
||||
encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
};
|
||||
/**
|
||||
* Identifies this provider as BitLogin's, distinct from a browser extension (Alby, nos2x,
|
||||
* …) that may also implement window.nostr. Only one provider can occupy window.nostr at a
|
||||
* time — this lets a host site that supports multiple signing methods tell which one is
|
||||
* actually active, e.g. `window.nostr?._bitlogin === true`. Prefer `window.bitlogin` (see
|
||||
* index.ts) for this check where available; it survives another provider later overwriting
|
||||
* window.nostr, which this flag on the object itself cannot.
|
||||
*/
|
||||
readonly _bitlogin: true;
|
||||
}
|
||||
|
||||
const NOT_UNLOCKED_MESSAGE =
|
||||
@@ -52,6 +61,7 @@ export function createNip07Provider(workerClient: WorkerClient, configuredRelays
|
||||
const { plaintext } = await wrapUnlockError(workerClient.nip44Decrypt({ peerPublicKey, payload }));
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
},
|
||||
_bitlogin: true
|
||||
};
|
||||
}
|
||||
@@ -15,10 +15,13 @@ import {
|
||||
buildProfileEvent,
|
||||
buildRecoveryExport,
|
||||
repairReplicas,
|
||||
importAccount,
|
||||
decodeEverydayPrivateKey,
|
||||
NostrSigner,
|
||||
type RecoveredIdentity
|
||||
} from "@bitlogin/core/account";
|
||||
import { RelayPool, countAcknowledgements, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr";
|
||||
import { getPublicKeyHex } from "@bitlogin/core/crypto";
|
||||
import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js";
|
||||
import type {
|
||||
WorkerRequest,
|
||||
@@ -32,7 +35,8 @@ import type {
|
||||
PublishProfilePayload,
|
||||
SignEventPayload,
|
||||
Nip44EncryptPayload,
|
||||
Nip44DecryptPayload
|
||||
Nip44DecryptPayload,
|
||||
PreviewImportKeyPayload
|
||||
} from "./protocol.js";
|
||||
|
||||
interface SessionState {
|
||||
@@ -94,7 +98,9 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
|
||||
case "register": {
|
||||
const p = payload as RegisterPayload;
|
||||
const result = await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls });
|
||||
const result = p.importKey
|
||||
? await importAccount({ nsecOrHex: p.importKey, loginName: p.loginName, password: p.password, vaultRelayUrls })
|
||||
: await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls });
|
||||
clearSession();
|
||||
session.signer = new NostrSigner(result.everydayPrivateKey);
|
||||
session.everydayPrivateKey = result.everydayPrivateKey;
|
||||
@@ -107,11 +113,23 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
everydayPublicKey: result.everydayPublicKey,
|
||||
recoveryPublicKey: result.recoveryPublicKey,
|
||||
accountId: result.accountId,
|
||||
imported: result.imported,
|
||||
credentialEventId: result.credentialEvent.id,
|
||||
recoveryEventId: result.recoveryEvent.id
|
||||
};
|
||||
}
|
||||
|
||||
case "previewImportKey": {
|
||||
// Validates and previews the npub WITHOUT publishing anything, so the user can
|
||||
// confirm they pasted the right key before committing to registration (§SF10).
|
||||
const p = payload as PreviewImportKeyPayload;
|
||||
const key = decodeEverydayPrivateKey(p.nsecOrHex);
|
||||
const everydayPublicKey = getPublicKeyHex(key);
|
||||
const preview = { everydayPublicKey, npub: encodeNpub(everydayPublicKey) };
|
||||
key.fill(0); // §11.10 — the previewed key is not retained
|
||||
return preview;
|
||||
}
|
||||
|
||||
case "login": {
|
||||
const p = payload as LoginPayload;
|
||||
const result = await loginWithPassword({ loginName: p.loginName, password: p.password, vaultRelayUrls, store });
|
||||
@@ -121,6 +139,7 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
session.accountId = result.accountId;
|
||||
session.recoveryPublicKey = result.recoveryPublicKey;
|
||||
session.activeCredentialEvent = result.credentialEvent;
|
||||
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
|
||||
return {
|
||||
everydayPublicKey: result.everydayPublicKey,
|
||||
accountId: result.accountId,
|
||||
@@ -179,6 +198,12 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
vaultRelayUrls,
|
||||
store
|
||||
});
|
||||
// Keep the session's capsule references current so a recovery export or replica
|
||||
// repair requested right after rotation (without an intervening re-login) still
|
||||
// finds the NEW credential capsule and the (unchanged) recovery capsule.
|
||||
session.activeCredentialEvent = result.newCredentialEvent;
|
||||
session.recoveryPublicKey = result.recoveryPublicKey;
|
||||
session.activeRecoveryEvent = result.recoveryCapsuleEvent;
|
||||
return {
|
||||
newLocatorPublicKey: result.newLocatorPublicKey,
|
||||
newGeneration: result.newGeneration,
|
||||
|
||||
@@ -19,16 +19,27 @@ export interface ConfigurePayload {
|
||||
export interface RegisterPayload {
|
||||
loginName: string;
|
||||
password: string;
|
||||
/** When present, import this existing identity (nsec or 64-char hex) instead of generating one (§SF10). */
|
||||
importKey?: string;
|
||||
}
|
||||
export interface RegisterResult {
|
||||
recoveryPhrase: string;
|
||||
everydayPublicKey: string;
|
||||
recoveryPublicKey: string;
|
||||
accountId: string;
|
||||
imported: boolean;
|
||||
credentialEventId: string;
|
||||
recoveryEventId: string;
|
||||
}
|
||||
|
||||
export interface PreviewImportKeyPayload {
|
||||
nsecOrHex: string;
|
||||
}
|
||||
export interface PreviewImportKeyResult {
|
||||
everydayPublicKey: string;
|
||||
npub: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
loginName: string;
|
||||
password: string;
|
||||
@@ -122,6 +133,7 @@ export interface SessionStatusResult {
|
||||
export type WorkerActionMap = {
|
||||
configure: [ConfigurePayload, Record<string, never>];
|
||||
register: [RegisterPayload, RegisterResult];
|
||||
previewImportKey: [PreviewImportKeyPayload, PreviewImportKeyResult];
|
||||
login: [LoginPayload, LoginResult];
|
||||
recover: [RecoverPayload, RecoverResult];
|
||||
completeRecovery: [CompleteRecoveryPayload, CompleteRecoveryResult];
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkerResponse,
|
||||
ConfigurePayload,
|
||||
RegisterPayload,
|
||||
PreviewImportKeyPayload,
|
||||
LoginPayload,
|
||||
RecoverPayload,
|
||||
CompleteRecoveryPayload,
|
||||
@@ -61,6 +62,9 @@ export class WorkerClient {
|
||||
register(payload: RegisterPayload) {
|
||||
return this.call("register", payload);
|
||||
}
|
||||
previewImportKey(payload: PreviewImportKeyPayload) {
|
||||
return this.call("previewImportKey", payload);
|
||||
}
|
||||
login(payload: LoginPayload) {
|
||||
return this.call("login", payload);
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"buildCommand": "npm run build -w @bitlogin/core && npm run build -w @bitlogin/widget && npm run build -w @bitlogin/demo",
|
||||
"outputDirectory": "packages/demo/dist",
|
||||
"cleanUrls": true
|
||||
"outputDirectory": "packages/demo/dist"
|
||||
}
|
||||
Reference in new issue
Block a user