mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Implement the Connection Vault protocol layer (phases A-C)
connection-vault.md 5-13 as code, with the implementation-time decisions recorded in a new section 18 and vault-ux.md holding the agreed UX walkthroughs as the authorization layer's acceptance criteria. Capsules gain paired optional fields connection_vault_root and vault_sudo_key (both or neither, validated); registration mints them, login/change-password/recovery carry them forward, and enableConnectionVault is the phrase-gated migration for existing accounts -- recovery capsule written first, fail closed, idempotent by reusing roots found in either capsule so a partial failure can never mint twice and strand records. src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand constants pinned by test vectors that must only ever be reverted, never updated), per-connection encrypted records signed by a derived vault identity with the capsule JCS+padding+AES-GCM pipeline and shared buckets so record types are indistinguishable by size, the NWC profile with lossless URI parse/export, relay sync reusing the publish-and- verify quorum bar, and per-record high-water marks so a stale replica is withheld and reported rather than silently resurrecting a revoked credential. The two-tier model makes sudo mode cryptographically real: personal- tier record keys require vault_sudo_key as HKDF input, VaultSession holds it only inside an enableSudo/endSudo window (wiping on the way in and out), and outside a window personal records are invisible -- the same trial-decrypt returns null, not an error. The app-facing API is structurally blind to the tier by construction. 118 tests passing, including four end-to-end scenarios against mock relays: clean-device restore, password-change carry, legacy-account migration (wrong phrase refused), and rollback detection. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
1 parent
e9a5a41ff7
commit
a465e1d8fa
22 files changed
+1843
-12
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -14,8 +14,31 @@ export interface EncryptedEnvelope {
|
||||
ciphertext: string; // base64url
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection Vault key material carried by BOTH capsules (connection-vault.md
|
||||
* §5.2). Two independent 32-byte random roots, base64url:
|
||||
*
|
||||
* - `connection_vault_root` locates, signs, and encrypts connectable-tier
|
||||
* connection records (NWC, scoped S3). Cacheable for the session, so daily
|
||||
* wallet use never re-prompts.
|
||||
* - `vault_sudo_key` is REQUIRED additional key material for personal-tier
|
||||
* records (stored passwords, secure notes). The honest-client contract: it
|
||||
* is never persisted outside the capsules and is held in memory only for
|
||||
* the duration of a sudo window — re-obtaining it costs a fresh capsule
|
||||
* decryption (an Argon2id run on the password, or the phrase), which is
|
||||
* what makes "re-enter your password to reveal" a real key ceremony rather
|
||||
* than a skippable UI gate (§SF2).
|
||||
*
|
||||
* Both fields appear together or not at all; absent means the account
|
||||
* predates the vault (see enableConnectionVault).
|
||||
*/
|
||||
export interface VaultCapsuleFields {
|
||||
connection_vault_root?: string; // base64url, 32 bytes
|
||||
vault_sudo_key?: string; // base64url, 32 bytes
|
||||
}
|
||||
|
||||
/** §12.1 — the credential capsule: infrequently changed access material only. */
|
||||
export interface CredentialPayload {
|
||||
export interface CredentialPayload extends VaultCapsuleFields {
|
||||
schema: typeof SCHEMA_CREDENTIAL_V1;
|
||||
account_id: string; // base64url, 128-bit random
|
||||
generation: number;
|
||||
@@ -33,7 +56,7 @@ export interface CredentialPayload {
|
||||
}
|
||||
|
||||
/** §12.3 — the recovery capsule: minimal and immutable outside the write moments of §14.1. */
|
||||
export interface RecoveryPayload {
|
||||
export interface RecoveryPayload extends VaultCapsuleFields {
|
||||
schema: typeof SCHEMA_RECOVERY_V1;
|
||||
account_id: string;
|
||||
recovery_generation: number;
|
||||
|
||||
@@ -59,6 +59,35 @@ export function validateOperationalKeyPair(privateKeyB64: unknown, publicKeyHex:
|
||||
assert(derived === publicKeyHex, "operational_public_key does not match the derived public key (§12.4.4).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection Vault fields (connection-vault.md §5.2): optional, but they
|
||||
* appear together or not at all — a capsule carrying only one of the two
|
||||
* roots is a corrupt or tampered migration and must not grant a session, or
|
||||
* personal-tier records would silently become unrecoverable.
|
||||
*/
|
||||
export function validateVaultFields(payload: {
|
||||
connection_vault_root?: unknown;
|
||||
vault_sudo_key?: unknown;
|
||||
}): void {
|
||||
const root = payload.connection_vault_root;
|
||||
const sudo = payload.vault_sudo_key;
|
||||
if (root === undefined && sudo === undefined) return;
|
||||
assert(root !== undefined && sudo !== undefined, "connection_vault_root and vault_sudo_key must appear together (§CV5.2).");
|
||||
for (const [field, value] of [
|
||||
["connection_vault_root", root],
|
||||
["vault_sudo_key", sudo]
|
||||
] as const) {
|
||||
assert(typeof value === "string", `${field} must be a string (§CV5.2).`);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = base64urlToBytes(value);
|
||||
} catch {
|
||||
throw new CapsuleValidationError(`${field} is not valid base64url (§CV5.2).`);
|
||||
}
|
||||
assert(bytes.length === 32, `${field} must decode to exactly 32 bytes (§CV5.2).`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertGenerationInBounds(value: unknown, field: string): void {
|
||||
assert(
|
||||
Number.isInteger(value) && (value as number) >= 0 && (value as number) <= MAX_GENERATION,
|
||||
@@ -77,6 +106,7 @@ export function validateCredentialPayload(payload: CredentialPayload): void {
|
||||
validateOperationalKeyPair(payload.operational_private_key, payload.operational_public_key);
|
||||
assert(isHex64(payload.recovery_public_key), "recovery_public_key must be lowercase 64-char hex (§12.4.5).");
|
||||
validateRelayUrls(payload.vault_relay_hints);
|
||||
validateVaultFields(payload);
|
||||
|
||||
const embedded = payload.recovery_capsule_event;
|
||||
assert(!!embedded && typeof embedded === "object", "recovery_capsule_event must be present (§12.4.6).");
|
||||
@@ -102,6 +132,7 @@ export function validateRecoveryPayload(payload: RecoveryPayload): void {
|
||||
validateOperationalKeyPair(payload.operational_private_key, payload.operational_public_key);
|
||||
assert(isHex64(payload.recovery_public_key), "recovery_public_key must be lowercase 64-char hex.");
|
||||
validateRelayUrls(payload.vault_relay_hints);
|
||||
validateVaultFields(payload);
|
||||
}
|
||||
|
||||
/** §12.4.9 — detects a broken previous_recovery_event_id hash chain when multiple generations are visible. */
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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]>;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user