mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-05 04:51:21 +00:00
Implement the Connection Vault protocol layer (phases A-C)
connection-vault.md 5-13 as code, with the implementation-time decisions recorded in a new section 18 and vault-ux.md holding the agreed UX walkthroughs as the authorization layer's acceptance criteria. Capsules gain paired optional fields connection_vault_root and vault_sudo_key (both or neither, validated); registration mints them, login/change-password/recovery carry them forward, and enableConnectionVault is the phrase-gated migration for existing accounts -- recovery capsule written first, fail closed, idempotent by reusing roots found in either capsule so a partial failure can never mint twice and strand records. src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand constants pinned by test vectors that must only ever be reverted, never updated), per-connection encrypted records signed by a derived vault identity with the capsule JCS+padding+AES-GCM pipeline and shared buckets so record types are indistinguishable by size, the NWC profile with lossless URI parse/export, relay sync reusing the publish-and- verify quorum bar, and per-record high-water marks so a stale replica is withheld and reported rather than silently resurrecting a revoked credential. The two-tier model makes sudo mode cryptographically real: personal- tier record keys require vault_sudo_key as HKDF input, VaultSession holds it only inside an enableSudo/endSudo window (wiping on the way in and out), and outside a window personal records are invisible -- the same trial-decrypt returns null, not an error. The app-facing API is structurally blind to the tier by construction. 118 tests passing, including four end-to-end scenarios against mock relays: clean-device restore, password-change carry, legacy-account migration (wrong phrase refused), and rollback detection. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
1 parent
e9a5a41ff7
commit
a465e1d8fa
22 files changed
+1843
-12
No files matched your search
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Vault relay synchronization (connection-vault.md §9, §10, Phase C).
|
||||
*
|
||||
* Publishing reuses the capsule publish-and-verify quorum bar. Fetching
|
||||
* queries the derived vault identity's kind-30078 events and filters the
|
||||
* `bitlogin:connection:` prefix locally — relay filters match exact tag
|
||||
* values, and a per-id filter list would leak the vault's size and ids to
|
||||
* anyone watching query traffic (§CV10).
|
||||
*
|
||||
* Rollback detection is per record (§CV9): a KeyValueStore high-water mark
|
||||
* remembers the newest created_at ever accepted per connection, and a relay
|
||||
* serving something older is reported, never silently accepted — a stale
|
||||
* replica resurrecting a revoked credential is this design's nastiest
|
||||
* failure mode.
|
||||
*/
|
||||
import { RelayPool } from "../nostr/pool.js";
|
||||
import { KIND_APP_DATA } from "../nostr/kinds.js";
|
||||
import { verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js";
|
||||
import { publishAndVerify, type PublishVerificationResult } from "../account/publish.js";
|
||||
import type { KeyValueStore } from "../storage/interface.js";
|
||||
import { deriveVaultSigningKey, connectionDTag, connectionIdFromDTag } from "./derivation.js";
|
||||
import { getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||
|
||||
function hwmKey(vaultPubkey: string, connectionId: string): string {
|
||||
return `bitlogin:vault-hwm:${vaultPubkey}:${connectionId}`;
|
||||
}
|
||||
|
||||
export interface RecordHighWaterMark {
|
||||
createdAt: number;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export async function getRecordHighWaterMark(
|
||||
store: KeyValueStore,
|
||||
vaultPubkey: string,
|
||||
connectionId: string
|
||||
): Promise<RecordHighWaterMark | null> {
|
||||
const raw = await store.get(hwmKey(vaultPubkey, connectionId));
|
||||
return raw ? (JSON.parse(raw) as RecordHighWaterMark) : null;
|
||||
}
|
||||
|
||||
export async function raiseRecordHighWaterMark(
|
||||
store: KeyValueStore,
|
||||
vaultPubkey: string,
|
||||
connectionId: string,
|
||||
observed: RecordHighWaterMark
|
||||
): Promise<void> {
|
||||
const current = await getRecordHighWaterMark(store, vaultPubkey, connectionId);
|
||||
if (current && current.createdAt >= observed.createdAt) return;
|
||||
await store.set(hwmKey(vaultPubkey, connectionId), JSON.stringify(observed));
|
||||
}
|
||||
|
||||
/** Publishes one record event to the vault relays with the §15.6 quorum bar,
|
||||
* raising the local high-water mark only on success. */
|
||||
export async function publishConnectionRecord(params: {
|
||||
vaultPrk: Uint8Array;
|
||||
event: NostrEvent;
|
||||
connectionId: string;
|
||||
relayUrls: string[];
|
||||
store: KeyValueStore;
|
||||
minAcknowledgements?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<PublishVerificationResult> {
|
||||
const signingKey = deriveVaultSigningKey(params.vaultPrk);
|
||||
const pool = new RelayPool(params.relayUrls, { authPrivateKey: signingKey });
|
||||
let result: PublishVerificationResult;
|
||||
try {
|
||||
result = await publishAndVerify(pool, params.event, {
|
||||
dTag: connectionDTag(params.connectionId),
|
||||
minAcks: params.minAcknowledgements,
|
||||
// The readback bar follows the ack bar: a caller publishing to fewer
|
||||
// relays (a targeted repair, a test) has already lowered its quorum.
|
||||
minReadbacks: params.minAcknowledgements,
|
||||
timeoutMs: params.timeoutMs
|
||||
});
|
||||
} finally {
|
||||
pool.closeAll();
|
||||
}
|
||||
if (result.success) {
|
||||
await raiseRecordHighWaterMark(params.store, params.event.pubkey, params.connectionId, {
|
||||
createdAt: params.event.created_at,
|
||||
eventId: params.event.id
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface FetchedConnectionEvents {
|
||||
/** Newest valid event per connection id, high-water marks already applied. */
|
||||
events: Map<string, NostrEvent>;
|
||||
/** Connection ids where every visible event is OLDER than this device has
|
||||
* already accepted — stale replicas or replay, never auto-accepted (§CV9). */
|
||||
rollbackWarnings: string[];
|
||||
quorumMet: boolean;
|
||||
respondedCount: number;
|
||||
}
|
||||
|
||||
/** Fetches every connection record visible for this vault identity (§CV10). */
|
||||
export async function fetchConnectionRecordEvents(params: {
|
||||
vaultPrk: Uint8Array;
|
||||
relayUrls: string[];
|
||||
store: KeyValueStore;
|
||||
timeoutMs?: number;
|
||||
}): Promise<FetchedConnectionEvents> {
|
||||
const signingKey = deriveVaultSigningKey(params.vaultPrk);
|
||||
const vaultPubkey = getPublicKeyHex(signingKey);
|
||||
const pool = new RelayPool(params.relayUrls, { authPrivateKey: signingKey });
|
||||
let quorum;
|
||||
try {
|
||||
quorum = await pool.queryQuorum(
|
||||
{ kinds: [KIND_APP_DATA], authors: [vaultPubkey], limit: 500 },
|
||||
params.timeoutMs
|
||||
);
|
||||
} finally {
|
||||
pool.closeAll();
|
||||
}
|
||||
|
||||
const newestById = new Map<string, NostrEvent>();
|
||||
for (const event of quorum.outcomes.flatMap((o) => o.events)) {
|
||||
if (!verifyNostrEvent(event) || event.pubkey !== vaultPubkey) continue;
|
||||
const connectionId = connectionIdFromDTag(findTagValue(event, "d") ?? "");
|
||||
if (connectionId === null) continue;
|
||||
const current = newestById.get(connectionId);
|
||||
if (!current || event.created_at > current.created_at) newestById.set(connectionId, event);
|
||||
}
|
||||
|
||||
const events = new Map<string, NostrEvent>();
|
||||
const rollbackWarnings: string[] = [];
|
||||
for (const [connectionId, event] of newestById) {
|
||||
const hwm = await getRecordHighWaterMark(params.store, vaultPubkey, connectionId);
|
||||
if (hwm && event.created_at < hwm.createdAt) {
|
||||
rollbackWarnings.push(connectionId);
|
||||
continue;
|
||||
}
|
||||
events.set(connectionId, event);
|
||||
await raiseRecordHighWaterMark(params.store, vaultPubkey, connectionId, {
|
||||
createdAt: event.created_at,
|
||||
eventId: event.id
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
rollbackWarnings,
|
||||
quorumMet: quorum.quorumMet,
|
||||
respondedCount: quorum.respondedCount
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user