Add signer switching, manual-password parity, phrase-quiz split, and offline recovery import

Signer interop:
- <bitlogin-auth> gains claimSigner()/releaseSigner() instance methods, and
  window.bitlogin gains a matching releaseSigner(). Before this, once the
  widget claimed window.nostr it held it forever (even after logout), so a
  site offering multiple signing methods (an extension, a NIP-46 bunker,
  BitLogin) had no way to let a user switch away from BitLogin without a
  page reload. claimSigner() is now also called automatically on every
  successful sign-in/sign-up/recovery, and releaseSigner() on logout and
  DOM removal. Both fire bitlogin-signer-claimed/-released events.
  docs.html and llms.txt document the new API and a switch-signer example.

Manual password parity + fixes:
- The opt-in custom-password path (added earlier per spec §9.3) was only
  available at initial account creation; it's now offered on password
  rotation and phrase recovery too, via a shared renderManualPasswordFields()/
  readManualPassword() pair.
- Added a required confirm-password field with live match feedback,
  wherever a manual password can be set.
- Found and fixed a real bug while testing this: a validation failure
  (mismatched confirm, weak password) on any manual-password screen fully
  re-rendered the form, silently wiping the typed password and
  un-checking "I saved it" -- forcing a full retype. Fixed via draft
  fields that repopulate the inputs' value attribute across the
  failure re-render, and by reading the checkbox before validation
  instead of after.

Recovery-phrase display split (§anti-cheat):
- The 12-word phrase and its 3-word verification quiz were shown on the
  same screen, defeating the point of the quiz. Split into a
  display-only "confirm-phrase" screen and a separate "verify-phrase"
  screen where the phrase is hidden while the quiz is answered.

Offline recovery-export import (§19.5):
- recoverWithPhrase() now accepts optional offlineRecoveryCapsuleEvents,
  merged with whatever the live relay read returns; recovery still
  requires the phrase either way, since the export file never contains
  it or a phrase-derived key. Covered by a new core e2e test that
  recovers successfully against genuinely unreachable relay URLs using
  only the offline candidate.
- The recover-phrase screen gets an optional file input for a previously
  downloaded recovery export, parsed and validated client-side before
  being passed through to the worker.

Landing page copy updated to reflect that manual passwords are allowed
(with a real entropy floor and common-password check), not disallowed.

60/60 tests pass; core and widget typecheck clean. Verified live in
Chromium via Playwright against a 3-relay in-memory mock quorum: account
creation (generated and manual, including the mismatch/retry-preserves-
draft path), phrase display/quiz split, signer claim/release/reclaim
across logout and re-login, password rotation and phrase recovery with
manual passwords (including logging back in with the result), and the
offline recovery-export file path. Zero page or console errors across
every run.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
This commit is contained in:
Claude committed 2026-07-21 16:41:35 +00:00
1 parent 0801371566
commit a24719184e
9 files changed
+394 -52

No files matched your search

@@ -17,6 +17,7 @@ import { derivePasswordKeys, normalizeLoginName } from "./normalize.js";
import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { RelayConnection } from "../nostr/relay.js";
import { readCredentialCapsule } from "./capsuleReader.js";
import { buildRecoveryExport, parseRecoveryExport } from "./exportImport.js";
const ARGON2_TIMEOUT = 20000;
@@ -186,6 +187,48 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
ARGON2_TIMEOUT * 2
);
it(
"recovery export file lets recovery succeed even when every configured relay is unreachable (§19.5)",
async () => {
const loginName = "offlinerecover";
const password = generatePassphrase().secret;
const registration = await registerAccount({ loginName, password, vaultRelayUrls });
// Build (and round-trip through JSON, as the downloaded file would be) the recovery
// export exactly as the widget does right after registration.
const exportFile = buildRecoveryExport({
recoveryPublicKeyHex: registration.recoveryPublicKey,
vaultRelayUrls,
recoveryCapsuleEvents: [registration.recoveryEvent],
relayListEvents: []
});
const parsedExportFile = parseRecoveryExport(JSON.parse(JSON.stringify(exportFile)));
// Simulate every configured relay being unreachable (not just empty -- an actual closed
// port, so the quorum read genuinely fails rather than vacuously succeeding on zero relays).
const unreachableRelayUrls = ["ws://127.0.0.1:1"];
const recovered = await recoverWithPhrase({
phrase: registration.recoveryPhrase,
vaultRelayUrls: unreachableRelayUrls,
discoveryRelayUrls: unreachableRelayUrls,
offlineRecoveryCapsuleEvents: parsedExportFile.recovery_capsule_events
});
expect(recovered.everydayPublicKey).toBe(registration.everydayPublicKey);
expect(recovered.accountId).toBe(registration.accountId);
// Without the offline file, the same unreachable relays must fail outright.
await expect(
recoverWithPhrase({
phrase: registration.recoveryPhrase,
vaultRelayUrls: unreachableRelayUrls,
discoveryRelayUrls: unreachableRelayUrls
})
).rejects.toThrow(AccountNotFoundError);
},
ARGON2_TIMEOUT
);
it(
"known-password change tombstones the old locator and a stale replay of the old capsule does not win",
async () => {
+27 -2
View File
@@ -14,8 +14,8 @@ import {
SCHEMA_CREDENTIAL_V1,
SCHEMA_RECOVERY_V1
} from "../nostr/kinds.js";
import { readRecoveryCapsule, checkRecoveryChainAcrossCandidates } from "./capsuleReader.js";
import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js";
import { readRecoveryCapsule, checkRecoveryChainAcrossCandidates, type Candidate } from "./capsuleReader.js";
import { buildRecoveryCapsuleEvent, decryptRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js";
import { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js";
import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js";
@@ -28,6 +28,14 @@ export interface RecoverWithPhraseParams {
vaultRelayUrls: string[];
discoveryRelayUrls: string[];
timeoutMs?: number;
/**
* Recovery-capsule events from a previously downloaded recovery export file (§19.5), used
* as a fallback alongside the live relay read. The file alone can never recover an account
* on its own — it holds only the encrypted capsule, never the phrase or a phrase-derived
* key — so this only ever supplements the phrase-derived read, e.g. when every configured
* relay is unreachable or has lost the capsule.
*/
offlineRecoveryCapsuleEvents?: NostrEvent[];
}
export interface RecoveredIdentity {
@@ -68,6 +76,23 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis
vaultPool.closeAll();
}
if (params.offlineRecoveryCapsuleEvents?.length) {
const offlineCandidates: Array<Candidate<RecoveryPayload>> = [];
for (const event of params.offlineRecoveryCapsuleEvents) {
if (!verifyNostrEvent(event)) continue;
try {
offlineCandidates.push({ event, payload: await decryptRecoveryCapsuleEvent(event, capsuleKey) });
} catch (err) {
offlineCandidates.push({ event, payload: null, error: (err as Error).message });
}
}
const merged = new Map<string, Candidate<RecoveryPayload>>();
for (const candidate of [...result.candidates, ...offlineCandidates]) merged.set(candidate.event.id, candidate);
const candidates = [...merged.values()].sort((a, b) => b.event.created_at - a.event.created_at);
const best = candidates.find((c) => c.payload !== null) ?? null;
result = { ...result, candidates, best, quorumMet: result.quorumMet || best !== null };
}
if (!result.quorumMet) throw new AccountNotFoundError("quorum-not-met");
if (!result.best) {
throw new AccountNotFoundError(result.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event");