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

+18
View File
@@ -6,6 +6,24 @@ site with its own vendored copy (see "Updating your integration" in
sitting on an older vendored copy is missing a fix or behavior change it
would plausibly want. Newest first.
## 2026-07-30
- **Connection Vault protocol layer in `@bitlogin/core`** (`src/vault/`):
encrypted per-connection records signed by a derived vault identity, the
finalized §CV6 key hierarchy with pinned test vectors, the NWC credential
profile (lossless URI parse/export), relay sync with quorum readback and
per-record rollback detection, and the two-tier model — connectable
records vs sudo-gated personal records (`connection-vault.md` §18).
- **Capsules gained paired optional fields** `connection_vault_root` and
`vault_sudo_key` (both or neither; validated). Registration mints them for
new accounts; `enableConnectionVault` is the phrase-gated, fail-closed,
idempotent migration for existing accounts. Login, password change, and
phrase recovery all carry them forward.
**Integration caveat:** a site on an OLDER vendored widget that performs a
password change will rebuild the credential capsule WITHOUT these fields,
stranding connectable records until a phrase ceremony repairs them —
update vendored copies before enabling the vault for users.
## 2026-07-21
- **Fix: registration/login could fail quorum if a single built-in relay was
+10 -5
View File
@@ -33,14 +33,19 @@ identity and recovery model.
## Connection Vault
The Connection Vault is an experimental design for portable application
credentials. It deliberately stores mutable credentials outside the core
identity capsules as independently encrypted records.
The Connection Vault stores portable application credentials outside the core
identity capsules as independently encrypted records. Its protocol layer
(records, key hierarchy, relay sync, migration, tiers) is **implemented in
`@bitlogin/core`'s `src/vault/`**; the widget authorization layer (consent
UI, grants, Connected apps) is still to come.
- [`connection-vault.md`](./connection-vault.md) — overall vault architecture,
key hierarchy, relay storage, authorization, recovery, and deletion
key hierarchy, relay storage, authorization, recovery, and deletion; §18
records the implementation-time decisions (tier model, sudo key)
- [`vault-ux.md`](./vault-ux.md) — the agreed user-experience walkthroughs,
serving as the authorization layer's acceptance criteria
- [`nwc-connections.md`](./nwc-connections.md) — Nostr Wallet Connect credential
profile
profile (parser/exporter implemented in `src/vault/nwc.ts`)
- [`s3c-format.md`](./s3c-format.md) — portable `s3c1:` format for S3-compatible
storage connections
+93 -4
View File
@@ -1,9 +1,18 @@
# BitLogin Connection Vault
**Status:** Experimental design
**Document version:** 0.1
**Date:** July 22, 2026
**Implementation status:** Not implemented
**Status:** Design with core implementation
**Document version:** 0.2
**Date:** July 30, 2026
**Implementation status:** §5–§11 and §13 are **implemented in `@bitlogin/core`**
(`src/vault/`, plus the capsule fields in `src/capsules/` and the migration in
`src/account/enableVault.ts`), with test vectors pinned in
`src/vault/vault.test.ts` and protocol round trips in `vault.e2e.test.ts`.
The application-authorization layer (§12: consent UI, grants, Connected-apps
management) is **not yet implemented** — it is the widget generation's work.
See §18 for the decisions finalized at implementation time, including the
**sensitivity-tier model and the sudo key**, which supersede this document
where they differ. The companion `vault-ux.md` holds the user-experience
walkthroughs that act as Phase D's acceptance criteria.
## 1. Summary
@@ -525,3 +534,83 @@ At minimum:
- Clearly distinguish BitLogin record deletion from provider-side revocation.
- Assume a malicious or compromised client can steal all secrets available in
that session.
## 18. Implementation-time decisions (2026-07-30, v0.2)
These were finalized when §5–§13 shipped in `@bitlogin/core`. Where this
section and the earlier prose disagree, this section wins.
### 18.1 Two capsule fields, not one
The capsules gained **two** paired 32-byte roots, not just the vault root of
§5.2:
```json
{
"connection_vault_root": "base64url-32-bytes",
"vault_sudo_key": "base64url-32-bytes"
}
```
Validation enforces that they appear together or not at all (a capsule with
one but not the other is corrupt and must not grant a session). Registration
mints both for every new account; `enableConnectionVault` is the §5.3
ceremony for accounts that predate them, requires the recovery phrase,
writes the recovery capsule first, fails closed, and is idempotent — a retry
after a partial failure REUSES roots found in either capsule rather than
minting twice, because minting twice would strand existing records.
### 18.2 The sensitivity-tier model and why the sudo key exists
Records carry a `tier` field:
- **`connectable`** (NWC, scoped S3): record keys derive from
`connection_vault_root` alone. The root is cacheable for the session, so
daily wallet use is silent. These are the records the §12 grant flow may
expose to application origins. The real security boundary is provider-side
policy — wallet budgets, scoped IAM keys — and import UIs must push users
toward it.
- **`personal`** (stored passwords, secure notes): record keys additionally
require `vault_sudo_key` as HKDF input
(`personal_prk = HKDF-Extract(SHA256("bitlogin/connection-vault-personal/v1"), root || sudo_key)`).
The honest-client contract: the sudo key is never persisted outside the
capsules and is held in memory only during a sudo window
(`VaultSession.enableSudo`/`endSudo`, which wipe it). Re-opening a window
requires re-decrypting a capsule — a fresh Argon2id run on the password, or
the phrase — which is what makes "re-enter your password to reveal" a real
key ceremony rather than the cosmetic gate §SF2 forbids.
**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.
Password-manager UX (autofill, origin matching) waits for the extension
generation. Crypto seed phrases remain banned from the vault entirely.
Tiers are invisible on the wire: identical envelopes, buckets, and d-tag
shapes, decryption is trial-based. A distinguishable ciphertext would tell
every relay which records are "the sensitive ones".
### 18.3 Resolved open decisions (§16)
1. **Capsule schema:** additive optional fields on the v1 schemas (§18.1) —
a new schema id would lock old clients out of login entirely.
**Mixed-version caveat:** an OLD client's password change rebuilds the
credential capsule without the vault fields and strands connectable
records until the next phrase ceremony repairs them. Deployments must
update vendored widgets before enabling the vault for users.
2. **Padding buckets:** the capsule buckets (1024/2048/4096) are reused.
Every current profile fixture fits 1024 with room, and a shared bucket
set keeps NWC and S3 records indistinguishable by size.
3. **Relay policy:** vault records publish to the account's configured vault
relays with the same publish-and-verify quorum bar as capsules
(`publishConnectionRecord` / `fetchConnectionRecordEvents`).
4. **App binding:** `application_binding.origin` is implemented;
`app_pubkey` remains reserved.
5. **Encrypted index:** not built; discovery lists the derived identity's
kind-30078 events and filters the `bitlogin:connection:` prefix locally.
6. **Grants:** deferred to the widget authorization layer (Phase D).
7. **S3C repository split:** deferred until a second implementation exists.
Rollback resistance (§9) is per record: a local high-water mark stores the
newest accepted `created_at` per connection id, and `fetchConnectionRecordEvents`
withholds and reports anything older instead of serving it — a stale replica
must never silently resurrect a revoked credential.
+99
View File
@@ -0,0 +1,99 @@
# Connection Vault — UX walkthroughs
**Status:** Agreed product direction (2026-07-30)
**Role:** These scenarios are the acceptance criteria for the Phase D
authorization layer (`connection-vault.md` §12, §18). The design principle
running through all of them: **friction appears exactly where danger is, and
nowhere else.** Count the friction across the whole set: one phrase entry
ever, one budget-facing tap per app, one tap per new device, sudo or phone
approval only at genuinely dangerous reveals. Everything else is silence.
## 1. Turning the vault on — existing account
Account manager card: *"Connection Vault — store wallet and storage
connections once, use them on every device."* Enabling asks for the recovery
phrase with the reason stated plainly (*"the vault must survive both your
password and your phrase"*), rewrites both capsules with quorum readback, and
fails closed with a retry that reuses the same roots
(`enableConnectionVault`). New accounts skip this scenario entirely — the
roots are minted at registration.
*Deliberate friction:* the phrase entry, once per account ever.
## 2. Connecting a wallet the first time — inside an app
The app calls the request API with a reason string. The sheet walks the user
through pasting/scanning an NWC URI, parses it, and shows what it learned —
provider, relay, and **the budget in large type**. No budget draws an amber
warning recommending a budgeted connection, never a hard block. Label
pre-filled from the app name. The final line before reveal is honest:
*"[App] will receive this connection and can spend within its budget until
you revoke it."* Save binds the record to the requesting origin.
*Deliberate friction:* one explicit share tap, budget staring at the user.
*Removed friction:* naming, binding, storage.
## 3. The payoff — new phone
Sign into the app's BitLogin, and at the first payment moment:
> **Use "Satisfied spending wallet"?**
> *You connected this wallet to Satisfied on another device. Budget: 500 sats/day.*
One tap. Zero pasting. This screen is the entire reason the vault exists.
A new device seeing a credential for the first time must never happen
silently, even with a durable grant.
## 4. Every ordinary day after
Nothing. No prompt, no branding. The grant is durable per origin per device;
the vault's best UX is absence.
## 5. Connected apps — management
Account manager page listing origin → connection → grant date → devices, with
**Revoke access** per row and one **"Revoke all app access"** panic button.
Each connection's detail page separates two red actions honestly:
- **Remove from BitLogin** — deletes the stored copy; the credential itself
keeps working for anyone who already holds it.
- **Revoke at wallet/provider** — an instruction with a guide, not a button
pretending BitLogin can reach into the wallet (§11).
## 6. S3 connections
Same sheet pattern; the import screen teaches while it collects: scoped IAM
key vs root key question, a recommended object-prefix field, amber warning on
unscoped credentials. Reveal to an app requires sudo mode — S3 secrets guard
buckets, not a 500-sat budget.
## 7. Personal tier — passwords and secure notes
Exists ONLY in the first-party account manager. No app can request, list, or
learn these records exist (the tier is invisible to the app-facing API by
construction — see §18.2). Viewing requires sudo mode: re-enter the account
password, ~5-minute window. Copy button, auto-clearing field. The copy is
honest: a safe place for a few important secrets, not a password manager —
autofill and site-matching wait for the extension generation. Seed phrases
stay banned.
## 8. Phone approval (after §SF5–7 ships)
High-tier reveals push to the enrolled phone; the record key is 2-of-2
derived so a laptop cryptographically cannot proceed on its own. Budgeted NWC
connections deliberately do NOT phone-prompt — the budget is already the
lock, and prompting every payment trains reflexive approval.
## 9. Disaster — phrase-only recovery
Twelve words on a clean device restore identity, then the vault roots ride
the recovery capsule (§13.2), so connections reappear: wallets, buckets,
passwords. The phrase remains the ungated master path (§SF9). The flow nudges
re-enrolling a phone and rotating anything sensitive.
## Build order for Phase D
The screens, in dependency order: request/consent sheet (2, 3) → guided NWC
import (2) → Connected apps + connection detail (5) → S3 import (6) → sudo
mode (7). The personal tier and phone approval layer on later without
reshaping anything before them.
@@ -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);
});
});