mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
This commit is contained in:
1 file changed
+393
@@ -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": "<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
|
||||||
|
|
||||||
|
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.
|
||||||
|
```
|
||||||
Reference in new issue
Block a user