diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c3c5ac --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + npm-security-and-maintenance: + patterns: + - "*" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..9c7a707 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,54 @@ +name: Security and release integrity + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - name: Run build, typecheck, tests, dependency audit, and registry signature verification + run: npm run security:ci + - name: Generate checksums and CycloneDX SBOM + shell: bash + run: | + mkdir -p security-artifacts + npm sbom --sbom-format cyclonedx > security-artifacts/sbom.cdx.json + find packages/widget/dist packages/demo/dist -type f -print0 | sort -z | xargs -0 sha256sum > security-artifacts/SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: bitlogin-security-artifacts + path: security-artifacts/ + if-no-files-found: error + + static-analysis: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install pinned Semgrep + run: pipx install semgrep==1.171.0 + - name: Scan repository rules without per-rule timeouts + run: semgrep scan --config auto --error --timeout 0 packages + - name: Scan repository history for secrets + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 2eb4a34..9978e50 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ packages/ runs in Node or a browser/worker. widget/ @bitlogin/widget — custom element + a NIP-07-shaped window.nostr provider, backed by a dedicated - crypto Web Worker. Built with Vite into two - static files any site can + discovery-relays="wss://discovery-one.example,wss://discovery-two.example" +> ``` @@ -63,14 +64,14 @@ the worker and are not handed to the main thread by the signing APIs — those return public keys, signed events, and ciphertext only. **Be precise about what that does and does not buy you.** The worker protects -keys from *accidental* exposure through the normal API surface; it is not a +keys from _accidental_ exposure through the normal API surface; it is not a boundary against the host page itself. A page that embeds `` runs same-origin with it and can reach the element's worker client directly, so a malicious or XSS-compromised host can call `exportIdentity` and obtain the `nsec`, and can synthesize UI events (the widget rejects untrusted ones, and can still be visually overlaid by host CSS). This is inherent to an embedded same-origin component — the same reason `docs/connection-vault.md` -§12.3 says wallet credentials are *revealed*, not brokered. Treat the host +§12.3 says wallet credentials are _revealed_, not brokered. Treat the host page as trusted; embed BitLogin only in sites you control, and prefer the recovery phrase (never the exported nsec) as the durable backup. A cross-origin or extension build is what would change this, and is not @@ -98,8 +99,12 @@ whichever provider signed in last currently owns. user to share an NWC wallet connection with its origin: ```javascript -const uri = await document.querySelector("bitlogin-auth") - .requestNwcConnection({ appName: "Satisfied", reason: "Pay for meal analysis" }); +const uri = await document + .querySelector("bitlogin-auth") + .requestNwcConnection({ + appName: "Satisfied", + reason: "Pay for meal analysis", + }); if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined ``` @@ -200,21 +205,21 @@ or an inline style), without touching the widget's internals: ```css bitlogin-auth { - --bl-accent: #3d9bff; /* primary button / link / focus ring color */ + --bl-accent: #3d9bff; /* primary button / link / focus ring color */ --bl-accent-hover: #2f86e0; /* primary button hover */ - --bl-accent-fg: #04101f; /* text color on top of --bl-accent */ - --bl-bg: #12151f; /* card background */ - --bl-fg: #eaeef6; /* body text */ - --bl-muted: #98a2b6; /* secondary text */ - --bl-border: rgba(255, 255, 255, 0.10); - --bl-input-bg: #1a1e2b; /* input/credential-box background */ + --bl-accent-fg: #04101f; /* text color on top of --bl-accent */ + --bl-bg: #12151f; /* card background */ + --bl-fg: #eaeef6; /* body text */ + --bl-muted: #98a2b6; /* secondary text */ + --bl-border: rgba(255, 255, 255, 0.1); + --bl-input-bg: #1a1e2b; /* input/credential-box background */ --bl-danger: #ff6b6b; --bl-danger-bg: rgba(255, 107, 107, 0.16); --bl-warn: #ff6b6b; --bl-warn-bg: rgba(255, 107, 107, 0.16); --bl-radius: 14px; --bl-font-family: Inter, ui-sans-serif, system-ui, sans-serif; - --bl-max-width: none; /* default 380px keeps it a fixed-width card; + --bl-max-width: none; /* default 380px keeps it a fixed-width card; override to fill a wider container */ } ``` @@ -235,24 +240,29 @@ configuration. GitHub Pages was evaluated but dropped — Pages requires a paid plan for private repositories, whereas Vercel's free tier deploys a private repo without that restriction. -Because the deployed demo serves the built widget files as plain static -assets at `/vendor/bitlogin/`, **any other site can point directly at the -Vercel deployment today** without installing anything locally: +The widget and its dedicated worker must be served from the application's +own origin. Build the reviewed source, then vendor the complete output of +`packages/widget/dist/` into your deployment: ```html - + ``` +Keep `bitlogin.js`, `cryptoWorker.js`, and every `bitlogin-shared-*.js` chunk +together. Do not point production applications directly at the demo domain: +cross-origin workers are rejected by browsers, and remotely mutable account +code would add an unnecessary supply-chain trust dependency. + `bitlogin.network` and `www.bitlogin.network` are attached to the same Vercel project as production domains (`bitlogin.vercel.app` keeps working as the underlying `*.vercel.app` alias regardless). DNS for a domain bought outside Vercel has to be pointed at it separately — see "Custom domain DNS" below if you're standing this project up on a new domain yourself. -This is fine for prototyping across multiple projects; for production use in -someone else's app, self-hosting the built files (or a future published npm -package / CDN release) avoids depending on this demo deployment's uptime. +The Vercel deployment is the reference demo and account manager, not a widget +CDN. Published release checksums and an SBOM should be verified before vendoring +artifacts into another application. ### Custom domain DNS @@ -345,6 +355,7 @@ relay list plumbing (§19), and the full crypto stack from §11 (Argon2id, HKDF, ScalarExpand, AES-256-GCM, JCS, fixed padding buckets). **Deferred / not in this pass:** + - Phase 2 messaging (NIP-17 inbox/compose) — the NIP-44 primitive is built and tested, but no conversation UI is wired up. - Phase 3 application redirect-auth flow (§26.3) and NIP-46/NIP-07 browser @@ -353,8 +364,9 @@ HKDF, ScalarExpand, AES-256-GCM, JCS, fixed padding buckets). shipped unverified against official test vectors; plain `nsec`/`npub` (NIP-19) export is implemented instead. - The maintainer-signed bootstrap relay-list channel (§19.1) has the - verify/merge logic implemented but ships with a placeholder maintainer - public key — a real deployment needs to swap in a real one. + verify/merge logic implemented but is deliberately disabled (`null`) until + a real controlled maintainer key and rotation/revocation process are + reviewed and announced out-of-band. - Native clients, hardware-backed recovery, independent second implementation, and formal security audit (Phase 4). diff --git a/docs/connection-vault.md b/docs/connection-vault.md index 407ece1..d5405e9 100644 --- a/docs/connection-vault.md +++ b/docs/connection-vault.md @@ -224,9 +224,7 @@ Each connection is stored as its own NIP-78-style addressable event: "pubkey": "", "created_at": 1784750000, "kind": 30078, - "tags": [ - ["d", "bitlogin:connection:Vx7LgdZCsVbT_2uvB0YoGA"] - ], + "tags": [["d", "bitlogin:connection:Vx7LgdZCsVbT_2uvB0YoGA"]], "content": "", "sig": "" } @@ -384,13 +382,13 @@ Conceptual API: ```javascript const connections = await bitlogin.connections.list({ - type: "nwc" + type: "nwc", }); const grant = await bitlogin.connections.requestAccess({ connectionId: connections[0].id, operations: ["pay_invoice", "get_balance"], - reason: "Pay for AI inference" + reason: "Pay for AI inference", }); ``` @@ -544,10 +542,9 @@ At minimum: 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 +### 18.1 Phrase-gated sudo material -The capsules gained **two** paired 32-byte roots, not just the vault root of -§5.2: +The vault uses two independent 32-byte roots: ```json { @@ -556,13 +553,20 @@ The capsules gained **two** paired 32-byte roots, not just the vault root of } ``` -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. +The phrase-gated recovery capsule carries both fields together. A newly +written password credential capsule carries only `connection_vault_root`. +This lets an ordinary login restore connectable-tier records without placing +the personal-tier `vault_sudo_key` behind the same offline-guessable password +capsule. Pre-hardening credential capsules that contain both remain readable +for compatibility, but password login does not return their sudo key and the +next password rotation scrubs it from the credential capsule. + +Registration mints both roots 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. Legacy migration derives the same roots +deterministically from phrase-protected material and `account_id`, so two +concurrent migration attempts cannot mint divergent roots. ### 18.2 The sensitivity-tier model and why the sudo key exists @@ -577,12 +581,11 @@ Records carry a `tier` field: - **`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 sudo key is stored only in the phrase-gated recovery capsule and held + in memory only during a sudo window (`VaultSession.enableSudo`/`endSudo`, + which wipe it). Re-opening a window requires a phrase ceremony; an ordinary + password login cannot provide the key. Personal-tier UI remains deferred + until that ceremony has a separately reviewed interface. **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. @@ -619,24 +622,28 @@ 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. - ## 19. Known limitations (audit, 2026-07-31) An adversarial audit of the implementation produced fixes for the worst findings (origin binding now enforced worker-side; `destroy()` wipes the root and poisons the session; equal-timestamp rollback tie-break; per-record listing isolation; quorum enforced before the migration's mint decision). -These remain OPEN and are the honest limits of the current build: +The audit's implementation defects are remediated in the current working +tree. These fundamental limits remain and must stay visible in product claims: -1. **No compare-and-swap on capsule writes.** Two devices enabling the vault - concurrently can each mint a root and write it, leaving the credential and - recovery capsules disagreeing. Mitigation today: enable the vault once, - from one device. A `previous_recovery_event_id` precondition check on - write is the fix. +1. **No relay-enforced compare-and-swap on capsule writes.** Deterministic + legacy migration prevents concurrent vault-enable attempts from minting + different roots, and recovery now performs collision checks before writes + and stops after a failed recovery quorum. Relays still provide no atomic + compare-and-swap primitive, so simultaneous phrase-recovery refreshes can + produce signed siblings. The recovery hash chain detects such a fork when + both siblings are visible; it cannot prevent one. 2. **Rollback protection is per-device local state.** A fresh install has no high-water marks, so the oldest record a hostile relay serves is accepted - and becomes the mark. A record-level generation counter inside the - encrypted payload would close this; the local mark cannot. + and becomes the mark. Clearing or deleting the protected marker is likewise + indistinguishable from a genuinely fresh device; AEAD detects modification + of a marker that still exists, not its absence. A trusted remote monotonic + anchor would close this; purely local state cannot. 3. **A tombstone does not destroy the prior ciphertext.** Record keys are derived from the immutable `connection_id`, so the superseded event — which still contains the credential — remains decryptable by anyone who later @@ -644,15 +651,19 @@ These remain OPEN and are the honest limits of the current build: Deleting a connection in BitLogin is therefore NOT revocation: revoke at the wallet or provider, which §11 already says and which this makes cryptographically concrete. -4. **The sudo key ships with the vault root at every login**, because both - live in the same capsule under the same password-derived key. The - personal tier's separation is real against a stolen *record*, but the - "fresh Argon2id run to reveal" ceremony is a host-side convention, not a - cryptographic gate. Personal-tier UI should not ship until this is either - fixed (separate capsule) or the claim is weakened. -5. **Fetches do not fail closed on quorum failure**, and `limit: 500` - truncates silently — a single relay can shape a new device's view of the - vault. -6. **The high-water-mark store is plaintext and unauthenticated**, leaking the - derived vault pubkey and the connection inventory to same-origin storage - access, and is tamperable to disable rollback detection. +4. **Legacy password capsules may still contain the sudo key.** New + registration, recovery, migration, and password-rotation writes omit it; + password login deliberately ignores a legacy copy. An archived old capsule + remains decryptable forever by its old password, so existing users who + want the stronger separation must rotate and should assume previously + downloaded capsules retain the old exposure. +5. **The embedded host remains fully trusted.** CSP and same-origin vendoring + reduce accidental script and supply-chain exposure, but code already + executing on the application origin can drive the worker and export the + unlocked identity. A cross-origin signer, browser extension, or comparable + isolated surface is required to create a real boundary. + +Closed audit items: listing fails closed below quorum and exposes truncation +and unreadable records through the widget; per-record high-water marks are now +AEAD-authenticated and stored under keyed, inventory-hiding identifiers, with +fail-closed migration from the legacy plaintext format. diff --git a/package.json b/package.json index 0597f25..860de60 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ ], "scripts": { "build": "npm run build -w @bitlogin/core && npm run build -w @bitlogin/widget", + "build:demo": "npm run build && npm run build -w @bitlogin/demo", "dev:demo": "npm run dev -w @bitlogin/demo", "test": "vitest run", "test:watch": "vitest", - "typecheck": "npm run typecheck -w @bitlogin/core && npm run typecheck -w @bitlogin/widget" + "typecheck": "npm run typecheck -w @bitlogin/core && npm run typecheck -w @bitlogin/widget", + "security:audit": "npm audit --audit-level=high && npm audit signatures", + "security:ci": "npm run build:demo && npm run typecheck && npm test && npm run security:audit" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts index ff8e5e2..b8a29ad 100644 --- a/packages/core/src/account/account.e2e.test.ts +++ b/packages/core/src/account/account.e2e.test.ts @@ -1,23 +1,42 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { MockRelay } from "../test-support/mockRelay.js"; import { generatePassphrase } from "./passphrase.js"; -import { registerAccount, importAccount, decodeEverydayPrivateKey } from "./create.js"; +import { + registerAccount, + importAccount, + decodeEverydayPrivateKey, +} from "./create.js"; import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; import { encodeNsec, encodeNpub } from "../nostr/nip19.js"; import { bytesToHex } from "../crypto/encoding.js"; import { loginWithPassword } from "./login.js"; -import { recoverWithPhrase, completeRecoveryWithNewCredentials } from "./recover.js"; +import { + recoverWithPhrase, + completeRecoveryWithNewCredentials, +} from "./recover.js"; import { changePassword } from "./changePassword.js"; import { repairReplicas } from "./repair.js"; import { InMemoryKeyValueStore } from "../storage/interface.js"; import { raiseHighWaterMark } from "./highWaterMark.js"; import { RelayPool } from "../nostr/pool.js"; -import { AccountAlreadyExistsError, AccountNotFoundError, RollbackDetectedError } from "./errors.js"; +import { + AccountAlreadyExistsError, + AccountNotFoundError, + RecoveryFailedError, + RollbackDetectedError, +} from "./errors.js"; import { derivePasswordKeys, normalizeLoginName } from "./normalize.js"; -import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; +import { + buildCredentialCapsuleEvent, + decryptCredentialCapsuleEvent, +} from "../capsules/credentialCapsule.js"; import { RelayConnection } from "../nostr/relay.js"; import { readCredentialCapsule } from "./capsuleReader.js"; import { buildRecoveryExport, parseRecoveryExport } from "./exportImport.js"; +import { + D_TAG_PASSWORD_CAPSULE, + D_TAG_RECOVERY_CAPSULE, +} from "../nostr/kinds.js"; const ARGON2_TIMEOUT = 20000; @@ -26,7 +45,11 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { let vaultRelayUrls: string[] = []; beforeEach(async () => { - relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + relays = await Promise.all([ + MockRelay.start(), + MockRelay.start(), + MockRelay.start(), + ]); vaultRelayUrls = relays.map((r) => r.url); }); @@ -40,18 +63,26 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const loginName = "adam"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); expect(registration.credentialPublish.success).toBe(true); expect(registration.recoveryPublish.success).toBe(true); // Simulate a brand-new device/browser: no local state at all besides login name + password. - const login = await loginWithPassword({ loginName, password, vaultRelayUrls }); + const login = await loginWithPassword({ + loginName, + password, + vaultRelayUrls, + }); expect(login.everydayPublicKey).toBe(registration.everydayPublicKey); expect(login.accountId).toBe(registration.accountId); expect(login.generation).toBe(0); expect(login.rollbackWarning).toBeUndefined(); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -68,15 +99,28 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { // decodeEverydayPrivateKey accepts both nsec and 64-char hex forms. const existingKeyHex = bytesToHex(existingKey); - expect(getPublicKeyHex(decodeEverydayPrivateKey(nsec))).toBe(existingPubkey); - expect(getPublicKeyHex(decodeEverydayPrivateKey(existingKeyHex))).toBe(existingPubkey); + expect(getPublicKeyHex(decodeEverydayPrivateKey(nsec))).toBe( + existingPubkey, + ); + expect(getPublicKeyHex(decodeEverydayPrivateKey(existingKeyHex))).toBe( + existingPubkey, + ); - const registration = await importAccount({ nsecOrHex: nsec, loginName, password, vaultRelayUrls }); + const registration = await importAccount({ + nsecOrHex: nsec, + loginName, + password, + vaultRelayUrls, + }); expect(registration.imported).toBe(true); expect(registration.everydayPublicKey).toBe(existingPubkey); // Clean-device password login yields the imported identity, unchanged. - const login = await loginWithPassword({ loginName, password, vaultRelayUrls }); + const login = await loginWithPassword({ + loginName, + password, + vaultRelayUrls, + }); expect(login.everydayPublicKey).toBe(existingPubkey); expect(encodeNpub(login.everydayPublicKey)).toBe(npub); @@ -84,11 +128,11 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const recovered = await recoverWithPhrase({ phrase: registration.recoveryPhrase, vaultRelayUrls, - discoveryRelayUrls: vaultRelayUrls + discoveryRelayUrls: vaultRelayUrls, }); expect(recovered.everydayPublicKey).toBe(existingPubkey); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -97,24 +141,45 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const loginName = "exportbug"; const oldPassword = generatePassphrase().secret; const newPassword = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password: oldPassword, + vaultRelayUrls, + }); // A clean-device login (no prior registration/recovery in this "session") must still // return the embedded recovery event -- this is what a recovery-export button needs. - const login = await loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls }); + const login = await loginWithPassword({ + loginName, + password: oldPassword, + vaultRelayUrls, + }); expect(login.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); // After a password rotation, the (unchanged) recovery capsule event must still be // surfaced -- previously this was silently dropped, breaking "download export" after // a rotation even across a logout/login. - const changed = await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls }); - expect(changed.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); + const changed = await changePassword({ + loginName, + oldPassword, + newPassword, + vaultRelayUrls, + }); + expect(changed.recoveryCapsuleEvent.id).toBe( + registration.recoveryEvent.id, + ); expect(changed.recoveryPublicKey).toBe(registration.recoveryPublicKey); - const loginAfterRotation = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls }); - expect(loginAfterRotation.recoveryCapsuleEvent.id).toBe(registration.recoveryEvent.id); + const loginAfterRotation = await loginWithPassword({ + loginName, + password: newPassword, + vaultRelayUrls, + }); + expect(loginAfterRotation.recoveryCapsuleEvent.id).toBe( + registration.recoveryEvent.id, + ); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, ); it("rejects malformed keys on import (§SF10)", () => { @@ -131,14 +196,22 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const password = generatePassphrase().secret; await registerAccount({ loginName, password, vaultRelayUrls }); - await expect(loginWithPassword({ loginName, password: generatePassphrase().secret, vaultRelayUrls })).rejects.toThrow( - "Account not found or credentials incorrect." - ); - await expect(loginWithPassword({ loginName: "nobody-has-this-name", password, vaultRelayUrls })).rejects.toThrow( - "Account not found or credentials incorrect." - ); + await expect( + loginWithPassword({ + loginName, + password: generatePassphrase().secret, + vaultRelayUrls, + }), + ).rejects.toThrow("Account not found or credentials incorrect."); + await expect( + loginWithPassword({ + loginName: "nobody-has-this-name", + password, + vaultRelayUrls, + }), + ).rejects.toThrow("Account not found or credentials incorrect."); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -146,12 +219,16 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "recoverme"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); const recovered = await recoverWithPhrase({ phrase: registration.recoveryPhrase, vaultRelayUrls, - discoveryRelayUrls: vaultRelayUrls + discoveryRelayUrls: vaultRelayUrls, }); expect(recovered.everydayPublicKey).toBe(registration.everydayPublicKey); expect(recovered.accountId).toBe(registration.accountId); @@ -163,15 +240,21 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { recovered, newLoginName, newPassword, - vaultRelayUrls + vaultRelayUrls, }); expect(completion.credentialPublish.success).toBe(true); expect(completion.recoveryPublish.success).toBe(true); // Old credentials must no longer resolve to a fresh login path in the same way (new locator address); // the important invariant is that the NEW credentials work from a clean device. - const cleanDeviceLogin = await loginWithPassword({ loginName: newLoginName, password: newPassword, vaultRelayUrls }); - expect(cleanDeviceLogin.everydayPublicKey).toBe(registration.everydayPublicKey); + const cleanDeviceLogin = await loginWithPassword({ + loginName: newLoginName, + password: newPassword, + vaultRelayUrls, + }); + expect(cleanDeviceLogin.everydayPublicKey).toBe( + registration.everydayPublicKey, + ); expect(cleanDeviceLogin.generation).toBe(0); // The refreshed recovery capsule replaced the original; recovering again with the same phrase @@ -179,12 +262,176 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const recoveredAgain = await recoverWithPhrase({ phrase: registration.recoveryPhrase, vaultRelayUrls, - discoveryRelayUrls: vaultRelayUrls + discoveryRelayUrls: vaultRelayUrls, }); expect(recoveredAgain.currentRecoveryPayload.recovery_generation).toBe(1); - expect(recoveredAgain.currentRecoveryPayload.previous_recovery_event_id).toBe(registration.recoveryEvent.id); + expect( + recoveredAgain.currentRecoveryPayload.previous_recovery_event_id, + ).toBe(registration.recoveryEvent.id); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, + ); + + it( + "recovery collision refusal does not advance the recovery chain or overwrite the occupied credential", + async () => { + const original = await registerAccount({ + loginName: "recovery-source", + password: generatePassphrase().secret, + vaultRelayUrls, + }); + const occupiedLoginName = "recovery-occupied"; + const occupiedPassword = generatePassphrase().secret; + const occupied = await registerAccount({ + loginName: occupiedLoginName, + password: occupiedPassword, + vaultRelayUrls, + }); + const recovered = await recoverWithPhrase({ + phrase: original.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls, + }); + + await expect( + completeRecoveryWithNewCredentials({ + recovered, + newLoginName: occupiedLoginName, + newPassword: occupiedPassword, + vaultRelayUrls, + }), + ).rejects.toThrow(RecoveryFailedError); + + // Collision validation is a read-only precondition: the original recovery + // capsule must remain byte-for-byte the current generation after refusal. + const recoveredAfterRefusal = await recoverWithPhrase({ + phrase: original.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls, + }); + expect(recoveredAfterRefusal.currentRecoveryEvent.id).toBe( + original.recoveryEvent.id, + ); + expect( + recoveredAfterRefusal.currentRecoveryPayload.recovery_generation, + ).toBe(0); + + const occupiedLogin = await loginWithPassword({ + loginName: occupiedLoginName, + password: occupiedPassword, + vaultRelayUrls, + }); + expect(occupiedLogin.everydayPublicKey).toBe(occupied.everydayPublicKey); + }, + ARGON2_TIMEOUT * 3, + ); + + it( + "a rejected recovery refresh publishes neither a new credential nor a new recovery generation", + async () => { + const registration = await registerAccount({ + loginName: "recovery-order-source", + password: generatePassphrase().secret, + vaultRelayUrls, + }); + const recovered = await recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls, + }); + const newLoginName = "recovery-order-target"; + const newPassword = generatePassphrase().secret; + + for (const relay of relays) + relay.refusePublishDTags.add(D_TAG_RECOVERY_CAPSULE); + await expect( + completeRecoveryWithNewCredentials({ + recovered, + newLoginName, + newPassword, + vaultRelayUrls, + }), + ).rejects.toThrow(/No new credential was published/i); + for (const relay of relays) relay.refusePublishDTags.clear(); + + await expect( + loginWithPassword({ + loginName: newLoginName, + password: newPassword, + vaultRelayUrls, + }), + ).rejects.toThrow(AccountNotFoundError); + const recoveredAfterRefusal = await recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls, + }); + expect(recoveredAfterRefusal.currentRecoveryEvent.id).toBe( + registration.recoveryEvent.id, + ); + expect( + recoveredAfterRefusal.currentRecoveryPayload.recovery_generation, + ).toBe(0); + }, + ARGON2_TIMEOUT * 2, + ); + + it( + "a credential-write failure advances pending recovery state so retry cannot fork the recovery chain", + async () => { + const registration = await registerAccount({ + loginName: "recovery-retry-source", + password: generatePassphrase().secret, + vaultRelayUrls, + }); + const recovered = await recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls, + discoveryRelayUrls: vaultRelayUrls, + }); + const newLoginName = "recovery-retry-target"; + const newPassword = generatePassphrase().secret; + + // One relay accepts the credential while two reject it: publication + // fails quorum but leaves a real partial event that the retry must + // recognize as its own rather than mistake for another account. + for (const relay of relays.slice(0, 2)) + relay.refusePublishDTags.add(D_TAG_PASSWORD_CAPSULE); + await expect( + completeRecoveryWithNewCredentials({ + recovered, + newLoginName, + newPassword, + vaultRelayUrls, + }), + ).rejects.toThrow(/new credential capsule/i); + for (const relay of relays) relay.refusePublishDTags.clear(); + + expect(recovered.currentRecoveryPayload.recovery_generation).toBe(1); + expect(recovered.currentRecoveryPayload.previous_recovery_event_id).toBe( + registration.recoveryEvent.id, + ); + const durableFirstRefreshId = recovered.currentRecoveryEvent.id; + + await completeRecoveryWithNewCredentials({ + recovered, + newLoginName, + newPassword, + vaultRelayUrls, + }); + expect(recovered.currentRecoveryPayload.recovery_generation).toBe(2); + expect(recovered.currentRecoveryPayload.previous_recovery_event_id).toBe( + durableFirstRefreshId, + ); + + const login = await loginWithPassword({ + loginName: newLoginName, + password: newPassword, + vaultRelayUrls, + }); + expect(login.everydayPublicKey).toBe(registration.everydayPublicKey); + }, + ARGON2_TIMEOUT * 3, ); it( @@ -192,7 +439,11 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "offlinerecover"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); // Build (and round-trip through JSON, as the downloaded file would be) the recovery // export exactly as the widget does right after registration. @@ -200,9 +451,11 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { recoveryPublicKeyHex: registration.recoveryPublicKey, vaultRelayUrls, recoveryCapsuleEvents: [registration.recoveryEvent], - relayListEvents: [] + relayListEvents: [], }); - const parsedExportFile = parseRecoveryExport(JSON.parse(JSON.stringify(exportFile))); + const parsedExportFile = parseRecoveryExport( + JSON.parse(JSON.stringify(exportFile)), + ); // Simulate every configured relay being unreachable (not just empty -- an actual closed // port, so the quorum read genuinely fails rather than vacuously succeeding on zero relays). @@ -212,7 +465,7 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { phrase: registration.recoveryPhrase, vaultRelayUrls: unreachableRelayUrls, discoveryRelayUrls: unreachableRelayUrls, - offlineRecoveryCapsuleEvents: parsedExportFile.recovery_capsule_events + offlineRecoveryCapsuleEvents: parsedExportFile.recovery_capsule_events, }); expect(recovered.everydayPublicKey).toBe(registration.everydayPublicKey); expect(recovered.accountId).toBe(registration.accountId); @@ -222,11 +475,11 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { recoverWithPhrase({ phrase: registration.recoveryPhrase, vaultRelayUrls: unreachableRelayUrls, - discoveryRelayUrls: unreachableRelayUrls - }) + discoveryRelayUrls: unreachableRelayUrls, + }), ).rejects.toThrow(AccountNotFoundError); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -235,23 +488,38 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const loginName = "changeme"; const oldPassword = generatePassphrase().secret; const newPassword = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password: oldPassword, + vaultRelayUrls, + }); - const changed = await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls }); + const changed = await changePassword({ + loginName, + oldPassword, + newPassword, + vaultRelayUrls, + }); expect(changed.newCredentialPublish.success).toBe(true); expect(changed.tombstoneAcknowledgedCount).toBeGreaterThanOrEqual(2); expect(changed.deletionAcknowledgedCount).toBeGreaterThanOrEqual(2); expect(changed.newGeneration).toBe(1); // New password logs in to the same everyday identity. - const login = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls }); + const login = await loginWithPassword({ + loginName, + password: newPassword, + vaultRelayUrls, + }); expect(login.everydayPublicKey).toBe(registration.everydayPublicKey); expect(login.generation).toBe(1); // Old password's locator address is now tombstoned (empty content) -- old password no longer logs in. - await expect(loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls })).rejects.toThrow(AccountNotFoundError); + await expect( + loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls }), + ).rejects.toThrow(AccountNotFoundError); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, ); it( @@ -259,22 +527,36 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "repairme"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); // Simulate total data loss on one relay (§19.4, §29.3). relays[2]!.wipeAllData(); const pool = new RelayPool(vaultRelayUrls); - const repair = await repairReplicas(pool, registration.credentialEvent, registration.recoveryEvent); + const repair = await repairReplicas( + pool, + registration.credentialEvent, + registration.recoveryEvent, + ); pool.closeAll(); expect(repair.credentialAcknowledgedCount).toBe(3); expect(repair.recoveryAcknowledgedCount).toBe(3); // The repaired relay can now serve a clean-device login on its own. - const soleRelayLogin = await loginWithPassword({ loginName, password, vaultRelayUrls: [relays[2]!.url] }); - expect(soleRelayLogin.everydayPublicKey).toBe(registration.everydayPublicKey); + const soleRelayLogin = await loginWithPassword({ + loginName, + password, + vaultRelayUrls: [relays[2]!.url], + }); + expect(soleRelayLogin.everydayPublicKey).toBe( + registration.everydayPublicKey, + ); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -282,22 +564,36 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "rollback"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); const store = new InMemoryKeyValueStore(); // Simulate this device having previously observed a higher generation for this account. - await raiseHighWaterMark(store, registration.everydayPublicKey, { generation: 5 }); + await raiseHighWaterMark(store, registration.everydayPublicKey, { + generation: 5, + }); // Fails closed by default -- the client cannot tell "old, replayed credential" apart // from "relay is merely lagging," so it must not silently grant a session either way. - await expect(loginWithPassword({ loginName, password, vaultRelayUrls, store })).rejects.toThrow(RollbackDetectedError); + await expect( + loginWithPassword({ loginName, password, vaultRelayUrls, store }), + ).rejects.toThrow(RollbackDetectedError); // An explicit override still allows the (still-warned) login through. - const login = await loginWithPassword({ loginName, password, vaultRelayUrls, store, acknowledgeRollback: true }); + const login = await loginWithPassword({ + loginName, + password, + vaultRelayUrls, + store, + acknowledgeRollback: true, + }); expect(login.generation).toBe(0); expect(login.rollbackWarning).toBeDefined(); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -306,18 +602,32 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { const loginName = "holdout"; const oldPassword = generatePassphrase().secret; const newPassword = generatePassphrase().secret; - await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + await registerAccount({ + loginName, + password: oldPassword, + vaultRelayUrls, + }); // Rotate using only two of the three relays -- the third (relays[2]) never receives the // new capsule NOR the tombstone/deletion request, so it keeps serving the original, // "revoked" capsule indefinitely (a relay is never obligated to honor a deletion request). const rotatingRelays = [relays[0]!.url, relays[1]!.url]; - await changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls: rotatingRelays }); + await changePassword({ + loginName, + oldPassword, + newPassword, + vaultRelayUrls: rotatingRelays, + }); // Simulate one continuously-used device/browser: it logs in with the new password (across // all relays, including the holdout) and so locally records having seen the new generation. const store = new InMemoryKeyValueStore(); - const afterRotationLogin = await loginWithPassword({ loginName, password: newPassword, vaultRelayUrls, store }); + const afterRotationLogin = await loginWithPassword({ + loginName, + password: newPassword, + vaultRelayUrls, + store, + }); expect(afterRotationLogin.generation).toBe(1); // The same device is now asked to log in with the OLD password, querying ONLY the @@ -325,7 +635,12 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { // full session (with only a non-blocking warning) because the old password's locator // address still decrypts successfully there. It must now fail closed instead. await expect( - loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls: [relays[2]!.url], store }) + loginWithPassword({ + loginName, + password: oldPassword, + vaultRelayUrls: [relays[2]!.url], + store, + }), ).rejects.toThrow(RollbackDetectedError); // The explicit escape hatch still exists for a human who is confident this is relay lag. @@ -334,12 +649,12 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { password: oldPassword, vaultRelayUrls: [relays[2]!.url], store, - acknowledgeRollback: true + acknowledgeRollback: true, }); expect(acknowledged.generation).toBe(0); expect(acknowledged.rollbackWarning).toBeDefined(); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, ); it( @@ -347,18 +662,32 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "disagree"; const password = generatePassphrase().secret; - const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); // One relay receives a newer, independently re-signed generation that never reached the // other two -- simulating a relay that is ahead while its peers are ahead of *it* in a // split-brain sense; either way, "latest per relay" no longer agrees across the pool. const normalizedLoginName = normalizeLoginName(loginName); - const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys(password, normalizedLoginName); - const decrypted = await decryptCredentialCapsuleEvent(registration.credentialEvent, capsuleKey); + const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys( + password, + normalizedLoginName, + ); + const decrypted = await decryptCredentialCapsuleEvent( + registration.credentialEvent, + capsuleKey, + ); const conflictingEvent = await buildCredentialCapsuleEvent({ locatorPrivateKey, capsuleKey, - payload: { ...decrypted, generation: decrypted.generation + 1, created_at: registration.credentialEvent.created_at + 100 } + payload: { + ...decrypted, + generation: decrypted.generation + 1, + created_at: registration.credentialEvent.created_at + 100, + }, }); const holdoutConnection = new RelayConnection(relays[2]!.url); @@ -366,11 +695,15 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { holdoutConnection.close(); const pool = new RelayPool(vaultRelayUrls); - const result = await readCredentialCapsule(pool, registration.locatorPublicKey, capsuleKey); + const result = await readCredentialCapsule( + pool, + registration.locatorPublicKey, + capsuleKey, + ); pool.closeAll(); expect(result.relayDisagreement).toBe(true); }, - ARGON2_TIMEOUT + ARGON2_TIMEOUT, ); it( @@ -378,76 +711,131 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { async () => { const loginName = "reused"; const password = generatePassphrase().secret; - const original = await registerAccount({ loginName, password, vaultRelayUrls }); + const original = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); // Same login name + same password -> the exact same locator address. Without a // pre-publish existence check this would silently replace the original account's // credential capsule with a brand-new, unrelated everyday identity and recovery phrase. - await expect(registerAccount({ loginName, password, vaultRelayUrls })).rejects.toThrow(AccountAlreadyExistsError); + await expect( + registerAccount({ loginName, password, vaultRelayUrls }), + ).rejects.toThrow(AccountAlreadyExistsError); // The original account must still be exactly as it was -- same everyday identity, // same recovery phrase still valid. - const login = await loginWithPassword({ loginName, password, vaultRelayUrls }); + const login = await loginWithPassword({ + loginName, + password, + vaultRelayUrls, + }); expect(login.everydayPublicKey).toBe(original.everydayPublicKey); expect(login.generation).toBe(0); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, ); it( - "a rotation that cannot publish the new capsule leaves the OLD password working (§18.1 ordering)", + "a rejected registration recovery write never publishes the credential capsule", async () => { - // The tombstone DESTROYS the old capsule. Publishing it before the new - // capsule is known durable can leave an account with neither password -- - // recoverable only by phrase. Failing closed keeps the old one alive. - const loginName = "ordering"; - const oldPassword = "old ordering password 41"; - const newPassword = "new ordering password 42"; - await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + const loginName = "registration-ordering"; + const password = generatePassphrase().secret; + for (const relay of relays) + relay.refusePublishDTags.add(D_TAG_RECOVERY_CAPSULE); - // Every relay refuses the NEW locator's writes while still accepting - // reads and the tombstone -- the partially-hostile quorum case. - const newLocator = getPublicKeyHex( - (await derivePasswordKeys(newPassword, normalizeLoginName(loginName))).locatorPrivateKey - ); - for (const relay of relays) relay.refusePublishFrom.add(newLocator); await expect( - changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls }) - ).rejects.toThrow(/still works|quorum/i); - for (const relay of relays) relay.refusePublishFrom.clear(); + registerAccount({ loginName, password, vaultRelayUrls }), + ).rejects.toThrow(/No login credential was published/i); + for (const relay of relays) relay.refusePublishDTags.clear(); - // The old password must still sign in, and the new one must not exist. - const login = await loginWithPassword({ loginName, password: oldPassword, vaultRelayUrls }); - expect(login.everydayPublicKey).toMatch(/^[0-9a-f]{64}$/); + // Before the fix the credential publish still ran and this login + // succeeded even though registration had thrown without revealing the + // freshly generated recovery phrase. + await expect( + loginWithPassword({ loginName, password, vaultRelayUrls }), + ).rejects.toThrow(AccountNotFoundError); }, - 45_000 + ARGON2_TIMEOUT * 2, ); + it("a rotation that cannot publish the new capsule leaves the OLD password working (§18.1 ordering)", async () => { + // The tombstone DESTROYS the old capsule. Publishing it before the new + // capsule is known durable can leave an account with neither password -- + // recoverable only by phrase. Failing closed keeps the old one alive. + const loginName = "ordering"; + const oldPassword = "old ordering password 41"; + const newPassword = "new ordering password 42"; + await registerAccount({ loginName, password: oldPassword, vaultRelayUrls }); + + // Every relay refuses the NEW locator's writes while still accepting + // reads and the tombstone -- the partially-hostile quorum case. + const newLocator = getPublicKeyHex( + (await derivePasswordKeys(newPassword, normalizeLoginName(loginName))) + .locatorPrivateKey, + ); + for (const relay of relays) relay.refusePublishFrom.add(newLocator); + await expect( + changePassword({ loginName, oldPassword, newPassword, vaultRelayUrls }), + ).rejects.toThrow(/still works|quorum/i); + for (const relay of relays) relay.refusePublishFrom.clear(); + + // The old password must still sign in, and the new one must not exist. + const login = await loginWithPassword({ + loginName, + password: oldPassword, + vaultRelayUrls, + }); + expect(login.everydayPublicKey).toMatch(/^[0-9a-f]{64}$/); + }, 45_000); + it( "rotating to a new password that collides with another account under the same login name is refused (§18.1)", async () => { const loginName = "sharedname"; const passwordA = generatePassphrase().secret; const passwordB = generatePassphrase().secret; - const accountA = await registerAccount({ loginName, password: passwordA, vaultRelayUrls }); - const accountB = await registerAccount({ loginName, password: passwordB, vaultRelayUrls }); + const accountA = await registerAccount({ + loginName, + password: passwordA, + vaultRelayUrls, + }); + const accountB = await registerAccount({ + loginName, + password: passwordB, + vaultRelayUrls, + }); // Rotating account A to password B's exact password would target the exact same // locator address as account B's already-registered capsule. await expect( - changePassword({ loginName, oldPassword: passwordA, newPassword: passwordB, vaultRelayUrls }) + changePassword({ + loginName, + oldPassword: passwordA, + newPassword: passwordB, + vaultRelayUrls, + }), ).rejects.toThrow(AccountAlreadyExistsError); // Neither account was touched: A's old capsule was never tombstoned, and B's capsule // was never overwritten. - const loginA = await loginWithPassword({ loginName, password: passwordA, vaultRelayUrls }); + const loginA = await loginWithPassword({ + loginName, + password: passwordA, + vaultRelayUrls, + }); expect(loginA.everydayPublicKey).toBe(accountA.everydayPublicKey); expect(loginA.generation).toBe(0); - const loginB = await loginWithPassword({ loginName, password: passwordB, vaultRelayUrls }); + const loginB = await loginWithPassword({ + loginName, + password: passwordB, + vaultRelayUrls, + }); expect(loginB.everydayPublicKey).toBe(accountB.everydayPublicKey); expect(loginB.generation).toBe(0); }, - ARGON2_TIMEOUT * 2 + ARGON2_TIMEOUT * 2, ); }); diff --git a/packages/core/src/account/changePassword.ts b/packages/core/src/account/changePassword.ts index 856d7e5..b6bcb76 100644 --- a/packages/core/src/account/changePassword.ts +++ b/packages/core/src/account/changePassword.ts @@ -3,16 +3,35 @@ import { getPublicKeyHex } from "../crypto/secp256k1.js"; import { derivePasswordKeys, normalizeLoginName } from "./normalize.js"; import { nextCreatedAt } from "./timestamp.js"; import { RelayPool, countAcknowledgements } from "../nostr/pool.js"; -import { D_TAG_PASSWORD_CAPSULE, KIND_APP_DATA, SCHEMA_CREDENTIAL_V1 } from "../nostr/kinds.js"; +import { + D_TAG_PASSWORD_CAPSULE, + KIND_APP_DATA, + SCHEMA_CREDENTIAL_V1, +} from "../nostr/kinds.js"; import { readCredentialCapsule } from "./capsuleReader.js"; -import { buildCredentialCapsuleEvent, buildCredentialTombstoneEvent } from "../capsules/credentialCapsule.js"; +import { + buildCredentialCapsuleEvent, + buildCredentialTombstoneEvent, +} from "../capsules/credentialCapsule.js"; import { buildDeletionRequest } from "../nostr/nip09.js"; -import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js"; +import { + PROTOCOL_CAPSULE_ENCRYPTION, + PROTOCOL_PASSWORD_KDF, + PROTOCOL_RECOVERY_DERIVATION, +} from "../capsules/types.js"; import type { CredentialPayload } from "../capsules/types.js"; import { publishAndVerify, type PublishVerificationResult } from "./publish.js"; -import { AccountAlreadyExistsError, AccountNotFoundError, RegistrationFailedError, RollbackDetectedError } from "./errors.js"; +import { + AccountAlreadyExistsError, + AccountNotFoundError, + RegistrationFailedError, + RollbackDetectedError, +} from "./errors.js"; import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js"; -import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js"; +import { + InMemoryKeyValueStore, + type KeyValueStore, +} from "../storage/interface.js"; import type { NostrEvent } from "../nostr/event.js"; export interface ChangePasswordParams { @@ -44,23 +63,39 @@ export interface ChangePasswordResult { deletionAcknowledgedCount: number; } -export async function changePassword(params: ChangePasswordParams): Promise { +export async function changePassword( + params: ChangePasswordParams, +): Promise { const now = params.now ?? Math.floor(Date.now() / 1000); const normalizedLoginName = normalizeLoginName(params.loginName); - const oldKeys = await derivePasswordKeys(params.oldPassword, normalizedLoginName); + const oldKeys = await derivePasswordKeys( + params.oldPassword, + normalizedLoginName, + ); const oldLocatorPublicKey = getPublicKeyHex(oldKeys.locatorPrivateKey); - const readPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: oldKeys.locatorPrivateKey }); + const readPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: oldKeys.locatorPrivateKey, + }); let readResult; try { - readResult = await readCredentialCapsule(readPool, oldLocatorPublicKey, oldKeys.capsuleKey, params.timeoutMs); + readResult = await readCredentialCapsule( + readPool, + oldLocatorPublicKey, + oldKeys.capsuleKey, + params.timeoutMs, + ); } finally { readPool.closeAll(); } if (!readResult.quorumMet) throw new AccountNotFoundError("quorum-not-met"); if (!readResult.best) { - throw new AccountNotFoundError(readResult.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event"); + throw new AccountNotFoundError( + readResult.candidates.length > 0 + ? "no-valid-candidate" + : "no-matching-event", + ); } const oldPayload = readResult.best.payload!; const oldEvent = readResult.best.event; @@ -76,7 +111,10 @@ export async function changePassword(params: ChangePasswordParams): Promise 0) { throw new AccountAlreadyExistsError( - "Another account is already registered with this login name and the new password you chose. Pick a different new password." + "Another account is already registered with this login name and the new password you chose. Pick a different new password.", ); } @@ -112,32 +157,38 @@ export async function changePassword(params: ChangePasswordParams): Promise { +export async function registerAccount( + params: RegisterAccountParams, +): Promise { const normalizedLoginName = normalizeLoginName(params.loginName); const now = params.now ?? Math.floor(Date.now() / 1000); @@ -64,135 +94,192 @@ export async function registerAccount(params: RegisterAccountParams): Promise 0) { - throw new AccountAlreadyExistsError(); - } - - // §15.2 — recovery generation happens first so its signed event can be embedded below. - const recoveryPhrase = entropyToRecoveryPhrase(randomEntropy128()); - const bip39Seed = await recoveryPhraseToSeed(recoveryPhrase); - const { recoveryPrivateKey, capsuleKey: recoveryCapsuleKey } = deriveRecoveryKeys(bip39Seed); - const recoveryPublicKey = getPublicKeyHex(recoveryPrivateKey); - - // §15.3 — everyday identity: generated fresh, or an imported existing key (§28.1, §SF10). - const imported = params.everydayPrivateKey !== undefined; - if (imported && !isValidScalar(params.everydayPrivateKey!)) { - throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key."); - } - const everydayPrivateKey = imported ? params.everydayPrivateKey! : generatePrivateKey(); - 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, - account_id: accountId, - recovery_generation: 0, - previous_recovery_event_id: null, - 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 } - }; - const recoveryEvent = await buildRecoveryCapsuleEvent({ - recoveryPrivateKey, - capsuleKey: recoveryCapsuleKey, - payload: recoveryPayload - }); - - // §15.5 — password path, embedding the signed recovery event for keyless rebroadcast repair - // (locatorPrivateKey/credentialCapsuleKey/locatorPublicKey were already derived above for the - // existing-account check). - const credentialPayload: CredentialPayload = { - schema: SCHEMA_CREDENTIAL_V1, - account_id: accountId, - generation: 0, - operational_private_key: bytesToBase64url(everydayPrivateKey), - 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: { - password_kdf: PROTOCOL_PASSWORD_KDF, - capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, - recovery_derivation: PROTOCOL_RECOVERY_DERIVATION + const locatorPublicKey = getPublicKeyHex(locatorPrivateKey); + const checkPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: locatorPrivateKey, + }); + let existingCapsule; + try { + existingCapsule = await readCredentialCapsule( + checkPool, + locatorPublicKey, + credentialCapsuleKey, + params.timeoutMs, + ); + } finally { + checkPool.closeAll(); + } + if (!existingCapsule.quorumMet) { + throw new RegistrationFailedError( + "Couldn't verify this login name and password aren't already registered. Please retry, or add more vault relays.", + ); + } + // Any validly signed event at this address (decryptable by us or not) required + // knowing this exact locator private key to publish -- which requires this exact + // login name + password -- so its mere presence means an account already exists here. + if (existingCapsule.candidates.length > 0) { + throw new AccountAlreadyExistsError(); } - }; - const credentialEvent = await buildCredentialCapsuleEvent({ - locatorPrivateKey, - capsuleKey: credentialCapsuleKey, - payload: credentialPayload - }); - // §15.6 — publication; §23.4 connection hygiene: separate pools per identity, never shared with everyday-identity ops. - const recoveryPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recoveryPrivateKey }); - const recoveryPublish = await publishAndVerify(recoveryPool, recoveryEvent, { - dTag: D_TAG_RECOVERY_CAPSULE, - minAcks: params.minAcknowledgements, - timeoutMs: params.timeoutMs - }); - recoveryPool.closeAll(); + // §15.3 — validate an imported identity before minting any other account secrets. + const imported = params.everydayPrivateKey !== undefined; + if (imported && !isValidScalar(params.everydayPrivateKey!)) { + throw new RegistrationFailedError( + "The provided key is not a valid secp256k1 private key.", + ); + } - const credentialPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey }); - const credentialPublish = await publishAndVerify(credentialPool, credentialEvent, { - dTag: D_TAG_PASSWORD_CAPSULE, - minAcks: params.minAcknowledgements, - timeoutMs: params.timeoutMs - }); - credentialPool.closeAll(); + // §15.2 — recovery generation happens first so its signed event can be embedded below. + const recoveryPhrase = entropyToRecoveryPhrase(randomEntropy128()); + const bip39Seed = await recoveryPhraseToSeed(recoveryPhrase); + let recoveryPrivateKey: Uint8Array; + let recoveryCapsuleKey: Uint8Array; + try { + ({ recoveryPrivateKey, capsuleKey: recoveryCapsuleKey } = + deriveRecoveryKeys(bip39Seed)); + } finally { + wipe(bip39Seed); + } - if (!recoveryPublish.success || !credentialPublish.success) { - throw new RegistrationFailedError( - "Registration did not reach the required relay acknowledgement and readback quorum. Please retry, or add more vault relays." - ); + const everydayPrivateKey = imported + ? params.everydayPrivateKey! + : generatePrivateKey(); + const connectionVaultRoot = randomBytes(32); + const vaultSudoKey = randomBytes(32); + let completed = false; + try { + const recoveryPublicKey = getPublicKeyHex(recoveryPrivateKey); + 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. + + // §15.4 — recovery capsule first, previous_recovery_event_id null (registration is generation 0) + const recoveryPayload: RecoveryPayload = { + schema: SCHEMA_RECOVERY_V1, + account_id: accountId, + recovery_generation: 0, + previous_recovery_event_id: null, + 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, + }, + }; + const recoveryEvent = await buildRecoveryCapsuleEvent({ + recoveryPrivateKey, + capsuleKey: recoveryCapsuleKey, + payload: recoveryPayload, + }); + + // §15.6 — recovery must be durable before a credential capsule that the + // caller can use is allowed to exist. A failed recovery quorum therefore + // exits before credential construction or publication. + const recoveryPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: recoveryPrivateKey, + }); + let recoveryPublish: PublishVerificationResult; + try { + recoveryPublish = await publishAndVerify(recoveryPool, recoveryEvent, { + dTag: D_TAG_RECOVERY_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }); + } finally { + recoveryPool.closeAll(); + } + if (!recoveryPublish.success) { + throw new RegistrationFailedError( + "The recovery capsule did not reach the required relay acknowledgement and readback quorum. No login credential was published. Please retry, or add more vault relays.", + ); + } + + // §15.5 — password path, embedding the now-durable signed recovery event + // for keyless rebroadcast repair. + const credentialPayload: CredentialPayload = { + schema: SCHEMA_CREDENTIAL_V1, + account_id: accountId, + generation: 0, + operational_private_key: bytesToBase64url(everydayPrivateKey), + operational_public_key: everydayPublicKey, + recovery_public_key: recoveryPublicKey, + recovery_capsule_event: recoveryEvent, + connection_vault_root: bytesToBase64url(connectionVaultRoot), + 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 credentialEvent = await buildCredentialCapsuleEvent({ + locatorPrivateKey, + capsuleKey: credentialCapsuleKey, + payload: credentialPayload, + }); + + const credentialPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: locatorPrivateKey, + }); + let credentialPublish: PublishVerificationResult; + try { + credentialPublish = await publishAndVerify( + credentialPool, + credentialEvent, + { + dTag: D_TAG_PASSWORD_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }, + ); + } finally { + credentialPool.closeAll(); + } + if (!credentialPublish.success) { + throw new RegistrationFailedError( + "Registration did not reach the required credential acknowledgement and readback quorum. Please retry, or add more vault relays.", + ); + } + + completed = true; + return { + normalizedLoginName, + recoveryPhrase, + everydayPrivateKey, + everydayPublicKey, + recoveryPublicKey, + locatorPublicKey, + accountId, + imported, + connectionVaultRoot, + vaultSudoKey, + credentialEvent, + recoveryEvent, + credentialPublish, + recoveryPublish, + }; + } finally { + wipe(recoveryPrivateKey, recoveryCapsuleKey); + if (!completed) { + if (!imported) wipe(everydayPrivateKey); + wipe(connectionVaultRoot, vaultSudoKey); + } + } + } finally { + wipe(locatorPrivateKey, credentialCapsuleKey); } - - return { - normalizedLoginName, - recoveryPhrase, - everydayPrivateKey, - everydayPublicKey, - recoveryPublicKey, - locatorPublicKey, - accountId, - imported, - connectionVaultRoot, - vaultSudoKey, - credentialEvent, - recoveryEvent, - credentialPublish, - recoveryPublish - }; } /** @@ -208,7 +295,9 @@ export async function registerAccount(params: RegisterAccountParams): Promise & { nsecOrHex: string } + params: Omit & { + nsecOrHex: string; + }, ): Promise { const { nsecOrHex, ...rest } = params; const everydayPrivateKey = decodeEverydayPrivateKey(nsecOrHex); @@ -224,10 +313,14 @@ export function decodeEverydayPrivateKey(nsecOrHex: string): Uint8Array { } else if (/^[0-9a-fA-F]{64}$/u.test(trimmed)) { key = hexToBytes(trimmed.toLowerCase()); } else { - throw new RegistrationFailedError("Enter a valid nsec (nsec1…) or a 64-character hex private key."); + throw new RegistrationFailedError( + "Enter a valid nsec (nsec1…) or a 64-character hex private key.", + ); } if (!isValidScalar(key)) { - throw new RegistrationFailedError("The provided key is not a valid secp256k1 private key."); + throw new RegistrationFailedError( + "The provided key is not a valid secp256k1 private key.", + ); } return key; } diff --git a/packages/core/src/account/enableVault.ts b/packages/core/src/account/enableVault.ts index f8bdd7e..723aebf 100644 --- a/packages/core/src/account/enableVault.ts +++ b/packages/core/src/account/enableVault.ts @@ -16,13 +16,29 @@ * 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 { + base64urlToBytes, + bytesToBase64url, + constantTimeEqual, +} from "../crypto/encoding.js"; +import { wipe } from "../crypto/memory.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 { + 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"; @@ -31,11 +47,16 @@ import { PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION, type CredentialPayload, - type RecoveryPayload + type RecoveryPayload, } from "../capsules/types.js"; import { publishAndVerify } from "./publish.js"; -import { AccountNotFoundError, RecoveryFailedError, RegistrationFailedError } from "./errors.js"; +import { + AccountNotFoundError, + RecoveryFailedError, + RegistrationFailedError, +} from "./errors.js"; import type { PublishVerificationResult } from "./publish.js"; +import { deriveLegacyVaultMaterial } from "../vault/derivation.js"; export interface EnableConnectionVaultParams { loginName: string; @@ -58,27 +79,44 @@ export interface EnableConnectionVaultResult { } export async function enableConnectionVault( - params: EnableConnectionVaultParams + params: EnableConnectionVaultParams, ): Promise { const now = params.now ?? Math.floor(Date.now() / 1000); const normalizedLoginName = normalizeLoginName(params.loginName); if (!isValidRecoveryPhrase(params.phrase)) { - throw new RecoveryFailedError("This does not look like a valid 12-word BitLogin recovery phrase."); + 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 passwordKeys = await derivePasswordKeys( + params.password, + normalizedLoginName, + ); const locatorPublicKey = getPublicKeyHex(passwordKeys.locatorPrivateKey); - const credentialPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: passwordKeys.locatorPrivateKey }); + const credentialPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: passwordKeys.locatorPrivateKey, + }); let credentialRead; try { - credentialRead = await readCredentialCapsule(credentialPool, locatorPublicKey, passwordKeys.capsuleKey, params.timeoutMs); + credentialRead = await readCredentialCapsule( + credentialPool, + locatorPublicKey, + passwordKeys.capsuleKey, + params.timeoutMs, + ); } finally { credentialPool.closeAll(); } - if (!credentialRead.quorumMet) throw new AccountNotFoundError("quorum-not-met"); + 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"); + throw new AccountNotFoundError( + credentialRead.candidates.length > 0 + ? "no-valid-candidate" + : "no-matching-event", + ); } const credentialPayload = credentialRead.best.payload!; const credentialEvent = credentialRead.best.event; @@ -87,14 +125,24 @@ export async function enableConnectionVault( // account — a phrase for some other account must not be grafted onto this one. const bip39Seed = await recoveryPhraseToSeed(params.phrase); const recoveryKeys = deriveRecoveryKeys(bip39Seed); + wipe(bip39Seed); const recoveryPublicKey = getPublicKeyHex(recoveryKeys.recoveryPrivateKey); if (recoveryPublicKey !== credentialPayload.recovery_public_key) { - throw new RecoveryFailedError("This recovery phrase does not belong to this account."); + throw new RecoveryFailedError( + "This recovery phrase does not belong to this account.", + ); } - const recoveryPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recoveryKeys.recoveryPrivateKey }); + const recoveryPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: recoveryKeys.recoveryPrivateKey, + }); let recoveryRead; try { - recoveryRead = await readRecoveryCapsule(recoveryPool, recoveryPublicKey, recoveryKeys.capsuleKey, params.timeoutMs); + recoveryRead = await readRecoveryCapsule( + recoveryPool, + recoveryPublicKey, + recoveryKeys.capsuleKey, + params.timeoutMs, + ); } finally { recoveryPool.closeAll(); } @@ -107,26 +155,57 @@ export async function enableConnectionVault( 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."); + 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 - ) { + const credentialRoot = credentialPayload.connection_vault_root; + const recoveryRoot = recoveryPayload.connection_vault_root; + const credentialSudo = credentialPayload.vault_sudo_key; + const recoverySudo = recoveryPayload.vault_sudo_key; + if (credentialRoot !== undefined && recoveryRoot !== undefined) { + const rootsMatch = constantTimeEqual( + base64urlToBytes(credentialRoot), + base64urlToBytes(recoveryRoot), + ); + const sudoKeysMatch = + credentialSudo === undefined || + constantTimeEqual( + base64urlToBytes(credentialSudo), + base64urlToBytes(recoverySudo!), + ); + if (!rootsMatch || !sudoKeysMatch) { + throw new RecoveryFailedError( + "The credential and recovery capsules contain different Connection Vault roots. Refusing to choose one automatically; restore from a known-good recovery export.", + ); + } return { alreadyEnabled: true, - connectionVaultRoot: base64urlToBytes(credentialPayload.connection_vault_root), - vaultSudoKey: base64urlToBytes(credentialPayload.vault_sudo_key!) + connectionVaultRoot: base64urlToBytes(credentialRoot), + vaultSudoKey: base64urlToBytes(recoverySudo!), }; } - const connectionVaultRoot = existingRoot !== undefined ? base64urlToBytes(existingRoot) : randomBytes(32); - const vaultSudoKey = existingSudo !== undefined ? base64urlToBytes(existingSudo) : randomBytes(32); + const existingRoot = credentialRoot ?? recoveryRoot; + const existingSudo = recoverySudo ?? credentialSudo; + const derived = + existingRoot === undefined + ? deriveLegacyVaultMaterial( + recoveryKeys.capsuleKey, + recoveryPayload.account_id, + ) + : undefined; + const connectionVaultRoot = + existingRoot !== undefined + ? base64urlToBytes(existingRoot) + : derived!.connectionVaultRoot; + const vaultSudoKey = + existingSudo !== undefined + ? base64urlToBytes(existingSudo) + : derived!.vaultSudoKey; const rootB64 = bytesToBase64url(connectionVaultRoot); const sudoB64 = bytesToBase64url(vaultSudoKey); @@ -143,23 +222,36 @@ export async function enableConnectionVault( 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 } + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; const refreshedRecoveryEvent = await buildRecoveryCapsuleEvent({ recoveryPrivateKey: recoveryKeys.recoveryPrivateKey, capsuleKey: recoveryKeys.capsuleKey, - payload: refreshedRecoveryPayload + 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 + const recoveryPublishPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: recoveryKeys.recoveryPrivateKey, }); - recoveryPublishPool.closeAll(); + let recoveryPublish: PublishVerificationResult; + try { + recoveryPublish = await publishAndVerify( + recoveryPublishPool, + refreshedRecoveryEvent, + { + dTag: D_TAG_RECOVERY_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }, + ); + } finally { + 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." + "Enabling the vault did not reach recovery-capsule quorum. The credential capsule was not changed; retrying is safe and reuses the same deterministic vault roots.", ); } @@ -172,33 +264,48 @@ export async function enableConnectionVault( 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 - } + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; const newCredentialEvent = await buildCredentialCapsuleEvent({ locatorPrivateKey: passwordKeys.locatorPrivateKey, capsuleKey: passwordKeys.capsuleKey, - payload: newCredentialPayload + 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 + const credentialPublishPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: passwordKeys.locatorPrivateKey, }); - credentialPublishPool.closeAll(); + let credentialPublish: PublishVerificationResult; + try { + credentialPublish = await publishAndVerify( + credentialPublishPool, + newCredentialEvent, + { + dTag: D_TAG_PASSWORD_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }, + ); + } finally { + 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." + "Retry enableConnectionVault: it will reuse the same roots and only repair the credential capsule.", ); } - return { alreadyEnabled: false, connectionVaultRoot, vaultSudoKey, recoveryPublish, credentialPublish }; + return { + alreadyEnabled: false, + connectionVaultRoot, + vaultSudoKey, + recoveryPublish, + credentialPublish, + }; } diff --git a/packages/core/src/account/index.ts b/packages/core/src/account/index.ts index ce68043..2c26986 100644 --- a/packages/core/src/account/index.ts +++ b/packages/core/src/account/index.ts @@ -1,7 +1,6 @@ export * from "./errors.js"; export * from "./wordlist.js"; export * from "./passphrase.js"; -export * from "./manualPassword.js"; export * from "./normalize.js"; export * from "./timestamp.js"; export * from "./highWaterMark.js"; diff --git a/packages/core/src/account/login.ts b/packages/core/src/account/login.ts index 4a602e3..776c820 100644 --- a/packages/core/src/account/login.ts +++ b/packages/core/src/account/login.ts @@ -6,7 +6,10 @@ import { RelayPool } from "../nostr/pool.js"; import { readCredentialCapsule } from "./capsuleReader.js"; import { AccountNotFoundError, RollbackDetectedError } from "./errors.js"; import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js"; -import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js"; +import { + InMemoryKeyValueStore, + type KeyValueStore, +} from "../storage/interface.js"; import type { NostrEvent } from "../nostr/event.js"; export interface LoginParams { @@ -37,7 +40,7 @@ export interface LoginResult { 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. */ + /** @deprecated Password login never returns the phrase-gated sudo key. */ vaultSudoKey?: Uint8Array; /** Set when the accepted generation is lower than one this device has previously seen (§16.2 step 6). */ rollbackWarning?: string; @@ -52,18 +55,34 @@ export interface LoginResult { * that will carry everyday-identity traffic (§23.4) — capsule and * everyday-identity operations must never share a relay connection. */ -export async function loginWithPassword(params: LoginParams): Promise { +export async function loginWithPassword( + params: LoginParams, +): Promise { const normalizedLoginName = normalizeLoginName(params.loginName); - const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys(params.password, normalizedLoginName); + const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys( + params.password, + normalizedLoginName, + ); const locatorPublicKey = getPublicKeyHex(locatorPrivateKey); - const pool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey }); + const pool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: locatorPrivateKey, + }); try { - const result = await readCredentialCapsule(pool, locatorPublicKey, capsuleKey, params.timeoutMs); + const result = await readCredentialCapsule( + pool, + locatorPublicKey, + capsuleKey, + params.timeoutMs, + ); if (!result.quorumMet) throw new AccountNotFoundError("quorum-not-met"); if (!result.best) { - throw new AccountNotFoundError(result.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event"); + throw new AccountNotFoundError( + result.candidates.length > 0 + ? "no-valid-candidate" + : "no-matching-event", + ); } const payload = result.best.payload!; @@ -76,10 +95,12 @@ export async function loginWithPassword(params: LoginParams): Promise { - it("rejects passwords shorter than the minimum length", () => { - expect(checkManualPassword("Sh0rt!", "adam").ok).toBe(false); - }); - - it("rejects common weak passwords", () => { - expect(checkManualPassword("password123", "adam").ok).toBe(false); - expect(checkManualPassword("correcthorsebatterystaple", "adam").ok).toBe(false); - }); - - it("rejects a password containing the login name", () => { - expect(checkManualPassword("adamsSuperSecret1!", "adam").ok).toBe(false); - }); - - it("rejects repeated-character and sequence patterns", () => { - expect(checkManualPassword("aaaaaaaaaaaaaaaa", "user").ok).toBe(false); - expect(checkManualPassword("abcdabcdabcdabcd", "user").ok).toBe(false); - expect(checkManualPassword("12345678901234", "user").ok).toBe(false); - }); - - it("rejects passwords below the entropy floor even if structurally fine", () => { - // 12 lowercase letters ~= 12 * log2(26) ~= 56 bits, below the 64-bit floor. - expect(checkManualPassword("qwertyuiopzx", "user").ok).toBe(false); - }); - - it("accepts a sufficiently long, varied, non-blocklisted password", () => { - const result = checkManualPassword("Tr0mbone$Xylophone#42", "user"); - expect(result.ok).toBe(true); - expect(result.entropyBits).toBeGreaterThanOrEqual(64); - }); - - it("estimates higher entropy for a longer, more varied password", () => { - const short = estimatePasswordEntropyBits("abcdefgh"); - const long = estimatePasswordEntropyBits("abcdefghijklmnop123!@#"); - expect(long).toBeGreaterThan(short); - }); -}); diff --git a/packages/core/src/account/manualPassword.ts b/packages/core/src/account/manualPassword.ts deleted file mode 100644 index 8b39468..0000000 --- a/packages/core/src/account/manualPassword.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Optional manual password support (§9.3). The alpha's default and - * recommendation is a generated credential (§9.2); a user may opt into - * typing their own, gated by a hard minimum-entropy estimate, a small - * known-weak-pattern check, and an explicit warning that offline guessing - * against a downloaded capsule cannot be prevented by any client-side check - * (§9.1). This is an alpha-grade heuristic, not a breach-database lookup — - * BitLogin is static and makes no third-party network calls, so it cannot - * check against HaveIBeenPwned-style corpora. A production release should - * use a proper strength estimator (e.g. zxcvbn) and/or a bundled breach list. - */ - -export const MANUAL_PASSWORD_MIN_ENTROPY_BITS = 64; -export const MANUAL_PASSWORD_MIN_LENGTH = 12; - -const COMMON_WEAK_PASSWORDS = new Set([ - "password", "password1", "password123", "123456", "123456789", "12345678", - "qwerty", "qwerty123", "letmein", "welcome", "monkey", "dragon", "master", - "abc123", "iloveyou", "admin", "login", "starwars", "sunshine", "princess", - "football", "baseball", "trustno1", "000000", "111111", "123123", "1234567890", - "changeme", "passw0rd", "correcthorsebatterystaple" -]); - -export interface ManualPasswordCheck { - ok: boolean; - entropyBits: number; - reason?: string; -} - -function characterClassAlphabetSize(password: string): number { - let size = 0; - if (/[a-z]/u.test(password)) size += 26; - if (/[A-Z]/u.test(password)) size += 26; - if (/[0-9]/u.test(password)) size += 10; - if (/[^a-zA-Z0-9]/u.test(password)) size += 33; // common printable symbols - return size || 1; -} - -/** - * A conservative, dependency-free entropy estimate: character-class alphabet - * size times length. This deliberately under-credits patterned passwords - * rather than over-trusts them, but it cannot detect every weak pattern — - * it is combined with the blocklist and structural checks below. - */ -export function estimatePasswordEntropyBits(password: string): number { - return password.length * Math.log2(characterClassAlphabetSize(password)); -} - -function hasLowVarietyPattern(password: string): boolean { - if (/^(.)\1*$/u.test(password)) return true; // all one character - if (/(.)\1{2,}/u.test(password)) return true; // 3+ repeated character run - const lower = password.toLowerCase(); - const sequences = ["0123456789", "abcdefghijklmnopqrstuvwxyz"]; - for (const seq of sequences) { - for (let i = 0; i + 4 <= seq.length; i++) { - const forward = seq.slice(i, i + 4); - const backward = [...forward].reverse().join(""); - if (lower.includes(forward) || lower.includes(backward)) return true; - } - } - return false; -} - -/** Validates a user-chosen password against the alpha's manual-password floor (§9.3). */ -export function checkManualPassword(password: string, normalizedLoginName: string): ManualPasswordCheck { - const entropyBits = estimatePasswordEntropyBits(password); - - if (password.length < MANUAL_PASSWORD_MIN_LENGTH) { - return { ok: false, entropyBits, reason: `Must be at least ${MANUAL_PASSWORD_MIN_LENGTH} characters.` }; - } - if (COMMON_WEAK_PASSWORDS.has(password.toLowerCase())) { - return { ok: false, entropyBits, reason: "This is on a list of extremely common passwords." }; - } - if (normalizedLoginName.length >= 3 && password.toLowerCase().includes(normalizedLoginName)) { - return { ok: false, entropyBits, reason: "Must not contain your login name." }; - } - if (hasLowVarietyPattern(password)) { - return { ok: false, entropyBits, reason: "Too predictable (repeated characters or a simple sequence)." }; - } - if (entropyBits < MANUAL_PASSWORD_MIN_ENTROPY_BITS) { - return { - ok: false, - entropyBits, - reason: `Estimated entropy (~${entropyBits.toFixed(0)} bits) is below the required ${MANUAL_PASSWORD_MIN_ENTROPY_BITS}. Use a longer password or mix character types.` - }; - } - return { ok: true, entropyBits }; -} diff --git a/packages/core/src/account/recover.ts b/packages/core/src/account/recover.ts index 309d723..0a04b55 100644 --- a/packages/core/src/account/recover.ts +++ b/packages/core/src/account/recover.ts @@ -2,8 +2,15 @@ import { getPublicKeyHex } from "../crypto/secp256k1.js"; import { wipe } from "../crypto/memory.js"; import { base64urlToBytes, bytesToBase64url } from "../crypto/encoding.js"; -import { isValidRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js"; -import { deriveRecoveryKeys, derivePasswordKeys, normalizeLoginName } from "./normalize.js"; +import { + isValidRecoveryPhrase, + recoveryPhraseToSeed, +} from "../crypto/bip39.js"; +import { + deriveRecoveryKeys, + derivePasswordKeys, + normalizeLoginName, +} from "./normalize.js"; import { nextCreatedAt } from "./timestamp.js"; import { RelayPool } from "../nostr/pool.js"; import { verifyNostrEvent, type NostrEvent } from "../nostr/event.js"; @@ -13,17 +20,24 @@ import { KIND_DM_RELAY_LIST, KIND_RELAY_LIST, SCHEMA_CREDENTIAL_V1, - SCHEMA_RECOVERY_V1 + SCHEMA_RECOVERY_V1, } from "../nostr/kinds.js"; import { readCredentialCapsule, readRecoveryCapsule, checkRecoveryChainAcrossCandidates, - type Candidate + type Candidate, } from "./capsuleReader.js"; -import { buildRecoveryCapsuleEvent, decryptRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js"; +import { + buildRecoveryCapsuleEvent, + decryptRecoveryCapsuleEvent, +} from "../capsules/recoveryCapsule.js"; import { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; -import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js"; +import { + PROTOCOL_CAPSULE_ENCRYPTION, + PROTOCOL_PASSWORD_KDF, + PROTOCOL_RECOVERY_DERIVATION, +} from "../capsules/types.js"; import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js"; import { parseDmRelayListEvent, parseRelayListEvent } from "./profileEvents.js"; import { publishAndVerify, type PublishVerificationResult } from "./publish.js"; @@ -68,9 +82,13 @@ function pickNewestValidEvent(events: NostrEvent[]): NostrEvent | undefined { } /** §17.1-§17.4: derives the recovery identity, locates the recovery capsule, and restores relay preferences. */ -export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promise { +export async function recoverWithPhrase( + params: RecoverWithPhraseParams, +): Promise { if (!isValidRecoveryPhrase(params.phrase)) { - throw new RecoveryFailedError("This does not look like a valid 12-word BitLogin recovery phrase."); + throw new RecoveryFailedError( + "This does not look like a valid 12-word BitLogin recovery phrase.", + ); } const bip39Seed = await recoveryPhraseToSeed(params.phrase); const { recoveryPrivateKey, capsuleKey } = deriveRecoveryKeys(bip39Seed); @@ -80,10 +98,17 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis wipe(bip39Seed); const recoveryPublicKey = getPublicKeyHex(recoveryPrivateKey); - const vaultPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recoveryPrivateKey }); + const vaultPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: recoveryPrivateKey, + }); let result; try { - result = await readRecoveryCapsule(vaultPool, recoveryPublicKey, capsuleKey, params.timeoutMs); + result = await readRecoveryCapsule( + vaultPool, + recoveryPublicKey, + capsuleKey, + params.timeoutMs, + ); } finally { vaultPool.closeAll(); } @@ -93,21 +118,38 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis for (const event of params.offlineRecoveryCapsuleEvents) { if (!verifyNostrEvent(event)) continue; try { - offlineCandidates.push({ event, payload: await decryptRecoveryCapsuleEvent(event, capsuleKey) }); + offlineCandidates.push({ + event, + payload: await decryptRecoveryCapsuleEvent(event, capsuleKey), + }); } catch (err) { - offlineCandidates.push({ event, payload: null, error: (err as Error).message }); + offlineCandidates.push({ + event, + payload: null, + error: (err as Error).message, + }); } } const merged = new Map>(); - for (const candidate of [...result.candidates, ...offlineCandidates]) merged.set(candidate.event.id, candidate); - const candidates = [...merged.values()].sort((a, b) => b.event.created_at - a.event.created_at); + for (const candidate of [...result.candidates, ...offlineCandidates]) + merged.set(candidate.event.id, candidate); + const candidates = [...merged.values()].sort( + (a, b) => b.event.created_at - a.event.created_at, + ); const best = candidates.find((c) => c.payload !== null) ?? null; - result = { ...result, candidates, best, quorumMet: result.quorumMet || best !== null }; + result = { + ...result, + candidates, + best, + quorumMet: result.quorumMet || best !== null, + }; } if (!result.quorumMet) throw new AccountNotFoundError("quorum-not-met"); if (!result.best) { - throw new AccountNotFoundError(result.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event"); + throw new AccountNotFoundError( + result.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event", + ); } const payload = result.best.payload!; const chainCheck = checkRecoveryChainAcrossCandidates(result.candidates); @@ -117,23 +159,29 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis // §17.4 step 4 — restore relay preferences from the user's own public kind 10002/10050 events, // searched across the discovery relays plus this capsule's own vault-relay hints. - const discoveryTargets = [...new Set([...params.discoveryRelayUrls, ...payload.vault_relay_hints])]; + const discoveryTargets = [ + ...new Set([...params.discoveryRelayUrls, ...payload.vault_relay_hints]), + ]; const discoveryPool = new RelayPool(discoveryTargets); let generalRelays: string[] = []; let dmRelays: string[] = []; try { const relayListQuorum = await discoveryPool.queryQuorum( { kinds: [KIND_RELAY_LIST], authors: [everydayPublicKey], limit: 5 }, - params.timeoutMs + params.timeoutMs, + ); + const relayListEvent = pickNewestValidEvent( + relayListQuorum.outcomes.flatMap((o) => o.events), ); - const relayListEvent = pickNewestValidEvent(relayListQuorum.outcomes.flatMap((o) => o.events)); if (relayListEvent) generalRelays = parseRelayListEvent(relayListEvent); const dmListQuorum = await discoveryPool.queryQuorum( { kinds: [KIND_DM_RELAY_LIST], authors: [everydayPublicKey], limit: 5 }, - params.timeoutMs + params.timeoutMs, + ); + const dmListEvent = pickNewestValidEvent( + dmListQuorum.outcomes.flatMap((o) => o.events), ); - const dmListEvent = pickNewestValidEvent(dmListQuorum.outcomes.flatMap((o) => o.events)); if (dmListEvent) dmRelays = parseDmRelayListEvent(dmListEvent); } finally { discoveryPool.closeAll(); @@ -150,7 +198,7 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis currentRecoveryPayload: payload, generalRelays, dmRelays, - chainWarning: chainCheck.consistent ? undefined : chainCheck.warning + chainWarning: chainCheck.consistent ? undefined : chainCheck.warning, }; } @@ -188,119 +236,201 @@ export interface CompleteRecoveryResult { * roll back. Account-level continuity across this reset is carried by the * unchanged everyday identity and by the recovery_generation hash chain. */ -export async function completeRecoveryWithNewCredentials(params: CompleteRecoveryParams): Promise { +export async function completeRecoveryWithNewCredentials( + params: CompleteRecoveryParams, +): Promise { const now = params.now ?? Math.floor(Date.now() / 1000); 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 = + const recoveryVaultFields = recovered.currentRecoveryPayload.connection_vault_root !== undefined ? { - connection_vault_root: recovered.currentRecoveryPayload.connection_vault_root, - vault_sudo_key: recovered.currentRecoveryPayload.vault_sudo_key + 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, - recovery_generation: recovered.currentRecoveryPayload.recovery_generation + 1, - previous_recovery_event_id: recovered.currentRecoveryEvent.id, - 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 } - }; - const refreshedRecoveryEvent = await buildRecoveryCapsuleEvent({ - recoveryPrivateKey: recovered.recoveryPrivateKey, - capsuleKey: recovered.recoveryCapsuleKey, - payload: refreshedPayload - }); - - const recoveryPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: recovered.recoveryPrivateKey }); - const recoveryPublish = await publishAndVerify(recoveryPool, refreshedRecoveryEvent, { - dTag: D_TAG_RECOVERY_CAPSULE, - minAcks: params.minAcknowledgements, - timeoutMs: params.timeoutMs - }); - recoveryPool.closeAll(); - - const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys(params.newPassword, normalizedLoginName); - const locatorPublicKey = getPublicKeyHex(locatorPrivateKey); - - // §15.6 -- the SAME guard registration and rotation already carry, and it - // was missing only here. The new locator address is fully determined by - // (login name, new password); if a DIFFERENT account already lives there, - // publishing would replace and destroy it. Recovery is the one flow where - // the user is picking fresh credentials under stress, so it is exactly - // where a collision is most likely to be shrugged past. - const collisionPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey }); - let existingAtLocator; + const credentialVaultFields = + recovered.currentRecoveryPayload.connection_vault_root !== undefined + ? { + connection_vault_root: + recovered.currentRecoveryPayload.connection_vault_root, + } + : {}; + const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys( + params.newPassword, + normalizedLoginName, + ); try { - existingAtLocator = await readCredentialCapsule( - collisionPool, - locatorPublicKey, - capsuleKey, - params.timeoutMs - ); - } finally { - collisionPool.closeAll(); - } - if (!existingAtLocator.quorumMet) { - throw new RecoveryFailedError( - "Couldn't verify the new login name and password aren't already registered. Please retry, or add more vault relays." - ); - } - if (existingAtLocator.candidates.length > 0) { - throw new RecoveryFailedError( - "Another account is already registered with that login name and password. Pick a different one." - ); - } + const locatorPublicKey = getPublicKeyHex(locatorPrivateKey); - const credentialPayload: CredentialPayload = { - schema: SCHEMA_CREDENTIAL_V1, - account_id: recovered.accountId, - generation: 0, - operational_private_key: bytesToBase64url(recovered.everydayPrivateKey), - operational_public_key: recovered.everydayPublicKey, - recovery_public_key: recovered.recoveryPublicKey, - recovery_capsule_event: refreshedRecoveryEvent, - ...vaultFields, - 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 credentialEvent = await buildCredentialCapsuleEvent({ locatorPrivateKey, capsuleKey, payload: credentialPayload }); - const credentialPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey }); - const credentialPublish = await publishAndVerify(credentialPool, credentialEvent, { - dTag: D_TAG_PASSWORD_CAPSULE, - minAcks: params.minAcknowledgements, - timeoutMs: params.timeoutMs - }); - credentialPool.closeAll(); - - if (!recoveryPublish.success || !credentialPublish.success) { - throw new RecoveryFailedError("Could not publish the refreshed recovery and credential capsules to enough relays. Please retry."); - } - - // Only after both capsules are durable: the recovered account is now - // authoritatively at generation 0, so the mark must follow it down rather - // than keep flagging every future login as a rollback (see - // resetHighWaterMark for why the phrase is entitled to do this). - if (params.store) { - await resetHighWaterMark(params.store, recovered.everydayPublicKey, { - generation: 0, - recoveryGeneration: recovered.currentRecoveryPayload.recovery_generation + 1 + // §15.6 -- collision refusal is a PRECONDITION for mutating the recovery + // chain. A failed or occupied locator read must leave the current recovery + // event untouched so the caller can safely retry with different credentials. + const collisionPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: locatorPrivateKey, }); - } + let existingAtLocator; + try { + existingAtLocator = await readCredentialCapsule( + collisionPool, + locatorPublicKey, + capsuleKey, + params.timeoutMs, + ); + } finally { + collisionPool.closeAll(); + } + if (!existingAtLocator.quorumMet) { + throw new RecoveryFailedError( + "Couldn't verify the new login name and password aren't already registered. Please retry, or add more vault relays.", + ); + } + const existingPayload = existingAtLocator.best?.payload ?? null; + const resumesPartialCredentialWrite = + existingPayload !== null && + existingPayload.account_id === recovered.accountId && + existingPayload.operational_public_key === recovered.everydayPublicKey && + existingPayload.recovery_public_key === recovered.recoveryPublicKey && + existingPayload.generation === 0 && + existingPayload.recovery_capsule_event.id === + recovered.currentRecoveryEvent.id; + if ( + existingAtLocator.candidates.length > 0 && + !resumesPartialCredentialWrite + ) { + throw new RecoveryFailedError( + "Another account is already registered with that login name and password. Pick a different one.", + ); + } - return { normalizedLoginName, locatorPublicKey, credentialEvent, refreshedRecoveryEvent, credentialPublish, recoveryPublish }; + const nextRecoveryGeneration = + recovered.currentRecoveryPayload.recovery_generation + 1; + const refreshedPayload: RecoveryPayload = { + schema: SCHEMA_RECOVERY_V1, + account_id: recovered.accountId, + recovery_generation: nextRecoveryGeneration, + previous_recovery_event_id: recovered.currentRecoveryEvent.id, + operational_private_key: bytesToBase64url(recovered.everydayPrivateKey), + operational_public_key: recovered.everydayPublicKey, + recovery_public_key: recovered.recoveryPublicKey, + ...recoveryVaultFields, + created_at: nextCreatedAt(recovered.currentRecoveryEvent.created_at, now), + vault_relay_hints: params.vaultRelayUrls, + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, + }; + const refreshedRecoveryEvent = await buildRecoveryCapsuleEvent({ + recoveryPrivateKey: recovered.recoveryPrivateKey, + capsuleKey: recovered.recoveryCapsuleKey, + payload: refreshedPayload, + }); + + const recoveryPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: recovered.recoveryPrivateKey, + }); + let recoveryPublish: PublishVerificationResult; + try { + recoveryPublish = await publishAndVerify( + recoveryPool, + refreshedRecoveryEvent, + { + dTag: D_TAG_RECOVERY_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }, + ); + } finally { + recoveryPool.closeAll(); + } + if (!recoveryPublish.success) { + throw new RecoveryFailedError( + "Could not publish the refreshed recovery capsule to enough relays. No new credential was published; please retry.", + ); + } + + // The recovery event is already authoritative at this point even if the + // following credential write fails. Advance the caller's pending state + // immediately so an in-memory retry chains from this durable event rather + // than creating a same-generation sibling from the stale predecessor. + recovered.currentRecoveryEvent = refreshedRecoveryEvent; + recovered.currentRecoveryPayload = refreshedPayload; + + const credentialPayload: CredentialPayload = { + schema: SCHEMA_CREDENTIAL_V1, + account_id: recovered.accountId, + generation: 0, + operational_private_key: bytesToBase64url(recovered.everydayPrivateKey), + operational_public_key: recovered.everydayPublicKey, + recovery_public_key: recovered.recoveryPublicKey, + recovery_capsule_event: refreshedRecoveryEvent, + ...credentialVaultFields, + // A retry may be repairing a credential that reached only one relay. + // It must replace that partial event deterministically on every relay. + created_at: existingAtLocator.best + ? nextCreatedAt(existingAtLocator.best.event.created_at, now) + : now, + vault_relay_hints: params.vaultRelayUrls, + protocol: { + password_kdf: PROTOCOL_PASSWORD_KDF, + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, + }; + const credentialEvent = await buildCredentialCapsuleEvent({ + locatorPrivateKey, + capsuleKey, + payload: credentialPayload, + }); + const credentialPool = new RelayPool(params.vaultRelayUrls, { + authPrivateKey: locatorPrivateKey, + }); + let credentialPublish: PublishVerificationResult; + try { + credentialPublish = await publishAndVerify( + credentialPool, + credentialEvent, + { + dTag: D_TAG_PASSWORD_CAPSULE, + minAcks: params.minAcknowledgements, + timeoutMs: params.timeoutMs, + }, + ); + } finally { + credentialPool.closeAll(); + } + + if (!credentialPublish.success) { + throw new RecoveryFailedError( + "Could not publish the new credential capsule to enough relays. Please retry.", + ); + } + + // Only after both capsules are durable: the recovered account is now + // authoritatively at generation 0, so the mark must follow it down rather + // than keep flagging every future login as a rollback (see + // resetHighWaterMark for why the phrase is entitled to do this). + if (params.store) { + await resetHighWaterMark(params.store, recovered.everydayPublicKey, { + generation: 0, + recoveryGeneration: nextRecoveryGeneration, + }); + } + + return { + normalizedLoginName, + locatorPublicKey, + credentialEvent, + refreshedRecoveryEvent, + credentialPublish, + recoveryPublish, + }; + } finally { + wipe(locatorPrivateKey, capsuleKey); + } } diff --git a/packages/core/src/capsules/capsules.test.ts b/packages/core/src/capsules/capsules.test.ts index e8ef75b..8b3d312 100644 --- a/packages/core/src/capsules/capsules.test.ts +++ b/packages/core/src/capsules/capsules.test.ts @@ -3,11 +3,31 @@ import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; import { randomAccountId } from "../crypto/random.js"; import { bytesToBase64url } from "../crypto/encoding.js"; import { signNostrEvent } from "../nostr/event.js"; -import { KIND_APP_DATA, D_TAG_RECOVERY_CAPSULE, SCHEMA_RECOVERY_V1, SCHEMA_CREDENTIAL_V1 } from "../nostr/kinds.js"; -import { buildRecoveryCapsuleEvent, decryptRecoveryCapsuleEvent } from "./recoveryCapsule.js"; -import { buildCredentialCapsuleEvent, buildCredentialTombstoneEvent, decryptCredentialCapsuleEvent } from "./credentialCapsule.js"; -import { CapsuleValidationError, checkRecoveryChainConsistency } from "./validation.js"; -import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "./types.js"; +import { + KIND_APP_DATA, + D_TAG_RECOVERY_CAPSULE, + SCHEMA_RECOVERY_V1, + SCHEMA_CREDENTIAL_V1, +} from "../nostr/kinds.js"; +import { + buildRecoveryCapsuleEvent, + decryptRecoveryCapsuleEvent, +} from "./recoveryCapsule.js"; +import { + buildCredentialCapsuleEvent, + buildCredentialTombstoneEvent, + decryptCredentialCapsuleEvent, +} from "./credentialCapsule.js"; +import { + CapsuleValidationError, + checkRecoveryChainConsistency, + validateRelayUrls, +} from "./validation.js"; +import { + PROTOCOL_CAPSULE_ENCRYPTION, + PROTOCOL_PASSWORD_KDF, + PROTOCOL_RECOVERY_DERIVATION, +} from "./types.js"; import type { RecoveryPayload, CredentialPayload } from "./types.js"; function makeEverydayIdentity() { @@ -16,6 +36,26 @@ function makeEverydayIdentity() { } describe("Recovery capsule round trip (§14, §12.3)", () => { + it("requires TLS except for explicit loopback development relays", () => { + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- explicit positive and negative loopback policy fixtures + expect(() => + validateRelayUrls([ + "wss://relay.example", + "ws://localhost:7777", // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- permitted loopback fixture + "ws://127.9.8.7:7777", // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- permitted loopback fixture + "ws://[::1]:7777", // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- permitted loopback fixture + ]), + ).not.toThrow(); + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- verifies that non-loopback cleartext is rejected + expect(() => validateRelayUrls(["ws://relay.example"])).toThrow( + CapsuleValidationError, + ); + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- verifies deceptive hostnames are rejected + expect(() => + validateRelayUrls(["ws://localhost.attacker.example"]), + ).toThrow(CapsuleValidationError); + }); + it("builds, signs, decrypts, and validates a recovery capsule", async () => { const recoveryKey = generatePrivateKey(); const recoveryPub = getPublicKeyHex(recoveryKey); @@ -33,10 +73,17 @@ describe("Recovery capsule round trip (§14, §12.3)", () => { recovery_public_key: recoveryPub, created_at: 1700000000, vault_relay_hints: ["wss://relay-one.example"], - protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION } + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; - const event = await buildRecoveryCapsuleEvent({ recoveryPrivateKey: recoveryKey, capsuleKey, payload }); + const event = await buildRecoveryCapsuleEvent({ + recoveryPrivateKey: recoveryKey, + capsuleKey, + payload, + }); expect(event.kind).toBe(KIND_APP_DATA); expect(event.tags).toContainEqual(["d", D_TAG_RECOVERY_CAPSULE]); expect(event.pubkey).toBe(recoveryPub); @@ -61,24 +108,49 @@ describe("Recovery capsule round trip (§14, §12.3)", () => { recovery_public_key: getPublicKeyHex(recoveryKey), created_at: 1700000000, vault_relay_hints: [], - protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION } + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; - const event = await buildRecoveryCapsuleEvent({ recoveryPrivateKey: recoveryKey, capsuleKey, payload }); - await expect(decryptRecoveryCapsuleEvent(event, wrongKey)).rejects.toThrow(); + const event = await buildRecoveryCapsuleEvent({ + recoveryPrivateKey: recoveryKey, + capsuleKey, + payload, + }); + await expect( + decryptRecoveryCapsuleEvent(event, wrongKey), + ).rejects.toThrow(); }); it("detects a broken previous_recovery_event_id hash chain (§12.4.9)", () => { const result = checkRecoveryChainConsistency([ - { eventId: "a".repeat(64), recoveryGeneration: 0, previousRecoveryEventId: null }, - { eventId: "b".repeat(64), recoveryGeneration: 1, previousRecoveryEventId: "ffff".repeat(16) } // wrong link + { + eventId: "a".repeat(64), + recoveryGeneration: 0, + previousRecoveryEventId: null, + }, + { + eventId: "b".repeat(64), + recoveryGeneration: 1, + previousRecoveryEventId: "ffff".repeat(16), + }, // wrong link ]); expect(result.consistent).toBe(false); }); it("accepts a valid hash chain", () => { const result = checkRecoveryChainConsistency([ - { eventId: "a".repeat(64), recoveryGeneration: 0, previousRecoveryEventId: null }, - { eventId: "b".repeat(64), recoveryGeneration: 1, previousRecoveryEventId: "a".repeat(64) } + { + eventId: "a".repeat(64), + recoveryGeneration: 0, + previousRecoveryEventId: null, + }, + { + eventId: "b".repeat(64), + recoveryGeneration: 1, + previousRecoveryEventId: "a".repeat(64), + }, ]); expect(result.consistent).toBe(true); }); @@ -103,9 +175,16 @@ describe("Credential capsule round trip (§13, §12.1, §12.2)", () => { recovery_public_key: getPublicKeyHex(recoveryKey), created_at: 1700000000, vault_relay_hints: [], - protocol: { capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, recovery_derivation: PROTOCOL_RECOVERY_DERIVATION } + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; - const recoveryEvent = await buildRecoveryCapsuleEvent({ recoveryPrivateKey: recoveryKey, capsuleKey: recoveryCapsuleKey, payload: recoveryPayload }); + const recoveryEvent = await buildRecoveryCapsuleEvent({ + recoveryPrivateKey: recoveryKey, + capsuleKey: recoveryCapsuleKey, + payload: recoveryPayload, + }); const credentialPayload: CredentialPayload = { schema: SCHEMA_CREDENTIAL_V1, @@ -120,11 +199,15 @@ describe("Credential capsule round trip (§13, §12.1, §12.2)", () => { protocol: { password_kdf: PROTOCOL_PASSWORD_KDF, capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, - recovery_derivation: PROTOCOL_RECOVERY_DERIVATION - } + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; - const event = await buildCredentialCapsuleEvent({ locatorPrivateKey: locatorKey, capsuleKey, payload: credentialPayload }); + const event = await buildCredentialCapsuleEvent({ + locatorPrivateKey: locatorKey, + capsuleKey, + payload: credentialPayload, + }); const decrypted = await decryptCredentialCapsuleEvent(event, capsuleKey); expect(decrypted.operational_public_key).toBe(everyday.pub); expect(decrypted.recovery_capsule_event.id).toBe(recoveryEvent.id); @@ -140,8 +223,14 @@ describe("Credential capsule round trip (§13, §12.1, §12.2)", () => { // Recovery event signed by A, but payload claims recovery_public_key B. const foreignRecoveryEvent = signNostrEvent( - { pubkey: getPublicKeyHex(recoveryKeyA), created_at: 1, kind: KIND_APP_DATA, tags: [["d", D_TAG_RECOVERY_CAPSULE]], content: "{}" }, - recoveryKeyA + { + pubkey: getPublicKeyHex(recoveryKeyA), + created_at: 1, + kind: KIND_APP_DATA, + tags: [["d", D_TAG_RECOVERY_CAPSULE]], + content: "{}", + }, + recoveryKeyA, ); const credentialPayload: CredentialPayload = { @@ -157,17 +246,26 @@ describe("Credential capsule round trip (§13, §12.1, §12.2)", () => { protocol: { password_kdf: PROTOCOL_PASSWORD_KDF, capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, - recovery_derivation: PROTOCOL_RECOVERY_DERIVATION - } + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; - const event = await buildCredentialCapsuleEvent({ locatorPrivateKey: locatorKey, capsuleKey, payload: credentialPayload }); - await expect(decryptCredentialCapsuleEvent(event, capsuleKey)).rejects.toThrow(CapsuleValidationError); + const event = await buildCredentialCapsuleEvent({ + locatorPrivateKey: locatorKey, + capsuleKey, + payload: credentialPayload, + }); + await expect( + decryptCredentialCapsuleEvent(event, capsuleKey), + ).rejects.toThrow(CapsuleValidationError); }); it("produces a signed tombstone with empty content for the old locator (§18.1)", () => { const oldLocatorKey = generatePrivateKey(); - const event = buildCredentialTombstoneEvent({ oldLocatorPrivateKey: oldLocatorKey, createdAt: 1700000002 }); + const event = buildCredentialTombstoneEvent({ + oldLocatorPrivateKey: oldLocatorKey, + createdAt: 1700000002, + }); expect(event.content).toBe(""); expect(event.pubkey).toBe(getPublicKeyHex(oldLocatorKey)); }); diff --git a/packages/core/src/capsules/types.ts b/packages/core/src/capsules/types.ts index 023f0c8..1680e8d 100644 --- a/packages/core/src/capsules/types.ts +++ b/packages/core/src/capsules/types.ts @@ -15,22 +15,22 @@ export interface EncryptedEnvelope { } /** - * Connection Vault key material carried by BOTH capsules (connection-vault.md - * §5.2). Two independent 32-byte random roots, base64url: + * Connection Vault key material (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). + * is stored only in the phrase-gated recovery capsule and held in memory + * only for the duration of a sudo window. It is intentionally absent from + * newly-written password credential capsules, so a password/offline + * credential-capsule compromise does not unlock personal-tier records. * - * Both fields appear together or not at all; absent means the account - * predates the vault (see enableConnectionVault). + * Recovery capsules carry both fields together. Credential capsules carry + * only `connection_vault_root`; `vault_sudo_key` remains optional here solely + * so pre-hardening credential capsules can be read and scrubbed on rotation. */ export interface VaultCapsuleFields { connection_vault_root?: string; // base64url, 32 bytes diff --git a/packages/core/src/capsules/validation.ts b/packages/core/src/capsules/validation.ts index d07d15a..9d09cfe 100644 --- a/packages/core/src/capsules/validation.ts +++ b/packages/core/src/capsules/validation.ts @@ -2,6 +2,7 @@ import { isValidScalar, getPublicKeyHex } from "../crypto/secp256k1.js"; import { base64urlToBytes } from "../crypto/encoding.js"; import { verifyNostrEvent } from "../nostr/event.js"; +import { isAllowedRelayUrl } from "../nostr/relayUrl.js"; import { SCHEMA_CREDENTIAL_V1, SCHEMA_RECOVERY_V1 } from "../nostr/kinds.js"; import type { CredentialPayload, RecoveryPayload } from "./types.js"; @@ -12,7 +13,6 @@ export class CapsuleValidationError extends Error { } } -const ALLOWED_RELAY_SCHEMES = new Set(["wss:", "ws:"]); const MAX_GENERATION = 1_000_000; const HEX64 = /^[0-9a-f]{64}$/u; @@ -27,44 +27,84 @@ function isHex64(value: unknown): value is string { export function validateRelayUrls(urls: unknown): void { assert(Array.isArray(urls), "vault_relay_hints must be an array (§12.4.7)."); for (const url of urls) { - assert(typeof url === "string", "Each relay hint must be a string (§12.4.7)."); - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new CapsuleValidationError(`Invalid relay URL: ${String(url)} (§12.4.7)`); - } - assert(ALLOWED_RELAY_SCHEMES.has(parsed.protocol), `Relay URL uses a disallowed scheme: ${url} (§12.4.7)`); + assert( + typeof url === "string", + "Each relay hint must be a string (§12.4.7).", + ); + assert( + isAllowedRelayUrl(url), + `Relay URL must use secure WebSockets, except for an explicit loopback development endpoint (§12.4.7): ${url}`, + ); } } export function validateAccountId(accountId: unknown): void { - assert(typeof accountId === "string", "account_id must be a string (§12.4.2)."); + assert( + typeof accountId === "string", + "account_id must be a string (§12.4.2).", + ); let bytes: Uint8Array; try { bytes = base64urlToBytes(accountId); } catch { - throw new CapsuleValidationError("account_id is not valid base64url (§12.4.2)."); + throw new CapsuleValidationError( + "account_id is not valid base64url (§12.4.2).", + ); } - assert(bytes.length === 16, "account_id must decode to exactly 128 bits (§12.4.2)."); + assert( + bytes.length === 16, + "account_id must decode to exactly 128 bits (§12.4.2).", + ); } -export function validateOperationalKeyPair(privateKeyB64: unknown, publicKeyHex: unknown): void { - assert(typeof privateKeyB64 === "string", "operational_private_key must be a string (§12.4.3)."); +export function validateOperationalKeyPair( + privateKeyB64: unknown, + publicKeyHex: unknown, +): void { + assert( + typeof privateKeyB64 === "string", + "operational_private_key must be a string (§12.4.3).", + ); const priv = base64urlToBytes(privateKeyB64); - assert(priv.length === 32, "operational_private_key must be exactly 32 bytes (§12.4.3)."); - assert(isValidScalar(priv), "operational_private_key is not a valid secp256k1 scalar (§12.4.3)."); - assert(isHex64(publicKeyHex), "operational_public_key must be lowercase 64-char hex (§12.4.4)."); + assert( + priv.length === 32, + "operational_private_key must be exactly 32 bytes (§12.4.3).", + ); + assert( + isValidScalar(priv), + "operational_private_key is not a valid secp256k1 scalar (§12.4.3).", + ); + assert( + isHex64(publicKeyHex), + "operational_public_key must be lowercase 64-char hex (§12.4.4).", + ); const derived = getPublicKeyHex(priv); - assert(derived === publicKeyHex, "operational_public_key does not match the derived public key (§12.4.4)."); + 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. - */ +function validateVaultField( + field: "connection_vault_root" | "vault_sudo_key", + value: unknown, +): void { + 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).`, + ); +} + +/** Recovery capsules are the phrase-gated source of both vault secrets. */ export function validateVaultFields(payload: { connection_vault_root?: unknown; vault_sudo_key?: unknown; @@ -72,26 +112,41 @@ export function validateVaultFields(payload: { 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).`); - } + assert( + root !== undefined && sudo !== undefined, + "connection_vault_root and vault_sudo_key must appear together (§CV5.2).", + ); + validateVaultField("connection_vault_root", root); + validateVaultField("vault_sudo_key", sudo); +} + +/** + * Credential capsules carry the session root only. A legacy credential may + * still contain the sudo key so old accounts remain readable, but a sudo key + * without a root is always corrupt. Login deliberately does not return the + * legacy copy; the phrase-gated recovery capsule is authoritative. + */ +export function validateCredentialVaultFields(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, + "vault_sudo_key cannot appear without connection_vault_root (§CV5.2).", + ); + validateVaultField("connection_vault_root", root); + if (sudo !== undefined) validateVaultField("vault_sudo_key", sudo); } function assertGenerationInBounds(value: unknown, field: string): void { assert( - Number.isInteger(value) && (value as number) >= 0 && (value as number) <= MAX_GENERATION, - `${field} is out of supported bounds (§12.4.8).` + Number.isInteger(value) && + (value as number) >= 0 && + (value as number) <= MAX_GENERATION, + `${field} is out of supported bounds (§12.4.8).`, ); } @@ -99,21 +154,33 @@ function assertGenerationInBounds(value: unknown, field: string): void { export function validateCredentialPayload(payload: CredentialPayload): void { assert( (payload as { schema?: unknown }).schema === SCHEMA_CREDENTIAL_V1, - `Unsupported or unknown schema: ${String((payload as { schema?: unknown }).schema)} (§12.4.1)` + `Unsupported or unknown schema: ${String((payload as { schema?: unknown }).schema)} (§12.4.1)`, ); validateAccountId(payload.account_id); assertGenerationInBounds(payload.generation, "generation"); - 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)."); + 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); + validateCredentialVaultFields(payload); const embedded = payload.recovery_capsule_event; - assert(!!embedded && typeof embedded === "object", "recovery_capsule_event must be present (§12.4.6)."); - assert(verifyNostrEvent(embedded), "Embedded recovery_capsule_event has an invalid event id or signature (§12.4.6)."); + assert( + !!embedded && typeof embedded === "object", + "recovery_capsule_event must be present (§12.4.6).", + ); + assert( + verifyNostrEvent(embedded), + "Embedded recovery_capsule_event has an invalid event id or signature (§12.4.6).", + ); assert( embedded.pubkey === payload.recovery_public_key, - "Embedded recovery_capsule_event author does not match recovery_public_key (§12.4.5)." + "Embedded recovery_capsule_event author does not match recovery_public_key (§12.4.5).", ); } @@ -121,23 +188,34 @@ export function validateCredentialPayload(payload: CredentialPayload): void { export function validateRecoveryPayload(payload: RecoveryPayload): void { assert( (payload as { schema?: unknown }).schema === SCHEMA_RECOVERY_V1, - `Unsupported or unknown schema: ${String((payload as { schema?: unknown }).schema)} (§12.4.1)` + `Unsupported or unknown schema: ${String((payload as { schema?: unknown }).schema)} (§12.4.1)`, ); validateAccountId(payload.account_id); assertGenerationInBounds(payload.recovery_generation, "recovery_generation"); assert( - payload.previous_recovery_event_id === null || isHex64(payload.previous_recovery_event_id), - "previous_recovery_event_id must be null or lowercase 64-char hex (§12.3)." + payload.previous_recovery_event_id === null || + isHex64(payload.previous_recovery_event_id), + "previous_recovery_event_id must be null or lowercase 64-char hex (§12.3).", + ); + validateOperationalKeyPair( + payload.operational_private_key, + payload.operational_public_key, + ); + assert( + isHex64(payload.recovery_public_key), + "recovery_public_key must be lowercase 64-char hex.", ); - 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. */ export function checkRecoveryChainConsistency( - generations: Array<{ eventId: string; recoveryGeneration: number; previousRecoveryEventId: string | null }> + generations: Array<{ + eventId: string; + recoveryGeneration: number; + previousRecoveryEventId: string | null; + }>, ): { consistent: boolean; warning?: string } { // §12.3 claims this detects gaps AND replays; the first version detected // neither. A Map keyed by generation SILENTLY COLLAPSED two capsules @@ -150,19 +228,21 @@ export function checkRecoveryChainConsistency( if (existing && existing.eventId !== entry.eventId) { return { consistent: false, - warning: `Two different recovery capsules both claim generation ${entry.recoveryGeneration}: possible fork, replay, or relay misbehavior.` + warning: `Two different recovery capsules both claim generation ${entry.recoveryGeneration}: possible fork, replay, or relay misbehavior.`, }; } byGeneration.set(entry.recoveryGeneration, entry); } - const sorted = [...generations].sort((a, b) => a.recoveryGeneration - b.recoveryGeneration); + const sorted = [...generations].sort( + (a, b) => a.recoveryGeneration - b.recoveryGeneration, + ); for (let i = 1; i < sorted.length; i++) { const current = sorted[i]!; if (current.previousRecoveryEventId === null) { return { consistent: false, - warning: `Generation ${current.recoveryGeneration} has a null previous-event link but is not the first generation.` + warning: `Generation ${current.recoveryGeneration} has a null previous-event link but is not the first generation.`, }; } const prior = byGeneration.get(current.recoveryGeneration - 1); @@ -172,16 +252,15 @@ export function checkRecoveryChainConsistency( // and reporting that as "consistent" was the lie worth fixing. return { consistent: false, - warning: `Recovery generation ${current.recoveryGeneration - 1} is missing, so the chain up to generation ${current.recoveryGeneration} cannot be verified.` + warning: `Recovery generation ${current.recoveryGeneration - 1} is missing, so the chain up to generation ${current.recoveryGeneration} cannot be verified.`, }; } if (current.previousRecoveryEventId !== prior.eventId) { return { consistent: false, - warning: `Recovery generation chain is broken between generation ${current.recoveryGeneration - 1} and ${current.recoveryGeneration}: possible replay or relay misbehavior.` + warning: `Recovery generation chain is broken between generation ${current.recoveryGeneration - 1} and ${current.recoveryGeneration}: possible replay or relay misbehavior.`, }; } } return { consistent: true }; } - diff --git a/packages/core/src/crypto/crypto.test.ts b/packages/core/src/crypto/crypto.test.ts index 5c2ef50..a6691dc 100644 --- a/packages/core/src/crypto/crypto.test.ts +++ b/packages/core/src/crypto/crypto.test.ts @@ -8,7 +8,7 @@ import { entropyToRecoveryPhrase, isValidRecoveryPhrase, recoveryPhraseToSeed } import { generatePrivateKey, getPublicKeyHex, isValidScalar, sign, verify } from "./secp256k1.js"; import { aesGcmOpen, aesGcmSeal } from "./aesGcm.js"; import { bytesToHex, hexToBytes, bytesToBase64url, base64urlToBytes, utf8ToBytes } from "./encoding.js"; -import { getConversationKey, nip44Encrypt, nip44Decrypt } from "./nip44.js"; +import { getConversationKey, MAX_NIP44_BROWSER_PLAINTEXT_LEN, MAX_NIP44_ENCODED_PAYLOAD_LEN, nip44Encrypt, nip44Decrypt } from "./nip44.js"; import { nip04Encrypt, nip04Decrypt } from "./nip04.js"; describe("ScalarExpand (§11.4 test vector)", () => { @@ -192,21 +192,40 @@ describe("NIP-44 v2 (window.nostr.nip44 provider surface)", () => { expect(bytesToHex(getConversationKey(skA, pubB))).toBe(bytesToHex(getConversationKey(skB, pubA))); }); - it.each([1, 31, 32, 33, 64, 100, 1000, 65535, 65536, 70000])( - "round-trips a %i-byte plaintext", - (len) => { - const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); - const plaintext = "x".repeat(len); - const encrypted = nip44Encrypt(key, plaintext); - expect(nip44Decrypt(key, encrypted)).toBe(plaintext); - } - ); + it.each([1, 31, 32, 33, 64, 100, 1000, 65535, 65536, 70000])("round-trips a %i-byte plaintext", (len) => { + const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); + const plaintext = "x".repeat(len); + const encrypted = nip44Encrypt(key, plaintext); + expect(nip44Decrypt(key, encrypted)).toBe(plaintext); + }); it("rejects a plaintext of zero bytes", () => { const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); expect(() => nip44Encrypt(key, "")).toThrow(); }); + it("validates conversation-key and nonce lengths before cryptographic use", () => { + const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); + expect(() => nip44Encrypt(new Uint8Array(31), "hello")).toThrow(/conversation key/u); + expect(() => nip44Encrypt(key, "hello", new Uint8Array(31))).toThrow(/nonce/u); + }); + + it("rejects unsupported encodings and undersized payloads before parsing fields", () => { + const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); + expect(() => nip44Decrypt(key, "#future-format")).toThrow(/Unsupported NIP-44 non-base64 encoding/u); + expect(() => nip44Decrypt(key, "A".repeat(131))).toThrow(/payload size/u); + // 98 decoded bytes can still occupy the 132-character encoded minimum. + expect(bytesToBase64urlForTest(new Uint8Array(98))).toHaveLength(132); + expect(() => nip44Decrypt(key, bytesToBase64urlForTest(new Uint8Array(98)))).toThrow(/decoded payload size/u); + }); + + it("enforces the documented browser cap before base64 decoding", () => { + const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); + expect(MAX_NIP44_BROWSER_PLAINTEXT_LEN).toBe(1024 * 1024); + expect(() => nip44Decrypt(key, "A".repeat(MAX_NIP44_ENCODED_PAYLOAD_LEN + 1))).toThrow(/payload size/u); + expect(() => nip44Encrypt(key, "x".repeat(MAX_NIP44_BROWSER_PLAINTEXT_LEN + 1))).toThrow(/plaintext length/u); + }); + it("fails to decrypt with the wrong conversation key", () => { const keyA = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); const keyB = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey())); @@ -234,7 +253,7 @@ describe("NIP-04 (window.nostr.nip04 provider surface)", () => { expect(nip04Decrypt(skB, pubA, encrypted)).toBe(plaintext); }); - it("produces the documented \"?iv=\" payload shape", () => { + it('produces the documented "?iv=" payload shape', () => { const skA = generatePrivateKey(); const pubB = getPublicKeyHex(generatePrivateKey()); const encrypted = nip04Encrypt(skA, pubB, "hello"); @@ -260,7 +279,7 @@ describe("NIP-04 (window.nostr.nip04 provider surface)", () => { expect(threw || decrypted !== "secret message").toBe(true); }); - it("rejects a payload missing the \"?iv=\" suffix", () => { + it('rejects a payload missing the "?iv=" suffix', () => { const sk = generatePrivateKey(); const pub = getPublicKeyHex(generatePrivateKey()); expect(() => nip04Decrypt(sk, pub, "not-a-real-payload")).toThrow(); diff --git a/packages/core/src/crypto/nip44.ts b/packages/core/src/crypto/nip44.ts index 325b304..9e80a01 100644 --- a/packages/core/src/crypto/nip44.ts +++ b/packages/core/src/crypto/nip44.ts @@ -23,6 +23,14 @@ const MIN_PLAINTEXT_LEN = 1; const EXTENDED_PREFIX_THRESHOLD = 0x10000; /** Absolute ceiling for the 4-byte extended-length prefix (2^32 - 1). */ const MAX_PLAINTEXT_LEN = 0xffffffff; +const MIN_ENCODED_PAYLOAD_LEN = 132; +const MIN_DECODED_PAYLOAD_LEN = 99; +/** + * Browser-safe implementation ceiling. NIP-44's wire format can represent + * nearly 4 GiB, but decoding and crypto require several simultaneous copies. + * One MiB is ample for signed Nostr event content and bounds memory DoS. + */ +export const MAX_NIP44_BROWSER_PLAINTEXT_LEN = 1024 * 1024; export function getConversationKey(privateKey: Uint8Array, peerPublicKeyHex: string): Uint8Array { const peerPoint = concatBytes(new Uint8Array([0x02]), hexToBytes(peerPublicKeyHex)); @@ -38,6 +46,8 @@ interface MessageKeys { } function deriveMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array): MessageKeys { + if (conversationKey.length !== 32) throw new Error("NIP-44 conversation key must be exactly 32 bytes."); + if (nonce.length !== 32) throw new Error("NIP-44 nonce must be exactly 32 bytes."); const expanded = hkdfExpand(conversationKey, nonce, 76); return { chachaKey: expanded.slice(0, 32), @@ -53,6 +63,10 @@ function calcPaddedLen(unpaddedLen: number): number { return chunk * (Math.floor((unpaddedLen - 1) / chunk) + 1); } +const MAX_NIP44_DECODED_PAYLOAD_LEN = 1 + 32 + 6 + calcPaddedLen(MAX_NIP44_BROWSER_PLAINTEXT_LEN) + 32; +/** Maximum accepted base64 characters, checked before allocating decoded bytes. */ +export const MAX_NIP44_ENCODED_PAYLOAD_LEN = Math.ceil(MAX_NIP44_DECODED_PAYLOAD_LEN / 3) * 4; + /** * Length prefix: 2-byte big-endian length below the extended threshold, matching every * other NIP-44 implementation's common case; at or above it, two zero bytes (which can @@ -73,8 +87,8 @@ function lengthPrefix(len: number): Uint8Array { function pad(plaintext: Uint8Array): Uint8Array { const len = plaintext.length; - if (len < MIN_PLAINTEXT_LEN || len > MAX_PLAINTEXT_LEN) { - throw new Error(`NIP-44 plaintext length must be between ${MIN_PLAINTEXT_LEN} and ${MAX_PLAINTEXT_LEN} bytes.`); + if (len < MIN_PLAINTEXT_LEN || len > MAX_NIP44_BROWSER_PLAINTEXT_LEN) { + throw new Error(`NIP-44 plaintext length must be between ${MIN_PLAINTEXT_LEN} and ${MAX_NIP44_BROWSER_PLAINTEXT_LEN} bytes on this platform.`); } const prefix = lengthPrefix(len); const paddedLen = calcPaddedLen(len); @@ -109,6 +123,7 @@ function calcMac(hmacKey: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array) export function nip44Encrypt(conversationKey: Uint8Array, plaintext: string, nonceOverride?: Uint8Array): string { const nonce = nonceOverride ?? randomBytes(32); + if (nonce.length !== 32) throw new Error("NIP-44 nonce must be exactly 32 bytes."); const { chachaKey, chachaNonce, hmacKey } = deriveMessageKeys(conversationKey, nonce); const padded = pad(utf8ToBytes(plaintext)); const ciphertext = chacha20(chachaKey, chachaNonce, padded); @@ -117,9 +132,17 @@ export function nip44Encrypt(conversationKey: Uint8Array, plaintext: string, non } export function nip44Decrypt(conversationKey: Uint8Array, payload: string): string { + if (payload.startsWith("#")) throw new Error("Unsupported NIP-44 non-base64 encoding."); + if (payload.length < MIN_ENCODED_PAYLOAD_LEN || payload.length > MAX_NIP44_ENCODED_PAYLOAD_LEN) { + throw new Error("NIP-44 payload size is outside this platform's supported range."); + } const decoded = base64.decode(payload); + if (decoded.length < MIN_DECODED_PAYLOAD_LEN || decoded.length > MAX_NIP44_DECODED_PAYLOAD_LEN) { + throw new Error("NIP-44 decoded payload size is outside this platform's supported range."); + } if (decoded[0] !== NIP44_VERSION) throw new Error(`Unsupported NIP-44 version: ${decoded[0]}`); const nonce = decoded.slice(1, 33); + if (nonce.length !== 32) throw new Error("NIP-44 nonce must be exactly 32 bytes."); const mac = decoded.slice(decoded.length - 32); const ciphertext = decoded.slice(33, decoded.length - 32); diff --git a/packages/core/src/nostr/bootstrap.ts b/packages/core/src/nostr/bootstrap.ts index 922e58a..e0ca999 100644 --- a/packages/core/src/nostr/bootstrap.ts +++ b/packages/core/src/nostr/bootstrap.ts @@ -8,14 +8,14 @@ */ import { verifyNostrEvent, findTagValue, type NostrEvent } from "./event.js"; import { D_TAG_BOOTSTRAP_RELAYS, KIND_APP_DATA } from "./kinds.js"; +import { isAllowedRelayUrl } from "./relayUrl.js"; /** - * Placeholder pinned maintainer public key for this reference build. - * A real deployment must replace this with the key the maintainers actually - * control and publish that fact out-of-band (release notes, source repo). + * The update channel is deliberately disabled until a real maintainer key is + * pinned and announced out-of-band. `null` cannot accidentally verify an + * attacker-controlled event, unlike a syntactically valid placeholder key. */ -export const MAINTAINER_PUBLIC_KEY_HEX = - "0000000000000000000000000000000000000000000000000000000000000000".slice(0, 64); +export const MAINTAINER_PUBLIC_KEY_HEX: string | null = null; // §15.6's publishAndVerify enforces a fixed floor (minAcks/minReadbacks = 2) // regardless of how many relays are configured, so this list needs enough @@ -23,19 +23,9 @@ export const MAINTAINER_PUBLIC_KEY_HEX = // comfortably -- a 3-relay list has zero margin the moment one relay is // unreachable, which is exactly what "add more vault relays" (the // registration failure message) is telling operators to fix. -export const BUILTIN_VAULT_RELAYS: readonly string[] = [ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.nostr.band", - "wss://nostr.wine", - "wss://relay.snort.social" -]; +export const BUILTIN_VAULT_RELAYS: readonly string[] = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", "wss://nostr.wine", "wss://relay.snort.social"]; -export const BUILTIN_DISCOVERY_RELAYS: readonly string[] = [ - "wss://purplepag.es", - "wss://relay.nostr.band", - "wss://nostr-pub.wellorder.net" -]; +export const BUILTIN_DISCOVERY_RELAYS: readonly string[] = ["wss://purplepag.es", "wss://relay.nostr.band", "wss://nostr-pub.wellorder.net"]; /** Well-known static HTTPS fallback URLs for the signed relay-list document (§19.1). */ export const BOOTSTRAP_HTTPS_FALLBACK_URLS: readonly string[] = []; @@ -47,49 +37,66 @@ export interface BootstrapRelayList { deprecated?: string[]; } -export function parseAndVerifyBootstrapList( - event: NostrEvent, - maintainerPubkeyHex: string -): BootstrapRelayList | null { +const HEX64 = /^[0-9a-f]{64}$/u; +const MAX_BOOTSTRAP_RELAYS_PER_LIST = 64; + +function isValidRelayList(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= MAX_BOOTSTRAP_RELAYS_PER_LIST && value.every((relay) => typeof relay === "string" && isAllowedRelayUrl(relay)) && new Set(value).size === value.length; +} + +export function parseAndVerifyBootstrapList(event: unknown, maintainerPubkeyHex: string | null = MAINTAINER_PUBLIC_KEY_HEX, minimumVersionExclusive = -1): BootstrapRelayList | null { + if (!maintainerPubkeyHex || !HEX64.test(maintainerPubkeyHex) || /^0{64}$/u.test(maintainerPubkeyHex)) return null; + if (!verifyNostrEvent(event)) return null; if (event.pubkey !== maintainerPubkeyHex) return null; if (event.kind !== KIND_APP_DATA) return null; if (findTagValue(event, "d") !== D_TAG_BOOTSTRAP_RELAYS) return null; - if (!verifyNostrEvent(event)) return null; try { const parsed = JSON.parse(event.content) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const candidate = parsed as Record; if ( - typeof parsed === "object" && - parsed !== null && - Array.isArray((parsed as BootstrapRelayList).vaultRelays) && - Array.isArray((parsed as BootstrapRelayList).discoveryRelays) - ) { - return parsed as BootstrapRelayList; - } - return null; + !Number.isSafeInteger(candidate.version) || + (candidate.version as number) < 1 || + (candidate.version as number) <= minimumVersionExclusive || + !isValidRelayList(candidate.vaultRelays) || + !isValidRelayList(candidate.discoveryRelays) || + (candidate.deprecated !== undefined && !isValidRelayList(candidate.deprecated)) + ) + return null; + return { + version: candidate.version as number, + vaultRelays: candidate.vaultRelays, + discoveryRelays: candidate.discoveryRelays, + ...(candidate.deprecated === undefined ? {} : { deprecated: candidate.deprecated }) + }; } catch { return null; } } -/** Merges a verified fetched list into the built-in list. Never drops built-in relays outright — only marks them deprecated. */ -export function mergeRelayLists( - builtIn: readonly string[], - fetched: BootstrapRelayList | null, - kind: "vault" | "discovery" -): string[] { +/** Merges a verified fetched list without ever removing a built-in relay. */ +export function mergeRelayLists(builtIn: readonly string[], fetched: BootstrapRelayList | null, kind: "vault" | "discovery"): string[] { const fetchedUrls = fetched ? (kind === "vault" ? fetched.vaultRelays : fetched.discoveryRelays) : []; const deprecated = new Set(fetched?.deprecated ?? []); - const merged = new Set([...builtIn.filter((u) => !deprecated.has(u)), ...fetchedUrls]); + // Deprecation suppresses only remotely-added relays. Removing the pinned + // recovery path requires a reviewed client release, never a signed update. + const merged = new Set([...builtIn, ...fetchedUrls.filter((url) => !deprecated.has(url))]); return [...merged]; } -export async function fetchBootstrapListOverHttps(urls: readonly string[]): Promise { +export async function fetchBootstrapListOverHttps(urls: readonly string[], maintainerPubkeyHex: string | null = MAINTAINER_PUBLIC_KEY_HEX, minimumVersionExclusive = -1): Promise { + if (!maintainerPubkeyHex) return null; for (const url of urls) { try { + if (new URL(url).protocol !== "https:") continue; const response = await fetch(url); if (!response.ok) continue; - const event = (await response.json()) as NostrEvent; - if (verifyNostrEvent(event)) return event; + const contentLength = Number(response.headers.get("content-length") ?? 0); + if (contentLength > 1_000_000) continue; + const body = await response.text(); + if (body.length > 1_000_000) continue; + const event = JSON.parse(body) as unknown; + if (verifyNostrEvent(event) && parseAndVerifyBootstrapList(event, maintainerPubkeyHex, minimumVersionExclusive)) return event; } catch { // try the next fallback URL } diff --git a/packages/core/src/nostr/event.ts b/packages/core/src/nostr/event.ts index 5894cd7..c8a63f4 100644 --- a/packages/core/src/nostr/event.ts +++ b/packages/core/src/nostr/event.ts @@ -33,19 +33,61 @@ export function signNostrEvent(unsigned: UnsignedNostrEvent, privateKey: Uint8Ar return { ...unsigned, id, sig }; } -export function verifyNostrEvent(event: NostrEvent): boolean { - if (!/^[0-9a-f]{64}$/u.test(event.id) || !/^[0-9a-f]{64}$/u.test(event.pubkey) || !/^[0-9a-f]{128}$/u.test(event.sig)) { +/** + * Verifies untrusted runtime input without ever throwing. Relay messages and + * imported capsules are JSON, so their compile-time type is not evidence of + * their shape. Keeping the structural guard here makes every caller safe by + * default and lets successful verification narrow `unknown` to NostrEvent. + */ +export function verifyNostrEvent(event: unknown): event is NostrEvent { + if (typeof event !== "object" || event === null || Array.isArray(event)) return false; + const candidate = event as Record; + if ( + typeof candidate.id !== "string" || + typeof candidate.pubkey !== "string" || + typeof candidate.sig !== "string" || + typeof candidate.content !== "string" || + !Number.isSafeInteger(candidate.created_at) || + (candidate.created_at as number) < 0 || + !Number.isSafeInteger(candidate.kind) || + (candidate.kind as number) < 0 || + (candidate.kind as number) > 0xffff || + !Array.isArray(candidate.tags) || + !candidate.tags.every((tag) => Array.isArray(tag) && tag.every((value) => typeof value === "string")) + ) { return false; } - const expectedId = computeEventId(event); - if (expectedId !== event.id) return false; + if (!/^[0-9a-f]{64}$/u.test(candidate.id) || !/^[0-9a-f]{64}$/u.test(candidate.pubkey) || !/^[0-9a-f]{128}$/u.test(candidate.sig)) { + return false; + } + const typed = candidate as unknown as NostrEvent; + const expectedId = computeEventId(typed); + if (expectedId !== typed.id) return false; try { - return schnorrVerify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey)); + return schnorrVerify(hexToBytes(typed.sig), hexToBytes(typed.id), hexToBytes(typed.pubkey)); } catch { return false; } } +export interface NostrEventCoordinate { + created_at: number; + id: string; +} + +/** + * NIP-01 replacement ordering. Positive means `candidate` wins over + * `current`: a newer timestamp wins and, at equal timestamps, the + * lexicographically lower event id wins. + */ +export function compareNip01ReplacementOrder(candidate: NostrEventCoordinate, current: NostrEventCoordinate): number { + if (candidate.created_at !== current.created_at) { + return candidate.created_at > current.created_at ? 1 : -1; + } + if (candidate.id === current.id) return 0; + return candidate.id < current.id ? 1 : -1; +} + export function findTagValue(event: Pick, tagName: string): string | undefined { return event.tags.find((t) => t[0] === tagName)?.[1]; } diff --git a/packages/core/src/nostr/index.ts b/packages/core/src/nostr/index.ts index ce5f472..a8d8a4c 100644 --- a/packages/core/src/nostr/index.ts +++ b/packages/core/src/nostr/index.ts @@ -2,6 +2,7 @@ export * from "./kinds.js"; export * from "./event.js"; export * from "./nip09.js"; export * from "./relay.js"; +export * from "./relayUrl.js"; export * from "./pool.js"; export * from "./bootstrap.js"; export * from "./nip19.js"; diff --git a/packages/core/src/nostr/nostr.test.ts b/packages/core/src/nostr/nostr.test.ts index 6305a5e..9298a96 100644 --- a/packages/core/src/nostr/nostr.test.ts +++ b/packages/core/src/nostr/nostr.test.ts @@ -1,18 +1,40 @@ -import { describe, expect, it, afterEach } from "vitest"; +import { describe, expect, it, afterEach, vi } from "vitest"; import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; -import { signNostrEvent, verifyNostrEvent } from "./event.js"; -import { KIND_APP_DATA } from "./kinds.js"; -import { RelayConnection } from "./relay.js"; +import { + compareNip01ReplacementOrder, + signNostrEvent, + verifyNostrEvent, + type NostrEvent, +} from "./event.js"; +import { D_TAG_BOOTSTRAP_RELAYS, KIND_APP_DATA } from "./kinds.js"; +import { + fetchRelayInfo, + matchesNostrFilter, + RelayConnection, + type NostrFilter, +} from "./relay.js"; import { RelayPool } from "./pool.js"; import { MockRelay } from "../test-support/mockRelay.js"; +import { + MAINTAINER_PUBLIC_KEY_HEX, + mergeRelayLists, + parseAndVerifyBootstrapList, + type BootstrapRelayList, +} from "./bootstrap.js"; describe("NIP-01 event id/sign/verify", () => { it("signs an event and verifies it", () => { const sk = generatePrivateKey(); const pubkey = getPublicKeyHex(sk); const event = signNostrEvent( - { pubkey, created_at: 1700000000, kind: KIND_APP_DATA, tags: [["d", "bitlogin:password:v1"]], content: "hello" }, - sk + { + pubkey, + created_at: 1700000000, + kind: KIND_APP_DATA, + tags: [["d", "bitlogin:password:v1"]], + content: "hello", + }, + sk, ); expect(verifyNostrEvent(event)).toBe(true); }); @@ -21,12 +43,146 @@ describe("NIP-01 event id/sign/verify", () => { const sk = generatePrivateKey(); const pubkey = getPublicKeyHex(sk); const event = signNostrEvent( - { pubkey, created_at: 1700000000, kind: KIND_APP_DATA, tags: [["d", "x"]], content: "hello" }, - sk + { + pubkey, + created_at: 1700000000, + kind: KIND_APP_DATA, + tags: [["d", "x"]], + content: "hello", + }, + sk, ); const tampered = { ...event, content: "goodbye" }; expect(verifyNostrEvent(tampered)).toBe(false); }); + + it.each([ + null, + undefined, + [], + "event", + 1, + {}, + { id: "a".repeat(64) }, + { + id: "a".repeat(64), + pubkey: "b".repeat(64), + sig: "c".repeat(128), + created_at: 1, + kind: 1, + tags: [null], + content: "", + }, + { + id: "a".repeat(64), + pubkey: "b".repeat(64), + sig: "c".repeat(128), + created_at: -1, + kind: 1, + tags: [], + content: "", + }, + { + id: "a".repeat(64), + pubkey: "b".repeat(64), + sig: "c".repeat(128), + created_at: 1, + kind: 65_536, + tags: [], + content: "", + }, + ])( + "totally rejects malformed runtime input without throwing: %j", + (candidate) => { + expect(() => verifyNostrEvent(candidate)).not.toThrow(); + expect(verifyNostrEvent(candidate)).toBe(false); + }, + ); + + it("uses NIP-01 replacement ordering: newer timestamp, then lower id", () => { + expect( + compareNip01ReplacementOrder( + { created_at: 2, id: "f" }, + { created_at: 1, id: "0" }, + ), + ).toBeGreaterThan(0); + expect( + compareNip01ReplacementOrder( + { created_at: 1, id: "0" }, + { created_at: 1, id: "f" }, + ), + ).toBeGreaterThan(0); + expect( + compareNip01ReplacementOrder( + { created_at: 1, id: "f" }, + { created_at: 1, id: "0" }, + ), + ).toBeLessThan(0); + expect( + compareNip01ReplacementOrder( + { created_at: 1, id: "0" }, + { created_at: 1, id: "0" }, + ), + ).toBe(0); + }); +}); + +describe("NIP-01 local subscription-filter enforcement", () => { + const signer = generatePrivateKey(); + const pubkey = getPublicKeyHex(signer); + const base = { + pubkey, + created_at: 100, + kind: KIND_APP_DATA, + tags: [ + ["d", "wanted"], + ["e", "parent"], + ], + content: "ok", + }; + const event = signNostrEvent(base, signer); + const filter: NostrFilter = { + ids: [event.id.slice(0, 16)], + authors: [pubkey.slice(0, 16)], + kinds: [KIND_APP_DATA], + since: 100, + until: 100, + "#d": ["wanted"], + "#e": ["parent"], + }; + + it("accepts an event matching every requested dimension", () => { + expect(matchesNostrFilter(event, filter)).toBe(true); + }); + + it.each([ + ["id", { ...filter, ids: ["f".repeat(64)] }], + ["author", { ...filter, authors: ["f".repeat(64)] }], + ["kind", { ...filter, kinds: [1] }], + ["since", { ...filter, since: 101 }], + ["until", { ...filter, until: 99 }], + ["d tag", { ...filter, "#d": ["other"] }], + ["e tag", { ...filter, "#e": ["other"] }], + ] satisfies Array<[string, NostrFilter]>)( + "rejects a valid signed event with the wrong %s", + (_label, wrongFilter) => { + expect(matchesNostrFilter(event, wrongFilter)).toBe(false); + }, + ); +}); + +describe("relay transport policy", () => { + it("rejects a non-WebSocket NIP-11 target before fetch", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("must not be called")); + try { + await expect(fetchRelayInfo("http://relay.example")).resolves.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); }); describe("RelayConnection + MockRelay", () => { @@ -42,13 +198,23 @@ describe("RelayConnection + MockRelay", () => { const sk = generatePrivateKey(); const pubkey = getPublicKeyHex(sk); const event = signNostrEvent( - { pubkey, created_at: 1700000000, kind: KIND_APP_DATA, tags: [["d", "bitlogin:password:v1"]], content: "ciphertext" }, - sk + { + pubkey, + created_at: 1700000000, + kind: KIND_APP_DATA, + tags: [["d", "bitlogin:password:v1"]], + content: "ciphertext", + }, + sk, ); const result = await conn.publish(event); expect(result.ok).toBe(true); - const found = await conn.queryOnce({ kinds: [KIND_APP_DATA], authors: [pubkey], "#d": ["bitlogin:password:v1"] }); + const found = await conn.queryOnce({ + kinds: [KIND_APP_DATA], + authors: [pubkey], + "#d": ["bitlogin:password:v1"], + }); expect(found).toHaveLength(1); expect(found[0]?.id).toBe(event.id); conn.close(); @@ -60,20 +226,165 @@ describe("RelayConnection + MockRelay", () => { const sk = generatePrivateKey(); const pubkey = getPublicKeyHex(sk); const older = signNostrEvent( - { pubkey, created_at: 1000, kind: KIND_APP_DATA, tags: [["d", "bitlogin:recovery:v1"]], content: "gen1" }, - sk + { + pubkey, + created_at: 1000, + kind: KIND_APP_DATA, + tags: [["d", "bitlogin:recovery:v1"]], + content: "gen1", + }, + sk, ); const newer = signNostrEvent( - { pubkey, created_at: 2000, kind: KIND_APP_DATA, tags: [["d", "bitlogin:recovery:v1"]], content: "gen2" }, - sk + { + pubkey, + created_at: 2000, + kind: KIND_APP_DATA, + tags: [["d", "bitlogin:recovery:v1"]], + content: "gen2", + }, + sk, ); await conn.publish(newer); await conn.publish(older); // stale replay attempt, must not win - const found = await conn.queryOnce({ kinds: [KIND_APP_DATA], authors: [pubkey], "#d": ["bitlogin:recovery:v1"] }); + const found = await conn.queryOnce({ + kinds: [KIND_APP_DATA], + authors: [pubkey], + "#d": ["bitlogin:recovery:v1"], + }); expect(found).toHaveLength(1); expect(found[0]?.content).toBe("gen2"); conn.close(); }); + + it("drops malformed and valid-but-out-of-filter EVENT frames from a malicious relay", async () => { + relay = await MockRelay.start(); + const conn = new RelayConnection(relay.url); + const sk = generatePrivateKey(); + const otherSk = generatePrivateKey(); + const pubkey = getPublicKeyHex(sk); + const wanted = signNostrEvent( + { + pubkey, + created_at: 100, + kind: KIND_APP_DATA, + tags: [["d", "wanted"]], + content: "wanted", + }, + sk, + ); + const variants: NostrEvent[] = [ + signNostrEvent( + { + ...wanted, + pubkey: getPublicKeyHex(otherSk), + content: "wrong author", + }, + otherSk, + ), + signNostrEvent({ ...wanted, kind: 1, content: "wrong kind" }, sk), + signNostrEvent( + { ...wanted, tags: [["d", "wrong"]], content: "wrong tag" }, + sk, + ), + signNostrEvent({ ...wanted, created_at: 99, content: "too old" }, sk), + signNostrEvent({ ...wanted, created_at: 101, content: "too new" }, sk), + signNostrEvent({ ...wanted, content: "wrong id" }, sk), + ]; + relay.unsolicitedQueryEvents = [null, ...variants, wanted]; + + const found = await conn.queryOnce({ + ids: [wanted.id], + authors: [pubkey], + kinds: [KIND_APP_DATA], + since: 100, + until: 100, + "#d": ["wanted"], + }); + expect(found.map((candidate) => candidate.id)).toEqual([wanted.id]); + conn.close(); + }); +}); + +describe("signed bootstrap relay updates", () => { + const maintainerKey = generatePrivateKey(); + const maintainerPubkey = getPublicKeyHex(maintainerKey); + + function signedList(list: BootstrapRelayList): NostrEvent { + return signNostrEvent( + { + pubkey: maintainerPubkey, + created_at: 100, + kind: KIND_APP_DATA, + tags: [["d", D_TAG_BOOTSTRAP_RELAYS]], + content: JSON.stringify(list), + }, + maintainerKey, + ); + } + + it("is explicitly disabled when no real maintainer key is pinned", () => { + expect(MAINTAINER_PUBLIC_KEY_HEX).toBeNull(); + expect( + parseAndVerifyBootstrapList( + signedList({ version: 1, vaultRelays: [], discoveryRelays: [] }), + ), + ).toBeNull(); + expect( + parseAndVerifyBootstrapList( + signedList({ version: 1, vaultRelays: [], discoveryRelays: [] }), + "0".repeat(64), + ), + ).toBeNull(); + }); + + it("validates relay URLs, field shapes, and monotonic versions", () => { + const valid = signedList({ + version: 2, + vaultRelays: ["wss://vault.example"], + discoveryRelays: ["wss://discovery.example"], + deprecated: ["wss://old.example"], + }); + expect( + parseAndVerifyBootstrapList(valid, maintainerPubkey, 1)?.version, + ).toBe(2); + expect(parseAndVerifyBootstrapList(valid, maintainerPubkey, 2)).toBeNull(); + expect( + parseAndVerifyBootstrapList( + signedList({ + version: 3, + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- rejected transport-policy fixture + vaultRelays: ["ws://relay.example"], + discoveryRelays: [], + }), + maintainerPubkey, + 2, + ), + ).toBeNull(); + expect( + parseAndVerifyBootstrapList( + signedList({ + version: 3, + vaultRelays: ["wss://duplicate.example", "wss://duplicate.example"], + discoveryRelays: [], + }), + maintainerPubkey, + 2, + ), + ).toBeNull(); + }); + + it("never lets a signed update remove a pinned built-in relay", () => { + const fetched: BootstrapRelayList = { + version: 1, + vaultRelays: ["wss://remote.example", "wss://retired-remote.example"], + discoveryRelays: [], + deprecated: ["wss://pinned.example", "wss://retired-remote.example"], + }; + expect(mergeRelayLists(["wss://pinned.example"], fetched, "vault")).toEqual( + ["wss://pinned.example", "wss://remote.example"], + ); + }); }); describe("RelayPool quorum", () => { @@ -85,16 +396,30 @@ describe("RelayPool quorum", () => { }); it("reports quorum met when a majority of relays respond", async () => { - relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + relays = await Promise.all([ + MockRelay.start(), + MockRelay.start(), + MockRelay.start(), + ]); const pool = new RelayPool(relays.map((r) => r.url)); const sk = generatePrivateKey(); const pubkey = getPublicKeyHex(sk); const event = signNostrEvent( - { pubkey, created_at: 1700000000, kind: KIND_APP_DATA, tags: [["d", "bitlogin:password:v1"]], content: "x" }, - sk + { + pubkey, + created_at: 1700000000, + kind: KIND_APP_DATA, + tags: [["d", "bitlogin:password:v1"]], + content: "x", + }, + sk, ); await pool.publishAll(event); - const result = await pool.queryQuorum({ kinds: [KIND_APP_DATA], authors: [pubkey], "#d": ["bitlogin:password:v1"] }); + const result = await pool.queryQuorum({ + kinds: [KIND_APP_DATA], + authors: [pubkey], + "#d": ["bitlogin:password:v1"], + }); expect(result.quorumMet).toBe(true); expect(result.respondedCount).toBe(3); const withEvents = result.outcomes.filter((o) => o.events.length > 0); diff --git a/packages/core/src/nostr/relay.ts b/packages/core/src/nostr/relay.ts index ceafa26..3cf8fb2 100644 --- a/packages/core/src/nostr/relay.ts +++ b/packages/core/src/nostr/relay.ts @@ -2,9 +2,15 @@ * A single relay WebSocket connection: NIP-01 EVENT/REQ/CLOSE, NIP-42 AUTH, * and NIP-11 relay information document fetches (§11.8, §16.1, §17.3, §19.2). */ -import { computeEventId, signNostrEvent, verifyNostrEvent, type NostrEvent } from "./event.js"; +import { + computeEventId, + signNostrEvent, + verifyNostrEvent, + type NostrEvent, +} from "./event.js"; import { KIND_AUTH } from "./kinds.js"; import { getPublicKeyHex } from "../crypto/secp256k1.js"; +import { isAllowedRelayUrl } from "./relayUrl.js"; export interface NostrFilter { ids?: string[]; @@ -15,6 +21,7 @@ export interface NostrFilter { limit?: number; "#d"?: string[]; "#e"?: string[]; + [key: string]: string[] | number[] | number | undefined; } export interface Nip11Info { @@ -33,7 +40,10 @@ type WebSocketLike = InstanceType; function webSocketCtor(): typeof WebSocket { const ctor = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket; - if (!ctor) throw new Error("No global WebSocket implementation is available in this environment."); + if (!ctor) + throw new Error( + "No global WebSocket implementation is available in this environment.", + ); return ctor; } @@ -47,13 +57,21 @@ export class RelayConnection { readonly url: string; private ws: WebSocketLike | null = null; private connectPromise: Promise | null = null; - private subs = new Map void }>(); + private subs = new Map< + string, + { events: NostrEvent[]; filter: NostrFilter; onEose: () => void } + >(); private pendingPublishes = new Map void>(); private readonly authPrivateKey?: Uint8Array; private readonly connectTimeoutMs: number; private authenticated = false; constructor(url: string, options: RelayConnectionOptions = {}) { + if (!isAllowedRelayUrl(url)) { + throw new Error( + "Relay URL must use secure WebSockets, except for an explicit loopback development endpoint.", + ); + } this.url = url; this.authPrivateKey = options.authPrivateKey; this.connectTimeoutMs = options.connectTimeoutMs ?? 8000; @@ -111,18 +129,35 @@ export class RelayConnection { const [type, ...rest] = msg as [string, ...unknown[]]; if (type === "EVENT") { - const [subId, event] = rest as [string, NostrEvent]; + const [subId, event] = rest; + if (typeof subId !== "string") return; const sub = this.subs.get(subId); - if (sub && verifyNostrEvent(event)) sub.events.push(event); + // Relays are untrusted and are not entitled to define the result set. + // Verify both the event and the full requested NIP-01 filter centrally; + // callers may still re-check security-sensitive identity constraints. + if ( + sub && + verifyNostrEvent(event) && + matchesNostrFilter(event, sub.filter) + ) + sub.events.push(event); return; } if (type === "EOSE") { const [subId] = rest as [string]; + if (typeof subId !== "string") return; this.subs.get(subId)?.onEose(); return; } if (type === "OK") { const [eventId, ok, message] = rest as [string, boolean, string]; + if ( + typeof eventId !== "string" || + typeof ok !== "boolean" || + (message !== undefined && typeof message !== "string") + ) { + return; + } const resolver = this.pendingPublishes.get(eventId); resolver?.({ ok, message: message ?? "" }); this.pendingPublishes.delete(eventId); @@ -130,6 +165,7 @@ export class RelayConnection { } if (type === "AUTH") { const [challenge] = rest as [string]; + if (typeof challenge !== "string") return; void this.respondToAuthChallenge(challenge); return; } @@ -145,11 +181,11 @@ export class RelayConnection { kind: KIND_AUTH, tags: [ ["relay", this.url], - ["challenge", challenge] + ["challenge", challenge], ], - content: "" + content: "", }, - this.authPrivateKey + this.authPrivateKey, ); this.authenticated = true; this.send(["AUTH", event]); @@ -174,14 +210,17 @@ export class RelayConnection { }); } - async queryOnce(filter: NostrFilter, timeoutMs = 8000): Promise { + async queryOnce( + filter: NostrFilter, + timeoutMs = 8000, + ): Promise { await this.connect(); const subId = computeEventId({ pubkey: "0".repeat(64), created_at: Date.now(), kind: 0, tags: [], - content: JSON.stringify(filter) + Math.random() + content: JSON.stringify(filter) + Math.random(), }).slice(0, 16); return new Promise((resolve, reject) => { @@ -201,27 +240,70 @@ export class RelayConnection { // against a set of connectable-but-silent relays. Only EOSE is an // answer; the timeout is a failure. const timer = setTimeout( - () => settle(new Error("relay did not answer the subscription before the timeout")), - timeoutMs + () => + settle( + new Error( + "relay did not answer the subscription before the timeout", + ), + ), + timeoutMs, ); - this.subs.set(subId, { events, onEose: () => settle() }); + this.subs.set(subId, { events, filter, onEose: () => settle() }); this.send(["REQ", subId, filter]); }); } } +/** Applies every NIP-01 subscription filter dimension supported by the client. */ +export function matchesNostrFilter( + event: NostrEvent, + filter: NostrFilter, +): boolean { + if (filter.ids && !filter.ids.some((prefix) => event.id.startsWith(prefix))) + return false; + if ( + filter.authors && + !filter.authors.some((prefix) => event.pubkey.startsWith(prefix)) + ) + return false; + if (filter.kinds && !filter.kinds.includes(event.kind)) return false; + if (filter.since !== undefined && event.created_at < filter.since) + return false; + if (filter.until !== undefined && event.created_at > filter.until) + return false; + + for (const [key, values] of Object.entries(filter)) { + if (!key.startsWith("#") || !Array.isArray(values)) continue; + const tagName = key.slice(1); + const tagValues = values as unknown[]; + if ( + !event.tags.some( + (tag) => + tag[0] === tagName && + tagValues.some((value) => value === (tag[1] ?? "")), + ) + ) + return false; + } + return true; +} + function toHttpUrl(relayUrl: string): string { return relayUrl.replace(/^ws/u, "http"); } /** Fetches and validates a relay's NIP-11 document, used to check size limits before selecting a vault relay (§11.8, §19.2). */ -export async function fetchRelayInfo(relayUrl: string, timeoutMs = 5000): Promise { +export async function fetchRelayInfo( + relayUrl: string, + timeoutMs = 5000, +): Promise { + if (!isAllowedRelayUrl(relayUrl)) return null; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(toHttpUrl(relayUrl), { headers: { Accept: "application/nostr+json" }, - signal: controller.signal + signal: controller.signal, }); clearTimeout(timer); if (!response.ok) return null; @@ -232,9 +314,16 @@ export async function fetchRelayInfo(relayUrl: string, timeoutMs = 5000): Promis } /** Checks that a relay's advertised NIP-11 limits can carry the largest BitLogin capsule event (§11.8). */ -export function relaySupportsCapsuleSize(info: Nip11Info | null, requiredBytes: number): boolean { +export function relaySupportsCapsuleSize( + info: Nip11Info | null, + requiredBytes: number, +): boolean { if (!info) return true; // no NIP-11 document: cannot rule it out, caller may still choose to accept - const contentOk = info.max_content_length === undefined || info.max_content_length >= requiredBytes; - const messageOk = info.max_message_length === undefined || info.max_message_length >= requiredBytes; + const contentOk = + info.max_content_length === undefined || + info.max_content_length >= requiredBytes; + const messageOk = + info.max_message_length === undefined || + info.max_message_length >= requiredBytes; return contentOk && messageOk; } diff --git a/packages/core/src/nostr/relayUrl.ts b/packages/core/src/nostr/relayUrl.ts new file mode 100644 index 0000000..99147b5 --- /dev/null +++ b/packages/core/src/nostr/relayUrl.ts @@ -0,0 +1,26 @@ +/** Transport policy shared by capsules, NWC credentials, and bootstrap lists. */ + +function isIpv4Loopback(hostname: string): boolean { + const octets = hostname.split("."); + if (octets.length !== 4 || octets.some((part) => !/^\d{1,3}$/u.test(part))) return false; + const numbers = octets.map(Number); + return numbers.every((part) => part >= 0 && part <= 255) && numbers[0] === 127; +} + +/** + * Production relays require authenticated TLS. Cleartext WebSockets are + * accepted only for explicit loopback development endpoints; names that + * merely end in "localhost" are intentionally not trusted. + */ +export function isAllowedRelayUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.username || url.password || url.hash) return false; + if (url.protocol === "wss:") return true; + if (url.protocol !== "ws:") return false; + return url.hostname === "localhost" || url.hostname === "[::1]" || isIpv4Loopback(url.hostname); +} diff --git a/packages/core/src/test-support/mockRelay.ts b/packages/core/src/test-support/mockRelay.ts index 0ae117f..3481d1b 100644 --- a/packages/core/src/test-support/mockRelay.ts +++ b/packages/core/src/test-support/mockRelay.ts @@ -6,7 +6,7 @@ * Not shipped in the published package. */ import { WebSocketServer, type WebSocket } from "ws"; -import { verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js"; +import { compareNip01ReplacementOrder, verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js"; function storageKey(event: NostrEvent): string { if (event.kind >= 30000 && event.kind < 40000) { @@ -33,6 +33,13 @@ export class MockRelay { * that accepts some writes and drops others -- the case that makes write * ORDERING a safety property rather than a style choice. */ public refusePublishFrom: Set = new Set(); + /** Frames injected before normal query results, even when they do not match + * the requested filter. Models a malicious or buggy relay. */ + public unsolicitedQueryEvents: unknown[] = []; + /** Addressable-event d tags whose publishes are refused. Lets ordering tests + * reject recovery writes while still accepting credential writes (or vice + * versa), independently of the signing identity. */ + public refusePublishDTags: Set = new Set(); private constructor(wss: WebSocketServer, port: number) { this.wss = wss; @@ -66,14 +73,16 @@ export class MockRelay { if (type === "EVENT") { const [event] = rest as [NostrEvent]; if (!verifyNostrEvent(event)) { - socket.send(JSON.stringify(["OK", event.id, false, "invalid: bad signature or id"])); + const eventId = typeof event === "object" && event !== null && "id" in event && typeof event.id === "string" ? event.id : ""; + socket.send(JSON.stringify(["OK", eventId, false, "invalid: bad signature, id, or shape"])); return; } if (this.requireAuthForKinds.has(event.kind)) { socket.send(JSON.stringify(["OK", event.id, false, "auth-required: publish requires NIP-42 AUTH"])); return; } - if (this.refusePublishFrom.has(event.pubkey)) { + const dTag = findTagValue(event, "d"); + if (this.refusePublishFrom.has(event.pubkey) || (dTag !== undefined && this.refusePublishDTags.has(dTag))) { socket.send(JSON.stringify(["OK", event.id, false, "blocked: refused by test relay"])); return; } @@ -84,6 +93,7 @@ export class MockRelay { if (type === "REQ") { const [subId, ...filters] = rest as [string, Record]; const matches = this.query(filters[0] ?? {}); + for (const event of this.unsolicitedQueryEvents) socket.send(JSON.stringify(["EVENT", subId, event])); for (const event of matches) socket.send(JSON.stringify(["EVENT", subId, event])); socket.send(JSON.stringify(["EOSE", subId])); return; @@ -105,9 +115,7 @@ export class MockRelay { this.events.set(key, event); return; } - if (event.created_at > existing.created_at) { - this.events.set(key, event); - } else if (event.created_at === existing.created_at && event.id < existing.id) { + if (compareNip01ReplacementOrder(event, existing) > 0) { this.events.set(key, event); } // else: existing event wins, replacement discarded (NIP-01 replacement rule) diff --git a/packages/core/src/vault/derivation.ts b/packages/core/src/vault/derivation.ts index 14cabf7..19d5b18 100644 --- a/packages/core/src/vault/derivation.ts +++ b/packages/core/src/vault/derivation.ts @@ -25,11 +25,15 @@ 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"; +import { wipe } from "../crypto/memory.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"; +export const VAULT_MIGRATION_LABEL = "bitlogin/connection-vault-migration/v1"; +export const VAULT_MIGRATION_ROOT_INFO = "bitlogin/connection-vault-migration-root/v1"; +export const VAULT_MIGRATION_SUDO_INFO = "bitlogin/connection-vault-migration-sudo/v1"; /** `d` tag prefix (§CV8.1): generic on purpose — it must not reveal the credential type. */ export const D_TAG_CONNECTION_PREFIX = "bitlogin:connection:"; @@ -49,7 +53,12 @@ export function deriveVaultSigningKey(vaultPrk: Uint8Array): Uint8Array { } export function deriveVaultPublicKey(vaultPrk: Uint8Array): string { - return getPublicKeyHex(deriveVaultSigningKey(vaultPrk)); + const signingKey = deriveVaultSigningKey(vaultPrk); + try { + return getPublicKeyHex(signingKey); + } finally { + wipe(signingKey); + } } export function derivePersonalPrk(connectionVaultRoot: Uint8Array, vaultSudoKey: Uint8Array): Uint8Array { @@ -58,6 +67,36 @@ export function derivePersonalPrk(connectionVaultRoot: Uint8Array, vaultSudoKey: return hkdfExtract(labelSalt(VAULT_PERSONAL_LABEL), concatBytes(connectionVaultRoot, vaultSudoKey)); } +/** + * Deterministic material for the one-time migration of a pre-vault account. + * + * Two devices can run the phrase-gated migration concurrently. Random roots + * would let both devices publish internally valid but mutually incompatible + * capsules. The recovery capsule key and account id are stable, secret inputs, + * so both devices instead derive the same roots. This is intentionally scoped + * to legacy migration; new registrations continue to mint random roots. + */ +export function deriveLegacyVaultMaterial( + recoveryCapsuleKey: Uint8Array, + accountId: string +): { connectionVaultRoot: Uint8Array; vaultSudoKey: Uint8Array } { + if (recoveryCapsuleKey.length !== 32) throw new Error("recovery capsule key must be exactly 32 bytes."); + const accountIdBytes = base64urlToBytes(accountId); + if (accountIdBytes.length !== 16) throw new Error("account_id must decode to exactly 16 bytes."); + const migrationPrk = hkdfExtract( + labelSalt(VAULT_MIGRATION_LABEL), + concatBytes(recoveryCapsuleKey, accountIdBytes) + ); + try { + return { + connectionVaultRoot: hkdfExpand(migrationPrk, VAULT_MIGRATION_ROOT_INFO, 32), + vaultSudoKey: hkdfExpand(migrationPrk, VAULT_MIGRATION_SUDO_INFO, 32) + }; + } finally { + wipe(migrationPrk, accountIdBytes); + } +} + /** 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 { diff --git a/packages/core/src/vault/migration.test.ts b/packages/core/src/vault/migration.test.ts new file mode 100644 index 0000000..0742531 --- /dev/null +++ b/packages/core/src/vault/migration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { bytesToBase64url, bytesToHex } from "../crypto/encoding.js"; +import { deriveLegacyVaultMaterial } from "./derivation.js"; + +describe("legacy Connection Vault migration material", () => { + const recoveryCapsuleKey = Uint8Array.from({ length: 32 }, (_, index) => index + 1); + const accountId = bytesToBase64url(Uint8Array.from({ length: 16 }, (_, index) => 0xa0 + index)); + + it("is deterministic so concurrent migrations cannot mint split roots", () => { + const first = deriveLegacyVaultMaterial(recoveryCapsuleKey, accountId); + const second = deriveLegacyVaultMaterial(recoveryCapsuleKey, accountId); + + expect(bytesToHex(first.connectionVaultRoot)).toBe(bytesToHex(second.connectionVaultRoot)); + expect(bytesToHex(first.vaultSudoKey)).toBe(bytesToHex(second.vaultSudoKey)); + expect(bytesToHex(first.connectionVaultRoot)).not.toBe(bytesToHex(first.vaultSudoKey)); + }); + + it("domain-separates accounts and does not consume the caller's recovery key", () => { + const before = recoveryCapsuleKey.slice(); + const otherAccountId = bytesToBase64url(new Uint8Array(16).fill(0x55)); + const first = deriveLegacyVaultMaterial(recoveryCapsuleKey, accountId); + const other = deriveLegacyVaultMaterial(recoveryCapsuleKey, otherAccountId); + + expect(recoveryCapsuleKey).toEqual(before); + expect(bytesToHex(first.connectionVaultRoot)).not.toBe(bytesToHex(other.connectionVaultRoot)); + expect(bytesToHex(first.vaultSudoKey)).not.toBe(bytesToHex(other.vaultSudoKey)); + }); + + it("rejects malformed secret and account-id inputs", () => { + expect(() => deriveLegacyVaultMaterial(new Uint8Array(31), accountId)).toThrow(); + expect(() => deriveLegacyVaultMaterial(recoveryCapsuleKey, bytesToBase64url(new Uint8Array(15)))).toThrow(); + }); +}); diff --git a/packages/core/src/vault/nwc.ts b/packages/core/src/vault/nwc.ts index ab8f1eb..ba95a52 100644 --- a/packages/core/src/vault/nwc.ts +++ b/packages/core/src/vault/nwc.ts @@ -8,6 +8,7 @@ * file therefore describe SHAPE problems without echoing values. */ import { SCHEMA_CONNECTION_NWC_V1, type NwcCredential } from "./types.js"; +import { isAllowedRelayUrl } from "../nostr/relayUrl.js"; const HEX64 = /^[0-9a-f]{64}$/u; const KNOWN_PARAMS = new Set(["relay", "secret", "lud16"]); @@ -23,18 +24,24 @@ export class NwcParseError extends Error { 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://"); + 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://")); + 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."); + throw new NwcParseError( + "The NWC URI's wallet service pubkey is not 64-char hex.", + ); } const relays: string[] = []; @@ -43,20 +50,18 @@ export function parseNwcUri(uri: string): NwcCredential { 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."); + if (!isAllowedRelayUrl(value)) { + throw new NwcParseError( + "An NWC relay must use secure WebSockets, except for an explicit loopback development endpoint.", + ); } 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."); + throw new NwcParseError( + "The NWC URI's secret is not a 64-char hex client key.", + ); } secret = lowered; } else if (key === "lud16") { @@ -66,8 +71,10 @@ export function parseNwcUri(uri: string): NwcCredential { 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."); + 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, @@ -75,7 +82,7 @@ export function parseNwcUri(uri: string): NwcCredential { relays, secret, lud16, - extra_params: extraParams + extra_params: extraParams, }; } @@ -97,26 +104,45 @@ export function sameNwcCredential(a: NwcCredential, b: NwcCredential): boolean { return a.wallet_pubkey === b.wallet_pubkey && a.secret === b.secret; } -export function validateNwcCredential(credential: unknown): asserts credential is NwcCredential { +export function validateNwcCredential( + credential: unknown, +): asserts credential is NwcCredential { const c = credential as Partial | null; const fail = (message: string) => { throw new NwcParseError(message); }; if (!c || typeof c !== "object") fail("NWC credential must be an object."); - if (c!.schema !== SCHEMA_CONNECTION_NWC_V1) fail("NWC credential has the wrong schema."); + if (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")) { + 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 (c!.relays!.some((relay) => !isAllowedRelayUrl(relay))) { + fail( + "NWC relays must use secure WebSockets, except for explicit loopback development endpoints.", + ); + } 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 (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") + c!.extra_params.some( + (p) => + !Array.isArray(p) || + p.length !== 2 || + typeof p[0] !== "string" || + typeof p[1] !== "string", + ) ) { fail("NWC credential extra_params must be [key, value] string pairs."); } diff --git a/packages/core/src/vault/recordHighWaterMark.test.ts b/packages/core/src/vault/recordHighWaterMark.test.ts new file mode 100644 index 0000000..cfd34f5 --- /dev/null +++ b/packages/core/src/vault/recordHighWaterMark.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { KeyValueStore } from "../storage/interface.js"; +import { + getRecordHighWaterMark, + raiseRecordHighWaterMark, + RecordHighWaterMarkError +} from "./recordHighWaterMark.js"; +import { deriveVaultPrk, deriveVaultPublicKey } from "./derivation.js"; + +class InspectableStore implements KeyValueStore { + readonly values = new Map(); + async get(key: string): Promise { return this.values.get(key); } + async set(key: string, value: string): Promise { this.values.set(key, value); } + async delete(key: string): Promise { this.values.delete(key); } +} + +const vaultPrk = deriveVaultPrk(new Uint8Array(32).fill(7)); +const connectionId = "AAECAwQFBgcICQoLDA0ODw"; +const mark = { createdAt: 1_800_000_000, eventId: "ab".repeat(32) }; + +describe("authenticated Connection Vault high-water marks", () => { + it("hides the vault inventory and authenticates the stored value", async () => { + const store = new InspectableStore(); + await raiseRecordHighWaterMark(store, vaultPrk, connectionId, mark); + + expect(store.values.size).toBe(1); + const [[key, value]] = [...store.values.entries()]; + expect(key).not.toContain(connectionId); + expect(key).not.toContain(deriveVaultPublicKey(vaultPrk)); + expect(value).not.toContain(String(mark.createdAt)); + expect(value).not.toContain(mark.eventId); + expect(await getRecordHighWaterMark(store, vaultPrk, connectionId)).toEqual(mark); + + const parsed = JSON.parse(value) as { ciphertext: string }; + parsed.ciphertext = `${parsed.ciphertext.slice(0, -1)}${parsed.ciphertext.endsWith("A") ? "B" : "A"}`; + store.values.set(key, JSON.stringify(parsed)); + await expect(getRecordHighWaterMark(store, vaultPrk, connectionId)).rejects.toThrow(RecordHighWaterMarkError); + }); + + it("migrates a valid plaintext v1 marker and deletes the inventory-revealing key", async () => { + const store = new InspectableStore(); + const legacyKey = `bitlogin:vault-hwm:${deriveVaultPublicKey(vaultPrk)}:${connectionId}`; + store.values.set(legacyKey, JSON.stringify(mark)); + + expect(await getRecordHighWaterMark(store, vaultPrk, connectionId)).toEqual(mark); + expect(store.values.has(legacyKey)).toBe(false); + expect(store.values.size).toBe(1); + }); + + it("uses the NIP-01 lower-id tie break", async () => { + const store = new InspectableStore(); + const higher = { createdAt: mark.createdAt, eventId: "f0".repeat(32) }; + const lower = { createdAt: mark.createdAt, eventId: "01".repeat(32) }; + await raiseRecordHighWaterMark(store, vaultPrk, connectionId, higher); + await raiseRecordHighWaterMark(store, vaultPrk, connectionId, lower); + await raiseRecordHighWaterMark(store, vaultPrk, connectionId, higher); + expect(await getRecordHighWaterMark(store, vaultPrk, connectionId)).toEqual(lower); + }); +}); diff --git a/packages/core/src/vault/recordHighWaterMark.ts b/packages/core/src/vault/recordHighWaterMark.ts new file mode 100644 index 0000000..604d1e2 --- /dev/null +++ b/packages/core/src/vault/recordHighWaterMark.ts @@ -0,0 +1,181 @@ +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha2"; +import { aesGcmOpen, aesGcmSeal } from "../crypto/aesGcm.js"; +import { + base64urlToBytes, + bytesToBase64url, + utf8ToBytes, + bytesToUtf8, +} from "../crypto/encoding.js"; +import { hkdfExpand } from "../crypto/hkdf.js"; +import { wipe } from "../crypto/memory.js"; +import { compareNip01ReplacementOrder } from "../nostr/event.js"; +import type { KeyValueStore } from "../storage/interface.js"; +import { decodeConnectionId, deriveVaultPublicKey } from "./derivation.js"; + +const STORAGE_PREFIX = "bitlogin:vault-hwm:v2:"; +const INDEX_INFO = "bitlogin/vault-hwm-index/v2"; +const ENCRYPTION_INFO = "bitlogin/vault-hwm-encryption/v2"; +const HEX64 = /^[0-9a-f]{64}$/u; + +export interface RecordHighWaterMark { + createdAt: number; + eventId: string; +} + +interface SealedRecordHighWaterMark { + v: 2; + nonce: string; + ciphertext: string; +} + +export class RecordHighWaterMarkError extends Error { + constructor() { + super( + "The local Connection Vault rollback marker is invalid or has been tampered with.", + ); + this.name = "RecordHighWaterMarkError"; + } +} + +function validateMark(value: unknown): value is RecordHighWaterMark { + if (!value || typeof value !== "object") return false; + const mark = value as Partial; + return ( + Number.isInteger(mark.createdAt) && + (mark.createdAt ?? -1) >= 0 && + typeof mark.eventId === "string" && + HEX64.test(mark.eventId) + ); +} + +function deriveStorageKey(vaultPrk: Uint8Array, connectionId: string): string { + decodeConnectionId(connectionId); + const indexKey = hkdfExpand(vaultPrk, INDEX_INFO, 32); + const idBytes = utf8ToBytes(connectionId); + try { + return `${STORAGE_PREFIX}${bytesToBase64url(hmac(sha256, indexKey, idBytes))}`; + } finally { + wipe(indexKey, idBytes); + } +} + +function legacyStorageKey(vaultPrk: Uint8Array, connectionId: string): string { + return `bitlogin:vault-hwm:${deriveVaultPublicKey(vaultPrk)}:${connectionId}`; +} + +async function sealMark( + vaultPrk: Uint8Array, + storageKey: string, + mark: RecordHighWaterMark, +): Promise { + const encryptionKey = hkdfExpand(vaultPrk, ENCRYPTION_INFO, 32); + const plaintext = utf8ToBytes(JSON.stringify(mark)); + const associatedData = utf8ToBytes(storageKey); + try { + const sealed = await aesGcmSeal(encryptionKey, plaintext, associatedData); + const stored: SealedRecordHighWaterMark = { + v: 2, + nonce: bytesToBase64url(sealed.nonce), + ciphertext: bytesToBase64url(sealed.ciphertext), + }; + return JSON.stringify(stored); + } finally { + wipe(encryptionKey, plaintext, associatedData); + } +} + +async function openMark( + vaultPrk: Uint8Array, + storageKey: string, + raw: string, +): Promise { + const encryptionKey = hkdfExpand(vaultPrk, ENCRYPTION_INFO, 32); + const associatedData = utf8ToBytes(storageKey); + let plaintext: Uint8Array | undefined; + try { + const stored = JSON.parse(raw) as Partial; + if ( + stored.v !== 2 || + typeof stored.nonce !== "string" || + typeof stored.ciphertext !== "string" + ) { + throw new RecordHighWaterMarkError(); + } + plaintext = await aesGcmOpen( + encryptionKey, + base64urlToBytes(stored.nonce), + base64urlToBytes(stored.ciphertext), + associatedData, + ); + const parsed = JSON.parse(bytesToUtf8(plaintext)) as unknown; + if (!validateMark(parsed)) throw new RecordHighWaterMarkError(); + return parsed; + } catch (error) { + if (error instanceof RecordHighWaterMarkError) throw error; + throw new RecordHighWaterMarkError(); + } finally { + wipe(encryptionKey, associatedData); + if (plaintext) wipe(plaintext); + } +} + +async function persistMark( + store: KeyValueStore, + vaultPrk: Uint8Array, + connectionId: string, + mark: RecordHighWaterMark, +): Promise { + const storageKey = deriveStorageKey(vaultPrk, connectionId); + await store.set(storageKey, await sealMark(vaultPrk, storageKey, mark)); +} + +/** + * Reads the authenticated, inventory-hiding v2 marker. A valid legacy marker + * is migrated in place on first access; malformed legacy state fails closed. + */ +export async function getRecordHighWaterMark( + store: KeyValueStore, + vaultPrk: Uint8Array, + connectionId: string, +): Promise { + const storageKey = deriveStorageKey(vaultPrk, connectionId); + const protectedRaw = await store.get(storageKey); + if (protectedRaw !== undefined) + return openMark(vaultPrk, storageKey, protectedRaw); + + const legacyKey = legacyStorageKey(vaultPrk, connectionId); + const legacyRaw = await store.get(legacyKey); + if (legacyRaw === undefined) return null; + try { + const mark = JSON.parse(legacyRaw) as unknown; + if (!validateMark(mark)) throw new RecordHighWaterMarkError(); + await persistMark(store, vaultPrk, connectionId, mark); + await store.delete(legacyKey); + return mark; + } catch (error) { + if (error instanceof RecordHighWaterMarkError) throw error; + throw new RecordHighWaterMarkError(); + } +} + +/** Keeps the NIP-01-canonical maximum: newest timestamp, then lowest id. */ +export async function raiseRecordHighWaterMark( + store: KeyValueStore, + vaultPrk: Uint8Array, + connectionId: string, + observed: RecordHighWaterMark, +): Promise { + if (!validateMark(observed)) throw new RecordHighWaterMarkError(); + const current = await getRecordHighWaterMark(store, vaultPrk, connectionId); + if ( + current && + compareNip01ReplacementOrder( + { created_at: observed.createdAt, id: observed.eventId }, + { created_at: current.createdAt, id: current.eventId }, + ) <= 0 + ) { + return; + } + await persistMark(store, vaultPrk, connectionId, observed); +} diff --git a/packages/core/src/vault/session.ts b/packages/core/src/vault/session.ts index 7234d07..e5f0f4c 100644 --- a/packages/core/src/vault/session.ts +++ b/packages/core/src/vault/session.ts @@ -7,10 +7,9 @@ * 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. + * which the host can only get from the phrase-gated recovery capsule. + * 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"; @@ -22,17 +21,26 @@ import { deriveVaultPrk, deriveVaultPublicKey, deriveVaultSigningKey, - newConnectionId + newConnectionId, } from "./derivation.js"; import { buildConnectionRecordEvent, decryptConnectionRecordEvent, tombstoneRecord, - type DecryptedConnectionRecord + type DecryptedConnectionRecord, } from "./record.js"; -import { fetchConnectionRecordEvents, publishConnectionRecord, type FetchedConnectionEvents } from "./sync.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"; +import { + SCHEMA_CONNECTION_V1, + type ApplicationBinding, + type ConnectionRecord, + type ConnectionTier, +} from "./types.js"; export interface CreateConnectionParams { connection_type: string; @@ -67,7 +75,10 @@ export class VaultSession { /** Opens a sudo window. Consumes (wipes) the caller's sudo key copy. */ enableSudo(vaultSudoKey: Uint8Array): void { this.endSudo(); - this.personalPrk = derivePersonalPrk(this.connectionVaultRoot, vaultSudoKey); + this.personalPrk = derivePersonalPrk( + this.connectionVaultRoot, + vaultSudoKey, + ); wipe(vaultSudoKey); } @@ -98,17 +109,23 @@ export class VaultSession { private prkForTier(tier: ConnectionTier): Uint8Array { if (this.destroyed) { - throw new Error("This vault session was destroyed; create a new one from the account's root."); + throw new Error( + "This vault session was destroyed; create a new one from the account's root.", + ); } if (tier === "connectable") return this.vaultPrk; if (!this.personalPrk) { - throw new Error("Personal-tier records require an open sudo window (enableSudo)."); + 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 }> { + 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, @@ -120,14 +137,17 @@ export class VaultSession { created_at: now, updated_at: now, credential: params.credential, - application_binding: params.application_binding ?? { origin: null, app_pubkey: null }, - notes: params.notes ?? null + 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 + now, }); return { record, event }; } @@ -136,8 +156,13 @@ export class VaultSession { * between tiers is a delete-and-recreate, never a silent re-encryption. */ async updateConnection( current: DecryptedConnectionRecord, - changes: Partial>, - now?: number + 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 = { @@ -146,14 +171,14 @@ export class VaultSession { tier: current.record.tier, connection_id: current.record.connection_id, created_at: current.record.created_at, - updated_at: at + updated_at: at, }; const event = await buildConnectionRecordEvent({ vaultPrk: this.vaultPrk, recordPrk: this.prkForTier(record.tier), record, previousCreatedAt: current.event.created_at, - now: at + now: at, }); return { record, event }; } @@ -161,7 +186,7 @@ export class VaultSession { /** §CV11 step 2: the encrypted tombstone replacement. */ async deleteConnection( current: DecryptedConnectionRecord, - now?: number + now?: number, ): Promise<{ record: ConnectionRecord; event: NostrEvent }> { const at = now ?? Math.floor(Date.now() / 1000); const record = tombstoneRecord(current.record, at); @@ -170,7 +195,7 @@ export class VaultSession { recordPrk: this.prkForTier(record.tier), record, previousCreatedAt: current.event.created_at, - now: at + now: at, }); return { record, event }; } @@ -178,9 +203,17 @@ export class VaultSession { /** Trial-decrypts one event; personal-tier records resolve only inside a sudo window. */ decryptEvent(event: NostrEvent): Promise { if (this.destroyed) { - return Promise.reject(new Error("This vault session was destroyed; create a new one from the account's root.")); + return Promise.reject( + new Error( + "This vault session was destroyed; create a new one from the account's root.", + ), + ); } - return decryptConnectionRecordEvent(event, this.vaultPrk, this.personalPrk ?? undefined); + return decryptConnectionRecordEvent( + event, + this.vaultPrk, + this.personalPrk ?? undefined, + ); } /** §CV11 step 3: a NIP-09 deletion request for a replaced record event, @@ -188,13 +221,15 @@ export class VaultSession { * tombstone is the durable part of a deletion, this is the courtesy ask. */ buildDeletionRequest(eventIdToDelete: string, now?: number): NostrEvent { if (this.destroyed) { - throw new Error("This vault session was destroyed; create a new one from the account's root."); + throw new Error( + "This vault session was destroyed; create a new one from the account's root.", + ); } return buildDeletionRequest({ privateKey: deriveVaultSigningKey(this.vaultPrk), eventIdToDelete, deletedEventKind: KIND_APP_DATA, - createdAt: now ?? Math.floor(Date.now() / 1000) + createdAt: now ?? Math.floor(Date.now() / 1000), }); } @@ -245,7 +280,7 @@ export class VaultSession { // single relay deciding that is how a revoked-looking vault gets // re-populated or a live credential gets hidden. throw new Error( - "Not enough vault relays answered to list your connections reliably. Check your connection and retry." + "Not enough vault relays answered to list your connections reliably. Check your connection and retry.", ); } const connections: DecryptedConnectionRecord[] = []; @@ -269,7 +304,7 @@ export class VaultSession { rollbackWarnings: fetched.rollbackWarnings, unreadable, truncated: fetched.truncated, - quorumMet: fetched.quorumMet + quorumMet: fetched.quorumMet, }; } } diff --git a/packages/core/src/vault/sync.ts b/packages/core/src/vault/sync.ts index d12c2c6..b1be0c4 100644 --- a/packages/core/src/vault/sync.ts +++ b/packages/core/src/vault/sync.ts @@ -15,46 +15,35 @@ */ 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 { + compareNip01ReplacementOrder, + verifyNostrEvent, + findTagValue, + type NostrEvent, +} from "../nostr/event.js"; +import { + publishAndVerify, + type PublishVerificationResult, +} from "../account/publish.js"; +import { wipe } from "../crypto/memory.js"; import type { KeyValueStore } from "../storage/interface.js"; -import { deriveVaultSigningKey, connectionDTag, connectionIdFromDTag } from "./derivation.js"; +import { + deriveVaultSigningKey, + connectionDTag, + connectionIdFromDTag, +} from "./derivation.js"; import { getPublicKeyHex } from "../crypto/secp256k1.js"; +import { + getRecordHighWaterMark, + raiseRecordHighWaterMark, +} from "./recordHighWaterMark.js"; -function hwmKey(vaultPubkey: string, connectionId: string): string { - return `bitlogin:vault-hwm:${vaultPubkey}:${connectionId}`; -} - -export interface RecordHighWaterMark { - createdAt: number; - eventId: string; -} - -export async function getRecordHighWaterMark( - store: KeyValueStore, - vaultPubkey: string, - connectionId: string -): Promise { - const raw = await store.get(hwmKey(vaultPubkey, connectionId)); - return raw ? (JSON.parse(raw) as RecordHighWaterMark) : null; -} - -export async function raiseRecordHighWaterMark( - store: KeyValueStore, - vaultPubkey: string, - connectionId: string, - observed: RecordHighWaterMark -): Promise { - const current = await getRecordHighWaterMark(store, vaultPubkey, connectionId); - if (current && current.createdAt > observed.createdAt) return; - // Equal timestamps are NOT a no-op: two devices editing the same record in - // the same second both compute nextCreatedAt(T) = T+1, so a benign edit can - // land at the exact timestamp of a REVOCATION. Ties break on event id (the - // addressable-event convention) so the mark advances deterministically - // rather than depending on which write arrived first. - if (current && current.createdAt === observed.createdAt && current.eventId <= observed.eventId) return; - await store.set(hwmKey(vaultPubkey, connectionId), JSON.stringify(observed)); -} +export { + getRecordHighWaterMark, + raiseRecordHighWaterMark, + RecordHighWaterMarkError, + type RecordHighWaterMark, +} from "./recordHighWaterMark.js"; /** Publishes one record event to the vault relays with the §15.6 quorum bar, * raising the local high-water mark only on success. */ @@ -77,16 +66,22 @@ export async function publishConnectionRecord(params: { // 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 + timeoutMs: params.timeoutMs, }); } finally { pool.closeAll(); + wipe(signingKey); } if (result.success) { - await raiseRecordHighWaterMark(params.store, params.event.pubkey, params.connectionId, { - createdAt: params.event.created_at, - eventId: params.event.id - }); + await raiseRecordHighWaterMark( + params.store, + params.vaultPrk, + params.connectionId, + { + createdAt: params.event.created_at, + eventId: params.event.id, + }, + ); } return result; } @@ -121,43 +116,56 @@ export async function fetchConnectionRecordEvents(params: { try { quorum = await pool.queryQuorum( { kinds: [KIND_APP_DATA], authors: [vaultPubkey], limit: FETCH_LIMIT }, - params.timeoutMs + params.timeoutMs, ); } finally { pool.closeAll(); + wipe(signingKey); } - const truncated = quorum.outcomes.some((outcome) => outcome.events.length >= FETCH_LIMIT); + const truncated = quorum.outcomes.some( + (outcome) => outcome.events.length >= FETCH_LIMIT, + ); const newestById = new Map(); for (const event of quorum.outcomes.flatMap((o) => o.events)) { if (!verifyNostrEvent(event) || event.pubkey !== vaultPubkey) continue; const connectionId = connectionIdFromDTag(findTagValue(event, "d") ?? ""); if (connectionId === null) continue; const current = newestById.get(connectionId); - if (!current || event.created_at > current.created_at) newestById.set(connectionId, event); + if (!current || compareNip01ReplacementOrder(event, current) > 0) + newestById.set(connectionId, event); } const events = new Map(); const rollbackWarnings: string[] = []; for (const [connectionId, event] of newestById) { - const hwm = await getRecordHighWaterMark(params.store, vaultPubkey, connectionId); - // Strict `<` alone let a same-second sibling of a revocation through - // forever (the relay may keep both events at one address). An equal - // timestamp is only acceptable from the SAME event, or a later-sorting - // id under the same tie-break the mark itself uses. + const hwm = await getRecordHighWaterMark( + params.store, + params.vaultPrk, + connectionId, + ); + // The event is stale only when it loses to the newest coordinate already + // accepted locally. At equal timestamps NIP-01's LOWER id is canonical. const stale = hwm !== null && - (event.created_at < hwm.createdAt || - (event.created_at === hwm.createdAt && event.id < hwm.eventId)); + compareNip01ReplacementOrder(event, { + created_at: hwm.createdAt, + id: hwm.eventId, + }) < 0; if (stale) { rollbackWarnings.push(connectionId); continue; } events.set(connectionId, event); - await raiseRecordHighWaterMark(params.store, vaultPubkey, connectionId, { - createdAt: event.created_at, - eventId: event.id - }); + await raiseRecordHighWaterMark( + params.store, + params.vaultPrk, + connectionId, + { + createdAt: event.created_at, + eventId: event.id, + }, + ); } return { @@ -165,6 +173,6 @@ export async function fetchConnectionRecordEvents(params: { rollbackWarnings, quorumMet: quorum.quorumMet, respondedCount: quorum.respondedCount, - truncated + truncated, }; } diff --git a/packages/core/src/vault/types.ts b/packages/core/src/vault/types.ts index b56d3b6..85ecc3b 100644 --- a/packages/core/src/vault/types.ts +++ b/packages/core/src/vault/types.ts @@ -48,7 +48,7 @@ export interface ConnectionRecord { export interface NwcCredential { schema: typeof SCHEMA_CONNECTION_NWC_V1; wallet_pubkey: string; // lowercase hex - relays: string[]; // at least one, wss:// or ws:// + relays: string[]; // at least one; TLS required except for loopback development secret: string; // lowercase hex, 32 bytes — the bearer client key lud16: string | null; /** Unknown query parameters retained verbatim for lossless export (§5). */ diff --git a/packages/core/src/vault/vault.e2e.test.ts b/packages/core/src/vault/vault.e2e.test.ts index 79059ce..c2ae885 100644 --- a/packages/core/src/vault/vault.e2e.test.ts +++ b/packages/core/src/vault/vault.e2e.test.ts @@ -11,28 +11,47 @@ 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 { + recoverWithPhrase, + completeRecoveryWithNewCredentials, +} from "../account/recover.js"; import { enableConnectionVault } from "../account/enableVault.js"; -import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "../account/normalize.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 { + D_TAG_PASSWORD_CAPSULE, + D_TAG_RECOVERY_CAPSULE, + SCHEMA_CREDENTIAL_V1, + SCHEMA_RECOVERY_V1, +} from "../nostr/kinds.js"; +import { + buildCredentialCapsuleEvent, + decryptCredentialCapsuleEvent, +} from "../capsules/credentialCapsule.js"; import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js"; import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION, type CredentialPayload, - type RecoveryPayload + 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"; +import { + publishConnectionRecord, + fetchConnectionRecordEvents, +} from "./sync.js"; +import { deriveVaultPrk } from "./derivation.js"; const NWC_URI = `nostr+walletconnect://${"ab".repeat(32)}?relay=wss%3A%2F%2Frelay.example&secret=${"cd".repeat(32)}`; @@ -41,7 +60,11 @@ describe("Connection Vault end-to-end", () => { let vaultRelayUrls: string[] = []; beforeEach(async () => { - relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + relays = await Promise.all([ + MockRelay.start(), + MockRelay.start(), + MockRelay.start(), + ]); vaultRelayUrls = relays.map((r) => r.url); }); @@ -52,9 +75,23 @@ describe("Connection Vault end-to-end", () => { 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 }); + const registration = await registerAccount({ + loginName, + password, + vaultRelayUrls, + }); expect(registration.connectionVaultRoot.length).toBe(32); expect(registration.vaultSudoKey.length).toBe(32); + const registrationKeys = await derivePasswordKeys( + password, + normalizeLoginName(loginName), + ); + const registrationCredential = await decryptCredentialCapsuleEvent( + registration.credentialEvent, + registrationKeys.capsuleKey, + ); + expect(registrationCredential.connection_vault_root).toBeDefined(); + expect(registrationCredential.vault_sudo_key).toBeUndefined(); // Device A: store one NWC connection, bound to Satisfied's origin. const sessionA = new VaultSession(registration.connectionVaultRoot); @@ -64,65 +101,115 @@ describe("Connection Vault end-to-end", () => { tier: "connectable", label: "Satisfied spending wallet", credential: parseNwcUri(NWC_URI), - application_binding: { origin: "https://imsatisfied.app", app_pubkey: null } + application_binding: { + origin: "https://imsatisfied.app", + app_pubkey: null, + }, }); const publish = await sessionA.publish({ event, connectionId: record.connection_id, relayUrls: vaultRelayUrls, - store: storeA + 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 }); + const login = await loginWithPassword({ + loginName, + password, + vaultRelayUrls, + }); expect(login.connectionVaultRoot).toBeDefined(); - expect(Buffer.from(login.connectionVaultRoot!)).toEqual(Buffer.from(registration.connectionVaultRoot)); + 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() }); + 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)); + 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 }); + 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)); + const rotation = await changePassword({ + loginName, + oldPassword, + newPassword, + vaultRelayUrls, + }); + const rotatedKeys = await derivePasswordKeys( + newPassword, + normalizeLoginName(loginName), + ); + const rotatedCredential = await decryptCredentialCapsuleEvent( + rotation.newCredentialEvent, + rotatedKeys.capsuleKey, + ); + expect(rotatedCredential.vault_sudo_key).toBeUndefined(); + const login = await loginWithPassword({ + loginName, + password: newPassword, + vaultRelayUrls, + }); + expect(Buffer.from(login.connectionVaultRoot!)).toEqual( + Buffer.from(registration.connectionVaultRoot), + ); + expect(login.vaultSudoKey).toBeUndefined(); // House-fire scenario: phrase only, new login credentials. const recovered = await recoverWithPhrase({ phrase: registration.recoveryPhrase, vaultRelayUrls, - discoveryRelayUrls: vaultRelayUrls + discoveryRelayUrls: vaultRelayUrls, }); - expect(recovered.currentRecoveryPayload.connection_vault_root).toBeDefined(); + expect( + recovered.currentRecoveryPayload.connection_vault_root, + ).toBeDefined(); await completeRecoveryWithNewCredentials({ recovered, newLoginName: "vault-rotator-reborn", newPassword: "phoenix password 12", - vaultRelayUrls + vaultRelayUrls, }); const reborn = await loginWithPassword({ loginName: "vault-rotator-reborn", password: "phoenix password 12", - vaultRelayUrls + vaultRelayUrls, }); - expect(Buffer.from(reborn.connectionVaultRoot!)).toEqual(Buffer.from(registration.connectionVaultRoot)); + 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 }); + 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. @@ -130,7 +217,9 @@ describe("Connection Vault end-to-end", () => { 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 operationalPrivB64 = Buffer.from( + registration.everydayPrivateKey, + ).toString("base64url"); const strippedRecovery: RecoveryPayload = { schema: SCHEMA_RECOVERY_V1, account_id: registration.accountId, @@ -141,16 +230,25 @@ describe("Connection Vault end-to-end", () => { 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 } + protocol: { + capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; const strippedRecoveryEvent = await buildRecoveryCapsuleEvent({ recoveryPrivateKey: recoveryKeys.recoveryPrivateKey, capsuleKey: recoveryKeys.capsuleKey, - payload: strippedRecovery + payload: strippedRecovery, + }); + const recoveryPool = new RelayPool(vaultRelayUrls, { + authPrivateKey: recoveryKeys.recoveryPrivateKey, }); - const recoveryPool = new RelayPool(vaultRelayUrls, { authPrivateKey: recoveryKeys.recoveryPrivateKey }); expect( - (await publishAndVerify(recoveryPool, strippedRecoveryEvent, { dTag: D_TAG_RECOVERY_CAPSULE })).success + ( + await publishAndVerify(recoveryPool, strippedRecoveryEvent, { + dTag: D_TAG_RECOVERY_CAPSULE, + }) + ).success, ).toBe(true); recoveryPool.closeAll(); @@ -167,33 +265,46 @@ describe("Connection Vault end-to-end", () => { protocol: { password_kdf: PROTOCOL_PASSWORD_KDF, capsule_encryption: PROTOCOL_CAPSULE_ENCRYPTION, - recovery_derivation: PROTOCOL_RECOVERY_DERIVATION - } + recovery_derivation: PROTOCOL_RECOVERY_DERIVATION, + }, }; const strippedCredentialEvent = await buildCredentialCapsuleEvent({ locatorPrivateKey: passwordKeys.locatorPrivateKey, capsuleKey: passwordKeys.capsuleKey, - payload: strippedCredential + payload: strippedCredential, }); const credentialPool = new RelayPool(vaultRelayUrls, { - authPrivateKey: passwordKeys.locatorPrivateKey + authPrivateKey: passwordKeys.locatorPrivateKey, }); expect( - (await publishAndVerify(credentialPool, strippedCredentialEvent, { dTag: D_TAG_PASSWORD_CAPSULE })).success + ( + await publishAndVerify(credentialPool, strippedCredentialEvent, { + dTag: D_TAG_PASSWORD_CAPSULE, + }) + ).success, ).toBe(true); credentialPool.closeAll(); - const legacyLogin = await loginWithPassword({ loginName, password, vaultRelayUrls }); + 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 + vaultRelayUrls, }); await expect( - enableConnectionVault({ loginName, password, phrase: stranger.recoveryPhrase, vaultRelayUrls }) + enableConnectionVault({ + loginName, + password, + phrase: stranger.recoveryPhrase, + vaultRelayUrls, + }), ).rejects.toThrow(/does not belong to this account/); // The ceremony proper. @@ -201,28 +312,36 @@ describe("Connection Vault end-to-end", () => { loginName, password, phrase: registration.recoveryPhrase, - vaultRelayUrls + vaultRelayUrls, }); expect(enabled.alreadyEnabled).toBe(false); - const after = await loginWithPassword({ loginName, password, vaultRelayUrls }); - expect(Buffer.from(after.connectionVaultRoot!)).toEqual(Buffer.from(enabled.connectionVaultRoot)); + 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 + vaultRelayUrls, }); expect(again.alreadyEnabled).toBe(true); - expect(Buffer.from(again.connectionVaultRoot)).toEqual(Buffer.from(enabled.connectionVaultRoot)); + 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 + vaultRelayUrls, }); const session = new VaultSession(registration.connectionVaultRoot); const store = new InMemoryKeyValueStore(); @@ -231,16 +350,24 @@ describe("Connection Vault end-to-end", () => { connection_type: "nwc", tier: "connectable", label: "wallet v1", - credential: parseNwcUri(NWC_URI) + credential: parseNwcUri(NWC_URI), }); expect( - (await session.publish({ event: v1, connectionId: record.connection_id, relayUrls: vaultRelayUrls, store })) - .success + ( + 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" }); + const { event: v2 } = await session.updateConnection(current!, { + label: "wallet v2", + }); expect( ( await session.publish({ @@ -248,21 +375,70 @@ describe("Connection Vault end-to-end", () => { connectionId: record.connection_id, relayUrls: [vaultRelayUrls[0]!], store, - minAcknowledgements: 1 + minAcknowledgements: 1, }) - ).success + ).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 }); + 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)!); + const decrypted = await session.decryptEvent( + all.events.get(record.connection_id)!, + ); expect(decrypted!.record.label).toBe("wallet v2"); }); + + it("chooses the canonical lower-id sibling at equal timestamps in either relay arrival order", async () => { + const root = new Uint8Array(32).fill(23); + const vaultPrk = deriveVaultPrk(root); + const session = new VaultSession(root); + const { record } = await session.createConnection({ + connection_type: "nwc", + tier: "connectable", + label: "base", + credential: parseNwcUri(NWC_URI), + now: 1_800_000_000, + }); + const eventA = await buildConnectionRecordEvent({ + vaultPrk, + recordPrk: vaultPrk, + record: { ...record, label: "sibling A" }, + now: 1_800_000_100, + }); + const eventB = await buildConnectionRecordEvent({ + vaultPrk, + recordPrk: vaultPrk, + record: { ...record, label: "sibling B" }, + now: 1_800_000_100, + }); + const [lower, higher] = [eventA, eventB].sort((a, b) => + a.id.localeCompare(b.id), + ); + + for (const order of [ + [higher, lower, higher], + [lower, higher, lower], + ]) { + relays.forEach((relay, index) => { + relay.unsolicitedQueryEvents = [order[index]!]; + }); + const fetched = await fetchConnectionRecordEvents({ + vaultPrk, + relayUrls: vaultRelayUrls, + store: new InMemoryKeyValueStore(), + }); + expect(fetched.events.get(record.connection_id)?.id).toBe(lower!.id); + expect(fetched.rollbackWarnings).toEqual([]); + } + }); }); diff --git a/packages/core/src/vault/vault.test.ts b/packages/core/src/vault/vault.test.ts index 82ca976..786491c 100644 --- a/packages/core/src/vault/vault.test.ts +++ b/packages/core/src/vault/vault.test.ts @@ -8,7 +8,11 @@ */ import { describe, expect, it } from "vitest"; import { bytesToHex, base64urlToBytes } from "../crypto/encoding.js"; -import { validateVaultFields, CapsuleValidationError } from "../capsules/validation.js"; +import { + validateCredentialVaultFields, + validateVaultFields, + CapsuleValidationError, +} from "../capsules/validation.js"; import { connectionDTag, connectionIdFromDTag, @@ -16,17 +20,26 @@ import { deriveRecordKey, deriveVaultPrk, deriveVaultPublicKey, - newConnectionId + newConnectionId, } from "./derivation.js"; -import { parseNwcUri, toNwcUri, sameNwcCredential, NwcParseError } from "./nwc.js"; +import { + parseNwcUri, + toNwcUri, + sameNwcCredential, + NwcParseError, +} from "./nwc.js"; import { buildConnectionRecordEvent, decryptConnectionRecordEvent, tombstoneRecord, - ConnectionRecordError + ConnectionRecordError, } from "./record.js"; import { VaultSession } from "./session.js"; -import { SCHEMA_CONNECTION_V1, SCHEMA_CONNECTION_NWC_V1, type ConnectionRecord } from "./types.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)); @@ -48,11 +61,14 @@ function record(overrides: Partial = {}): ConnectionRecord { relays: ["wss://relay.example"], secret: "cd".repeat(32), lud16: null, - extra_params: [] + extra_params: [], + }, + application_binding: { + origin: "https://imsatisfied.app", + app_pubkey: null, }, - application_binding: { origin: "https://imsatisfied.app", app_pubkey: null }, notes: null, - ...overrides + ...overrides, }; } @@ -60,21 +76,27 @@ describe("§CV6 derivation vectors (compatibility contract — never update, onl const prk = deriveVaultPrk(ROOT); it("vault PRK", () => { - expect(bytesToHex(prk)).toBe("51c3b1186d216e29366e28bda2bf254b40a99a1fdb36759363749ba6be15d5de"); + expect(bytesToHex(prk)).toBe( + "51c3b1186d216e29366e28bda2bf254b40a99a1fdb36759363749ba6be15d5de", + ); }); it("vault signing identity", () => { - expect(deriveVaultPublicKey(prk)).toBe("71084b9ea80ad9c54ccab57f3586e7017c194c2266af188abfb433c6e9498af4"); + expect(deriveVaultPublicKey(prk)).toBe( + "71084b9ea80ad9c54ccab57f3586e7017c194c2266af188abfb433c6e9498af4", + ); }); it("connectable record key", () => { expect(bytesToHex(deriveRecordKey(prk, FIXED_ID))).toBe( - "a01c357bb49de944dc7529947f3bdf781cc368c1823031544e9a50a2c3009dcf" + "a01c357bb49de944dc7529947f3bdf781cc368c1823031544e9a50a2c3009dcf", ); }); it("personal PRK and record key", () => { const personal = derivePersonalPrk(ROOT, SUDO); - expect(bytesToHex(personal)).toBe("bef59847801ae2017f3b45f415c24ab596784392cac23a227261c61e875cf9a3"); + expect(bytesToHex(personal)).toBe( + "bef59847801ae2017f3b45f415c24ab596784392cac23a227261c61e875cf9a3", + ); expect(bytesToHex(deriveRecordKey(personal, FIXED_ID))).toBe( - "63214e56f371acbedbbc8f1106a3ba2aedc6151e13b3374f6410f71adeebdb6c" + "63214e56f371acbedbbc8f1106a3ba2aedc6151e13b3374f6410f71adeebdb6c", ); }); it("rejects roots of the wrong size", () => { @@ -92,7 +114,9 @@ describe("§CV7 connection identifiers", () => { 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(); + expect( + connectionIdFromDTag("bitlogin:connection:not!base64url!!"), + ).toBeNull(); }); }); @@ -100,13 +124,46 @@ 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); + 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(); + expect(() => + validateVaultFields({ + connection_vault_root: "AAAA", + vault_sudo_key: root, + }), + ).toThrow(); + expect(() => + validateVaultFields({ + connection_vault_root: "!!!", + vault_sudo_key: root, + }), + ).toThrow(); + }); + it("allows a credential to carry the session root without the phrase-gated sudo key", () => { + expect(() => + validateCredentialVaultFields({ connection_vault_root: root }), + ).not.toThrow(); + expect(() => + validateCredentialVaultFields({ + connection_vault_root: root, + vault_sudo_key: root, + }), + ).not.toThrow(); + expect(() => + validateCredentialVaultFields({ vault_sudo_key: root }), + ).toThrow(CapsuleValidationError); }); }); @@ -142,12 +199,37 @@ describe("NWC profile (nwc-connections.md §5-§6)", () => { 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}?secret=${"cd".repeat(32)}`)).toThrow( + NwcParseError, ); - expect(() => parseNwcUri(`${base}?relay=https%3A%2F%2Fr.example&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); + expect(() => + parseNwcUri( + `${base}?relay=ws%3A%2F%2Frelay.example&secret=${"cd".repeat(32)}`, + ), + ).toThrow(NwcParseError); + expect(() => + parseNwcUri( + `${base}?relay=ws%3A%2F%2Flocalhost.attacker.example&secret=${"cd".repeat(32)}`, + ), + ).toThrow(NwcParseError); + expect(() => + parseNwcUri( + `${base}?relay=ws%3A%2F%2F127.0.0.1%3A7777&secret=${"cd".repeat(32)}`, + ), + ).not.toThrow(); }); }); @@ -155,7 +237,12 @@ 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 }); + 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); @@ -164,36 +251,60 @@ describe("§CV8 record events", () => { }); 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 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/ + /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 }); + 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 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) + 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); + 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(); @@ -206,8 +317,8 @@ describe("§CV8 record events", () => { vaultPrk: prk, recordPrk: prk, record: record({ state: "revoked" as never }), - now: 4 - }) + now: 4, + }), ).rejects.toThrow(ConnectionRecordError); }); }); @@ -220,8 +331,11 @@ describe("VaultSession sudo-window policy", () => { connection_type: "password", tier: "personal", label: "insurance portal", - credential: { schema: "bitlogin.connection.password.v1", password: "hunter2" } - }) + credential: { + schema: "bitlogin.connection.password.v1", + password: "hunter2", + }, + }), ).rejects.toThrow(/sudo window/); session.enableSudo(SUDO.slice()); @@ -229,7 +343,10 @@ describe("VaultSession sudo-window policy", () => { connection_type: "password", tier: "personal", label: "insurance portal", - credential: { schema: "bitlogin.connection.password.v1", password: "hunter2" } + credential: { + schema: "bitlogin.connection.password.v1", + password: "hunter2", + }, }); expect(created.tier).toBe("personal"); expect(await session.decryptEvent(event)).not.toBeNull(); @@ -253,10 +370,14 @@ describe("VaultSession sudo-window policy", () => { tier: "connectable", label: "wallet", credential: record().credential, - now: 1_785_400_000 + now: 1_785_400_000, }); const current = await session.decryptEvent(event); - const updated = await session.updateConnection(current!, { label: "renamed" }, 1_785_400_000); + 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); diff --git a/packages/demo/build.mjs b/packages/demo/build.mjs index 211c337..5ba608c 100644 --- a/packages/demo/build.mjs +++ b/packages/demo/build.mjs @@ -1,10 +1,10 @@ -// Copies the built @bitlogin/widget bundle in as a vendored script and assembles the -// static demo site into dist/. No bundler needed for the demo itself -- that's the point: -// any static site can consume BitLogin the same way. -import { cpSync, mkdirSync, rmSync, existsSync } from "node:fs"; +// Copies the built @bitlogin/widget bundle into the static demo and stamps the +// service worker with a hash and manifest covering the complete release graph. +import { cpSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { execSync } from "node:child_process"; +import { renderServiceWorker } from "./swBuild.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const widgetDir = join(__dirname, "..", "widget"); @@ -24,4 +24,18 @@ cpSync(publicDir, outDir, { recursive: true }); mkdirSync(join(outDir, "vendor", "bitlogin"), { recursive: true }); cpSync(widgetDist, join(outDir, "vendor", "bitlogin"), { recursive: true }); -console.log(`Demo site assembled at ${outDir}`); +function filesBelow(directory, prefix = "") { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relative = join(prefix, entry.name); + return entry.isDirectory() ? filesBelow(join(directory, entry.name), relative) : [relative]; + }); +} + +const swPath = join(outDir, "sw.js"); +const swTemplate = readFileSync(swPath, "utf8"); +const artifacts = filesBelow(outDir) + .filter((path) => path !== "sw.js") + .map((path) => ({ path, content: readFileSync(join(outDir, path)) })); +writeFileSync(swPath, renderServiceWorker(swTemplate, artifacts)); + +console.log(`Demo site assembled with a content-versioned service worker at ${outDir}`); diff --git a/packages/demo/public/account.html b/packages/demo/public/account.html index c6f5627..7acc114 100644 --- a/packages/demo/public/account.html +++ b/packages/demo/public/account.html @@ -7,9 +7,6 @@ - - - @@ -52,17 +49,7 @@ - - - - + + diff --git a/packages/demo/public/assets/account.js b/packages/demo/public/assets/account.js new file mode 100644 index 0000000..67bc567 --- /dev/null +++ b/packages/demo/public/assets/account.js @@ -0,0 +1,6 @@ +const element = document.getElementById("bitlogin-widget"); +element?.addEventListener("bitlogin-login", (event) => { + const detail = event instanceof CustomEvent ? event.detail : undefined; + console.info("[bitlogin] signed in", detail?.publicKey); +}); +element?.addEventListener("bitlogin-logout", () => console.info("[bitlogin] signed out")); diff --git a/packages/demo/public/assets/site.css b/packages/demo/public/assets/site.css index fcc4710..f7a8a45 100644 --- a/packages/demo/public/assets/site.css +++ b/packages/demo/public/assets/site.css @@ -14,7 +14,7 @@ --line-strong: rgba(244, 241, 234, 0.2); --code-bg: #100e17; --shadow: 0 24px 60px rgba(0, 0, 0, 0.45); - --font-display: "Fraunces", "Iowan Old Style", "Palatino Linotype", Georgia, serif; + --font-display: "Iowan Old Style", "Palatino Linotype", Georgia, serif; --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; color-scheme: dark; diff --git a/packages/demo/public/assets/site.js b/packages/demo/public/assets/site.js new file mode 100644 index 0000000..aa0f168 --- /dev/null +++ b/packages/demo/public/assets/site.js @@ -0,0 +1,3 @@ +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("./sw.js").catch(() => {}); +} diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 66c23f6..2d0c46a 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -7,9 +7,6 @@ - - - @@ -34,8 +31,8 @@
  1. -

    Copy the widget files next to your site

    -

    Grab bitlogin.js and cryptoWorker.js plus every bitlogin-shared-*.js chunk beside them (five files in a current build — two of the chunks are the lazily loaded wallet-connect flow, downloaded only when a user opens it) from a BitLogin release (or build @bitlogin/widget yourself) and drop them in a folder your site serves, e.g. /vendor/bitlogin/. All of them must live in the same directory — the main script resolves the others by relative URL.

    +

    Self-host the widget on your site's origin

    +

    Build @bitlogin/widget from the reviewed source and copy bitlogin.js, cryptoWorker.js, and every bitlogin-shared-*.js chunk into a folder served by the same origin as your app, e.g. /vendor/bitlogin/. Keep every file together because the entrypoint resolves its worker and chunks by relative URL. Do not load the demo deployment directly from another origin.

  2. Include the script and the element

    @@ -276,10 +273,6 @@ if (window.bitlogin?.releaseSigner()) { - + diff --git a/packages/demo/public/index.html b/packages/demo/public/index.html index 7f55b2e..a373c21 100644 --- a/packages/demo/public/index.html +++ b/packages/demo/public/index.html @@ -7,9 +7,6 @@ - - - @@ -141,7 +138,7 @@

    Static-first

    -

    The whole client — crypto, UI, worker — ships as files. Host it anywhere: Pages, IPFS, a folder on a CDN. No backend to run or pay for.

    +

    The whole client — crypto, UI, worker — ships as static files. Serve the complete reviewed bundle from your app's own origin. No backend to run or pay for.

    No single point of failure

    @@ -149,7 +146,7 @@

    Generated by default, never weak

    -

    A downloadable encrypted capsule can be attacked offline forever, so BitLogin generates a high-entropy passphrase for you by default. Prefer your own? It's allowed, but it has to clear a real entropy floor and a common-password check — no "password123", ever.

    +

    A downloadable encrypted capsule can be attacked offline forever, so BitLogin always generates a high-entropy passphrase. Human-chosen passwords are disabled because composition rules cannot make them safe against unrestricted offline guessing.

    Argon2id + AES-256-GCM

    @@ -184,10 +181,6 @@ - + diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index a8dbae7..58db2bf 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -10,7 +10,7 @@ ## Integrate in one snippet ```html - + @@ -80,16 +80,17 @@ deployment above. ## Key facts for integration -- No build step required to consume it — plain `