mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-05 13:01:23 +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
@@ -14,8 +14,31 @@ export interface EncryptedEnvelope {
|
||||
ciphertext: string; // base64url
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection Vault key material carried by BOTH capsules (connection-vault.md
|
||||
* §5.2). Two independent 32-byte random roots, base64url:
|
||||
*
|
||||
* - `connection_vault_root` locates, signs, and encrypts connectable-tier
|
||||
* connection records (NWC, scoped S3). Cacheable for the session, so daily
|
||||
* wallet use never re-prompts.
|
||||
* - `vault_sudo_key` is REQUIRED additional key material for personal-tier
|
||||
* records (stored passwords, secure notes). The honest-client contract: it
|
||||
* is never persisted outside the capsules and is held in memory only for
|
||||
* the duration of a sudo window — re-obtaining it costs a fresh capsule
|
||||
* decryption (an Argon2id run on the password, or the phrase), which is
|
||||
* what makes "re-enter your password to reveal" a real key ceremony rather
|
||||
* than a skippable UI gate (§SF2).
|
||||
*
|
||||
* Both fields appear together or not at all; absent means the account
|
||||
* predates the vault (see enableConnectionVault).
|
||||
*/
|
||||
export interface VaultCapsuleFields {
|
||||
connection_vault_root?: string; // base64url, 32 bytes
|
||||
vault_sudo_key?: string; // base64url, 32 bytes
|
||||
}
|
||||
|
||||
/** §12.1 — the credential capsule: infrequently changed access material only. */
|
||||
export interface CredentialPayload {
|
||||
export interface CredentialPayload extends VaultCapsuleFields {
|
||||
schema: typeof SCHEMA_CREDENTIAL_V1;
|
||||
account_id: string; // base64url, 128-bit random
|
||||
generation: number;
|
||||
@@ -33,7 +56,7 @@ export interface CredentialPayload {
|
||||
}
|
||||
|
||||
/** §12.3 — the recovery capsule: minimal and immutable outside the write moments of §14.1. */
|
||||
export interface RecoveryPayload {
|
||||
export interface RecoveryPayload extends VaultCapsuleFields {
|
||||
schema: typeof SCHEMA_RECOVERY_V1;
|
||||
account_id: string;
|
||||
recovery_generation: number;
|
||||
|
||||
@@ -59,6 +59,35 @@ export function validateOperationalKeyPair(privateKeyB64: unknown, publicKeyHex:
|
||||
assert(derived === publicKeyHex, "operational_public_key does not match the derived public key (§12.4.4).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection Vault fields (connection-vault.md §5.2): optional, but they
|
||||
* appear together or not at all — a capsule carrying only one of the two
|
||||
* roots is a corrupt or tampered migration and must not grant a session, or
|
||||
* personal-tier records would silently become unrecoverable.
|
||||
*/
|
||||
export function validateVaultFields(payload: {
|
||||
connection_vault_root?: unknown;
|
||||
vault_sudo_key?: unknown;
|
||||
}): void {
|
||||
const root = payload.connection_vault_root;
|
||||
const sudo = payload.vault_sudo_key;
|
||||
if (root === undefined && sudo === undefined) return;
|
||||
assert(root !== undefined && sudo !== undefined, "connection_vault_root and vault_sudo_key must appear together (§CV5.2).");
|
||||
for (const [field, value] of [
|
||||
["connection_vault_root", root],
|
||||
["vault_sudo_key", sudo]
|
||||
] as const) {
|
||||
assert(typeof value === "string", `${field} must be a string (§CV5.2).`);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = base64urlToBytes(value);
|
||||
} catch {
|
||||
throw new CapsuleValidationError(`${field} is not valid base64url (§CV5.2).`);
|
||||
}
|
||||
assert(bytes.length === 32, `${field} must decode to exactly 32 bytes (§CV5.2).`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertGenerationInBounds(value: unknown, field: string): void {
|
||||
assert(
|
||||
Number.isInteger(value) && (value as number) >= 0 && (value as number) <= MAX_GENERATION,
|
||||
@@ -77,6 +106,7 @@ export function validateCredentialPayload(payload: CredentialPayload): void {
|
||||
validateOperationalKeyPair(payload.operational_private_key, payload.operational_public_key);
|
||||
assert(isHex64(payload.recovery_public_key), "recovery_public_key must be lowercase 64-char hex (§12.4.5).");
|
||||
validateRelayUrls(payload.vault_relay_hints);
|
||||
validateVaultFields(payload);
|
||||
|
||||
const embedded = payload.recovery_capsule_event;
|
||||
assert(!!embedded && typeof embedded === "object", "recovery_capsule_event must be present (§12.4.6).");
|
||||
@@ -102,6 +132,7 @@ export function validateRecoveryPayload(payload: RecoveryPayload): void {
|
||||
validateOperationalKeyPair(payload.operational_private_key, payload.operational_public_key);
|
||||
assert(isHex64(payload.recovery_public_key), "recovery_public_key must be lowercase 64-char hex.");
|
||||
validateRelayUrls(payload.vault_relay_hints);
|
||||
validateVaultFields(payload);
|
||||
}
|
||||
|
||||
/** §12.4.9 — detects a broken previous_recovery_event_id hash chain when multiple generations are visible. */
|
||||
|
||||
Reference in new issue
Block a user