diff --git a/README.md b/README.md index 6738777..f253db1 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,11 @@ Implemented: registration (§15), any-device password login with rollback and relay-disagreement detection (§16), phrase recovery with recovery-capsule refresh (§17), known-password change with mandatory tombstone + NIP-09 deletion (§18), replica repair (§24.4), recovery export (§19.5), NIP-19 -nsec/npub encoding, a NIP-44 v2 implementation for the `window.nostr.nip44` -surface, bootstrap/discovery relay list plumbing (§19), and the full -crypto stack from §11 (Argon2id, HKDF, ScalarExpand, AES-256-GCM, JCS, -fixed padding buckets). +nsec/npub encoding, importing an existing Nostr `nsec` as the everyday +identity (§28.1, §SF10 — with a preview-before-commit UI step), a NIP-44 v2 +implementation for the `window.nostr.nip44` surface, bootstrap/discovery +relay list plumbing (§19), and the full crypto stack from §11 (Argon2id, +HKDF, ScalarExpand, AES-256-GCM, JCS, fixed padding buckets). **Deferred / not in this pass:** - Phase 2 messaging (NIP-17 inbox/compose) — the NIP-44 primitive is built diff --git a/docs/second-factor.md b/docs/second-factor.md index dfac628..4357a76 100644 --- a/docs/second-factor.md +++ b/docs/second-factor.md @@ -1,7 +1,11 @@ # BitLogin Second Factor, Backup Codes, and Imported Identities **Document version:** 0.1 (draft addendum to the v0.3 core specification) -**Status:** Design proposal — not implemented, not final +**Status:** Mixed. §SF10 (imported identities) is **implemented**. Phone-approval +sign-in (§SF5–SF7) and backup codes (§SF8) are **design proposals only** — and +backup codes are unlikely to ship, since the recovery phrase already covers the +same ground (see §SF8 note). The "why not TOTP" analysis (§SF2) is a settled +rejection, kept on the record. **Depends on:** `docs/spec.md` (§ references below point there unless marked §SF) --- @@ -307,7 +311,7 @@ Lost everything incl. phrase → unrecoverable (unchanged, §19.4) --- -# SF10. Imported identities +# SF10. Imported identities — IMPLEMENTED An existing Nostr identity may be used as the everyday identity under all of the above (§28.1 anticipated this; the everyday key is by design never diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts index 0aef20f..74808af 100644 --- a/packages/core/src/account/account.e2e.test.ts +++ b/packages/core/src/account/account.e2e.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { MockRelay } from "../test-support/mockRelay.js"; import { generatePassphrase } from "./passphrase.js"; -import { registerAccount } from "./create.js"; +import { registerAccount, importAccount, decodeEverydayPrivateKey } from "./create.js"; +import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; +import { encodeNsec, encodeNpub } from "../nostr/nip19.js"; +import { bytesToHex } from "../crypto/encoding.js"; import { loginWithPassword } from "./login.js"; import { recoverWithPhrase, completeRecoveryWithNewCredentials } from "./recover.js"; import { changePassword } from "./changePassword.js"; @@ -50,6 +53,50 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { ARGON2_TIMEOUT ); + it( + "import an existing nsec -> clean-device login and phrase recovery both reproduce the imported npub (§SF10)", + async () => { + // A pre-existing Nostr identity the user already controls. + const existingKey = generatePrivateKey(); + const existingPubkey = getPublicKeyHex(existingKey); + const nsec = encodeNsec(existingKey); + const npub = encodeNpub(existingPubkey); + + const loginName = "importer"; + const password = generatePassphrase().secret; + + // decodeEverydayPrivateKey accepts both nsec and 64-char hex forms. + const existingKeyHex = bytesToHex(existingKey); + expect(getPublicKeyHex(decodeEverydayPrivateKey(nsec))).toBe(existingPubkey); + expect(getPublicKeyHex(decodeEverydayPrivateKey(existingKeyHex))).toBe(existingPubkey); + + const registration = await importAccount({ nsecOrHex: nsec, loginName, password, vaultRelayUrls }); + expect(registration.imported).toBe(true); + expect(registration.everydayPublicKey).toBe(existingPubkey); + + // Clean-device password login yields the imported identity, unchanged. + const login = await loginWithPassword({ loginName, password, vaultRelayUrls }); + expect(login.everydayPublicKey).toBe(existingPubkey); + expect(encodeNpub(login.everydayPublicKey)).toBe(npub); + + // The freshly generated BitLogin phrase recovers the SAME imported identity. + const recovered = await recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls + }); + expect(recovered.everydayPublicKey).toBe(existingPubkey); + }, + ARGON2_TIMEOUT + ); + + it("rejects malformed keys on import (§SF10)", () => { + expect(() => decodeEverydayPrivateKey("not-a-key")).toThrow(); + expect(() => decodeEverydayPrivateKey("nsec1invalid")).toThrow(); + expect(() => decodeEverydayPrivateKey("00".repeat(32))).toThrow(); // zero scalar is invalid + expect(() => decodeEverydayPrivateKey("ab".repeat(20))).toThrow(); // wrong length hex + }); + it( "wrong password on a real account is indistinguishable from a nonexistent account (§16.3)", async () => { diff --git a/packages/core/src/account/create.ts b/packages/core/src/account/create.ts index 12d0634..abe4911 100644 --- a/packages/core/src/account/create.ts +++ b/packages/core/src/account/create.ts @@ -1,7 +1,8 @@ /** Registration flow (§15). Recovery capsule is built and published before the credential capsule. */ import { randomEntropy128, randomAccountId } from "../crypto/random.js"; -import { bytesToBase64url } from "../crypto/encoding.js"; -import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; +import { bytesToBase64url, hexToBytes } from "../crypto/encoding.js"; +import { generatePrivateKey, getPublicKeyHex, isValidScalar } from "../crypto/secp256k1.js"; +import { decodeNsec } from "../nostr/nip19.js"; import { entropyToRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js"; import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "./normalize.js"; import { RelayPool } from "../nostr/pool.js"; @@ -19,6 +20,13 @@ export interface RegisterAccountParams { /** A client-generated credential (§9.2); manual passwords are prohibited in the alpha (§9.3). */ password: string; vaultRelayUrls: string[]; + /** + * Optional existing everyday identity to wrap instead of generating a fresh one (§28.1, §SF10). + * The everyday key is never derived from any BitLogin secret (§7.2), so an imported key is + * indistinguishable to the rest of the protocol — only the origin differs. Must be a valid + * 32-byte secp256k1 scalar; the caller is responsible for decoding an `nsec` first. + */ + everydayPrivateKey?: Uint8Array; minAcknowledgements?: number; timeoutMs?: number; /** Testing hook; defaults to the current time. */ @@ -33,6 +41,8 @@ export interface RegisterAccountResult { recoveryPublicKey: string; locatorPublicKey: string; accountId: string; + /** True when the everyday identity was imported (§SF10) rather than freshly generated. */ + imported: boolean; credentialEvent: NostrEvent; recoveryEvent: NostrEvent; credentialPublish: PublishVerificationResult; @@ -49,8 +59,12 @@ export async function registerAccount(params: RegisterAccountParams): Promise & { nsecOrHex: string } +): Promise { + const { nsecOrHex, ...rest } = params; + const everydayPrivateKey = decodeEverydayPrivateKey(nsecOrHex); + return registerAccount({ ...rest, everydayPrivateKey }); +} + +/** Decodes an `nsec` or 64-char hex string to a validated 32-byte scalar (§SF10). */ +export function decodeEverydayPrivateKey(nsecOrHex: string): Uint8Array { + const trimmed = nsecOrHex.trim(); + let key: Uint8Array; + if (trimmed.startsWith("nsec1")) { + key = decodeNsec(trimmed); + } else if (/^[0-9a-fA-F]{64}$/u.test(trimmed)) { + key = hexToBytes(trimmed.toLowerCase()); + } else { + throw new RegistrationFailedError("Enter a valid nsec (nsec1…) or a 64-character hex private key."); + } + if (!isValidScalar(key)) { + throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key."); + } + return key; +} diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 57b77b8..497dddd 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -8,6 +8,7 @@ import { WIDGET_STYLES } from "./styles.js"; type Screen = | "welcome" + | "import-key" | "create-name" | "create-credential" | "confirm-phrase" @@ -39,6 +40,10 @@ export class BitLoginAuthElement extends HTMLElement { private recoveryPhrase = ""; private confirmSlots: ConfirmSlot[] = []; + // Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes. + private importKey = ""; + private importPreviewNpub = ""; + private recoverPhraseInput = ""; private recoveredPreview: { generalRelaysCount: number; dmRelaysCount: number; chainWarning?: string } | null = null; private newCredentialAfterRecovery = ""; @@ -116,8 +121,19 @@ export class BitLoginAuthElement extends HTMLElement { switch (action) { case "goto-create": this.loginName = ""; + this.importKey = ""; + this.importPreviewNpub = ""; this.goto("create-name"); return; + case "goto-import": + this.importKey = ""; + this.importPreviewNpub = ""; + this.goto("import-key"); + return; + case "preview-import": + return this.handlePreviewImport(); + case "import-continue": + return this.handleImportContinue(); case "goto-login": this.goto("login"); return; @@ -166,6 +182,8 @@ export class BitLoginAuthElement extends HTMLElement { const formName = form.dataset.form; try { switch (formName) { + case "import-key": + return await this.handlePreviewImport(); case "create-name": return this.handleCreateNameSubmit(); case "create-credential": @@ -188,6 +206,39 @@ export class BitLoginAuthElement extends HTMLElement { } } + private async handlePreviewImport(): Promise { + const pasted = this.field("importKey").trim(); + if (!pasted) { + this.errorMessage = "Paste your nsec or hex private key first."; + this.render(); + return; + } + this.setBusy(true); + try { + const preview = await this.worker.previewImportKey({ nsecOrHex: pasted }); + this.importKey = pasted; + this.importPreviewNpub = preview.npub; + this.busy = false; + this.render(); + } catch (err) { + this.importKey = ""; + this.importPreviewNpub = ""; + this.fail(err); + } + } + + private handleImportContinue(): void { + // Reached only after a successful preview; move into the normal name -> credential flow, + // which will register with importKey set (§SF10). + if (!this.importKey || !this.importPreviewNpub) { + this.errorMessage = "Check your key before continuing."; + this.render(); + return; + } + this.loginName = ""; + this.goto("create-name"); + } + private handleCreateNameSubmit(): void { const name = this.field("loginName").trim().toLowerCase(); if (!isValidLoginName(name)) { @@ -209,7 +260,14 @@ export class BitLoginAuthElement extends HTMLElement { return; } this.setBusy(true); - const result = await this.worker.register({ loginName: this.loginName, password: this.generatedCredential }); + const result = await this.worker.register({ + loginName: this.loginName, + password: this.generatedCredential, + importKey: this.importKey || undefined + }); + // The pasted key is now wrapped in the capsules; drop the main-thread copy (§11.10). + this.importKey = ""; + this.importPreviewNpub = ""; this.recoveryPhrase = result.recoveryPhrase; const words = this.recoveryPhrase.split(" "); const indices = pickRandomIndices(words.length, 3); @@ -338,13 +396,43 @@ export class BitLoginAuthElement extends HTMLElement { ${this.renderError()} + `; + case "import-key": { + const previewed = !!this.importPreviewNpub; + return ` +

Import an existing Nostr key

+

Wrap a Nostr identity you already control in a BitLogin login name and password. Your key never changes — you just get a friendlier way in.

+
Pasting a private key into any web page is risky. Only do this on a BitLogin build you trust, and clear your clipboard afterward. BitLogin can't secure copies of this key that already exist elsewhere.
+ ${this.renderError()} +
+ + + +
+ ${ + previewed + ? `
This key's public identity:
+
${escapeHtml(this.importPreviewNpub)}
+ ` + : "" + } + + `; + } + case "create-name": return ` -

Create your BitLogin

-

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

+

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

+

${ + this.importKey + ? "Choose a login name for your imported identity. It's a convenience, not a secret." + : "Choose a login name. It's a convenience, not a secret (it contributes no security)." + }

${this.renderError()}
@@ -368,7 +456,13 @@ export class BitLoginAuthElement extends HTMLElement { I have saved this password somewhere safe.
`; diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index f06cb37..f21bd10 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -15,10 +15,13 @@ import { buildProfileEvent, buildRecoveryExport, repairReplicas, + importAccount, + decodeEverydayPrivateKey, NostrSigner, type RecoveredIdentity } from "@bitlogin/core/account"; import { RelayPool, countAcknowledgements, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr"; +import { getPublicKeyHex } from "@bitlogin/core/crypto"; import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js"; import type { WorkerRequest, @@ -32,7 +35,8 @@ import type { PublishProfilePayload, SignEventPayload, Nip44EncryptPayload, - Nip44DecryptPayload + Nip44DecryptPayload, + PreviewImportKeyPayload } from "./protocol.js"; interface SessionState { @@ -94,7 +98,9 @@ async function handle(action: string, payload: unknown): Promise { case "register": { const p = payload as RegisterPayload; - const result = await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls }); + const result = p.importKey + ? await importAccount({ nsecOrHex: p.importKey, loginName: p.loginName, password: p.password, vaultRelayUrls }) + : await registerAccount({ loginName: p.loginName, password: p.password, vaultRelayUrls }); clearSession(); session.signer = new NostrSigner(result.everydayPrivateKey); session.everydayPrivateKey = result.everydayPrivateKey; @@ -107,11 +113,23 @@ async function handle(action: string, payload: unknown): Promise { everydayPublicKey: result.everydayPublicKey, recoveryPublicKey: result.recoveryPublicKey, accountId: result.accountId, + imported: result.imported, credentialEventId: result.credentialEvent.id, recoveryEventId: result.recoveryEvent.id }; } + case "previewImportKey": { + // Validates and previews the npub WITHOUT publishing anything, so the user can + // confirm they pasted the right key before committing to registration (§SF10). + const p = payload as PreviewImportKeyPayload; + const key = decodeEverydayPrivateKey(p.nsecOrHex); + const everydayPublicKey = getPublicKeyHex(key); + const preview = { everydayPublicKey, npub: encodeNpub(everydayPublicKey) }; + key.fill(0); // §11.10 — the previewed key is not retained + return preview; + } + case "login": { const p = payload as LoginPayload; const result = await loginWithPassword({ loginName: p.loginName, password: p.password, vaultRelayUrls, store }); diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 45a5877..87d048e 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -19,16 +19,27 @@ export interface ConfigurePayload { export interface RegisterPayload { loginName: string; password: string; + /** When present, import this existing identity (nsec or 64-char hex) instead of generating one (§SF10). */ + importKey?: string; } export interface RegisterResult { recoveryPhrase: string; everydayPublicKey: string; recoveryPublicKey: string; accountId: string; + imported: boolean; credentialEventId: string; recoveryEventId: string; } +export interface PreviewImportKeyPayload { + nsecOrHex: string; +} +export interface PreviewImportKeyResult { + everydayPublicKey: string; + npub: string; +} + export interface LoginPayload { loginName: string; password: string; @@ -122,6 +133,7 @@ export interface SessionStatusResult { export type WorkerActionMap = { configure: [ConfigurePayload, Record]; register: [RegisterPayload, RegisterResult]; + previewImportKey: [PreviewImportKeyPayload, PreviewImportKeyResult]; login: [LoginPayload, LoginResult]; recover: [RecoverPayload, RecoverResult]; completeRecovery: [CompleteRecoveryPayload, CompleteRecoveryResult]; diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 8f0f0fb..b6c4997 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -6,6 +6,7 @@ import type { WorkerResponse, ConfigurePayload, RegisterPayload, + PreviewImportKeyPayload, LoginPayload, RecoverPayload, CompleteRecoveryPayload, @@ -61,6 +62,9 @@ export class WorkerClient { register(payload: RegisterPayload) { return this.call("register", payload); } + previewImportKey(payload: PreviewImportKeyPayload) { + return this.call("previewImportKey", payload); + } login(payload: LoginPayload) { return this.call("login", payload); }