From a465e1d8fabced8022ee143af1fc0dccf7def278 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:29:46 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1 --- CHANGELOG.md | 18 ++ docs/README.md | 15 +- docs/connection-vault.md | 97 ++++++- docs/vault-ux.md | 99 ++++++++ packages/core/src/account/changePassword.ts | 6 + packages/core/src/account/create.ts | 18 +- packages/core/src/account/enableVault.ts | 198 +++++++++++++++ packages/core/src/account/index.ts | 1 + packages/core/src/account/login.ts | 8 + packages/core/src/account/recover.ts | 11 + packages/core/src/capsules/types.ts | 27 +- packages/core/src/capsules/validation.ts | 31 +++ packages/core/src/index.ts | 1 + packages/core/src/vault/derivation.ts | 94 +++++++ packages/core/src/vault/index.ts | 6 + packages/core/src/vault/nwc.ts | 116 +++++++++ packages/core/src/vault/record.ts | 185 ++++++++++++++ packages/core/src/vault/session.ts | 196 ++++++++++++++ packages/core/src/vault/sync.ts | 148 +++++++++++ packages/core/src/vault/types.ts | 56 ++++ packages/core/src/vault/vault.e2e.test.ts | 268 ++++++++++++++++++++ packages/core/src/vault/vault.test.ts | 256 +++++++++++++++++++ 22 files changed, 1843 insertions(+), 12 deletions(-) create mode 100644 docs/vault-ux.md create mode 100644 packages/core/src/account/enableVault.ts create mode 100644 packages/core/src/vault/derivation.ts create mode 100644 packages/core/src/vault/index.ts create mode 100644 packages/core/src/vault/nwc.ts create mode 100644 packages/core/src/vault/record.ts create mode 100644 packages/core/src/vault/session.ts create mode 100644 packages/core/src/vault/sync.ts create mode 100644 packages/core/src/vault/types.ts create mode 100644 packages/core/src/vault/vault.e2e.test.ts create mode 100644 packages/core/src/vault/vault.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4ea27..0fe6544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 3a620d7..141bb31 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/connection-vault.md b/docs/connection-vault.md index 82d8af7..0bd06ed 100644 --- a/docs/connection-vault.md +++ b/docs/connection-vault.md @@ -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. diff --git a/docs/vault-ux.md b/docs/vault-ux.md new file mode 100644 index 0000000..69ac194 --- /dev/null +++ b/docs/vault-ux.md @@ -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. diff --git a/packages/core/src/account/changePassword.ts b/packages/core/src/account/changePassword.ts index 026c8c1..91f5327 100644 --- a/packages/core/src/account/changePassword.ts +++ b/packages/core/src/account/changePassword.ts @@ -112,6 +112,12 @@ export async function changePassword(params: ChangePasswordParams): Promise 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 { + 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 }; +} diff --git a/packages/core/src/account/index.ts b/packages/core/src/account/index.ts index 2fff6bc..ce68043 100644 --- a/packages/core/src/account/index.ts +++ b/packages/core/src/account/index.ts @@ -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"; diff --git a/packages/core/src/account/login.ts b/packages/core/src/account/login.ts index 73eee71..4a602e3 100644 --- a/packages/core/src/account/login.ts +++ b/packages/core/src/account/login.ts @@ -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= 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. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3dcee42..24d27ee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/vault/derivation.ts b/packages/core/src/vault/derivation.ts new file mode 100644 index 0000000..14cabf7 --- /dev/null +++ b/packages/core/src/vault/derivation.ts @@ -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; + } +} diff --git a/packages/core/src/vault/index.ts b/packages/core/src/vault/index.ts new file mode 100644 index 0000000..44cd72b --- /dev/null +++ b/packages/core/src/vault/index.ts @@ -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"; diff --git a/packages/core/src/vault/nwc.ts b/packages/core/src/vault/nwc.ts new file mode 100644 index 0000000..a88b33d --- /dev/null +++ b/packages/core/src/vault/nwc.ts @@ -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 | 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."); + } +} diff --git a/packages/core/src/vault/record.ts b/packages/core/src/vault/record.ts new file mode 100644 index 0000000..b943bd6 --- /dev/null +++ b/packages/core/src/vault/record.ts @@ -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 | 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 { + 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 { + 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(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 + }; +} diff --git a/packages/core/src/vault/session.ts b/packages/core/src/vault/session.ts new file mode 100644 index 0000000..1bdc208 --- /dev/null +++ b/packages/core/src/vault/session.ts @@ -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; + 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>, + 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 { + return decryptConnectionRecordEvent(event, this.vaultPrk, this.personalPrk ?? undefined); + } + + publish(params: { + event: NostrEvent; + connectionId: string; + relayUrls: string[]; + store: KeyValueStore; + minAcknowledgements?: number; + timeoutMs?: number; + }): Promise { + return publishConnectionRecord({ vaultPrk: this.vaultPrk, ...params }); + } + + fetchEvents(params: { + relayUrls: string[]; + store: KeyValueStore; + timeoutMs?: number; + }): Promise { + 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 }; + } +} diff --git a/packages/core/src/vault/sync.ts b/packages/core/src/vault/sync.ts new file mode 100644 index 0000000..2f657dc --- /dev/null +++ b/packages/core/src/vault/sync.ts @@ -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 { + 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 { + 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 { + 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; + /** 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 { + 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(); + 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(); + 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 + }; +} diff --git a/packages/core/src/vault/types.ts b/packages/core/src/vault/types.ts new file mode 100644 index 0000000..b56d3b6 --- /dev/null +++ b/packages/core/src/vault/types.ts @@ -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; + 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]>; +} diff --git a/packages/core/src/vault/vault.e2e.test.ts b/packages/core/src/vault/vault.e2e.test.ts new file mode 100644 index 0000000..79059ce --- /dev/null +++ b/packages/core/src/vault/vault.e2e.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/vault/vault.test.ts b/packages/core/src/vault/vault.test.ts new file mode 100644 index 0000000..5359af2 --- /dev/null +++ b/packages/core/src/vault/vault.test.ts @@ -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 { + 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("adam@example.com"); + 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); + }); +});