mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-01 11:01:22 +00:00
Tier 1 hardening: no more silent hangs, no writes before consent
Reliability, which is where most of these reach users: - The worker RPC had no message validation, no error/messageerror listener, no timeout, and terminate() left every pending promise unsettled. A malformed frame threw inside the listener (posting no response at all) and a dead worker stranded its callers -- the documented innerHTML-remount hazard manifested as a permanent freeze rather than an error. Both sides now validate the envelope, calls time out at 60s, and worker death rejects everything outstanding. - requestNwcConnection fired no pending event, so a host using the RECOMMENDED hidden-mount pattern navigated a hidden element to a consent screen nobody could see and waited forever. Now symmetric with bitlogin-offer-pending. Consent integrity: - vaultOfferCheck published an origin rebinding to relays -- a durable mutation of the user's account -- from a plain host API call before any consent screen existed. It is read-only now, and a wallet stored under a DIFFERENT origin no longer reports as already-saved, so the binding can only change through the consented path. - offerNwcConnection's guard sat above three awaits; two concurrent calls both passed it and the second dropped the first's promise. The slot is claimed synchronously. Vault reads fail closed: - listConnections threw rather than returning a relay-shaped subset when quorum is not met -- a short listing is indistinguishable from "you have no connections", and one relay should not get to say that. - The 500-record page limit is reported (truncated) instead of silently deciding which connections exist. Supply chain: - All eight crypto dependencies pinned to exact versions (they held users' keys behind caret ranges), and Bitcoin Connect with them. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
1 parent
b414cc53d2
commit
d040c2f5af
7 files changed
+141
-36
No files matched your search
@@ -226,14 +226,28 @@ export class VaultSession {
|
||||
relayUrls: string[];
|
||||
store: KeyValueStore;
|
||||
timeoutMs?: number;
|
||||
/** Accept a below-quorum answer anyway. Off by default: a partial view
|
||||
* of a credential store reads as authoritative and isn't. */
|
||||
acknowledgeIncompleteQuorum?: boolean;
|
||||
}): Promise<{
|
||||
connections: DecryptedConnectionRecord[];
|
||||
rollbackWarnings: string[];
|
||||
/** Event ids that failed to decrypt or validate. Surfaced, never silent. */
|
||||
unreadable: string[];
|
||||
/** True when the relay page filled, so records may be missing. */
|
||||
truncated: boolean;
|
||||
quorumMet: boolean;
|
||||
}> {
|
||||
const fetched = await this.fetchEvents(params);
|
||||
if (!fetched.quorumMet && !params.acknowledgeIncompleteQuorum) {
|
||||
// Failing closed matters more here than for a read of public data: a
|
||||
// short listing looks identical to "you have no connections", and a
|
||||
// single relay deciding that is how a revoked-looking vault gets
|
||||
// re-populated or a live credential gets hidden.
|
||||
throw new Error(
|
||||
"Not enough vault relays answered to list your connections reliably. Check your connection and retry."
|
||||
);
|
||||
}
|
||||
const connections: DecryptedConnectionRecord[] = [];
|
||||
const unreadable: string[] = [];
|
||||
for (const event of fetched.events.values()) {
|
||||
@@ -254,6 +268,7 @@ export class VaultSession {
|
||||
connections,
|
||||
rollbackWarnings: fetched.rollbackWarnings,
|
||||
unreadable,
|
||||
truncated: fetched.truncated,
|
||||
quorumMet: fetched.quorumMet
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,8 +99,14 @@ export interface FetchedConnectionEvents {
|
||||
rollbackWarnings: string[];
|
||||
quorumMet: boolean;
|
||||
respondedCount: number;
|
||||
/** True when a relay returned the full page, so records may be missing. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Ceiling on records fetched in one query. Hit means the relay, not the
|
||||
* user, decided which connections exist -- so it is REPORTED, not silent. */
|
||||
const FETCH_LIMIT = 500;
|
||||
|
||||
/** Fetches every connection record visible for this vault identity (§CV10). */
|
||||
export async function fetchConnectionRecordEvents(params: {
|
||||
vaultPrk: Uint8Array;
|
||||
@@ -114,13 +120,14 @@ export async function fetchConnectionRecordEvents(params: {
|
||||
let quorum;
|
||||
try {
|
||||
quorum = await pool.queryQuorum(
|
||||
{ kinds: [KIND_APP_DATA], authors: [vaultPubkey], limit: 500 },
|
||||
{ kinds: [KIND_APP_DATA], authors: [vaultPubkey], limit: FETCH_LIMIT },
|
||||
params.timeoutMs
|
||||
);
|
||||
} finally {
|
||||
pool.closeAll();
|
||||
}
|
||||
|
||||
const truncated = quorum.outcomes.some((outcome) => outcome.events.length >= FETCH_LIMIT);
|
||||
const newestById = new Map<string, NostrEvent>();
|
||||
for (const event of quorum.outcomes.flatMap((o) => o.events)) {
|
||||
if (!verifyNostrEvent(event) || event.pubkey !== vaultPubkey) continue;
|
||||
@@ -157,6 +164,7 @@ export async function fetchConnectionRecordEvents(params: {
|
||||
events,
|
||||
rollbackWarnings,
|
||||
quorumMet: quorum.quorumMet,
|
||||
respondedCount: quorum.respondedCount
|
||||
respondedCount: quorum.respondedCount,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user