Files
bitlogin/packages/core/src/account/login.ts
T
thePR0M3TH3ANandClaude Fable 5 a465e1d8fa 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
2026-07-30 12:29:46 -04:00

104 lines
5.0 KiB
TypeScript

/** Any-device password login (§16). */
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { base64urlToBytes } from "../crypto/encoding.js";
import { derivePasswordKeys, normalizeLoginName } from "./normalize.js";
import { RelayPool } from "../nostr/pool.js";
import { readCredentialCapsule } from "./capsuleReader.js";
import { AccountNotFoundError, RollbackDetectedError } from "./errors.js";
import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js";
import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js";
import type { NostrEvent } from "../nostr/event.js";
export interface LoginParams {
loginName: string;
password: string;
vaultRelayUrls: string[];
store?: KeyValueStore;
timeoutMs?: number;
/**
* Proceed even if this device has previously observed a higher credential
* generation than the one this capsule reports (§16.2 step 6). Off by default:
* without it, that condition throws {@link RollbackDetectedError} instead of
* granting a session, since the client cannot distinguish "an old, rotated-away
* password replayed from a stale relay" from "this relay is merely lagging" --
* failing closed is the safe default for the former, far more consequential case.
*/
acknowledgeRollback?: boolean;
}
export interface LoginResult {
everydayPrivateKey: Uint8Array;
everydayPublicKey: string;
recoveryPublicKey: string;
accountId: string;
generation: number;
credentialEvent: NostrEvent;
/** The signed recovery capsule event embedded in the credential capsule (§12.1, §24.4) — needed to build a recovery export or rebroadcast replicas without re-deriving anything from the phrase. */
recoveryCapsuleEvent: NostrEvent;
/** Connection Vault root (§CV5.2) — undefined on accounts that have not run enableConnectionVault. Cacheable for the session. */
connectionVaultRoot?: Uint8Array;
/** Sudo key (§CV5.2). The caller must NOT cache this beyond a sudo window — obtaining it again is the ceremony. */
vaultSudoKey?: Uint8Array;
/** Set when the accepted generation is lower than one this device has previously seen (§16.2 step 6). */
rollbackWarning?: string;
/** Set when responsive relays disagree about which capsule is newest (§16.2 step 8). */
relayDisagreementWarning?: string;
}
/**
* Queries bootstrap relays for the password-derived locator's credential
* capsule, decrypts it, and applies rollback resistance (§16.2). The caller
* is responsible for closing this connection before opening any connection
* that will carry everyday-identity traffic (§23.4) — capsule and
* everyday-identity operations must never share a relay connection.
*/
export async function loginWithPassword(params: LoginParams): Promise<LoginResult> {
const normalizedLoginName = normalizeLoginName(params.loginName);
const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys(params.password, normalizedLoginName);
const locatorPublicKey = getPublicKeyHex(locatorPrivateKey);
const pool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey });
try {
const result = await readCredentialCapsule(pool, locatorPublicKey, capsuleKey, params.timeoutMs);
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");
}
const payload = result.best.payload!;
const store = params.store ?? new InMemoryKeyValueStore();
const hwm = await getHighWaterMark(store, payload.operational_public_key);
const isRollback = payload.generation < hwm.generation;
if (isRollback && !params.acknowledgeRollback) {
throw new RollbackDetectedError(hwm.generation, payload.generation);
}
const rollbackWarning = isRollback
? `This device previously saw credential generation ${hwm.generation}, but the accepted capsule is generation ${payload.generation}. Relays may be serving stale data, or an old capsule is being replayed.`
: undefined;
await raiseHighWaterMark(store, payload.operational_public_key, { generation: payload.generation });
const relayDisagreementWarning = result.relayDisagreement
? "Configured relays returned different credential capsules as \"latest\" for this account. Some relays may be stale, censored, or malicious."
: undefined;
return {
everydayPrivateKey: base64urlToBytes(payload.operational_private_key),
everydayPublicKey: payload.operational_public_key,
recoveryPublicKey: payload.recovery_public_key,
accountId: payload.account_id,
generation: payload.generation,
credentialEvent: result.best.event,
recoveryCapsuleEvent: payload.recovery_capsule_event,
connectionVaultRoot: payload.connection_vault_root
? base64urlToBytes(payload.connection_vault_root)
: undefined,
vaultSudoKey: payload.vault_sudo_key ? base64urlToBytes(payload.vault_sudo_key) : undefined,
rollbackWarning,
relayDisagreementWarning
};
} finally {
pool.closeAll();
}
}