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

@@ -112,6 +112,12 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
operational_public_key: oldPayload.operational_public_key,
recovery_public_key: oldPayload.recovery_public_key,
recovery_capsule_event: oldPayload.recovery_capsule_event,
// Carried forward like every other long-lived field (§CV5.2): dropping
// the vault roots on a password change would strand every connection
// record until the next phrase ceremony.
...(oldPayload.connection_vault_root !== undefined
? { connection_vault_root: oldPayload.connection_vault_root, vault_sudo_key: oldPayload.vault_sudo_key }
: {}),
created_at: now,
vault_relay_hints: params.vaultRelayUrls,
protocol: {
+17 -1
View File
@@ -1,5 +1,5 @@
/** Registration flow (§15). Recovery capsule is built and published before the credential capsule. */
import { randomEntropy128, randomAccountId } from "../crypto/random.js";
import { randomEntropy128, randomAccountId, randomBytes } from "../crypto/random.js";
import { bytesToBase64url, hexToBytes } from "../crypto/encoding.js";
import { generatePrivateKey, getPublicKeyHex, isValidScalar } from "../crypto/secp256k1.js";
import { decodeNsec } from "../nostr/nip19.js";
@@ -44,6 +44,10 @@ export interface RegisterAccountResult {
accountId: string;
/** True when the everyday identity was imported (§SF10) rather than freshly generated. */
imported: boolean;
/** Connection Vault root (§CV5.2, §CV6): derives the vault identity and connectable-tier record keys. */
connectionVaultRoot: Uint8Array;
/** Sudo key (§CV5.2): additional IKM for personal-tier records. Hold only inside a sudo window. */
vaultSudoKey: Uint8Array;
credentialEvent: NostrEvent;
recoveryEvent: NostrEvent;
credentialPublish: PublishVerificationResult;
@@ -96,6 +100,12 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
const everydayPublicKey = getPublicKeyHex(everydayPrivateKey);
const accountId = bytesToBase64url(randomAccountId());
// Connection Vault roots (§CV5.2): minted at registration so every new
// account gets the vault for free — the phrase-gated migration ceremony
// exists only for accounts that predate this field.
const connectionVaultRoot = randomBytes(32);
const vaultSudoKey = randomBytes(32);
// §15.4 — recovery capsule first, previous_recovery_event_id null (registration is generation 0)
const recoveryPayload: RecoveryPayload = {
schema: SCHEMA_RECOVERY_V1,
@@ -105,6 +115,8 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
operational_private_key: bytesToBase64url(everydayPrivateKey),
operational_public_key: everydayPublicKey,
recovery_public_key: recoveryPublicKey,
connection_vault_root: bytesToBase64url(connectionVaultRoot),
vault_sudo_key: bytesToBase64url(vaultSudoKey),
created_at: now,
vault_relay_hints: params.vaultRelayUrls,
protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION }
@@ -126,6 +138,8 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
operational_public_key: everydayPublicKey,
recovery_public_key: recoveryPublicKey,
recovery_capsule_event: recoveryEvent,
connection_vault_root: bytesToBase64url(connectionVaultRoot),
vault_sudo_key: bytesToBase64url(vaultSudoKey),
created_at: now,
vault_relay_hints: params.vaultRelayUrls,
protocol: {
@@ -172,6 +186,8 @@ export async function registerAccount(params: RegisterAccountParams): Promise<Re
locatorPublicKey,
accountId,
imported,
connectionVaultRoot,
vaultSudoKey,
credentialEvent,
recoveryEvent,
credentialPublish,
+198
View File
@@ -0,0 +1,198 @@
/**
* Existing-account Connection Vault migration (connection-vault.md §5.3).
*
* Adding the vault roots requires the RECOVERY PHRASE, not just the password:
* a password-only write could update the credential capsule while leaving the
* phrase-recovery path without the roots, and the first phrase recovery after
* that would silently lose every connection. The ceremony writes the
* refreshed recovery capsule FIRST (same ordering as registration §15.4), so
* every intermediate failure state is recoverable:
*
* - recovery write fails -> nothing changed, retry freely.
* - recovery ok, credential write fails -> the roots exist on the phrase
* path; retrying repairs the credential capsule using the SAME roots (the
* flow reuses roots found in either capsule rather than minting twice).
*
* New accounts never come here — registration mints the roots for free.
*/
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { base64urlToBytes, bytesToBase64url } from "../crypto/encoding.js";
import { randomBytes } from "../crypto/random.js";
import { isValidRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js";
import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "./normalize.js";
import { nextCreatedAt } from "./timestamp.js";
import { RelayPool } from "../nostr/pool.js";
import { D_TAG_PASSWORD_CAPSULE, D_TAG_RECOVERY_CAPSULE, SCHEMA_CREDENTIAL_V1, SCHEMA_RECOVERY_V1 } from "../nostr/kinds.js";
import { readCredentialCapsule, readRecoveryCapsule } from "./capsuleReader.js";
import { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js";
import {
PROTOCOL_CAPSULE_ENCRYPTION,
PROTOCOL_PASSWORD_KDF,
PROTOCOL_RECOVERY_DERIVATION,
type CredentialPayload,
type RecoveryPayload
} from "../capsules/types.js";
import { publishAndVerify } from "./publish.js";
import { AccountNotFoundError, RecoveryFailedError, RegistrationFailedError } from "./errors.js";
import type { PublishVerificationResult } from "./publish.js";
export interface EnableConnectionVaultParams {
loginName: string;
password: string;
/** The account's recovery phrase — required, see the module comment. */
phrase: string;
vaultRelayUrls: string[];
minAcknowledgements?: number;
timeoutMs?: number;
now?: number;
}
export interface EnableConnectionVaultResult {
/** True when the account already carried the roots and nothing was written. */
alreadyEnabled: boolean;
connectionVaultRoot: Uint8Array;
vaultSudoKey: Uint8Array;
recoveryPublish?: PublishVerificationResult;
credentialPublish?: PublishVerificationResult;
}
export async function enableConnectionVault(
params: EnableConnectionVaultParams
): Promise<EnableConnectionVaultResult> {
const now = params.now ?? Math.floor(Date.now() / 1000);
const normalizedLoginName = normalizeLoginName(params.loginName);
if (!isValidRecoveryPhrase(params.phrase)) {
throw new RecoveryFailedError("This does not look like a valid 12-word BitLogin recovery phrase.");
}
// Password path: the current credential capsule.
const passwordKeys = await derivePasswordKeys(params.password, normalizedLoginName);
const locatorPublicKey = getPublicKeyHex(passwordKeys.locatorPrivateKey);
const credentialPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: passwordKeys.locatorPrivateKey });
let credentialRead;
try {
credentialRead = await readCredentialCapsule(credentialPool, locatorPublicKey, passwordKeys.capsuleKey, params.timeoutMs);
} finally {
credentialPool.closeAll();
}
if (!credentialRead.quorumMet) throw new AccountNotFoundError("quorum-not-met");
if (!credentialRead.best) {
throw new AccountNotFoundError(credentialRead.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event");
}
const credentialPayload = credentialRead.best.payload!;
const credentialEvent = credentialRead.best.event;
// Phrase path: the current recovery capsule, which must belong to the SAME
// account — a phrase for some other account must not be grafted onto this one.
const bip39Seed = await recoveryPhraseToSeed(params.phrase);
const recoveryKeys = deriveRecoveryKeys(bip39Seed);
const recoveryPublicKey = getPublicKeyHex(recoveryKeys.recoveryPrivateKey);
if (recoveryPublicKey !== credentialPayload.recovery_public_key) {
throw new RecoveryFailedError("This recovery phrase does not belong to this account.");
}
const recoveryPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recoveryKeys.recoveryPrivateKey });
let recoveryRead;
try {
recoveryRead = await readRecoveryCapsule(recoveryPool, recoveryPublicKey, recoveryKeys.capsuleKey, params.timeoutMs);
} finally {
recoveryPool.closeAll();
}
if (!recoveryRead.best) throw new AccountNotFoundError("no-valid-candidate");
const recoveryPayload = recoveryRead.best.payload!;
if (recoveryPayload.account_id !== credentialPayload.account_id) {
throw new RecoveryFailedError("This recovery phrase does not belong to this account.");
}
// Idempotency and partial-failure repair: reuse roots wherever they already
// exist. Minting twice is the one unforgivable outcome — it would strand
// whichever records were encrypted under the first mint.
const existingRoot = credentialPayload.connection_vault_root ?? recoveryPayload.connection_vault_root;
const existingSudo = credentialPayload.vault_sudo_key ?? recoveryPayload.vault_sudo_key;
if (
credentialPayload.connection_vault_root !== undefined &&
recoveryPayload.connection_vault_root !== undefined
) {
return {
alreadyEnabled: true,
connectionVaultRoot: base64urlToBytes(credentialPayload.connection_vault_root),
vaultSudoKey: base64urlToBytes(credentialPayload.vault_sudo_key!)
};
}
const connectionVaultRoot = existingRoot !== undefined ? base64urlToBytes(existingRoot) : randomBytes(32);
const vaultSudoKey = existingSudo !== undefined ? base64urlToBytes(existingSudo) : randomBytes(32);
const rootB64 = bytesToBase64url(connectionVaultRoot);
const sudoB64 = bytesToBase64url(vaultSudoKey);
// Recovery capsule first (§15.4 ordering; module comment explains why).
const refreshedRecoveryPayload: RecoveryPayload = {
schema: SCHEMA_RECOVERY_V1,
account_id: recoveryPayload.account_id,
recovery_generation: recoveryPayload.recovery_generation + 1,
previous_recovery_event_id: recoveryRead.best.event.id,
operational_private_key: recoveryPayload.operational_private_key,
operational_public_key: recoveryPayload.operational_public_key,
recovery_public_key: recoveryPayload.recovery_public_key,
connection_vault_root: rootB64,
vault_sudo_key: sudoB64,
created_at: nextCreatedAt(recoveryRead.best.event.created_at, now),
vault_relay_hints: params.vaultRelayUrls,
protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION }
};
const refreshedRecoveryEvent = await buildRecoveryCapsuleEvent({
recoveryPrivateKey: recoveryKeys.recoveryPrivateKey,
capsuleKey: recoveryKeys.capsuleKey,
payload: refreshedRecoveryPayload
});
const recoveryPublishPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recoveryKeys.recoveryPrivateKey });
const recoveryPublish = await publishAndVerify(recoveryPublishPool, refreshedRecoveryEvent, {
dTag: D_TAG_RECOVERY_CAPSULE,
minAcks: params.minAcknowledgements,
timeoutMs: params.timeoutMs
});
recoveryPublishPool.closeAll();
if (!recoveryPublish.success) {
throw new RegistrationFailedError(
"Enabling the vault did not reach the relay quorum while refreshing the recovery capsule. Nothing was changed; please retry."
);
}
const newCredentialPayload: CredentialPayload = {
schema: SCHEMA_CREDENTIAL_V1,
account_id: credentialPayload.account_id,
generation: credentialPayload.generation + 1,
operational_private_key: credentialPayload.operational_private_key,
operational_public_key: credentialPayload.operational_public_key,
recovery_public_key: credentialPayload.recovery_public_key,
recovery_capsule_event: refreshedRecoveryEvent,
connection_vault_root: rootB64,
vault_sudo_key: sudoB64,
created_at: nextCreatedAt(credentialEvent.created_at, now),
vault_relay_hints: params.vaultRelayUrls,
protocol: {
password_kdf: PROTOCOL_PASSWORD_KDF,
capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION,
recovery_derivation: PROTOCOL_RECOVERY_DERIVATION
}
};
const newCredentialEvent = await buildCredentialCapsuleEvent({
locatorPrivateKey: passwordKeys.locatorPrivateKey,
capsuleKey: passwordKeys.capsuleKey,
payload: newCredentialPayload
});
const credentialPublishPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: passwordKeys.locatorPrivateKey });
const credentialPublish = await publishAndVerify(credentialPublishPool, newCredentialEvent, {
dTag: D_TAG_PASSWORD_CAPSULE,
minAcks: params.minAcknowledgements,
timeoutMs: params.timeoutMs
});
credentialPublishPool.closeAll();
if (!credentialPublish.success) {
throw new RegistrationFailedError(
"The recovery capsule now carries the vault roots, but the credential capsule update did not reach quorum. " +
"Retry enableConnectionVault: it will reuse the same roots and only repair the credential capsule."
);
}
return { alreadyEnabled: false, connectionVaultRoot, vaultSudoKey, recoveryPublish, credentialPublish };
}
+1
View File
@@ -13,5 +13,6 @@ export * from "./create.js";
export * from "./login.js";
export * from "./recover.js";
export * from "./changePassword.js";
export * from "./enableVault.js";
export * from "./exportImport.js";
export * from "./signer.js";
+8
View File
@@ -35,6 +35,10 @@ export interface LoginResult {
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). */
@@ -86,6 +90,10 @@ export async function loginWithPassword(params: LoginParams): Promise<LoginResul
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
};
+11
View File
@@ -177,6 +177,15 @@ export async function completeRecoveryWithNewCredentials(params: CompleteRecover
const normalizedLoginName = normalizeLoginName(params.newLoginName);
const { recovered } = params;
// §CV13.2 — the vault roots ride the refresh so a phrase-only recovery
// restores connections, not just identity.
const vaultFields =
recovered.currentRecoveryPayload.connection_vault_root !== undefined
? {
connection_vault_root: recovered.currentRecoveryPayload.connection_vault_root,
vault_sudo_key: recovered.currentRecoveryPayload.vault_sudo_key
}
: {};
const refreshedPayload: RecoveryPayload = {
schema: SCHEMA_RECOVERY_V1,
account_id: recovered.accountId,
@@ -185,6 +194,7 @@ export async function completeRecoveryWithNewCredentials(params: CompleteRecover
operational_private_key: bytesToBase64url(recovered.everydayPrivateKey),
operational_public_key: recovered.everydayPublicKey,
recovery_public_key: recovered.recoveryPublicKey,
...vaultFields,
created_at: nextCreatedAt(recovered.currentRecoveryEvent.created_at, now),
vault_relay_hints: params.vaultRelayUrls,
protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION }
@@ -213,6 +223,7 @@ export async function completeRecoveryWithNewCredentials(params: CompleteRecover
operational_public_key: recovered.everydayPublicKey,
recovery_public_key: recovered.recoveryPublicKey,
recovery_capsule_event: refreshedRecoveryEvent,
...vaultFields,
created_at: now,
vault_relay_hints: params.vaultRelayUrls,
protocol: {
+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. */
+1
View File
@@ -3,3 +3,4 @@ export * as nostr from "./nostr/index.js";
export * as capsules from "./capsules/index.js";
export * as account from "./account/index.js";
export * as storage from "./storage/index.js";
export * as vault from "./vault/index.js";
+94
View File
@@ -0,0 +1,94 @@
/**
* Connection Vault key hierarchy (connection-vault.md §6, finalized).
*
* Everything derives from the two 32-byte roots carried by the capsules:
*
* vault_prk = HKDF-Extract(SHA256("bitlogin/connection-vault-root/v1"), root)
* vault signing key = ScalarExpand(vault_prk, "bitlogin/connection-vault-signing/v1")
* record_key(id) = HKDF-Expand(vault_prk,
* "bitlogin/connection-record-encryption/v1" || 0x00 || id_bytes, 32)
*
* Personal-tier records (stored passwords, secure notes) additionally require
* the sudo key as IKM — decrypting them without a fresh capsule unlock is
* impossible by construction, which is what makes sudo mode real (§SF2):
*
* personal_prk = HKDF-Extract(SHA256("bitlogin/connection-vault-personal/v1"),
* root || sudo_key)
* personal record key = HKDF-Expand(personal_prk, same info layout, 32)
*
* The same raw material is never used for both signing and content
* encryption: signing comes only from ScalarExpand on vault_prk, content keys
* only from HKDF-Expand with the record-encryption info label.
*/
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { hkdfExtract, hkdfExpand, labelSalt } from "../crypto/hkdf.js";
import { scalarExpand } from "../crypto/scalarExpand.js";
import { base64urlToBytes, bytesToBase64url, concatBytes, utf8ToBytes } from "../crypto/encoding.js";
import { randomBytes } from "../crypto/random.js";
export const VAULT_ROOT_LABEL = "bitlogin/connection-vault-root/v1";
export const VAULT_SIGNING_INFO = "bitlogin/connection-vault-signing/v1";
export const VAULT_RECORD_KEY_INFO = "bitlogin/connection-record-encryption/v1";
export const VAULT_PERSONAL_LABEL = "bitlogin/connection-vault-personal/v1";
/** `d` tag prefix (§CV8.1): generic on purpose — it must not reveal the credential type. */
export const D_TAG_CONNECTION_PREFIX = "bitlogin:connection:";
export function deriveVaultPrk(connectionVaultRoot: Uint8Array): Uint8Array {
if (connectionVaultRoot.length !== 32) {
throw new Error("connection_vault_root must be exactly 32 bytes.");
}
return hkdfExtract(labelSalt(VAULT_ROOT_LABEL), connectionVaultRoot);
}
/** The derived Nostr identity that signs every connection record (§CV8.1) —
* deliberately NOT the everyday identity, so a public profile never betrays
* the existence or timing of stored credentials. */
export function deriveVaultSigningKey(vaultPrk: Uint8Array): Uint8Array {
return scalarExpand(vaultPrk, VAULT_SIGNING_INFO).scalar;
}
export function deriveVaultPublicKey(vaultPrk: Uint8Array): string {
return getPublicKeyHex(deriveVaultSigningKey(vaultPrk));
}
export function derivePersonalPrk(connectionVaultRoot: Uint8Array, vaultSudoKey: Uint8Array): Uint8Array {
if (vaultSudoKey.length !== 32) throw new Error("vault_sudo_key must be exactly 32 bytes.");
if (connectionVaultRoot.length !== 32) throw new Error("connection_vault_root must be exactly 32 bytes.");
return hkdfExtract(labelSalt(VAULT_PERSONAL_LABEL), concatBytes(connectionVaultRoot, vaultSudoKey));
}
/** Per-record encryption key (§CV6): the info string, a 0x00 separator, then
* the RAW 16 identifier bytes — spec-fixed layout, see the vectors test. */
export function deriveRecordKey(prk: Uint8Array, connectionId: string): Uint8Array {
const idBytes = decodeConnectionId(connectionId);
return hkdfExpand(prk, concatBytes(utf8ToBytes(VAULT_RECORD_KEY_INFO), new Uint8Array([0x00]), idBytes), 32);
}
/** Random 128-bit connection identifier (§CV7): opaque, never derived from
* anything meaningful, stable for the record's lifetime. */
export function newConnectionId(): string {
return bytesToBase64url(randomBytes(16));
}
export function decodeConnectionId(connectionId: string): Uint8Array {
const bytes = base64urlToBytes(connectionId);
if (bytes.length !== 16) throw new Error("connection_id must decode to exactly 16 bytes (§CV7).");
return bytes;
}
export function connectionDTag(connectionId: string): string {
decodeConnectionId(connectionId); // shape check before it becomes an address
return `${D_TAG_CONNECTION_PREFIX}${connectionId}`;
}
export function connectionIdFromDTag(dTag: string): string | null {
if (!dTag.startsWith(D_TAG_CONNECTION_PREFIX)) return null;
const id = dTag.slice(D_TAG_CONNECTION_PREFIX.length);
try {
decodeConnectionId(id);
return id;
} catch {
return null;
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./types.js";
export * from "./derivation.js";
export * from "./record.js";
export * from "./nwc.js";
export * from "./sync.js";
export * from "./session.js";
+116
View File
@@ -0,0 +1,116 @@
/**
* NWC connection profile (nwc-connections.md §5-§7).
*
* An NWC URI is a bearer credential with active signing authority — parsing
* happens here, in pure code with no I/O, and the parsed secret must follow
* the same handling rules as the raw URI: never logged, never in URLs or
* errors, never at rest outside an encrypted record. Error messages in this
* file therefore describe SHAPE problems without echoing values.
*/
import { SCHEMA_CONNECTION_NWC_V1, type NwcCredential } from "./types.js";
const HEX64 = /^[0-9a-f]{64}$/u;
const KNOWN_PARAMS = new Set(["relay", "secret", "lud16"]);
export class NwcParseError extends Error {
constructor(message: string) {
super(message);
this.name = "NwcParseError";
}
}
/** Parses a `nostr+walletconnect://` URI into the canonical credential (§6). */
export function parseNwcUri(uri: string): NwcCredential {
const trimmed = uri.trim();
if (!/^nostr\+walletconnect:\/\//iu.test(trimmed)) {
throw new NwcParseError("Not an NWC URI: it must start with nostr+walletconnect://");
}
// URL cannot parse the custom scheme portably; swap it for http only to lex.
let parsed: URL;
try {
parsed = new URL(trimmed.replace(/^nostr\+walletconnect:\/\//iu, "http://"));
} catch {
throw new NwcParseError("The NWC URI is malformed.");
}
const walletPubkey = parsed.hostname.toLowerCase();
if (!HEX64.test(walletPubkey)) {
throw new NwcParseError("The NWC URI's wallet service pubkey is not 64-char hex.");
}
const relays: string[] = [];
const extraParams: Array<[string, string]> = [];
let secret: string | null = null;
let lud16: string | null = null;
for (const [key, value] of parsed.searchParams.entries()) {
if (key === "relay") {
let relayUrl: URL;
try {
relayUrl = new URL(value);
} catch {
throw new NwcParseError("A relay parameter in the NWC URI is not a valid URL.");
}
if (relayUrl.protocol !== "wss:" && relayUrl.protocol !== "ws:") {
throw new NwcParseError("A relay parameter in the NWC URI is not a websocket URL.");
}
relays.push(value);
} else if (key === "secret") {
const lowered = value.toLowerCase();
if (!HEX64.test(lowered)) {
throw new NwcParseError("The NWC URI's secret is not a 64-char hex client key.");
}
secret = lowered;
} else if (key === "lud16") {
lud16 = value;
} else {
// Unknown parameters are retained verbatim for lossless export (§5).
extraParams.push([key, value]);
}
}
if (relays.length === 0) throw new NwcParseError("The NWC URI names no relay.");
if (secret === null) throw new NwcParseError("The NWC URI carries no secret.");
return {
schema: SCHEMA_CONNECTION_NWC_V1,
wallet_pubkey: walletPubkey,
relays,
secret,
lud16,
extra_params: extraParams
};
}
/** Lossless export back to URI form (§5): known fields plus retained extras. */
export function toNwcUri(credential: NwcCredential): string {
validateNwcCredential(credential);
const params = new URLSearchParams();
for (const relay of credential.relays) params.append("relay", relay);
params.append("secret", credential.secret);
if (credential.lud16 !== null) params.append("lud16", credential.lud16);
for (const [key, value] of credential.extra_params) params.append(key, value);
return `nostr+walletconnect://${credential.wallet_pubkey}?${params.toString()}`;
}
export function validateNwcCredential(credential: unknown): asserts credential is NwcCredential {
const c = credential as Partial<NwcCredential> | null;
const fail = (message: string) => {
throw new NwcParseError(message);
};
if (!c || typeof c !== "object") fail("NWC credential must be an object.");
if (c!.schema !== SCHEMA_CONNECTION_NWC_V1) fail("NWC credential has the wrong schema.");
if (typeof c!.wallet_pubkey !== "string" || !HEX64.test(c!.wallet_pubkey)) {
fail("NWC credential wallet_pubkey must be 64-char lowercase hex.");
}
if (!Array.isArray(c!.relays) || c!.relays.length === 0 || c!.relays.some((r) => typeof r !== "string")) {
fail("NWC credential must name at least one relay.");
}
if (typeof c!.secret !== "string" || !HEX64.test(c!.secret)) {
fail("NWC credential secret must be 64-char lowercase hex.");
}
if (c!.lud16 !== null && typeof c!.lud16 !== "string") fail("NWC credential lud16 must be a string or null.");
if (
!Array.isArray(c!.extra_params) ||
c!.extra_params.some((p) => !Array.isArray(p) || p.length !== 2 || typeof p[0] !== "string" || typeof p[1] !== "string")
) {
fail("NWC credential extra_params must be [key, value] string pairs.");
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Connection record events (connection-vault.md §8, §9, §11).
*
* Each connection is one NIP-78 addressable event signed by the DERIVED
* vault identity, its content an encrypted envelope built with the same
* JCS + fixed-bucket padding + AES-256-GCM pipeline as the capsules. The
* padding buckets are the capsule buckets (1024/2048/4096) — resolved open
* decision §CV16.2: every profile fixture fits 1024 with room, and a shared
* bucket set means an NWC record and an S3 record are indistinguishable by
* size on a relay.
*
* Tier is invisible from the outside: the two tiers use different keys but
* identical envelopes, d-tag shapes, and buckets, so decryption is
* trial-based (connectable key first, then personal when a sudo window is
* open). Two AES attempts are microseconds; a distinguishable ciphertext
* would leak which records are "the sensitive ones" to every relay.
*/
import { signNostrEvent, verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js";
import { KIND_APP_DATA } from "../nostr/kinds.js";
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { utf8ToBytes } from "../crypto/encoding.js";
import { encryptEnvelope, decryptEnvelope } from "../capsules/serialization.js";
import type { EncryptedEnvelope } from "../capsules/types.js";
import { nextCreatedAt } from "../account/timestamp.js";
import {
connectionDTag,
connectionIdFromDTag,
deriveRecordKey,
deriveVaultSigningKey
} from "./derivation.js";
import { SCHEMA_CONNECTION_V1, type ConnectionRecord, type ConnectionTier } from "./types.js";
const MAX_LABEL_LENGTH = 120;
const STATES = new Set(["active", "suspended", "deleted"]);
const TIERS = new Set(["connectable", "personal"]);
export class ConnectionRecordError extends Error {
constructor(message: string) {
super(message);
this.name = "ConnectionRecordError";
}
}
/** §CV8.2 associated data — binds the ciphertext to its address. */
export function connectionRecordAad(vaultPubkeyHex: string, dTag: string): Uint8Array {
return utf8ToBytes(`bitlogin|connection-record|v1|${vaultPubkeyHex}|30078|${dTag}`);
}
export function validateConnectionRecord(record: unknown): asserts record is ConnectionRecord {
const r = record as Partial<ConnectionRecord> | null;
const fail = (message: string) => {
throw new ConnectionRecordError(message);
};
if (!r || typeof r !== "object") fail("Connection record must be an object.");
if (r!.schema !== SCHEMA_CONNECTION_V1) fail(`Unsupported connection record schema (§CV8.3).`);
if (typeof r!.connection_id !== "string") fail("connection_id must be a string (§CV7).");
if (typeof r!.connection_type !== "string" || r!.connection_type.length === 0) {
fail("connection_type must be a non-empty string (§CV8.3).");
}
if (!TIERS.has(r!.tier as string)) fail("tier must be connectable or personal.");
if (!STATES.has(r!.state as string)) fail("state must be active, suspended, or deleted (§CV8.3).");
if (typeof r!.label !== "string" || r!.label.length > MAX_LABEL_LENGTH) {
fail(`label must be a string of at most ${MAX_LABEL_LENGTH} characters.`);
}
if (!Number.isInteger(r!.created_at) || !Number.isInteger(r!.updated_at)) {
fail("created_at and updated_at must be integers (§CV8.3).");
}
const credential = r!.credential as { schema?: unknown } | undefined;
if (!credential || typeof credential !== "object" || typeof credential.schema !== "string") {
fail("credential must be an object naming its profile schema (§CV8.3).");
}
const binding = r!.application_binding as { origin?: unknown; app_pubkey?: unknown } | undefined;
if (
!binding ||
typeof binding !== "object" ||
(binding.origin !== null && typeof binding.origin !== "string") ||
(binding.app_pubkey !== null && typeof binding.app_pubkey !== "string")
) {
fail("application_binding must carry origin and app_pubkey (string or null).");
}
if (r!.notes !== null && typeof r!.notes !== "string") fail("notes must be a string or null.");
}
/**
* Encrypts and signs one record as its addressable event. `previousCreatedAt`
* feeds the monotonic replacement rule (§CV9) — pass the replaced event's
* created_at on every update or a same-second edit will silently lose.
*/
export async function buildConnectionRecordEvent(params: {
vaultPrk: Uint8Array;
/** The prk whose keys encrypt THIS record: vaultPrk for connectable, personalPrk for personal. */
recordPrk: Uint8Array;
record: ConnectionRecord;
previousCreatedAt?: number | null;
now?: number;
}): Promise<NostrEvent> {
validateConnectionRecord(params.record);
const signingKey = deriveVaultSigningKey(params.vaultPrk);
const vaultPubkey = getPublicKeyHex(signingKey);
const dTag = connectionDTag(params.record.connection_id);
const recordKey = deriveRecordKey(params.recordPrk, params.record.connection_id);
const envelope = await encryptEnvelope(params.record, recordKey, connectionRecordAad(vaultPubkey, dTag));
return signNostrEvent(
{
pubkey: vaultPubkey,
created_at: nextCreatedAt(params.previousCreatedAt, params.now),
kind: KIND_APP_DATA,
tags: [["d", dTag]],
content: JSON.stringify(envelope)
},
signingKey
);
}
export interface DecryptedConnectionRecord {
record: ConnectionRecord;
tier: ConnectionTier;
event: NostrEvent;
}
/**
* Trial-decrypts one event: the connectable key always, the personal key
* only when a sudo window has supplied `personalPrk`. Returns null for
* events that decrypt with neither — which, outside a sudo window, is the
* EXPECTED result for every personal-tier record, not an error.
*/
export async function decryptConnectionRecordEvent(
event: NostrEvent,
vaultPrk: Uint8Array,
personalPrk?: Uint8Array
): Promise<DecryptedConnectionRecord | null> {
if (!verifyNostrEvent(event)) {
throw new ConnectionRecordError("Connection record event has an invalid id or signature.");
}
const expectedPubkey = getPublicKeyHex(deriveVaultSigningKey(vaultPrk));
if (event.pubkey !== expectedPubkey) {
throw new ConnectionRecordError("Connection record event is not signed by this vault's identity.");
}
const dTag = findTagValue(event, "d") ?? "";
const connectionId = connectionIdFromDTag(dTag);
if (connectionId === null) {
throw new ConnectionRecordError("Connection record event has a malformed d tag.");
}
const envelope = JSON.parse(event.content) as EncryptedEnvelope;
const aad = connectionRecordAad(event.pubkey, dTag);
const attempts: Array<{ prk: Uint8Array; tier: ConnectionTier }> = [
{ prk: vaultPrk, tier: "connectable" },
...(personalPrk ? [{ prk: personalPrk, tier: "personal" as const }] : [])
];
for (const attempt of attempts) {
let record: ConnectionRecord;
try {
record = await decryptEnvelope<ConnectionRecord>(envelope, deriveRecordKey(attempt.prk, connectionId), aad);
} catch {
continue; // wrong tier for this key; try the next
}
validateConnectionRecord(record);
if (record.connection_id !== connectionId) {
// The d tag and the ciphertext must agree, or one record could be
// smuggled into another record's replaceable slot.
throw new ConnectionRecordError("Connection record id does not match its d tag.");
}
if (record.tier !== attempt.tier) {
throw new ConnectionRecordError("Connection record tier does not match the key that decrypted it.");
}
return { record, tier: attempt.tier, event };
}
return null;
}
/**
* §CV11 tombstone: the record replaced with state "deleted" and its
* credential wiped to the bare profile schema. Encrypted like any record —
* a relay must not learn that a deletion happened, only that a replacement did.
*/
export function tombstoneRecord(record: ConnectionRecord, now: number): ConnectionRecord {
return {
...record,
state: "deleted",
credential: { schema: record.credential.schema },
notes: null,
updated_at: now
};
}
+196
View File
@@ -0,0 +1,196 @@
/**
* VaultSession — the ergonomic surface a host (the widget, a native client)
* drives after login. It owns the derived key material and the sudo-window
* policy so callers cannot get the caching contract wrong by accident:
*
* - The vault prk lives for the session (connectable-tier operations are
* silent, which is the product).
* - The personal prk exists ONLY between enableSudo() and endSudo(), and
* endSudo() wipes it. Re-entering sudo requires the sudo key again —
* which the host can only get by re-decrypting a capsule (a fresh
* Argon2id run on the password, or the phrase). enableSudo() wipes the
* caller's copy of the sudo key on the way in so exactly one live copy
* exists at a time.
*/
import { wipe } from "../crypto/memory.js";
import type { NostrEvent } from "../nostr/event.js";
import type { KeyValueStore } from "../storage/interface.js";
import {
derivePersonalPrk,
deriveVaultPrk,
deriveVaultPublicKey,
newConnectionId
} from "./derivation.js";
import {
buildConnectionRecordEvent,
decryptConnectionRecordEvent,
tombstoneRecord,
type DecryptedConnectionRecord
} from "./record.js";
import { fetchConnectionRecordEvents, publishConnectionRecord, type FetchedConnectionEvents } from "./sync.js";
import type { PublishVerificationResult } from "../account/publish.js";
import { SCHEMA_CONNECTION_V1, type ApplicationBinding, type ConnectionRecord, type ConnectionTier } from "./types.js";
export interface CreateConnectionParams {
connection_type: string;
tier: ConnectionTier;
label: string;
credential: { schema: string } & Record<string, unknown>;
application_binding?: ApplicationBinding;
notes?: string | null;
now?: number;
}
export class VaultSession {
private vaultPrk: Uint8Array;
private personalPrk: Uint8Array | null = null;
readonly vaultPublicKey: string;
constructor(private readonly connectionVaultRoot: Uint8Array) {
this.vaultPrk = deriveVaultPrk(connectionVaultRoot);
this.vaultPublicKey = deriveVaultPublicKey(this.vaultPrk);
}
get sudoActive(): boolean {
return this.personalPrk !== null;
}
/** Opens a sudo window. Consumes (wipes) the caller's sudo key copy. */
enableSudo(vaultSudoKey: Uint8Array): void {
this.endSudo();
this.personalPrk = derivePersonalPrk(this.connectionVaultRoot, vaultSudoKey);
wipe(vaultSudoKey);
}
/** Closes the sudo window and wipes the personal-tier key material. */
endSudo(): void {
if (this.personalPrk) wipe(this.personalPrk);
this.personalPrk = null;
}
/** Wipes everything this session holds. The session is unusable after. */
destroy(): void {
this.endSudo();
wipe(this.vaultPrk);
}
private prkForTier(tier: ConnectionTier): Uint8Array {
if (tier === "connectable") return this.vaultPrk;
if (!this.personalPrk) {
throw new Error("Personal-tier records require an open sudo window (enableSudo).");
}
return this.personalPrk;
}
/** Builds a fresh record + its signed event. Purely local; publish separately. */
async createConnection(params: CreateConnectionParams): Promise<{ record: ConnectionRecord; event: NostrEvent }> {
const now = params.now ?? Math.floor(Date.now() / 1000);
const record: ConnectionRecord = {
schema: SCHEMA_CONNECTION_V1,
connection_id: newConnectionId(),
connection_type: params.connection_type,
tier: params.tier,
state: "active",
label: params.label,
created_at: now,
updated_at: now,
credential: params.credential,
application_binding: params.application_binding ?? { origin: null, app_pubkey: null },
notes: params.notes ?? null
};
const event = await buildConnectionRecordEvent({
vaultPrk: this.vaultPrk,
recordPrk: this.prkForTier(params.tier),
record,
now
});
return { record, event };
}
/** Replaces a record's content. The tier is immutable — moving a secret
* between tiers is a delete-and-recreate, never a silent re-encryption. */
async updateConnection(
current: DecryptedConnectionRecord,
changes: Partial<Pick<ConnectionRecord, "state" | "label" | "credential" | "application_binding" | "notes">>,
now?: number
): Promise<{ record: ConnectionRecord; event: NostrEvent }> {
const at = now ?? Math.floor(Date.now() / 1000);
const record: ConnectionRecord = {
...current.record,
...changes,
tier: current.record.tier,
connection_id: current.record.connection_id,
created_at: current.record.created_at,
updated_at: at
};
const event = await buildConnectionRecordEvent({
vaultPrk: this.vaultPrk,
recordPrk: this.prkForTier(record.tier),
record,
previousCreatedAt: current.event.created_at,
now: at
});
return { record, event };
}
/** §CV11 step 2: the encrypted tombstone replacement. */
async deleteConnection(
current: DecryptedConnectionRecord,
now?: number
): Promise<{ record: ConnectionRecord; event: NostrEvent }> {
const at = now ?? Math.floor(Date.now() / 1000);
const record = tombstoneRecord(current.record, at);
const event = await buildConnectionRecordEvent({
vaultPrk: this.vaultPrk,
recordPrk: this.prkForTier(record.tier),
record,
previousCreatedAt: current.event.created_at,
now: at
});
return { record, event };
}
/** Trial-decrypts one event; personal-tier records resolve only inside a sudo window. */
decryptEvent(event: NostrEvent): Promise<DecryptedConnectionRecord | null> {
return decryptConnectionRecordEvent(event, this.vaultPrk, this.personalPrk ?? undefined);
}
publish(params: {
event: NostrEvent;
connectionId: string;
relayUrls: string[];
store: KeyValueStore;
minAcknowledgements?: number;
timeoutMs?: number;
}): Promise<PublishVerificationResult> {
return publishConnectionRecord({ vaultPrk: this.vaultPrk, ...params });
}
fetchEvents(params: {
relayUrls: string[];
store: KeyValueStore;
timeoutMs?: number;
}): Promise<FetchedConnectionEvents> {
return fetchConnectionRecordEvents({ vaultPrk: this.vaultPrk, ...params });
}
/**
* Fetches and decrypts everything decryptable right now. Outside a sudo
* window the personal tier is simply absent from the result — invisible,
* not erroring, exactly like the app-facing API contract.
*/
async listConnections(params: {
relayUrls: string[];
store: KeyValueStore;
timeoutMs?: number;
}): Promise<{ connections: DecryptedConnectionRecord[]; rollbackWarnings: string[]; quorumMet: boolean }> {
const fetched = await this.fetchEvents(params);
const connections: DecryptedConnectionRecord[] = [];
for (const event of fetched.events.values()) {
const decrypted = await this.decryptEvent(event);
if (decrypted) connections.push(decrypted);
}
connections.sort((a, b) => b.record.updated_at - a.record.updated_at);
return { connections, rollbackWarnings: fetched.rollbackWarnings, quorumMet: fetched.quorumMet };
}
}
+148
View File
@@ -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
};
}
+56
View File
@@ -0,0 +1,56 @@
/** Connection Vault record schemas (connection-vault.md §8.3, §14). */
export const SCHEMA_CONNECTION_V1 = "bitlogin.connection.v1";
export const SCHEMA_CONNECTION_NWC_V1 = "bitlogin.connection.nwc.v1";
export type ConnectionState = "active" | "suspended" | "deleted";
/**
* The sensitivity tier decides which key hierarchy encrypts the record and
* what the client may do with it:
*
* - `connectable`: grantable to application origins through the consent flow
* (budget-capped NWC, scoped S3). Keys derive from the vault root alone,
* so daily use never re-prompts.
* - `personal`: stored passwords and secure notes. Keys additionally require
* the sudo key (derivation.ts), and the app-facing API must be
* structurally blind to this tier — an embedded widget cannot do
* exact-origin matching, and revealing one site's password to another
* site's page is a phishing machine with a consent screen.
*/
export type ConnectionTier = "connectable" | "personal";
export interface ApplicationBinding {
/** The web origin this connection is bound to, or null when unbound. */
origin: string | null;
/** Optional Nostr application key binding (§CV16.4, still open). */
app_pubkey: string | null;
}
export interface ConnectionRecord {
schema: typeof SCHEMA_CONNECTION_V1;
connection_id: string; // base64url, 128-bit random (§CV7)
connection_type: string; // "nwc", "s3", "password", ...
tier: ConnectionTier;
state: ConnectionState;
label: string;
created_at: number;
updated_at: number;
/** Profile-defined payload; `schema` names the profile. A tombstone keeps
* only the schema field (§CV11: secrets are wiped, the slot remains). */
credential: { schema: string } & Record<string, unknown>;
application_binding: ApplicationBinding;
notes: string | null;
}
/** Canonical NWC credential (nwc-connections.md §6): the parsed URI, stored
* structured so export is lossless and display never needs the secret. */
export interface NwcCredential {
schema: typeof SCHEMA_CONNECTION_NWC_V1;
wallet_pubkey: string; // lowercase hex
relays: string[]; // at least one, wss:// or ws://
secret: string; // lowercase hex, 32 bytes — the bearer client key
lud16: string | null;
/** Unknown query parameters retained verbatim for lossless export (§5). */
extra_params: Array<[string, string]>;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Connection Vault protocol round trips against mock relays.
*
* The scenarios mirror vault-ux.md: a new account gets the vault for free, a
* legacy account migrates with the phrase, records survive a clean-device
* login AND a phrase recovery, a password change never strands them, and a
* stale replica cannot resurrect an old credential.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { MockRelay } from "../test-support/mockRelay.js";
import { registerAccount } from "../account/create.js";
import { loginWithPassword } from "../account/login.js";
import { changePassword } from "../account/changePassword.js";
import { recoverWithPhrase, completeRecoveryWithNewCredentials } from "../account/recover.js";
import { enableConnectionVault } from "../account/enableVault.js";
import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "../account/normalize.js";
import { recoveryPhraseToSeed } from "../crypto/bip39.js";
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { RelayPool } from "../nostr/pool.js";
import { D_TAG_PASSWORD_CAPSULE, D_TAG_RECOVERY_CAPSULE, SCHEMA_CREDENTIAL_V1, SCHEMA_RECOVERY_V1 } from "../nostr/kinds.js";
import { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js";
import {
PROTOCOL_CAPSULE_ENCRYPTION,
PROTOCOL_PASSWORD_KDF,
PROTOCOL_RECOVERY_DERIVATION,
type CredentialPayload,
type RecoveryPayload
} from "../capsules/types.js";
import { publishAndVerify } from "../account/publish.js";
import { InMemoryKeyValueStore } from "../storage/interface.js";
import { VaultSession } from "./session.js";
import { parseNwcUri } from "./nwc.js";
import { buildConnectionRecordEvent } from "./record.js";
import { publishConnectionRecord, fetchConnectionRecordEvents } from "./sync.js";
const NWC_URI = `nostr+walletconnect://${"ab".repeat(32)}?relay=wss%3A%2F%2Frelay.example&secret=${"cd".repeat(32)}`;
describe("Connection Vault end-to-end", () => {
let relays: MockRelay[] = [];
let vaultRelayUrls: string[] = [];
beforeEach(async () => {
relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]);
vaultRelayUrls = relays.map((r) => r.url);
});
afterEach(async () => {
await Promise.all(relays.map((r) => r.close()));
});
it("registration mints the roots; records survive a clean-device login; sign-in restores the wallet", async () => {
const loginName = "vault-user";
const password = "correct horse battery staple 9";
const registration = await registerAccount({ loginName, password, vaultRelayUrls });
expect(registration.connectionVaultRoot.length).toBe(32);
expect(registration.vaultSudoKey.length).toBe(32);
// Device A: store one NWC connection, bound to Satisfied's origin.
const sessionA = new VaultSession(registration.connectionVaultRoot);
const storeA = new InMemoryKeyValueStore();
const { record, event } = await sessionA.createConnection({
connection_type: "nwc",
tier: "connectable",
label: "Satisfied spending wallet",
credential: parseNwcUri(NWC_URI),
application_binding: { origin: "https://imsatisfied.app", app_pubkey: null }
});
const publish = await sessionA.publish({
event,
connectionId: record.connection_id,
relayUrls: vaultRelayUrls,
store: storeA
});
expect(publish.success).toBe(true);
// Device B, clean: password login restores the roots, then the wallet.
const login = await loginWithPassword({ loginName, password, vaultRelayUrls });
expect(login.connectionVaultRoot).toBeDefined();
expect(Buffer.from(login.connectionVaultRoot!)).toEqual(Buffer.from(registration.connectionVaultRoot));
const sessionB = new VaultSession(login.connectionVaultRoot!);
const listed = await sessionB.listConnections({ relayUrls: vaultRelayUrls, store: new InMemoryKeyValueStore() });
expect(listed.connections).toHaveLength(1);
expect(listed.connections[0]!.record.label).toBe("Satisfied spending wallet");
expect(listed.connections[0]!.record.application_binding.origin).toBe("https://imsatisfied.app");
expect((listed.connections[0]!.record.credential as { secret: string }).secret).toBe("cd".repeat(32));
});
it("password change carries the roots; phrase recovery restores them on a clean device", async () => {
const loginName = "vault-rotator";
const oldPassword = "old password entirely 77";
const newPassword = "new password entirely 88";
const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls });
await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls });
const login = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls });
expect(Buffer.from(login.connectionVaultRoot!)).toEqual(Buffer.from(registration.connectionVaultRoot));
expect(Buffer.from(login.vaultSudoKey!)).toEqual(Buffer.from(registration.vaultSudoKey));
// House-fire scenario: phrase only, new login credentials.
const recovered = await recoverWithPhrase({
phrase: registration.recoveryPhrase,
vaultRelayUrls,
discoveryRelayUrls: vaultRelayUrls
});
expect(recovered.currentRecoveryPayload.connection_vault_root).toBeDefined();
await completeRecoveryWithNewCredentials({
recovered,
newLoginName: "vault-rotator-reborn",
newPassword: "phoenix password 12",
vaultRelayUrls
});
const reborn = await loginWithPassword({
loginName: "vault-rotator-reborn",
password: "phoenix password 12",
vaultRelayUrls
});
expect(Buffer.from(reborn.connectionVaultRoot!)).toEqual(Buffer.from(registration.connectionVaultRoot));
});
it("a legacy account (no roots) migrates via the phrase ceremony, idempotently", async () => {
const loginName = "legacy-account";
const password = "pre vault password 55";
const registration = await registerAccount({ loginName, password, vaultRelayUrls });
// Simulate a pre-vault account: republish both capsules WITHOUT the vault
// fields at higher generations, exactly what an old client would write.
const normalized = normalizeLoginName(loginName);
const passwordKeys = await derivePasswordKeys(password, normalized);
const seed = await recoveryPhraseToSeed(registration.recoveryPhrase);
const recoveryKeys = deriveRecoveryKeys(seed);
const operationalPrivB64 = Buffer.from(registration.everydayPrivateKey).toString("base64url");
const strippedRecovery: RecoveryPayload = {
schema: SCHEMA_RECOVERY_V1,
account_id: registration.accountId,
recovery_generation: 1,
previous_recovery_event_id: registration.recoveryEvent.id,
operational_private_key: operationalPrivB64,
operational_public_key: registration.everydayPublicKey,
recovery_public_key: registration.recoveryPublicKey,
created_at: registration.recoveryEvent.created_at + 1,
vault_relay_hints: vaultRelayUrls,
protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION }
};
const strippedRecoveryEvent = await buildRecoveryCapsuleEvent({
recoveryPrivateKey: recoveryKeys.recoveryPrivateKey,
capsuleKey: recoveryKeys.capsuleKey,
payload: strippedRecovery
});
const recoveryPool = new RelayPool(vaultRelayUrls, { authPrivateKey: recoveryKeys.recoveryPrivateKey });
expect(
(await publishAndVerify(recoveryPool, strippedRecoveryEvent, { dTag: D_TAG_RECOVERY_CAPSULE })).success
).toBe(true);
recoveryPool.closeAll();
const strippedCredential: CredentialPayload = {
schema: SCHEMA_CREDENTIAL_V1,
account_id: registration.accountId,
generation: 1,
operational_private_key: operationalPrivB64,
operational_public_key: registration.everydayPublicKey,
recovery_public_key: registration.recoveryPublicKey,
recovery_capsule_event: strippedRecoveryEvent,
created_at: registration.credentialEvent.created_at + 1,
vault_relay_hints: vaultRelayUrls,
protocol: {
password_kdf: PROTOCOL_PASSWORD_KDF,
capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION,
recovery_derivation: PROTOCOL_RECOVERY_DERIVATION
}
};
const strippedCredentialEvent = await buildCredentialCapsuleEvent({
locatorPrivateKey: passwordKeys.locatorPrivateKey,
capsuleKey: passwordKeys.capsuleKey,
payload: strippedCredential
});
const credentialPool = new RelayPool(vaultRelayUrls, {
authPrivateKey: passwordKeys.locatorPrivateKey
});
expect(
(await publishAndVerify(credentialPool, strippedCredentialEvent, { dTag: D_TAG_PASSWORD_CAPSULE })).success
).toBe(true);
credentialPool.closeAll();
const legacyLogin = await loginWithPassword({ loginName, password, vaultRelayUrls });
expect(legacyLogin.connectionVaultRoot).toBeUndefined();
// Wrong phrase must not graft a vault onto this account.
const stranger = await registerAccount({
loginName: "someone-else",
password: "another password 33",
vaultRelayUrls
});
await expect(
enableConnectionVault({ loginName, password, phrase: stranger.recoveryPhrase, vaultRelayUrls })
).rejects.toThrow(/does not belong to this account/);
// The ceremony proper.
const enabled = await enableConnectionVault({
loginName,
password,
phrase: registration.recoveryPhrase,
vaultRelayUrls
});
expect(enabled.alreadyEnabled).toBe(false);
const after = await loginWithPassword({ loginName, password, vaultRelayUrls });
expect(Buffer.from(after.connectionVaultRoot!)).toEqual(Buffer.from(enabled.connectionVaultRoot));
// Idempotent: a second run reuses the roots and writes nothing.
const again = await enableConnectionVault({
loginName,
password,
phrase: registration.recoveryPhrase,
vaultRelayUrls
});
expect(again.alreadyEnabled).toBe(true);
expect(Buffer.from(again.connectionVaultRoot)).toEqual(Buffer.from(enabled.connectionVaultRoot));
});
it("a stale replica cannot silently resurrect an old record (§CV9)", async () => {
const registration = await registerAccount({
loginName: "rollback-user",
password: "rollback password 44",
vaultRelayUrls
});
const session = new VaultSession(registration.connectionVaultRoot);
const store = new InMemoryKeyValueStore();
const { record, event: v1 } = await session.createConnection({
connection_type: "nwc",
tier: "connectable",
label: "wallet v1",
credential: parseNwcUri(NWC_URI)
});
expect(
(await session.publish({ event: v1, connectionId: record.connection_id, relayUrls: vaultRelayUrls, store }))
.success
).toBe(true);
// The update reaches only relay 0 — relays 1 and 2 keep the stale v1.
const current = await session.decryptEvent(v1);
const { event: v2 } = await session.updateConnection(current!, { label: "wallet v2" });
expect(
(
await session.publish({
event: v2,
connectionId: record.connection_id,
relayUrls: [vaultRelayUrls[0]!],
store,
minAcknowledgements: 1
})
).success
).toBe(true);
// Reading ONLY the stale relays: the record is withheld and warned about,
// not served at its old contents.
const stale = await session.fetchEvents({ relayUrls: [vaultRelayUrls[1]!, vaultRelayUrls[2]!], store });
expect(stale.rollbackWarnings).toEqual([record.connection_id]);
expect(stale.events.size).toBe(0);
// Reading everything: the newest version wins.
const all = await session.fetchEvents({ relayUrls: vaultRelayUrls, store });
expect(all.rollbackWarnings).toEqual([]);
const decrypted = await session.decryptEvent(all.events.get(record.connection_id)!);
expect(decrypted!.record.label).toBe("wallet v2");
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* Connection Vault unit tests (connection-vault.md, finalized constants).
*
* The vector block pins the §CV6 derivations byte-for-byte: these values are
* the compatibility contract. If a refactor changes any of them, existing
* users' vault records become undecryptable — the fix is to revert the
* refactor, never to update the vectors.
*/
import { describe, expect, it } from "vitest";
import { bytesToHex, base64urlToBytes } from "../crypto/encoding.js";
import { validateVaultFields, CapsuleValidationError } from "../capsules/validation.js";
import {
connectionDTag,
connectionIdFromDTag,
derivePersonalPrk,
deriveRecordKey,
deriveVaultPrk,
deriveVaultPublicKey,
newConnectionId
} from "./derivation.js";
import { parseNwcUri, toNwcUri, NwcParseError } from "./nwc.js";
import {
buildConnectionRecordEvent,
decryptConnectionRecordEvent,
tombstoneRecord,
ConnectionRecordError
} from "./record.js";
import { VaultSession } from "./session.js";
import { SCHEMA_CONNECTION_V1, SCHEMA_CONNECTION_NWC_V1, type ConnectionRecord } from "./types.js";
const ROOT = new Uint8Array(32).map((_, i) => i + 1); // 0x01..0x20
const SUDO = new Uint8Array(32).map((_, i) => 0xa0 + (i % 16));
const FIXED_ID = "AAECAwQFBgcICQoLDA0ODw"; // base64url(0x00..0x0f)
function record(overrides: Partial<ConnectionRecord> = {}): ConnectionRecord {
return {
schema: SCHEMA_CONNECTION_V1,
connection_id: FIXED_ID,
connection_type: "nwc",
tier: "connectable",
state: "active",
label: "Satisfied spending wallet",
created_at: 1_785_400_000,
updated_at: 1_785_400_000,
credential: {
schema: SCHEMA_CONNECTION_NWC_V1,
wallet_pubkey: "ab".repeat(32),
relays: ["wss://relay.example"],
secret: "cd".repeat(32),
lud16: null,
extra_params: []
},
application_binding: { origin: "https://imsatisfied.app", app_pubkey: null },
notes: null,
...overrides
};
}
describe("§CV6 derivation vectors (compatibility contract — never update, only revert)", () => {
const prk = deriveVaultPrk(ROOT);
it("vault PRK", () => {
expect(bytesToHex(prk)).toBe("51c3b1186d216e29366e28bda2bf254b40a99a1fdb36759363749ba6be15d5de");
});
it("vault signing identity", () => {
expect(deriveVaultPublicKey(prk)).toBe("71084b9ea80ad9c54ccab57f3586e7017c194c2266af188abfb433c6e9498af4");
});
it("connectable record key", () => {
expect(bytesToHex(deriveRecordKey(prk, FIXED_ID))).toBe(
"a01c357bb49de944dc7529947f3bdf781cc368c1823031544e9a50a2c3009dcf"
);
});
it("personal PRK and record key", () => {
const personal = derivePersonalPrk(ROOT, SUDO);
expect(bytesToHex(personal)).toBe("bef59847801ae2017f3b45f415c24ab596784392cac23a227261c61e875cf9a3");
expect(bytesToHex(deriveRecordKey(personal, FIXED_ID))).toBe(
"63214e56f371acbedbbc8f1106a3ba2aedc6151e13b3374f6410f71adeebdb6c"
);
});
it("rejects roots of the wrong size", () => {
expect(() => deriveVaultPrk(new Uint8Array(16))).toThrow();
expect(() => derivePersonalPrk(ROOT, new Uint8Array(31))).toThrow();
});
});
describe("§CV7 connection identifiers", () => {
it("generates 16-byte opaque ids that round-trip through the d tag", () => {
const id = newConnectionId();
expect(base64urlToBytes(id).length).toBe(16);
expect(connectionIdFromDTag(connectionDTag(id))).toBe(id);
});
it("refuses malformed ids and foreign d tags", () => {
expect(() => connectionDTag("too-short")).toThrow();
expect(connectionIdFromDTag("bitlogin:password:v1")).toBeNull();
expect(connectionIdFromDTag("bitlogin:connection:not!base64url!!")).toBeNull();
});
});
describe("§CV5.2 capsule vault fields", () => {
const root = "A".repeat(43); // 32 bytes of base64url
it("accepts both-present and both-absent, rejects one-without-the-other", () => {
expect(() => validateVaultFields({})).not.toThrow();
expect(() => validateVaultFields({ connection_vault_root: root, vault_sudo_key: root })).not.toThrow();
expect(() => validateVaultFields({ connection_vault_root: root })).toThrow(CapsuleValidationError);
expect(() => validateVaultFields({ vault_sudo_key: root })).toThrow(CapsuleValidationError);
});
it("rejects wrong-size and non-base64url values", () => {
expect(() => validateVaultFields({ connection_vault_root: "AAAA", vault_sudo_key: root })).toThrow();
expect(() => validateVaultFields({ connection_vault_root: "!!!", vault_sudo_key: root })).toThrow();
});
});
describe("NWC profile (nwc-connections.md §5-§6)", () => {
const URI =
"nostr+walletconnect://" +
"ab".repeat(32) +
"?relay=wss%3A%2F%2Frelay.one&relay=wss%3A%2F%2Frelay.two&secret=" +
"CD".repeat(32) +
"&lud16=adam%40example.com&budget_renewal=weekly";
it("parses, keeps every relay in order, lowercases hex, retains unknown params", () => {
const credential = parseNwcUri(URI);
expect(credential.wallet_pubkey).toBe("ab".repeat(32));
expect(credential.relays).toEqual(["wss://relay.one", "wss://relay.two"]);
expect(credential.secret).toBe("cd".repeat(32));
expect(credential.lud16).toBe("[email protected]");
expect(credential.extra_params).toEqual([["budget_renewal", "weekly"]]);
});
it("round-trips losslessly through export and re-parse", () => {
const credential = parseNwcUri(URI);
expect(parseNwcUri(toNwcUri(credential))).toEqual(credential);
});
it("refuses missing relay, missing secret, bad pubkey, and non-websocket relays", () => {
const base = `nostr+walletconnect://${"ab".repeat(32)}`;
expect(() => parseNwcUri(`${base}?secret=${"cd".repeat(32)}`)).toThrow(NwcParseError);
expect(() => parseNwcUri(`${base}?relay=wss%3A%2F%2Fr.example`)).toThrow(NwcParseError);
expect(() => parseNwcUri(`nostr+walletconnect://nothex?relay=wss%3A%2F%2Fr&secret=${"cd".repeat(32)}`)).toThrow(
NwcParseError
);
expect(() => parseNwcUri(`${base}?relay=https%3A%2F%2Fr.example&secret=${"cd".repeat(32)}`)).toThrow(NwcParseError);
});
});
describe("§CV8 record events", () => {
const prk = deriveVaultPrk(ROOT);
it("encrypts, signs with the vault identity, and round-trips", async () => {
const event = await buildConnectionRecordEvent({ vaultPrk: prk, recordPrk: prk, record: record(), now: 1_785_400_100 });
expect(event.pubkey).toBe(deriveVaultPublicKey(prk));
expect(event.tags).toEqual([["d", `bitlogin:connection:${FIXED_ID}`]]);
const decrypted = await decryptConnectionRecordEvent(event, prk);
expect(decrypted?.tier).toBe("connectable");
expect(decrypted?.record.label).toBe("Satisfied spending wallet");
});
it("a different vault's key cannot read or even attribute the record", async () => {
const event = await buildConnectionRecordEvent({ vaultPrk: prk, recordPrk: prk, record: record(), now: 1 });
const otherPrk = deriveVaultPrk(new Uint8Array(32).map((_, i) => 99 + i));
await expect(decryptConnectionRecordEvent(event, otherPrk)).rejects.toThrow(
/not signed by this vault's identity/
);
});
it("personal-tier records are INVISIBLE (null) without the personal prk, readable with it", async () => {
const personalPrk = derivePersonalPrk(ROOT, SUDO);
const secretRecord = record({ tier: "personal", connection_type: "password" });
const event = await buildConnectionRecordEvent({ vaultPrk: prk, recordPrk: personalPrk, record: secretRecord, now: 2 });
expect(await decryptConnectionRecordEvent(event, prk)).toBeNull();
const opened = await decryptConnectionRecordEvent(event, prk, personalPrk);
expect(opened?.tier).toBe("personal");
});
it("rejects a record whose ciphertext id disagrees with the d tag", async () => {
const event = await buildConnectionRecordEvent({ vaultPrk: prk, recordPrk: prk, record: record(), now: 3 });
const foreignId = newConnectionId();
const forged = { ...event, tags: [["d", connectionDTag(foreignId)]] };
// Re-signing under the vault key is what a compromised relay cannot do,
// but the AAD check must still hold even against a hypothetical signer:
// decryption fails because the AAD binds the ciphertext to its own d tag.
await expect(
decryptConnectionRecordEvent({ ...forged, id: event.id, sig: event.sig }, prk)
).rejects.toThrow(); // invalid signature for the altered tags
});
it("tombstones wipe the credential to its bare schema (§CV11)", () => {
const dead = tombstoneRecord(record({ notes: "has secrets" }), 1_785_400_500);
expect(dead.state).toBe("deleted");
expect(dead.credential).toEqual({ schema: SCHEMA_CONNECTION_NWC_V1 });
expect(dead.notes).toBeNull();
expect(dead.updated_at).toBe(1_785_400_500);
});
it("validates record shape strictly", async () => {
await expect(
buildConnectionRecordEvent({
vaultPrk: prk,
recordPrk: prk,
record: record({ state: "revoked" as never }),
now: 4
})
).rejects.toThrow(ConnectionRecordError);
});
});
describe("VaultSession sudo-window policy", () => {
it("personal-tier work requires an open window, and endSudo closes it", async () => {
const session = new VaultSession(ROOT.slice());
await expect(
session.createConnection({
connection_type: "password",
tier: "personal",
label: "insurance portal",
credential: { schema: "bitlogin.connection.password.v1", password: "hunter2" }
})
).rejects.toThrow(/sudo window/);
session.enableSudo(SUDO.slice());
const { record: created, event } = await session.createConnection({
connection_type: "password",
tier: "personal",
label: "insurance portal",
credential: { schema: "bitlogin.connection.password.v1", password: "hunter2" }
});
expect(created.tier).toBe("personal");
expect(await session.decryptEvent(event)).not.toBeNull();
session.endSudo();
// Same event, closed window: invisible again.
expect(await session.decryptEvent(event)).toBeNull();
});
it("enableSudo consumes (wipes) the caller's key copy", () => {
const session = new VaultSession(ROOT.slice());
const copy = SUDO.slice();
session.enableSudo(copy);
expect(copy.every((b) => b === 0)).toBe(true);
});
it("updates keep tier and id immutable and stay monotonic within a second", async () => {
const session = new VaultSession(ROOT.slice());
const { event } = await session.createConnection({
connection_type: "nwc",
tier: "connectable",
label: "wallet",
credential: record().credential,
now: 1_785_400_000
});
const current = await session.decryptEvent(event);
const updated = await session.updateConnection(current!, { label: "renamed" }, 1_785_400_000);
// §CV9: same-second replacement must still advance created_at.
expect(updated.event.created_at).toBe(event.created_at + 1);
expect(updated.record.connection_id).toBe(current!.record.connection_id);
});
});