From 8c01a121df7dafa40553fe8add6c36ea7579c31f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:39:00 -0400 Subject: [PATCH] Never overwrite an existing profile or relay list on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After every successful registration -- including nsec imports -- the widget unconditionally published a default kind 0 profile ({name: loginName}) plus kind 10002/10050 relay lists. All three are NIP-01/NIP-65/NIP-17 replaceable events, so importing an nsec that already had a real profile (display name, bio, avatar, NIP-05) and/or relay preferences published elsewhere silently erased all of it, replacing it with just the chosen BitLogin login name and the vault relays. publishInitialProfile() (moved into @bitlogin/core so it can be covered by the existing MockRelay-based test harness) now queries for an existing event of each kind before publishing, and only fills in whichever one is genuinely missing. A fresh registration has nothing to find, so behavior there is unchanged; an import now leaves whatever the identity already had untouched. Bumps docs/spec.md to v0.5 (§15.8, §28.1) and adds two new core tests covering the fresh-identity and existing-profile-preserved cases. --- README.md | 10 +- docs/spec.md | 16 ++- .../core/src/account/profileEvents.test.ts | 105 ++++++++++++++++++ packages/core/src/account/profileEvents.ts | 85 ++++++++++++++ packages/widget/src/element.ts | 4 +- packages/widget/src/worker/cryptoWorker.ts | 39 ++----- packages/widget/src/worker/protocol.ts | 6 + 7 files changed, 229 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/account/profileEvents.test.ts diff --git a/README.md b/README.md index d355125..1cb677d 100644 --- a/README.md +++ b/README.md @@ -199,14 +199,16 @@ routing. This build covers the protocol's Phase 0 (cryptographic core + test vectors) and the load-bearing parts of Phase 1 (account MVP), verified two ways: -- **Protocol-level tests** (`npm test`, 75 tests): Argon2id/ScalarExpand/JCS/ +- **Protocol-level tests** (`npm test`, 77 tests): Argon2id/ScalarExpand/JCS/ padding/timestamp/NIP-44 unit tests with the spec's exact byte layouts (§11), and full end-to-end scenarios — create → clean-device login, phrase recovery → new credentials → clean-device login, password change with mandatory tombstone + NIP-09 deletion, relay-loss → replica repair - (including keyless recovery-event rebroadcast), and generation-rollback - detection now failing closed by default rather than only warning (§16.2 - step 6) — run against an in-memory mock relay + (including keyless recovery-event rebroadcast), generation-rollback + detection failing closed by default rather than only warning (§16.2 step + 6), and initial profile/relay-list publication never overwriting an + existing kind 0/10002/10050 event when the everyday identity is an + imported nsec (§15.8, §28.1) — run against an in-memory mock relay (`packages/core/src/test-support/mockRelay.ts`), since this sandbox has no route to public Nostr relays. NIP-44 has additionally been cross-tested directly against the `nostr-tools` reference implementation (conversation diff --git a/docs/spec.md b/docs/spec.md index 828e321..f62b065 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2,7 +2,7 @@ ## Static, Relay-Backed Portable Account Protocol -**Document version:** 0.4 +**Document version:** 0.5 **Status:** Revised MVP specification (incorporates external protocol review) **Date:** July 21, 2026 **Client architecture:** Static progressive web application @@ -14,6 +14,12 @@ --- +## Changes from v0.4 + +1. **Initial profile publication no longer overwrites an existing profile.** §15.8's kind `0`/`10002`/`10050` publication previously ran unconditionally after every successful registration, including nsec imports — since all three are NIP-01/NIP-65/NIP-17 *replaceable* events, this silently erased an imported identity's real, pre-existing profile and relay lists, replacing them with BitLogin defaults (just the chosen login name as `name`, and only the vault relays). The client now queries for an existing event of each kind before publishing and only fills in whichever one comes back empty, never overwriting one that already exists (§15.8, §28.1). + +--- + ## Changes from v0.3 1. **Rollback detection is now enforced, not just displayed.** §16.2 step 6 previously surfaced a regression against the local generation high-water mark as a warning string while still granting the session — meaning an old, rotated-away password replayed from a relay that never processed its tombstone (§18.1) could fully unlock the account on any device that happened to query that relay. The client now fails closed by default in this case (`RollbackDetectedError`), on both password login and password change; a caller may explicitly override this (`acknowledgeRollback`) for the narrower, non-adversarial case of a single relay merely lagging behind on the *same* password's own capsule. This closes the gap between §16.2's stated guarantee ("a single relay withholding the newest capsule cannot silently roll the account back on a device that has seen the newer generation") and what the reference implementation actually did. The brand-new-device weak case described in §16.2 is unchanged by this — a device with no prior high-water mark still has only the quorum requirement. @@ -1021,6 +1027,8 @@ After successful capsule backup, the client may publish: NIP-65 uses kind `10002` for general read/write relay preferences and recommends small relay lists. NIP-17 uses kind `10050` for preferred private-message relays. These public events are also what phrase recovery uses to restore relay preferences (§17.4), so publishing kinds `10002` and `10050` is required, not optional, in the MVP — and they must be published to the discovery relays (§19.6) in addition to the user's own general relays, so that phrase recovery has a defined place to find them. +Kinds `0`, `10002`, and `10050` are all NIP-01/NIP-65/NIP-17 *replaceable* events: a relay keeps only the newest one it has seen for a given public key, and every other client treats that newest copy as authoritative. This matters because the everyday identity behind a BitLogin account is not always brand new (§28.1) — an imported `nsec` may already have a real kind `0` profile (display name, bio, avatar, NIP-05) and/or kind `10002`/`10050` lists published elsewhere. Before publishing any of the three, the client **must** query the target relays for an existing event of that kind for this public key; it publishes a default only for whichever kind comes back empty, and never overwrites one that already exists. A freshly generated identity has nothing to find, so this check is a no-op for the common case and only changes behavior for imports. + Everyday-identity events shall not be published over the same relay connections used for capsule operations in the same session (§23.4). --- @@ -1538,6 +1546,8 @@ Users who have not saved a recovery export receive a periodic, dismissible remin The client may allow the user to import an existing raw `nsec`, hexadecimal private key, or `ncryptsec`. The imported identity becomes the everyday identity and is wrapped by newly generated BitLogin password and recovery capsules. +Wrapping the key in new capsules must never touch anything the identity already had published under its own name. In particular, the initial-profile-publication step (§15.8) applies its existing-event check here specifically: an imported identity commonly already has a real kind `0` profile and/or relay lists, and the client must leave all of that exactly as it found it rather than replacing it with BitLogin defaults. + ## 28.2 NIP-49 export NIP-49 defines the `ncryptsec` format using scrypt and XChaCha20-Poly1305 for password-encrypted Nostr private keys. BitLogin may use it for user-controlled export even though the internal roaming capsule uses a separate versioned format. @@ -1761,6 +1771,8 @@ Resolved since v0.2: padding buckets and relay-limit compliance (§11.8); replac Resolved since v0.3: generation-rollback detection now enforced (fail-closed) rather than warn-only (§16.2); NIP-44 extended-length payload support, verified against the `nostr-tools` reference implementation; element-scoped `nip44Encrypt`/`nip44Decrypt` on `` (see README). +Resolved since v0.4: initial profile publication (§15.8) now checks for an existing kind `0`/`10002`/`10050` event before publishing and never overwrites one that already exists, closing the profile-clobbering gap for imported identities (§28.1). + Still open before implementation: 1. Which bootstrap relays ship with the client, and who holds the maintainer relay-list key (single key, or threshold)? @@ -1771,7 +1783,7 @@ Still open before implementation: 6. Should the local cache be password-encrypted or passkey-encrypted? 7. Should an existing `nsec` be importable during the MVP? 8. Should the MVP include a basic NIP-17 inbox, or ship account-only first? -9. Should public-profile creation be optional (note: kinds 10002/10050 are required regardless, §15.8)? +9. Should public-profile creation be optional (note: kinds 10002/10050 are required regardless, §15.8)? Independent of this, publishing now never overwrites an existing profile/relay-list event (§15.8) — this decision is only about whether to publish a *default* at all when nothing already exists. 10. Should BitLogin operate a dedicated archival vault relay? 11. Should future password login offer an optional OPAQUE overlay for hosted deployments that want revocable credentials? (Framed as an overlay, not a protocol evolution: OPAQUE reintroduces exactly the server dependency the base protocol exists to eliminate, so it must remain strictly optional and interoperable with pure-relay login.) 12. Should identity rotation be included before public beta? diff --git a/packages/core/src/account/profileEvents.test.ts b/packages/core/src/account/profileEvents.test.ts new file mode 100644 index 0000000..02d61aa --- /dev/null +++ b/packages/core/src/account/profileEvents.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { MockRelay } from "../test-support/mockRelay.js"; +import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; +import { RelayPool } from "../nostr/pool.js"; +import { KIND_PROFILE, KIND_RELAY_LIST, KIND_DM_RELAY_LIST } from "../nostr/kinds.js"; +import { buildProfileEvent, buildRelayListEvent, buildDmRelayListEvent, publishInitialProfile } from "./profileEvents.js"; + +describe("publishInitialProfile (§15.8, §28.1)", () => { + let relays: MockRelay[] = []; + let relayUrls: string[] = []; + + beforeEach(async () => { + relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + relayUrls = relays.map((r) => r.url); + }); + + afterEach(async () => { + await Promise.all(relays.map((r) => r.close())); + }); + + it("publishes default profile and relay lists for a genuinely new identity", async () => { + const privateKey = generatePrivateKey(); + const result = await publishInitialProfile({ + everydayPrivateKey: privateKey, + name: "river", + generalRelays: relayUrls, + dmRelays: relayUrls, + discoveryRelays: [] + }); + + expect(result.profilePublished).toBe(true); + expect(result.profileSkippedExisting).toBe(false); + expect(result.relayListSkippedExisting).toBe(false); + expect(result.dmRelayListSkippedExisting).toBe(false); + expect(result.relayListAcknowledgedCount).toBeGreaterThanOrEqual(2); + expect(result.dmRelayListAcknowledgedCount).toBeGreaterThanOrEqual(2); + + const pool = new RelayPool(relayUrls); + const pubkey = getPublicKeyHex(privateKey); + const profile = await pool.queryQuorum({ authors: [pubkey], kinds: [KIND_PROFILE] }); + pool.closeAll(); + const content = JSON.parse(profile.outcomes.find((o) => o.events.length > 0)!.events[0]!.content); + expect(content.name).toBe("river"); + }); + + it("never overwrites an existing profile when the everyday identity is an imported nsec (§28.1)", async () => { + const privateKey = generatePrivateKey(); + const now = Math.floor(Date.now() / 1000); + + // Simulate an nsec that already has a real profile published elsewhere, before it is + // ever imported into BitLogin. + const existingProfileEvent = buildProfileEvent( + privateKey, + { name: "realname", display_name: "Real Name", about: "An existing Nostr user", picture: "https://example.com/avatar.png" }, + now + ); + const existingRelayListEvent = buildRelayListEvent(privateKey, [{ url: "wss://existing-relay.example", read: true, write: true }], now); + const existingDmRelayListEvent = buildDmRelayListEvent(privateKey, ["wss://existing-dm-relay.example"], now); + + const seedPool = new RelayPool(relayUrls); + await Promise.all([ + seedPool.publishAll(existingProfileEvent), + seedPool.publishAll(existingRelayListEvent), + seedPool.publishAll(existingDmRelayListEvent) + ]); + seedPool.closeAll(); + + // BitLogin import flow now runs its default "seed a profile" step -- this must be a no-op + // for all three, since the identity already has real data. + const result = await publishInitialProfile({ + everydayPrivateKey: privateKey, + name: "imported-login-name", + generalRelays: relayUrls, + dmRelays: relayUrls, + discoveryRelays: [] + }); + + expect(result.profilePublished).toBe(false); + expect(result.profileSkippedExisting).toBe(true); + expect(result.relayListSkippedExisting).toBe(true); + expect(result.dmRelayListSkippedExisting).toBe(true); + + const pool = new RelayPool(relayUrls); + const pubkey = getPublicKeyHex(privateKey); + const [profile, relayList, dmRelayList] = await Promise.all([ + pool.queryQuorum({ authors: [pubkey], kinds: [KIND_PROFILE] }), + pool.queryQuorum({ authors: [pubkey], kinds: [KIND_RELAY_LIST] }), + pool.queryQuorum({ authors: [pubkey], kinds: [KIND_DM_RELAY_LIST] }) + ]); + pool.closeAll(); + + // The original profile survives untouched -- not replaced with "imported-login-name". + const survivingProfile = profile.outcomes.find((o) => o.events.length > 0)!.events[0]!; + const survivingContent = JSON.parse(survivingProfile.content); + expect(survivingContent.name).toBe("realname"); + expect(survivingContent.about).toBe("An existing Nostr user"); + expect(survivingContent.picture).toBe("https://example.com/avatar.png"); + + const survivingRelayList = relayList.outcomes.find((o) => o.events.length > 0)!.events[0]!; + expect(survivingRelayList.tags.some((t) => t[1] === "wss://existing-relay.example")).toBe(true); + + const survivingDmRelayList = dmRelayList.outcomes.find((o) => o.events.length > 0)!.events[0]!; + expect(survivingDmRelayList.tags.some((t) => t[1] === "wss://existing-dm-relay.example")).toBe(true); + }); +}); diff --git a/packages/core/src/account/profileEvents.ts b/packages/core/src/account/profileEvents.ts index b4eb392..7404e08 100644 --- a/packages/core/src/account/profileEvents.ts +++ b/packages/core/src/account/profileEvents.ts @@ -1,6 +1,7 @@ /** Public everyday-identity events excluded from the credential capsule by design (§12.2, §15.8, §19.6). */ import { signNostrEvent, type NostrEvent } from "../nostr/event.js"; import { KIND_PROFILE, KIND_RELAY_LIST, KIND_DM_RELAY_LIST } from "../nostr/kinds.js"; +import { RelayPool, countAcknowledgements, type RelayPublishOutcome } from "../nostr/pool.js"; import { getPublicKeyHex } from "../crypto/secp256k1.js"; export interface ProfileMetadata { @@ -59,3 +60,87 @@ export function parseRelayListEvent(event: NostrEvent): string[] { export function parseDmRelayListEvent(event: NostrEvent): string[] { return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]!); } + +export interface PublishInitialProfileParams { + everydayPrivateKey: Uint8Array; + name?: string; + about?: string; + picture?: string; + generalRelays: string[]; + dmRelays: string[]; + discoveryRelays: string[]; +} + +export interface PublishInitialProfileResult { + profilePublished: boolean; + relayListAcknowledgedCount: number; + dmRelayListAcknowledgedCount: number; + /** True when an existing kind 0 profile was already found for this identity, so it was left untouched (§28.1). */ + profileSkippedExisting: boolean; + /** True when an existing NIP-65 relay list was already found and left untouched (§28.1). */ + relayListSkippedExisting: boolean; + /** True when an existing NIP-17 DM relay list was already found and left untouched (§28.1). */ + dmRelayListSkippedExisting: boolean; +} + +/** + * Publishes default kind 0 / 10002 / 10050 events for a freshly registered or imported + * everyday identity, but never over an event that already exists for this public key — + * all three are NIP-01/NIP-65/NIP-17 *replaceable* events, so an imported identity's real + * profile and relay lists would otherwise be silently superseded by these defaults (§15.8, + * §28.1). + */ +export async function publishInitialProfile(params: PublishInitialProfileParams): Promise { + const pubkeyHex = getPublicKeyHex(params.everydayPrivateKey); + const now = Math.floor(Date.now() / 1000); + const targets = [...new Set([...params.generalRelays, ...params.dmRelays, ...params.discoveryRelays])]; + + const probePool = new RelayPool(targets); + const [existingProfile, existingRelayList, existingDmRelayList] = await Promise.all([ + probePool.queryQuorum({ authors: [pubkeyHex], kinds: [KIND_PROFILE] }), + probePool.queryQuorum({ authors: [pubkeyHex], kinds: [KIND_RELAY_LIST] }), + probePool.queryQuorum({ authors: [pubkeyHex], kinds: [KIND_DM_RELAY_LIST] }) + ]); + probePool.closeAll(); + const hasExistingProfile = existingProfile.outcomes.some((o) => o.events.length > 0); + const hasExistingRelayList = existingRelayList.outcomes.some((o) => o.events.length > 0); + const hasExistingDmRelayList = existingDmRelayList.outcomes.some((o) => o.events.length > 0); + + const pool = new RelayPool(targets); + let profileOutcomes: RelayPublishOutcome[] | null = null; + let relayListOutcomes: RelayPublishOutcome[] | null = null; + let dmRelayListOutcomes: RelayPublishOutcome[] | null = null; + + const publishes: Promise[] = []; + if (!hasExistingProfile && (params.name || params.about || params.picture)) { + const profileEvent = buildProfileEvent( + params.everydayPrivateKey, + { name: params.name, about: params.about, picture: params.picture }, + now + ); + publishes.push(pool.publishAll(profileEvent).then((o) => void (profileOutcomes = o))); + } + if (!hasExistingRelayList) { + const relayListEvent = buildRelayListEvent( + params.everydayPrivateKey, + params.generalRelays.map((url) => ({ url, read: true, write: true })), + now + ); + publishes.push(pool.publishAll(relayListEvent).then((o) => void (relayListOutcomes = o))); + } + if (!hasExistingDmRelayList) { + const dmRelayListEvent = buildDmRelayListEvent(params.everydayPrivateKey, params.dmRelays, now); + publishes.push(pool.publishAll(dmRelayListEvent).then((o) => void (dmRelayListOutcomes = o))); + } + await Promise.all(publishes); + pool.closeAll(); + + return { + profilePublished: profileOutcomes !== null && countAcknowledgements(profileOutcomes) > 0, + relayListAcknowledgedCount: relayListOutcomes !== null ? countAcknowledgements(relayListOutcomes) : 0, + dmRelayListAcknowledgedCount: dmRelayListOutcomes !== null ? countAcknowledgements(dmRelayListOutcomes) : 0, + profileSkippedExisting: hasExistingProfile, + relayListSkippedExisting: hasExistingRelayList, + dmRelayListSkippedExisting: hasExistingDmRelayList + }; +} diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index e8e3d7b..946f449 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -605,7 +605,9 @@ export class BitLoginAuthElement extends HTMLElement { this.goto("confirm-phrase"); // §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. + // just a bare npub in every other Nostr client. This same call also runs after + // importing an existing nsec; the worker checks for an existing profile/relay list + // first and never overwrites one, so an imported identity's real profile survives (§28.1). void this.worker.publishProfileAndRelayLists({ name: this.loginName, generalRelays: this.vaultRelayUrls, diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index 6559ba4..c03e840 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -10,9 +10,7 @@ import { recoverWithPhrase, completeRecoveryWithNewCredentials, changePassword as changePasswordFlow, - buildRelayListEvent, - buildDmRelayListEvent, - buildProfileEvent, + publishInitialProfile, buildRecoveryExport, repairReplicas, importAccount, @@ -20,7 +18,7 @@ import { 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 { RelayPool, 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 { @@ -227,32 +225,15 @@ async function handle(action: string, payload: unknown): Promise { case "publishProfileAndRelayLists": { const p = payload as PublishProfilePayload; const { everydayPrivateKey } = requireUnlocked(); - const now = Math.floor(Date.now() / 1000); - const events: NostrEvent[] = []; - let profilePublished = false; - if (p.name || p.about || p.picture) { - events.push(buildProfileEvent(everydayPrivateKey, { name: p.name, about: p.about, picture: p.picture }, now)); - } - const relayListEvent = buildRelayListEvent( + return publishInitialProfile({ everydayPrivateKey, - p.generalRelays.map((url) => ({ url, read: true, write: true })), - now - ); - const dmRelayListEvent = buildDmRelayListEvent(everydayPrivateKey, p.dmRelays, now); - - // §19.6: publish to both the user's general relays and the discovery relays. - const targets = [...new Set([...p.generalRelays, ...p.dmRelays, ...discoveryRelayUrls])]; - const pool = new RelayPool(targets); - const outcomes = await Promise.all([ - ...events.map((e) => pool.publishAll(e)), - pool.publishAll(relayListEvent), - pool.publishAll(dmRelayListEvent) - ]); - pool.closeAll(); - profilePublished = events.length > 0 && countAcknowledgements(outcomes[0]!) > 0; - const relayListAcknowledgedCount = countAcknowledgements(outcomes[outcomes.length - 2]!); - const dmRelayListAcknowledgedCount = countAcknowledgements(outcomes[outcomes.length - 1]!); - return { profilePublished, relayListAcknowledgedCount, dmRelayListAcknowledgedCount }; + name: p.name, + about: p.about, + picture: p.picture, + generalRelays: p.generalRelays, + dmRelays: p.dmRelays, + discoveryRelays: discoveryRelayUrls + }); } case "getPublicKey": { diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 375ac60..857544c 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -108,6 +108,12 @@ export interface PublishProfileResult { profilePublished: boolean; relayListAcknowledgedCount: number; dmRelayListAcknowledgedCount: number; + /** True when an existing kind:0 profile was already found for this identity, so BitLogin left it untouched (§28.1). */ + profileSkippedExisting: boolean; + /** True when an existing NIP-65 relay list was already found and left untouched (§28.1). */ + relayListSkippedExisting: boolean; + /** True when an existing NIP-17 DM relay list was already found and left untouched (§28.1). */ + dmRelayListSkippedExisting: boolean; } export interface SignEventPayload {