Refuse to overwrite an existing account on register or password change

registerAccount() never checked whether a credential capsule already
existed at the locator address it was about to publish to. That
address is a NIP-33 replaceable event fully determined by login name +
password, so registering with a login name + password combination that
was already registered -- by mistake (double-clicking "create account",
confusing it with "sign in"), or by coincidence with someone else's
account under a shared login name -- silently replaced the existing
account's identity binding. The old everyday identity became
unreachable through that login unless the original recovery phrase was
still on hand, with nothing warning the user beforehand.

changePassword() had the identical gap on the new locator: rotating to
a password that happened to already be registered under the same login
name would silently destroy that other account.

Both flows now query for an existing capsule at the target locator
before publishing, and refuse with AccountAlreadyExistsError if one is
found (or a retry-oriented error if no relay could be reached to check).
Two new end-to-end tests reproduce both scenarios and confirm the
pre-existing account survives untouched.

Bumps docs/spec.md to v0.6 (§15.6, §18.1).
This commit is contained in:
thePR0M3TH3AN committed 2026-07-21 16:54:32 -04:00
1 parent 8c01a121df
commit 01fc7c5ed3
6 files changed
+158 -15

No files matched your search

+6 -4
View File
@@ -199,16 +199,18 @@ routing.
This build covers the protocol's Phase 0 (cryptographic core + test vectors) 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: and the load-bearing parts of Phase 1 (account MVP), verified two ways:
- **Protocol-level tests** (`npm test`, 77 tests): Argon2id/ScalarExpand/JCS/ - **Protocol-level tests** (`npm test`, 79 tests): Argon2id/ScalarExpand/JCS/
padding/timestamp/NIP-44 unit tests with the spec's exact byte layouts 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 (§11), and full end-to-end scenarios — create → clean-device login, phrase
recovery → new credentials → clean-device login, password change with recovery → new credentials → clean-device login, password change with
mandatory tombstone + NIP-09 deletion, relay-loss → replica repair mandatory tombstone + NIP-09 deletion, relay-loss → replica repair
(including keyless recovery-event rebroadcast), generation-rollback (including keyless recovery-event rebroadcast), generation-rollback
detection failing closed by default rather than only warning (§16.2 step detection failing closed by default rather than only warning (§16.2 step
6), and initial profile/relay-list publication never overwriting an 6), initial profile/relay-list publication never overwriting an existing
existing kind 0/10002/10050 event when the everyday identity is an kind 0/10002/10050 event when the everyday identity is an imported nsec
imported nsec (§15.8, §28.1) — run against an in-memory mock relay (§15.8, §28.1), and registration/password-rotation refusing to overwrite
another account already registered at the same locator address (§15.6,
§18.1) — run against an in-memory mock relay
(`packages/core/src/test-support/mockRelay.ts`), since this sandbox has no (`packages/core/src/test-support/mockRelay.ts`), since this sandbox has no
route to public Nostr relays. NIP-44 has additionally been cross-tested route to public Nostr relays. NIP-44 has additionally been cross-tested
directly against the `nostr-tools` reference implementation (conversation directly against the `nostr-tools` reference implementation (conversation
+28 -6
View File
@@ -2,7 +2,7 @@
## Static, Relay-Backed Portable Account Protocol ## Static, Relay-Backed Portable Account Protocol
**Document version:** 0.5 **Document version:** 0.6
**Status:** Revised MVP specification (incorporates external protocol review) **Status:** Revised MVP specification (incorporates external protocol review)
**Date:** July 21, 2026 **Date:** July 21, 2026
**Client architecture:** Static progressive web application **Client architecture:** Static progressive web application
@@ -14,6 +14,12 @@
--- ---
## Changes from v0.5
1. **Registration and password rotation refuse to overwrite an existing account.** Neither `registerAccount` nor `changePassword` checked whether a credential capsule already existed at the locator address they were about to publish to. Since that address is a NIP-33 replaceable event fully determined by login name + password, registering (or rotating to a new password) with a login name + password combination that was already registered — by the same user re-registering by mistake, or by coincidence with someone else's account under a shared login name — silently destroyed the pre-existing account's identity binding, with no way back short of its own recovery phrase. Both flows now query for an existing capsule at the target locator first and refuse with `AccountAlreadyExistsError` if one is found, or with a retry-oriented error if no relay could be reached to check (§15.6, §18.1).
---
## Changes from v0.4 ## 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). 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).
@@ -1002,7 +1008,20 @@ The client:
## 15.6 Publication ## 15.6 Publication
Both events are published to the configured vault relays. Before publishing anything, the client checks whether a credential capsule already exists
at the locator address this login name + password derive to. That address is a NIP-33
replaceable event: publishing over one that already exists there would silently destroy
whatever account is already bound to it, with no way back short of that other account's
own recovery phrase. Any validly signed event found at the address — decryptable by this
client or not — refuses registration outright (`AccountAlreadyExistsError`); if no relay
can be reached to check, registration also refuses rather than proceed on an unverifiable
assumption of emptiness. This check requires knowing the same login name + password an
existing account was registered with, so it is not subject to §16.3's login/no-account
non-disclosure rule — reaching this state is already equivalent to being able to log in
as that account.
Once the check passes, both the recovery and credential events are published to the
configured vault relays.
The registration succeeds only when: The registration succeeds only when:
@@ -1173,10 +1192,11 @@ The client:
1. Unlocks the everyday identity. 1. Unlocks the everyday identity.
2. Derives the new locator and capsule key. 2. Derives the new locator and capsule key.
3. Publishes a new credential capsule with incremented generation and `created_at` per §24.6. 3. Checks whether a credential capsule already exists at the *new* locator address (§15.6's same reasoning: this address is fully determined by login name + the chosen new password, and is just as replaceable). If one is found, this refuses with `AccountAlreadyExistsError` rather than proceed — the new password happens to already be registered under this login name by a different account, and rotating onto it would silently destroy that other account's capsule.
4. **Mandatorily** publishes a tombstone at the old locator: a replacement kind-30078 event with the same `d` tag, signed by the old locator key, whose content is an empty envelope. Addressable replacement propagates the tombstone to cooperative relays. 4. Publishes a new credential capsule with incremented generation and `created_at` per §24.6.
5. **Mandatorily** publishes a NIP-09 deletion request from the old locator for the old capsule event. 5. **Mandatorily** publishes a tombstone at the old locator: a replacement kind-30078 event with the same `d` tag, signed by the old locator key, whose content is an empty envelope. Addressable replacement propagates the tombstone to cooperative relays.
6. Deletes the old local cache and updates the generation high-water mark. 6. **Mandatorily** publishes a NIP-09 deletion request from the old locator for the old capsule event.
7. Deletes the old local cache and updates the generation high-water mark.
Steps 4 and 5 shrink the exposure window on honest relays. They do not defeat an attacker who already downloaded the old capsule (§18.3), and the interface must not claim otherwise. Steps 4 and 5 shrink the exposure window on honest relays. They do not defeat an attacker who already downloaded the old capsule (§18.3), and the interface must not claim otherwise.
@@ -1773,6 +1793,8 @@ Resolved since v0.3: generation-rollback detection now enforced (fail-closed) ra
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). 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).
Resolved since v0.5: registration and password rotation (§15.6, §18.1) now check for an existing credential capsule at the target locator address before publishing and refuse with `AccountAlreadyExistsError` rather than silently overwrite another account's identity binding.
Still open before implementation: Still open before implementation:
1. Which bootstrap relays ship with the client, and who holds the maintainer relay-list key (single key, or threshold)? 1. Which bootstrap relays ship with the client, and who holds the maintainer relay-list key (single key, or threshold)?
+50 -1
View File
@@ -12,7 +12,7 @@ import { repairReplicas } from "./repair.js";
import { InMemoryKeyValueStore } from "../storage/interface.js"; import { InMemoryKeyValueStore } from "../storage/interface.js";
import { raiseHighWaterMark } from "./highWaterMark.js"; import { raiseHighWaterMark } from "./highWaterMark.js";
import { RelayPool } from "../nostr/pool.js"; import { RelayPool } from "../nostr/pool.js";
import { AccountNotFoundError, RollbackDetectedError } from "./errors.js"; import { AccountAlreadyExistsError, AccountNotFoundError, RollbackDetectedError } from "./errors.js";
import { derivePasswordKeys, normalizeLoginName } from "./normalize.js"; import { derivePasswordKeys, normalizeLoginName } from "./normalize.js";
import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { RelayConnection } from "../nostr/relay.js"; import { RelayConnection } from "../nostr/relay.js";
@@ -372,4 +372,53 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
}, },
ARGON2_TIMEOUT ARGON2_TIMEOUT
); );
it(
"registering again with a login name + password that's already taken refuses to overwrite the existing account (§15.6)",
async () => {
const loginName = "reused";
const password = generatePassphrase().secret;
const original = await registerAccount({ loginName, password, vaultRelayUrls });
// Same login name + same password -> the exact same locator address. Without a
// pre-publish existence check this would silently replace the original account's
// credential capsule with a brand-new, unrelated everyday identity and recovery phrase.
await expect(registerAccount({ loginName, password, vaultRelayUrls })).rejects.toThrow(AccountAlreadyExistsError);
// The original account must still be exactly as it was -- same everyday identity,
// same recovery phrase still valid.
const login = await loginWithPassword({ loginName, password, vaultRelayUrls });
expect(login.everydayPublicKey).toBe(original.everydayPublicKey);
expect(login.generation).toBe(0);
},
ARGON2_TIMEOUT * 2
);
it(
"rotating to a new password that collides with another account under the same login name is refused (§18.1)",
async () => {
const loginName = "sharedname";
const passwordA = generatePassphrase().secret;
const passwordB = generatePassphrase().secret;
const accountA = await registerAccount({ loginName, password: passwordA, vaultRelayUrls });
const accountB = await registerAccount({ loginName, password: passwordB, vaultRelayUrls });
// Rotating account A to password B's exact password would target the exact same
// locator address as account B's already-registered capsule.
await expect(
changePassword({ loginName, oldPassword: passwordA, newPassword: passwordB, vaultRelayUrls })
).rejects.toThrow(AccountAlreadyExistsError);
// Neither account was touched: A's old capsule was never tombstoned, and B's capsule
// was never overwritten.
const loginA = await loginWithPassword({ loginName, password: passwordA, vaultRelayUrls });
expect(loginA.everydayPublicKey).toBe(accountA.everydayPublicKey);
expect(loginA.generation).toBe(0);
const loginB = await loginWithPassword({ loginName, password: passwordB, vaultRelayUrls });
expect(loginB.everydayPublicKey).toBe(accountB.everydayPublicKey);
expect(loginB.generation).toBe(0);
},
ARGON2_TIMEOUT * 2
);
}); });
+25 -1
View File
@@ -10,7 +10,7 @@ import { buildDeletionRequest } from "../nostr/nip09.js";
import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js"; import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js";
import type { CredentialPayload } from "../capsules/types.js"; import type { CredentialPayload } from "../capsules/types.js";
import { publishAndVerify, type PublishVerificationResult } from "./publish.js"; import { publishAndVerify, type PublishVerificationResult } from "./publish.js";
import { AccountNotFoundError, RegistrationFailedError, RollbackDetectedError } from "./errors.js"; import { AccountAlreadyExistsError, AccountNotFoundError, RegistrationFailedError, RollbackDetectedError } from "./errors.js";
import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js"; import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js";
import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js"; import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js";
import type { NostrEvent } from "../nostr/event.js"; import type { NostrEvent } from "../nostr/event.js";
@@ -78,6 +78,30 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
const newKeys = await derivePasswordKeys(params.newPassword, normalizedLoginName); const newKeys = await derivePasswordKeys(params.newPassword, normalizedLoginName);
const newLocatorPublicKey = getPublicKeyHex(newKeys.locatorPrivateKey); const newLocatorPublicKey = getPublicKeyHex(newKeys.locatorPrivateKey);
// §15.6/§18.1 — the new locator address is a NIP-33 replaceable event too, fully
// determined by login name + the chosen new password. If some other account already
// exists there (this login name rotated to a password that happens to already be
// registered under it), publishing would silently destroy that other account exactly
// like an unchecked registration would.
const newLocatorCheckPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: newKeys.locatorPrivateKey });
let existingAtNewLocator;
try {
existingAtNewLocator = await readCredentialCapsule(newLocatorCheckPool, newLocatorPublicKey, newKeys.capsuleKey, params.timeoutMs);
} finally {
newLocatorCheckPool.closeAll();
}
if (!existingAtNewLocator.quorumMet) {
throw new RegistrationFailedError(
"Couldn't verify the new password isn't already registered under this login name. Please retry, or add more vault relays."
);
}
if (existingAtNewLocator.candidates.length > 0) {
throw new AccountAlreadyExistsError(
"Another account is already registered with this login name and the new password you chose. Pick a different new password."
);
}
const newGeneration = oldPayload.generation + 1; const newGeneration = oldPayload.generation + 1;
const newPayload: CredentialPayload = { const newPayload: CredentialPayload = {
+31 -3
View File
@@ -12,7 +12,8 @@ import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js";
import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js"; import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js";
import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js"; import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js";
import { publishAndVerify, type PublishVerificationResult } from "./publish.js"; import { publishAndVerify, type PublishVerificationResult } from "./publish.js";
import { RegistrationFailedError } from "./errors.js"; import { readCredentialCapsule } from "./capsuleReader.js";
import { RegistrationFailedError, AccountAlreadyExistsError } from "./errors.js";
import type { NostrEvent } from "../nostr/event.js"; import type { NostrEvent } from "../nostr/event.js";
export interface RegisterAccountParams { export interface RegisterAccountParams {
@@ -53,6 +54,33 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
const normalizedLoginName = normalizeLoginName(params.loginName); const normalizedLoginName = normalizeLoginName(params.loginName);
const now = params.now ?? Math.floor(Date.now() / 1000); const now = params.now ?? Math.floor(Date.now() / 1000);
// §15.6 — the credential capsule address is fully determined by login name + password,
// and is a NIP-33 replaceable event: publishing over one that already exists there would
// silently destroy that other account's identity binding, with no way back short of its
// own recovery phrase. Derive the locator early and check before generating anything else
// for this attempt, so a collision fails fast without discarding a freshly generated
// recovery phrase or everyday keypair.
const { locatorPrivateKey, capsuleKey: credentialCapsuleKey } = await derivePasswordKeys(params.password, normalizedLoginName);
const locatorPublicKey = getPublicKeyHex(locatorPrivateKey);
const checkPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey });
let existingCapsule;
try {
existingCapsule = await readCredentialCapsule(checkPool, locatorPublicKey, credentialCapsuleKey, params.timeoutMs);
} finally {
checkPool.closeAll();
}
if (!existingCapsule.quorumMet) {
throw new RegistrationFailedError(
"Couldn't verify this login name and password aren't already registered. Please retry, or add more vault relays."
);
}
// Any validly signed event at this address (decryptable by us or not) required
// knowing this exact locator private key to publish -- which requires this exact
// login name + password -- so its mere presence means an account already exists here.
if (existingCapsule.candidates.length > 0) {
throw new AccountAlreadyExistsError();
}
// §15.2 — recovery generation happens first so its signed event can be embedded below. // §15.2 — recovery generation happens first so its signed event can be embedded below.
const recoveryPhrase = entropyToRecoveryPhrase(randomEntropy128()); const recoveryPhrase = entropyToRecoveryPhrase(randomEntropy128());
const bip39Seed = await recoveryPhraseToSeed(recoveryPhrase); const bip39Seed = await recoveryPhraseToSeed(recoveryPhrase);
@@ -88,8 +116,8 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
}); });
// §15.5 — password path, embedding the signed recovery event for keyless rebroadcast repair // §15.5 — password path, embedding the signed recovery event for keyless rebroadcast repair
const { locatorPrivateKey, capsuleKey: credentialCapsuleKey } = await derivePasswordKeys(params.password, normalizedLoginName); // (locatorPrivateKey/credentialCapsuleKey/locatorPublicKey were already derived above for the
const locatorPublicKey = getPublicKeyHex(locatorPrivateKey); // existing-account check).
const credentialPayload: CredentialPayload = { const credentialPayload: CredentialPayload = {
schema: SCHEMA_CREDENTIAL_V1, schema: SCHEMA_CREDENTIAL_V1,
account_id: accountId, account_id: accountId,
+18
View File
@@ -18,6 +18,24 @@ export class RegistrationFailedError extends Error {
} }
} }
/**
* Thrown when a credential capsule already exists at the address a registration or
* password change is about to publish to (§15.6, §18.1). That address is a NIP-33
* replaceable event fully determined by login name + password (or, for a rotation,
* login name + the chosen new password) -- publishing over one that already exists
* there would silently destroy whatever account is already bound to it, with no way
* back short of that other account's own recovery phrase. Unlike {@link AccountNotFoundError},
* this is not subject to §16.3's login/no-account non-disclosure rule: reaching this
* error already requires supplying the exact login name + password an existing account
* was registered with, which is equivalent to already being able to log in as it.
*/
export class AccountAlreadyExistsError extends Error {
constructor(message = "An account already exists with this login name and password. Sign in instead, or choose different credentials.") {
super(message);
this.name = "AccountAlreadyExistsError";
}
}
export class RecoveryFailedError extends Error { export class RecoveryFailedError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);