Add nsec import: use an existing Nostr identity as the everyday key (§SF10)

core:
- registerAccount gains an optional everydayPrivateKey (validated scalar,
  used instead of generating); result carries an `imported` flag
- importAccount() + decodeEverydayPrivateKey() convenience helpers accept
  nsec (bech32) or 64-char hex, wiring in the existing decodeNsec primitive
- e2e tests: imported nsec round-trips to the same npub through both
  clean-device login and phrase recovery; malformed keys rejected

widget:
- worker gains a `previewImportKey` action (validate + preview npub without
  publishing) and passes importKey through `register`
- <bitlogin-auth> gets an "Import an existing Nostr key" screen: paste
  nsec/hex, check-key preview shows the resulting npub for confirmation,
  then the normal name -> password -> phrase flow, with the pasted key
  dropped from the main thread after registration (§11.10) and a
  risky-paste warning shown up front

docs: mark §SF10 implemented; note backup codes are redundant with the
phrase and unlikely to ship; README implemented-list updated.

Verified end-to-end in real Chromium (preview npub, import, clean-device
login all reproduce the imported identity). 51/51 tests pass.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
This commit is contained in:
Claude committed 2026-07-21 10:59:01 +00:00
1 parent 991c2ee639
commit a76c8484f7
8 files changed
+249 -17

No files matched your search

+5 -4
View File
@@ -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
+6 -2
View File
@@ -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
+48 -1
View File
@@ -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 () => {
+56 -4
View File
@@ -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;
}
+98 -4
View File
@@ -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<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)) {
@@ -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()}
<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>
@@ -368,7 +456,13 @@ export class BitLoginAuthElement extends HTMLElement {
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"}
${
this.busy
? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}`
: this.importKey
? "Import account"
: "Create account"
}
</button>
</form>
`;
+20 -2
View File
@@ -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 });
+12
View File
@@ -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);
}