mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-09 06:51:25 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user