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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-30 12:29:46 -04:00
1 parent e9a5a41ff7
commit a465e1d8fa
22 files changed
+1843 -12

No files matched your search

+25 -2
View File
@@ -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;
+31
View File
@@ -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. */