mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Second-audit hardening: phrase-gated sudo key, deterministic migration, policy tests
Authored by ChatGPT/Codex in a separate review pass. Reviewed and verified here before committing rather than taken on trust; findings below. The substantive change is that the vault sudo key now lives ONLY in the phrase-gated recovery capsule. A newly written password credential capsule carries just connection_vault_root, so an ordinary login restores connectable-tier records without putting personal-tier material behind a password capsule that is downloadable and therefore offline-guessable forever. Pre-hardening capsules stay readable, password login does not return their sudo key, and the next rotation scrubs it. That closes the gate this project had explicitly set on personal-tier UI. Legacy vault-enable migration now derives roots deterministically from phrase-protected material and account_id, so two concurrent attempts cannot mint divergent roots -- the failure mode the docs previously mitigated with "enable the vault once, from one device". Manual (user-chosen) passwords are REMOVED, not merely discouraged, and a new securityPolicy.test.ts pins that along with several things tests do not usually reach: the deployment CSP (default-src 'none', frame-ancestors 'none') plus six baseline headers, demo pages free of inline scripts and remote fonts, and README documenting same-origin self-hosting only rather than a remote demo embed. llms.txt was updated to match, which I checked because that test only reads README. What I verified, since a passing suite proves less than it appears to: - All 12 fixes from the 2026-07-30 audit survived the rewrite of recover.ts, changePassword.ts, validation.ts, relay.ts, session.ts and element.ts. Checked semantically where grep cannot help: changePassword still checks publish success BEFORE building the tombstone, and vaultSetBinding still unbinds on origin === null rather than silently rebinding to the worker global. - No dependency changes; the package.json diff is scripts only. - Argon2id and KDF parameters unchanged. The NIP-44 diff ADDS a base64 length cap before decoding, a memory-DoS guard. - Validation increased rather than decreased: 44 throws added, 33 removed. - No disabled or skipped tests, no secrets in the diff. typecheck clean, 165/165 tests, build green, and the account manager exercised in a browser: creation correctly refused when relay quorum could not be met, and on retry failed at the recovery capsule with "No login credential was published" -- the §18.1 fail-closed ordering doing its job against a flaky public relay set. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
1 parent
efd994c4de
commit
7d2209d706
56 files changed
+3597
-1318
No files matched your search
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -19,9 +19,9 @@ packages/
|
||||
runs in Node or a browser/worker.
|
||||
widget/ @bitlogin/widget — <bitlogin-auth> 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 <script> in.
|
||||
demo/ @bitlogin/demo — plain static site (no build step of its own)
|
||||
crypto Web Worker. Built with Vite into a
|
||||
same-origin, self-hosted module graph.
|
||||
demo/ @bitlogin/demo — plain static site with a release-manifest build
|
||||
consuming the widget: a landing/branding page,
|
||||
an integration guide, and an account-manager
|
||||
page (create/login/recover, password rotation,
|
||||
@@ -47,7 +47,8 @@ in `packages/demo/dist/` is a complete, ready-to-host static site.
|
||||
<script type="module" src="/vendor/bitlogin/bitlogin.js"></script>
|
||||
<bitlogin-auth
|
||||
vault-relays="wss://relay-one.example,wss://relay-two.example,wss://relay-three.example"
|
||||
discovery-relays="wss://discovery-one.example,wss://discovery-two.example">
|
||||
discovery-relays="wss://discovery-one.example,wss://discovery-two.example"
|
||||
>
|
||||
</bitlogin-auth>
|
||||
```
|
||||
|
||||
@@ -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 `<bitlogin-auth>`
|
||||
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
|
||||
<script type="module" src="https://bitlogin.network/vendor/bitlogin/bitlogin.js"></script>
|
||||
<script type="module" src="/vendor/bitlogin/bitlogin.js"></script>
|
||||
<bitlogin-auth></bitlogin-auth>
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
|
||||
+53
-42
@@ -224,9 +224,7 @@ Each connection is stored as its own NIP-78-style addressable event:
|
||||
"pubkey": "<connection-vault-public-key>",
|
||||
"created_at": 1784750000,
|
||||
"kind": 30078,
|
||||
"tags": [
|
||||
["d", "bitlogin:connection:Vx7LgdZCsVbT_2uvB0YoGA"]
|
||||
],
|
||||
"tags": [["d", "bitlogin:connection:Vx7LgdZCsVbT_2uvB0YoGA"]],
|
||||
"content": "<encoded-encrypted-envelope>",
|
||||
"sig": "<vault-signature>"
|
||||
}
|
||||
@@ -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.
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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<ChangePasswordResult> {
|
||||
export async function changePassword(
|
||||
params: ChangePasswordParams,
|
||||
): Promise<ChangePasswordResult> {
|
||||
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<Chan
|
||||
throw new RollbackDetectedError(hwm.generation, oldPayload.generation);
|
||||
}
|
||||
|
||||
const newKeys = await derivePasswordKeys(params.newPassword, normalizedLoginName);
|
||||
const newKeys = await derivePasswordKeys(
|
||||
params.newPassword,
|
||||
normalizedLoginName,
|
||||
);
|
||||
const newLocatorPublicKey = getPublicKeyHex(newKeys.locatorPrivateKey);
|
||||
|
||||
// §15.6/§18.1 — the new locator address is a NIP-33 replaceable event too, fully
|
||||
@@ -84,21 +122,28 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
|
||||
// exists there (this login name rotated to a password that happens to already be
|
||||
// registered under it), publishing would silently destroy that other account exactly
|
||||
// like an unchecked registration would.
|
||||
const newLocatorCheckPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: newKeys.locatorPrivateKey });
|
||||
const newLocatorCheckPool = new RelayPool(params.vaultRelayUrls, {
|
||||
authPrivateKey: newKeys.locatorPrivateKey,
|
||||
});
|
||||
let existingAtNewLocator;
|
||||
try {
|
||||
existingAtNewLocator = await readCredentialCapsule(newLocatorCheckPool, newLocatorPublicKey, newKeys.capsuleKey, params.timeoutMs);
|
||||
existingAtNewLocator = await readCredentialCapsule(
|
||||
newLocatorCheckPool,
|
||||
newLocatorPublicKey,
|
||||
newKeys.capsuleKey,
|
||||
params.timeoutMs,
|
||||
);
|
||||
} finally {
|
||||
newLocatorCheckPool.closeAll();
|
||||
}
|
||||
if (!existingAtNewLocator.quorumMet) {
|
||||
throw new RegistrationFailedError(
|
||||
"Couldn't verify the new password isn't already registered under this login name. Please retry, or add more vault relays."
|
||||
"Couldn't verify the new password isn't already registered under this login name. Please retry, or add more vault relays.",
|
||||
);
|
||||
}
|
||||
if (existingAtNewLocator.candidates.length > 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<Chan
|
||||
operational_public_key: oldPayload.operational_public_key,
|
||||
recovery_public_key: oldPayload.recovery_public_key,
|
||||
recovery_capsule_event: oldPayload.recovery_capsule_event,
|
||||
// Carried forward like every other long-lived field (§CV5.2): dropping
|
||||
// the vault roots on a password change would strand every connection
|
||||
// record until the next phrase ceremony.
|
||||
// Carry only the session root. A legacy credential may contain the sudo
|
||||
// key, but rotation deliberately scrubs it; personal-tier access remains
|
||||
// phrase-gated through the recovery capsule.
|
||||
...(oldPayload.connection_vault_root !== undefined
|
||||
? { connection_vault_root: oldPayload.connection_vault_root, vault_sudo_key: oldPayload.vault_sudo_key }
|
||||
? { connection_vault_root: oldPayload.connection_vault_root }
|
||||
: {}),
|
||||
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: newKeys.locatorPrivateKey,
|
||||
capsuleKey: newKeys.capsuleKey,
|
||||
payload: newPayload
|
||||
payload: newPayload,
|
||||
});
|
||||
|
||||
const newPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: newKeys.locatorPrivateKey });
|
||||
const newCredentialPublish = await publishAndVerify(newPool, newCredentialEvent, {
|
||||
dTag: D_TAG_PASSWORD_CAPSULE,
|
||||
minAcks: params.minAcknowledgements,
|
||||
timeoutMs: params.timeoutMs
|
||||
const newPool = new RelayPool(params.vaultRelayUrls, {
|
||||
authPrivateKey: newKeys.locatorPrivateKey,
|
||||
});
|
||||
const newCredentialPublish = await publishAndVerify(
|
||||
newPool,
|
||||
newCredentialEvent,
|
||||
{
|
||||
dTag: D_TAG_PASSWORD_CAPSULE,
|
||||
minAcks: params.minAcknowledgements,
|
||||
timeoutMs: params.timeoutMs,
|
||||
},
|
||||
);
|
||||
newPool.closeAll();
|
||||
|
||||
// ORDERING IS A SAFETY PROPERTY, not a style choice. The tombstone below
|
||||
@@ -151,30 +202,34 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
|
||||
if (!newCredentialPublish.success) {
|
||||
throw new RegistrationFailedError(
|
||||
"The new credential capsule did not reach the required relay acknowledgement and readback quorum. " +
|
||||
"Your existing password still works; nothing was changed. Please retry."
|
||||
"Your existing password still works; nothing was changed. Please retry.",
|
||||
);
|
||||
}
|
||||
|
||||
// §18.1 steps 4-5: mandatory tombstone + NIP-09 deletion at the OLD locator address.
|
||||
const tombstoneEvent = buildCredentialTombstoneEvent({
|
||||
oldLocatorPrivateKey: oldKeys.locatorPrivateKey,
|
||||
createdAt: nextCreatedAt(oldEvent.created_at, now)
|
||||
createdAt: nextCreatedAt(oldEvent.created_at, now),
|
||||
});
|
||||
const deletionRequestEvent = buildDeletionRequest({
|
||||
privateKey: oldKeys.locatorPrivateKey,
|
||||
eventIdToDelete: oldEvent.id,
|
||||
deletedEventKind: KIND_APP_DATA,
|
||||
createdAt: now
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const oldPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: oldKeys.locatorPrivateKey });
|
||||
const oldPool = new RelayPool(params.vaultRelayUrls, {
|
||||
authPrivateKey: oldKeys.locatorPrivateKey,
|
||||
});
|
||||
const [tombstoneOutcomes, deletionOutcomes] = await Promise.all([
|
||||
oldPool.publishAll(tombstoneEvent, params.timeoutMs),
|
||||
oldPool.publishAll(deletionRequestEvent, params.timeoutMs)
|
||||
oldPool.publishAll(deletionRequestEvent, params.timeoutMs),
|
||||
]);
|
||||
oldPool.closeAll();
|
||||
|
||||
await raiseHighWaterMark(store, oldPayload.operational_public_key, { generation: newGeneration });
|
||||
await raiseHighWaterMark(store, oldPayload.operational_public_key, {
|
||||
generation: newGeneration,
|
||||
});
|
||||
|
||||
return {
|
||||
normalizedLoginName,
|
||||
@@ -188,6 +243,6 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
|
||||
deletionRequestEvent,
|
||||
newCredentialPublish,
|
||||
tombstoneAcknowledgedCount: countAcknowledgements(tombstoneOutcomes),
|
||||
deletionAcknowledgedCount: countAcknowledgements(deletionOutcomes)
|
||||
deletionAcknowledgedCount: countAcknowledgements(deletionOutcomes),
|
||||
};
|
||||
}
|
||||
+227
-134
@@ -1,19 +1,47 @@
|
||||
/** Registration flow (§15). Recovery capsule is built and published before the credential capsule. */
|
||||
import { randomEntropy128, randomAccountId, randomBytes } from "../crypto/random.js";
|
||||
import {
|
||||
randomEntropy128,
|
||||
randomAccountId,
|
||||
randomBytes,
|
||||
} from "../crypto/random.js";
|
||||
import { bytesToBase64url, hexToBytes } from "../crypto/encoding.js";
|
||||
import { generatePrivateKey, getPublicKeyHex, isValidScalar } from "../crypto/secp256k1.js";
|
||||
import { wipe } from "../crypto/memory.js";
|
||||
import {
|
||||
generatePrivateKey,
|
||||
getPublicKeyHex,
|
||||
isValidScalar,
|
||||
} from "../crypto/secp256k1.js";
|
||||
import { decodeNsec } from "../nostr/nip19.js";
|
||||
import { entropyToRecoveryPhrase, recoveryPhraseToSeed } from "../crypto/bip39.js";
|
||||
import { derivePasswordKeys, deriveRecoveryKeys, normalizeLoginName } from "./normalize.js";
|
||||
import {
|
||||
entropyToRecoveryPhrase,
|
||||
recoveryPhraseToSeed,
|
||||
} from "../crypto/bip39.js";
|
||||
import {
|
||||
derivePasswordKeys,
|
||||
deriveRecoveryKeys,
|
||||
normalizeLoginName,
|
||||
} from "./normalize.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 { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
|
||||
import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.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 { publishAndVerify, type PublishVerificationResult } from "./publish.js";
|
||||
import { readCredentialCapsule } from "./capsuleReader.js";
|
||||
import { RegistrationFailedError, AccountAlreadyExistsError } from "./errors.js";
|
||||
import {
|
||||
RegistrationFailedError,
|
||||
AccountAlreadyExistsError,
|
||||
} from "./errors.js";
|
||||
import type { NostrEvent } from "../nostr/event.js";
|
||||
|
||||
export interface RegisterAccountParams {
|
||||
@@ -54,7 +82,9 @@ export interface RegisterAccountResult {
|
||||
recoveryPublish: PublishVerificationResult;
|
||||
}
|
||||
|
||||
export async function registerAccount(params: RegisterAccountParams): Promise<RegisterAccountResult> {
|
||||
export async function registerAccount(
|
||||
params: RegisterAccountParams,
|
||||
): Promise<RegisterAccountResult> {
|
||||
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<Re
|
||||
// own recovery phrase. Derive the locator early and check before generating anything else
|
||||
// for this attempt, so a collision fails fast without discarding a freshly generated
|
||||
// recovery phrase or everyday keypair.
|
||||
const { locatorPrivateKey, capsuleKey: credentialCapsuleKey } = await derivePasswordKeys(params.password, normalizedLoginName);
|
||||
const locatorPublicKey = getPublicKeyHex(locatorPrivateKey);
|
||||
const checkPool = new RelayPool(params.vaultRelayUrls, { authPrivateKey: locatorPrivateKey });
|
||||
let existingCapsule;
|
||||
const { locatorPrivateKey, capsuleKey: credentialCapsuleKey } =
|
||||
await derivePasswordKeys(params.password, normalizedLoginName);
|
||||
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();
|
||||
}
|
||||
|
||||
// §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<Re
|
||||
* may already be compromised, rotate to a fresh identity instead of importing.
|
||||
*/
|
||||
export async function importAccount(
|
||||
params: Omit<RegisterAccountParams, "everydayPrivateKey"> & { nsecOrHex: string }
|
||||
params: Omit<RegisterAccountParams, "everydayPrivateKey"> & {
|
||||
nsecOrHex: string;
|
||||
},
|
||||
): Promise<RegisterAccountResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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<EnableConnectionVaultResult> {
|
||||
const now = params.now ?? Math.floor(Date.now() / 1000);
|
||||
const normalizedLoginName = normalizeLoginName(params.loginName);
|
||||
if (!isValidRecoveryPhrase(params.phrase)) {
|
||||
throw new RecoveryFailedError("This does not look like a valid 12-word BitLogin recovery phrase.");
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<LoginResult> {
|
||||
export async function loginWithPassword(
|
||||
params: LoginParams,
|
||||
): Promise<LoginResult> {
|
||||
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<LoginResul
|
||||
const rollbackWarning = isRollback
|
||||
? `This device previously saw credential generation ${hwm.generation}, but the accepted capsule is generation ${payload.generation}. Relays may be serving stale data, or an old capsule is being replayed.`
|
||||
: undefined;
|
||||
await raiseHighWaterMark(store, payload.operational_public_key, { generation: payload.generation });
|
||||
await raiseHighWaterMark(store, payload.operational_public_key, {
|
||||
generation: payload.generation,
|
||||
});
|
||||
|
||||
const relayDisagreementWarning = result.relayDisagreement
|
||||
? "Configured relays returned different credential capsules as \"latest\" for this account. Some relays may be stale, censored, or malicious."
|
||||
? 'Configured relays returned different credential capsules as "latest" for this account. Some relays may be stale, censored, or malicious.'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@@ -93,9 +114,8 @@ export async function loginWithPassword(params: LoginParams): Promise<LoginResul
|
||||
connectionVaultRoot: payload.connection_vault_root
|
||||
? base64urlToBytes(payload.connection_vault_root)
|
||||
: undefined,
|
||||
vaultSudoKey: payload.vault_sudo_key ? base64urlToBytes(payload.vault_sudo_key) : undefined,
|
||||
rollbackWarning,
|
||||
relayDisagreementWarning
|
||||
relayDisagreementWarning,
|
||||
};
|
||||
} finally {
|
||||
pool.closeAll();
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkManualPassword, estimatePasswordEntropyBits } from "./manualPassword.js";
|
||||
|
||||
describe("checkManualPassword (§9.3)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<RecoveredIdentity> {
|
||||
export async function recoverWithPhrase(
|
||||
params: RecoverWithPhraseParams,
|
||||
): Promise<RecoveredIdentity> {
|
||||
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<string, Candidate<RecoveryPayload>>();
|
||||
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<CompleteRecoveryResult> {
|
||||
export async function completeRecoveryWithNewCredentials(
|
||||
params: CompleteRecoveryParams,
|
||||
): Promise<CompleteRecoveryResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 \"<ciphertext>?iv=<iv>\" payload shape", () => {
|
||||
it('produces the documented "<ciphertext>?iv=<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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string>([...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<string>([...builtIn, ...fetchedUrls.filter((url) => !deprecated.has(url))]);
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
export async function fetchBootstrapListOverHttps(urls: readonly string[]): Promise<NostrEvent | null> {
|
||||
export async function fetchBootstrapListOverHttps(urls: readonly string[], maintainerPubkeyHex: string | null = MAINTAINER_PUBLIC_KEY_HEX, minimumVersionExclusive = -1): Promise<NostrEvent | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<UnsignedNostrEvent, "tags">, tagName: string): string | undefined {
|
||||
return event.tags.find((t) => t[0] === tagName)?.[1];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof WebSocket>;
|
||||
|
||||
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<void> | null = null;
|
||||
private subs = new Map<string, { events: NostrEvent[]; onEose: () => void }>();
|
||||
private subs = new Map<
|
||||
string,
|
||||
{ events: NostrEvent[]; filter: NostrFilter; onEose: () => void }
|
||||
>();
|
||||
private pendingPublishes = new Map<string, (result: PublishResult) => 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<NostrEvent[]> {
|
||||
async queryOnce(
|
||||
filter: NostrFilter,
|
||||
timeoutMs = 8000,
|
||||
): Promise<NostrEvent[]> {
|
||||
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<NostrEvent[]>((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<Nip11Info | null> {
|
||||
export async function fetchRelayInfo(
|
||||
relayUrl: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Nip11Info | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string> = 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<string> = 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<string, unknown>];
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<NwcCredential> | null;
|
||||
const fail = (message: string) => {
|
||||
throw new NwcParseError(message);
|
||||
};
|
||||
if (!c || typeof c !== "object") fail("NWC credential must be an object.");
|
||||
if (c!.schema !== SCHEMA_CONNECTION_NWC_V1) fail("NWC credential has the wrong schema.");
|
||||
if (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.");
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
async get(key: string): Promise<string | undefined> { return this.values.get(key); }
|
||||
async set(key: string, value: string): Promise<void> { this.values.set(key, value); }
|
||||
async delete(key: string): Promise<void> { 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);
|
||||
});
|
||||
});
|
||||
@@ -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<RecordHighWaterMark>;
|
||||
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<string> {
|
||||
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<RecordHighWaterMark> {
|
||||
const encryptionKey = hkdfExpand(vaultPrk, ENCRYPTION_INFO, 32);
|
||||
const associatedData = utf8ToBytes(storageKey);
|
||||
let plaintext: Uint8Array | undefined;
|
||||
try {
|
||||
const stored = JSON.parse(raw) as Partial<SealedRecordHighWaterMark>;
|
||||
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<void> {
|
||||
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<RecordHighWaterMark | null> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Pick<ConnectionRecord, "state" | "label" | "credential" | "application_binding" | "notes">>,
|
||||
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<DecryptedConnectionRecord | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<RecordHighWaterMark | null> {
|
||||
const raw = await store.get(hwmKey(vaultPubkey, connectionId));
|
||||
return raw ? (JSON.parse(raw) as RecordHighWaterMark) : null;
|
||||
}
|
||||
|
||||
export async function raiseRecordHighWaterMark(
|
||||
store: KeyValueStore,
|
||||
vaultPubkey: string,
|
||||
connectionId: string,
|
||||
observed: RecordHighWaterMark
|
||||
): Promise<void> {
|
||||
const current = await getRecordHighWaterMark(store, vaultPubkey, connectionId);
|
||||
if (current && current.createdAt > observed.createdAt) return;
|
||||
// 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<string, NostrEvent>();
|
||||
for (const event of quorum.outcomes.flatMap((o) => o.events)) {
|
||||
if (!verifyNostrEvent(event) || event.pubkey !== vaultPubkey) continue;
|
||||
const connectionId = connectionIdFromDTag(findTagValue(event, "d") ?? "");
|
||||
if (connectionId === null) continue;
|
||||
const current = newestById.get(connectionId);
|
||||
if (!current || event.created_at > current.created_at) newestById.set(connectionId, event);
|
||||
if (!current || compareNip01ReplacementOrder(event, current) > 0)
|
||||
newestById.set(connectionId, event);
|
||||
}
|
||||
|
||||
const events = new Map<string, NostrEvent>();
|
||||
const rollbackWarnings: string[] = [];
|
||||
for (const [connectionId, event] of newestById) {
|
||||
const hwm = await getRecordHighWaterMark(params.store, vaultPubkey, connectionId);
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
|
||||
+19
-5
@@ -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}`);
|
||||
@@ -7,9 +7,6 @@
|
||||
<meta name="description" content="Create, sign in to, recover, and manage a BitLogin account — including password rotation and identity export." />
|
||||
<link rel="icon" href="./assets/icon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./assets/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -52,17 +49,7 @@
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./vendor/bitlogin/bitlogin.js"></script>
|
||||
<script type="module">
|
||||
const el = document.getElementById('bitlogin-widget');
|
||||
el.addEventListener('bitlogin-login', (e) => console.log('[bitlogin] signed in', e.detail.publicKey));
|
||||
el.addEventListener('bitlogin-logout', () => console.log('[bitlogin] signed out'));
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("./sw.js").catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="./assets/account.js"></script>
|
||||
<script type="module" src="./assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("./sw.js").catch(() => {});
|
||||
}
|
||||
@@ -7,9 +7,6 @@
|
||||
<meta name="description" content="How to add the BitLogin login widget to any static site in a few minutes." />
|
||||
<link rel="icon" href="./assets/icon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./assets/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -34,8 +31,8 @@
|
||||
<section class="container narrow">
|
||||
<ol class="step-list">
|
||||
<li>
|
||||
<h3>Copy the widget files next to your site</h3>
|
||||
<p>Grab <code>bitlogin.js</code> and <code>cryptoWorker.js</code> plus every <code>bitlogin-shared-*.js</code> 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 <code>@bitlogin/widget</code> yourself) and drop them in a folder your site serves, e.g. <code>/vendor/bitlogin/</code>. All of them must live in the same directory — the main script resolves the others by relative URL.</p>
|
||||
<h3>Self-host the widget on your site's origin</h3>
|
||||
<p>Build <code>@bitlogin/widget</code> from the reviewed source and copy <code>bitlogin.js</code>, <code>cryptoWorker.js</code>, and every <code>bitlogin-shared-*.js</code> chunk into a folder served by the same origin as your app, e.g. <code>/vendor/bitlogin/</code>. 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.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Include the script and the element</h3>
|
||||
@@ -276,10 +273,6 @@ if (window.bitlogin?.releaseSigner()) {
|
||||
</footer>
|
||||
|
||||
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("./sw.js").catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="./assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,9 +7,6 @@
|
||||
<meta name="description" content="A login name and password that work on every BitLogin site — no extension to install, no separate signer to keep running. Built on the open Nostr network, with no account server anyone can shut down." />
|
||||
<link rel="icon" href="./assets/icon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./assets/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -141,7 +138,7 @@
|
||||
<div class="grid grid-center-last-row">
|
||||
<div class="feature">
|
||||
<h3>Static-first</h3>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>No single point of failure</h3>
|
||||
@@ -149,7 +146,7 @@
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Generated by default, never weak</h3>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Argon2id + AES-256-GCM</h3>
|
||||
@@ -184,10 +181,6 @@
|
||||
</footer>
|
||||
|
||||
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("./sw.js").catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="./assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,7 @@
|
||||
## Integrate in one snippet
|
||||
|
||||
```html
|
||||
<script type="module" src="https://bitlogin.network/vendor/bitlogin/bitlogin.js"></script>
|
||||
<script type="module" src="/vendor/bitlogin/bitlogin.js"></script>
|
||||
<bitlogin-auth
|
||||
vault-relays="wss://relay-one.example,wss://relay-two.example,wss://relay-three.example"
|
||||
discovery-relays="wss://discovery-one.example,wss://discovery-two.example">
|
||||
@@ -80,16 +80,17 @@ deployment above.
|
||||
|
||||
## Key facts for integration
|
||||
|
||||
- No build step required to consume it — plain `<script type="module">`.
|
||||
- Build or obtain a reviewed release, then self-host its complete module graph
|
||||
on the application's own origin and load it with `<script type="module">`.
|
||||
- Don't give the element `id="bitlogin"` — browsers auto-expose elements with
|
||||
a matching `id`/`name` as a same-named global, which shadows
|
||||
`window.bitlogin`. Use `id="bitlogin-widget"` or similar.
|
||||
- Configure relays via the `vault-relays` and `discovery-relays` attributes
|
||||
(comma-separated `wss://` URLs); omit to use BitLogin's small built-in
|
||||
bootstrap list.
|
||||
- Manual (user-chosen) passwords are opt-in and discouraged by default — the
|
||||
widget generates a high-entropy passphrase and recommends it, since a
|
||||
downloadable encrypted capsule can be guessed against offline forever.
|
||||
- Human-chosen passwords are disabled. The widget always generates a
|
||||
high-entropy passphrase because a downloadable encrypted capsule can be
|
||||
guessed against offline forever.
|
||||
- Recovery is a 12-word BIP-39-style phrase, independent of the password;
|
||||
losing the password never locks the user out if they still have the
|
||||
phrase.
|
||||
|
||||
+52
-39
@@ -1,30 +1,16 @@
|
||||
/**
|
||||
* Static-asset-only cache (§22.3). This service worker never sees relay traffic
|
||||
* (Nostr relay I/O is WebSocket, not fetch) and never handles form submissions,
|
||||
* so there is nothing sensitive in its fetch path by construction. It precaches
|
||||
* a fixed, explicit list of public files and serves them cache-first; anything
|
||||
* not on that list passes straight through to the network untouched.
|
||||
*/
|
||||
const CACHE_NAME = "bitlogin-demo-v1";
|
||||
// Relative to this script's own location, so the same file works whether the
|
||||
// site is served from a domain root or a subpath (e.g. a GitHub Pages project
|
||||
// site at /bitlogin/).
|
||||
const PRECACHE_PATHS = [
|
||||
"./",
|
||||
"./index.html",
|
||||
"./docs.html",
|
||||
"./account.html",
|
||||
"./assets/site.css",
|
||||
"./assets/icon.svg",
|
||||
"./manifest.webmanifest",
|
||||
"./vendor/bitlogin/bitlogin.js",
|
||||
"./vendor/bitlogin/cryptoWorker.js"
|
||||
];
|
||||
const PRECACHE_URLS = PRECACHE_PATHS.map((p) => new URL(p, self.location.href).toString());
|
||||
/** Generated at build time: the version and manifest cover the complete release graph. */
|
||||
const CACHE_NAME = "bitlogin-demo-__BITLOGIN_BUILD_HASH__";
|
||||
const PRECACHE_PATHS = ["__BITLOGIN_PRECACHE_MANIFEST__"];
|
||||
const PRECACHE_URLS = new Set(
|
||||
PRECACHE_PATHS.map((path) => new URL(path, self.location.href).toString()),
|
||||
);
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Do not skipWaiting: an older page may still need lazy chunks and the
|
||||
// matching crypto worker from its own release. The new worker activates
|
||||
// only after those clients close, with its complete cache already present.
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll([...PRECACHE_URLS])),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,25 +18,52 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim())
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter(
|
||||
(key) => key.startsWith("bitlogin-demo-") && key !== CACHE_NAME,
|
||||
)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request, {
|
||||
cacheName: CACHE_NAME,
|
||||
ignoreSearch: true,
|
||||
});
|
||||
return cached ?? fetch(request);
|
||||
}
|
||||
|
||||
async function navigationForThisRelease(request) {
|
||||
const url = new URL(request.url);
|
||||
const relativePath = url.pathname.slice(
|
||||
new URL("./", self.location.href).pathname.length,
|
||||
);
|
||||
const releasePath =
|
||||
relativePath === "" ? "./index.html" : `./${relativePath}`;
|
||||
const cached = await caches.match(new URL(releasePath, self.location.href), {
|
||||
cacheName: CACHE_NAME,
|
||||
ignoreSearch: true,
|
||||
});
|
||||
return cached ?? fetch(request);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
if (!PRECACHE_URLS.includes(url.toString())) return;
|
||||
if (event.request.method !== "GET" || url.origin !== self.location.origin)
|
||||
return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
const network = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? network;
|
||||
})
|
||||
);
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(navigationForThisRelease(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PRECACHE_URLS.has(url.toString())) return;
|
||||
// Every controlled client stays on one complete release graph. This is
|
||||
// especially important for lazy chunks and the main/worker protocol pair.
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function renderServiceWorker(template, artifacts) {
|
||||
const ordered = [...artifacts].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const digest = createHash("sha256");
|
||||
for (const artifact of ordered) {
|
||||
digest.update(artifact.path);
|
||||
digest.update("\0");
|
||||
digest.update(artifact.content);
|
||||
digest.update("\0");
|
||||
}
|
||||
const buildHash = digest.digest("hex").slice(0, 20);
|
||||
const manifest = ordered.map((artifact) => `./${artifact.path.replaceAll("\\", "/")}`);
|
||||
return template
|
||||
.replace("__BITLOGIN_BUILD_HASH__", buildHash)
|
||||
.replace('["__BITLOGIN_PRECACHE_MANIFEST__"]', JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
+14
-183
@@ -2,10 +2,8 @@
|
||||
import {
|
||||
generatePassphrase,
|
||||
isValidLoginName,
|
||||
checkManualPassword,
|
||||
parseRecoveryExport,
|
||||
RecoveryExportParseError,
|
||||
type ManualPasswordCheck,
|
||||
type RecoveryExportFile
|
||||
} from "@bitlogin/core/account";
|
||||
import { encodeNpub } from "@bitlogin/core/nostr";
|
||||
@@ -15,6 +13,7 @@ import { readConfigFromElement } from "./config.js";
|
||||
import { WIDGET_STYLES } from "./styles.js";
|
||||
import { chooseWalletViaBitcoinConnect } from "./vault/bitcoinConnect.js";
|
||||
import type { VaultConnectionSummary } from "./worker/protocol.js";
|
||||
import { buildVaultIntegrityWarnings } from "./vault/integrityWarnings.js";
|
||||
|
||||
type Screen =
|
||||
| "welcome"
|
||||
@@ -57,17 +56,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private recoveryPhrase = "";
|
||||
private confirmSlots: ConfirmSlot[] = [];
|
||||
|
||||
// Manual password opt-in (§9.3). Off by default; the generated passphrase remains the
|
||||
// recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full
|
||||
// re-render (see onInput) so the field never loses focus while the user is typing.
|
||||
// manualPasswordDraft/manualPasswordConfirmDraft re-populate the fields' value attribute
|
||||
// across a validation-failure re-render, so a rejected password isn't silently wiped,
|
||||
// forcing the user to retype it (and re-check "I saved it") from scratch.
|
||||
private manualPasswordMode = false;
|
||||
private manualPasswordFeedback: ManualPasswordCheck | null = null;
|
||||
private manualPasswordDraft = "";
|
||||
private manualPasswordConfirmDraft = "";
|
||||
|
||||
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
|
||||
private importKey = "";
|
||||
private importPreviewNpub = "";
|
||||
@@ -121,6 +109,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private vaultUnsaved = false;
|
||||
private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined;
|
||||
private vaultConnections: VaultConnectionSummary[] | null = null;
|
||||
private vaultIntegrityWarnings: string[] = [];
|
||||
/** offerNwcConnection state: the app already holds this URI; the only
|
||||
* question on screen is whether a copy enters the user's vault. */
|
||||
/** Claimed synchronously by offerNwcConnection before any await (see there). */
|
||||
@@ -158,7 +147,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
this.root.addEventListener("click", (e) => this.onClick(e));
|
||||
this.root.addEventListener("submit", (e) => this.onSubmit(e));
|
||||
this.root.addEventListener("input", (e) => this.onInput(e));
|
||||
this.root.addEventListener("change", (e) => void this.onFileChange(e));
|
||||
this.render();
|
||||
|
||||
@@ -444,10 +432,12 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
private async loadVaultManage(): Promise<void> {
|
||||
this.vaultConnections = null;
|
||||
this.vaultIntegrityWarnings = [];
|
||||
this.goto("vault-manage");
|
||||
try {
|
||||
const listed = await this.worker.vaultList();
|
||||
this.vaultConnections = listed.connections;
|
||||
this.vaultIntegrityWarnings = buildVaultIntegrityWarnings(listed);
|
||||
} catch (err) {
|
||||
this.errorMessage = err instanceof Error ? err.message : String(err);
|
||||
this.vaultConnections = [];
|
||||
@@ -585,38 +575,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return (this.root.querySelector(`[name="${name}"]`) as HTMLInputElement | null)?.value ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared manual-password field markup (§9.3), reused on every screen that lets a user set
|
||||
* their own password (initial creation, phrase recovery, and password rotation) so the
|
||||
* option isn't only available at signup.
|
||||
*/
|
||||
private renderManualPasswordFields(): string {
|
||||
return `
|
||||
<label for="manualPassword">Password</label>
|
||||
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" value="${escapeHtml(this.manualPasswordDraft)}" />
|
||||
<div class="notice info" id="manual-password-feedback"></div>
|
||||
<label for="manualPasswordConfirm">Confirm password</label>
|
||||
<input type="password" name="manualPasswordConfirm" id="manualPasswordConfirm" autocomplete="new-password" required minlength="12" value="${escapeHtml(this.manualPasswordConfirmDraft)}" />
|
||||
<div class="notice info" id="manual-password-match-feedback"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates the manual-password fields against a caller-supplied login name.
|
||||
* Always stashes what was typed into manualPasswordDraft/manualPasswordConfirmDraft so a
|
||||
* validation-failure re-render can restore it (see renderManualPasswordFields).
|
||||
*/
|
||||
private readManualPassword(loginNameForValidation: string): { password: string; error?: string } {
|
||||
const typed = this.field("manualPassword");
|
||||
const confirmTyped = this.field("manualPasswordConfirm");
|
||||
this.manualPasswordDraft = typed;
|
||||
this.manualPasswordConfirmDraft = confirmTyped;
|
||||
const check = checkManualPassword(typed, loginNameForValidation);
|
||||
if (!check.ok) return { password: "", error: `Password not accepted: ${check.reason}` };
|
||||
if (typed !== confirmTyped) return { password: "", error: "Passwords do not match. Please re-enter both." };
|
||||
return { password: typed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly asks the browser to offer saving this credential via the Credential
|
||||
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
|
||||
@@ -715,10 +673,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return;
|
||||
case "goto-change-password":
|
||||
this.changePasswordNewCredential = generatePassphrase().secret;
|
||||
this.manualPasswordMode = false;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.manualPasswordDraft = "";
|
||||
this.manualPasswordConfirmDraft = "";
|
||||
this.goto("change-password");
|
||||
return;
|
||||
case "goto-export":
|
||||
@@ -729,14 +683,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "toggle-manual-password":
|
||||
this.manualPasswordMode = !this.manualPasswordMode;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.manualPasswordDraft = "";
|
||||
this.manualPasswordConfirmDraft = "";
|
||||
this.savedCheckbox = false;
|
||||
this.render();
|
||||
return;
|
||||
case "copy-credential": {
|
||||
const box = this.root.querySelector<HTMLElement>("#credential-box");
|
||||
const button = target;
|
||||
@@ -812,52 +758,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live entropy/strength and match feedback for the manual-password fields, updated by
|
||||
* DIRECTLY patching feedback elements' text — never calling this.render() — so neither
|
||||
* input loses focus or cursor position while the user is typing (§9.3).
|
||||
*/
|
||||
private onInput(e: Event): void {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
if (target.name !== "manualPassword" && target.name !== "manualPasswordConfirm") return;
|
||||
|
||||
const pw = (this.root.querySelector('input[name="manualPassword"]') as HTMLInputElement | null)?.value ?? "";
|
||||
const confirm = (this.root.querySelector('input[name="manualPasswordConfirm"]') as HTMLInputElement | null)?.value ?? "";
|
||||
|
||||
this.manualPasswordFeedback = pw ? checkManualPassword(pw, this.loginName) : null;
|
||||
const feedback = this.root.querySelector<HTMLElement>("#manual-password-feedback");
|
||||
if (feedback) {
|
||||
if (!this.manualPasswordFeedback) {
|
||||
feedback.textContent = "";
|
||||
feedback.className = "notice info";
|
||||
} else {
|
||||
const bits = this.manualPasswordFeedback.entropyBits.toFixed(0);
|
||||
if (this.manualPasswordFeedback.ok) {
|
||||
feedback.textContent = `Looks good (~${bits} bits estimated).`;
|
||||
feedback.className = "notice info";
|
||||
} else {
|
||||
feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`;
|
||||
feedback.className = "notice warn";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchFeedback = this.root.querySelector<HTMLElement>("#manual-password-match-feedback");
|
||||
if (matchFeedback) {
|
||||
if (!confirm) {
|
||||
matchFeedback.textContent = "";
|
||||
matchFeedback.className = "notice info";
|
||||
} else if (confirm === pw) {
|
||||
matchFeedback.textContent = "Passwords match.";
|
||||
matchFeedback.className = "notice info";
|
||||
} else {
|
||||
matchFeedback.textContent = "Passwords do not match.";
|
||||
matchFeedback.className = "notice warn";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates an optional recovery-export file (§19.5) for the recover-phrase
|
||||
* screen. Purely a relay-outage fallback -- the file never contains the phrase or any
|
||||
@@ -930,10 +830,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.loginName = name;
|
||||
this.generatedCredential = generatePassphrase().secret;
|
||||
this.savedCheckbox = false;
|
||||
this.manualPasswordMode = false;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.manualPasswordDraft = "";
|
||||
this.manualPasswordConfirmDraft = "";
|
||||
this.goto("create-credential");
|
||||
}
|
||||
|
||||
@@ -941,20 +837,9 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
// Read the checkbox before password validation so a rejected password doesn't also
|
||||
// silently un-check it on the retry render.
|
||||
this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false;
|
||||
let password = this.generatedCredential;
|
||||
if (this.manualPasswordMode) {
|
||||
const { password: manualPassword, error } = this.readManualPassword(this.loginName);
|
||||
if (error) {
|
||||
this.errorMessage = error;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
password = manualPassword;
|
||||
}
|
||||
const password = this.generatedCredential;
|
||||
if (!this.savedCheckbox) {
|
||||
this.errorMessage = this.manualPasswordMode
|
||||
? "Please confirm you've saved your password (or written it down) before continuing."
|
||||
: "Please confirm you saved your password before continuing.";
|
||||
this.errorMessage = "Please confirm you saved your password before continuing.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
@@ -1092,10 +977,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
};
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.newCredentialAfterRecovery = generatePassphrase().secret;
|
||||
this.manualPasswordMode = false;
|
||||
this.manualPasswordFeedback = null;
|
||||
this.manualPasswordDraft = "";
|
||||
this.manualPasswordConfirmDraft = "";
|
||||
this.busy = false;
|
||||
this.goto("recover-new-credentials");
|
||||
}
|
||||
@@ -1107,16 +988,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
let newPassword = this.newCredentialAfterRecovery;
|
||||
if (this.manualPasswordMode) {
|
||||
const { password, error } = this.readManualPassword(newLoginName);
|
||||
if (error) {
|
||||
this.errorMessage = error;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
newPassword = password;
|
||||
}
|
||||
const newPassword = this.newCredentialAfterRecovery;
|
||||
this.setBusy(true);
|
||||
await this.worker.completeRecovery({ newLoginName, newPassword });
|
||||
this.loginName = newLoginName;
|
||||
@@ -1130,16 +1002,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
private async handleChangePasswordSubmit(): Promise<void> {
|
||||
const oldPassword = this.field("oldPassword");
|
||||
let newPassword = this.changePasswordNewCredential;
|
||||
if (this.manualPasswordMode) {
|
||||
const { password, error } = this.readManualPassword(this.loginName);
|
||||
if (error) {
|
||||
this.errorMessage = error;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
newPassword = password;
|
||||
}
|
||||
const newPassword = this.changePasswordNewCredential;
|
||||
await this.attemptChangePassword(oldPassword, newPassword);
|
||||
}
|
||||
|
||||
@@ -1315,24 +1178,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
? "Import account"
|
||||
: "Create account";
|
||||
|
||||
if (this.manualPasswordMode) {
|
||||
return `
|
||||
<h2>Choose your own password</h2>
|
||||
<p class="sub">Not recommended: a downloadable encrypted file can be guessed against forever, offline, with no rate limit. BitLogin can only run basic checks here — not a breach-database lookup — so weak or reused passwords are still your risk to carry.</p>
|
||||
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
|
||||
${this.renderError()}
|
||||
<form data-form="create-credential">
|
||||
${this.renderManualPasswordFields()}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||
I have saved this password somewhere safe.
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use a generated password instead (recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<h2>Your generated password</h2>
|
||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
|
||||
@@ -1348,7 +1193,6 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
</label>
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">Use my own password instead (not recommended)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1438,22 +1282,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.recoveredPreview?.dmRelaysCount ?? 0
|
||||
} DM relay(s) from your public events.</p>
|
||||
${chainWarning}
|
||||
<p class="sub">Now set a new login name and password.</p>
|
||||
${
|
||||
this.manualPasswordMode
|
||||
? `<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>`
|
||||
: `<div class="credential-box">${escapeHtml(this.newCredentialAfterRecovery)}</div>`
|
||||
}
|
||||
<p class="sub">Now set a new login name. BitLogin generated a new high-entropy password for this account.</p>
|
||||
<div class="credential-box" id="credential-box">${escapeHtml(this.newCredentialAfterRecovery)}</div>
|
||||
<button class="secondary" type="button" data-action="copy-credential">Copy generated password</button>
|
||||
${this.renderError()}
|
||||
<form data-form="recover-new-credentials">
|
||||
<label for="newLoginName">New login name</label>
|
||||
<input type="text" name="newLoginName" id="newLoginName" autocomplete="off" required />
|
||||
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">
|
||||
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1572,6 +1409,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<h2>Wallet connections</h2>
|
||||
<p class="sub">Connections your apps use, stored encrypted on your account and restored on any device you sign in to.</p>
|
||||
${this.renderError()}
|
||||
${this.vaultIntegrityWarnings.map((warning) => `<div class="notice warn">${escapeHtml(warning)}</div>`).join("")}
|
||||
${rows}
|
||||
<div class="notice info">"Remove from BitLogin" deletes the stored copy only — the connection itself keeps working for any app that already has it. To revoke spending authority, delete the connection inside your wallet app.</div>
|
||||
<button class="link" data-action="goto-dashboard">Back</button>
|
||||
@@ -1592,23 +1430,16 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return `
|
||||
<h2>Rotate password</h2>
|
||||
<p class="sub">Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded, and a relay that hasn't processed the deletion may keep serving the old password's capsule until a device that has already seen the new generation refuses it.</p>
|
||||
${
|
||||
this.manualPasswordMode
|
||||
? `<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>`
|
||||
: `<div class="credential-box">${escapeHtml(this.changePasswordNewCredential)}</div>`
|
||||
}
|
||||
<div class="credential-box" id="credential-box">${escapeHtml(this.changePasswordNewCredential)}</div>
|
||||
<button class="secondary" type="button" data-action="copy-credential">Copy generated password</button>
|
||||
${this.renderError()}
|
||||
<form data-form="change-password">
|
||||
<label for="oldPassword">Current password</label>
|
||||
<input type="password" name="oldPassword" id="oldPassword" autocomplete="current-password" required />
|
||||
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
|
||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Rotating…' : "Confirm rotation"}
|
||||
</button>
|
||||
</form>
|
||||
<button class="link" data-action="toggle-manual-password">
|
||||
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
|
||||
</button>
|
||||
<button class="link" data-action="goto-dashboard">Cancel</button>
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const widgetSource = readFileSync(
|
||||
new URL("./element.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const readme = readFileSync(
|
||||
new URL("../../../README.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const vercel = JSON.parse(
|
||||
readFileSync(new URL("../../../vercel.json", import.meta.url), "utf8"),
|
||||
) as {
|
||||
headers: Array<{
|
||||
source: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
const demoPages = ["index.html", "docs.html", "account.html"].map((name) =>
|
||||
readFileSync(new URL(`../../demo/public/${name}`, import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
describe("widget security policy", () => {
|
||||
it("does not expose manual-password creation, recovery, or rotation controls", () => {
|
||||
expect(widgetSource).not.toContain("toggle-manual-password");
|
||||
expect(widgetSource).not.toContain('name="manualPassword"');
|
||||
expect(widgetSource).not.toContain("checkManualPassword");
|
||||
expect(widgetSource).not.toContain("entropyBits");
|
||||
});
|
||||
|
||||
it("renders vault integrity warnings on the management screen", () => {
|
||||
expect(widgetSource).toContain("buildVaultIntegrityWarnings(listed)");
|
||||
expect(widgetSource).toContain("this.vaultIntegrityWarnings.map");
|
||||
});
|
||||
|
||||
it("documents only same-origin self-hosting, never a remote demo embed", () => {
|
||||
expect(readme).toContain("same-origin");
|
||||
expect(readme).toContain("self-host");
|
||||
expect(readme).not.toContain(
|
||||
'src="https://bitlogin.network/vendor/bitlogin/bitlogin.js"',
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the deployment CSP and baseline browser headers", () => {
|
||||
const headers = new Map(
|
||||
vercel.headers
|
||||
.flatMap((entry) => entry.headers)
|
||||
.map((header) => [header.key, header.value]),
|
||||
);
|
||||
expect(headers.get("Content-Security-Policy")).toContain(
|
||||
"default-src 'none'",
|
||||
);
|
||||
expect(headers.get("Content-Security-Policy")).toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
for (const required of [
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Cross-Origin-Opener-Policy",
|
||||
"Cross-Origin-Resource-Policy",
|
||||
"X-Frame-Options",
|
||||
]) {
|
||||
expect(headers.has(required), `${required} must be configured`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps demo pages free of inline scripts and remote font dependencies", () => {
|
||||
for (const page of demoPages) {
|
||||
expect(page).not.toMatch(/<script(?![^>]*\bsrc=)[^>]*>/iu);
|
||||
expect(page).not.toMatch(/fonts\.(?:googleapis|gstatic)\.com/iu);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildVaultIntegrityWarnings } from "./integrityWarnings.js";
|
||||
|
||||
describe("buildVaultIntegrityWarnings", () => {
|
||||
it("surfaces rollback, quorum, truncation, and unreadable-record evidence", () => {
|
||||
const warnings = buildVaultIntegrityWarnings({
|
||||
connections: [],
|
||||
rollbackWarnings: ["opaque-connection-id"],
|
||||
unreadable: ["event-a", "event-b"],
|
||||
truncated: true,
|
||||
quorumMet: false,
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(4);
|
||||
expect(warnings.join(" ")).toContain("1 wallet record was withheld");
|
||||
expect(warnings.join(" ")).not.toContain("opaque-connection-id");
|
||||
expect(warnings.join(" ")).toContain("may be incomplete");
|
||||
expect(warnings.join(" ")).toContain("maximum record page");
|
||||
expect(warnings.join(" ")).toContain(
|
||||
"2 encrypted wallet records were unreadable",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays empty for a complete, readable result", () => {
|
||||
expect(
|
||||
buildVaultIntegrityWarnings({
|
||||
connections: [],
|
||||
rollbackWarnings: [],
|
||||
unreadable: [],
|
||||
truncated: false,
|
||||
quorumMet: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { VaultListResult } from "../worker/protocol.js";
|
||||
|
||||
/** User-facing summaries for every completeness signal returned by the vault. */
|
||||
export function buildVaultIntegrityWarnings(result: VaultListResult): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (result.rollbackWarnings.length > 0) {
|
||||
warnings.push(
|
||||
`${result.rollbackWarnings.length} wallet record${result.rollbackWarnings.length === 1 ? " was" : "s were"} withheld because relays served an older version than this device previously accepted.`,
|
||||
);
|
||||
}
|
||||
if (!result.quorumMet) {
|
||||
warnings.push(
|
||||
"Not enough vault relays answered. This connection list may be incomplete.",
|
||||
);
|
||||
}
|
||||
if (result.truncated) {
|
||||
warnings.push(
|
||||
"A relay returned the maximum record page. Some wallet connections may be missing.",
|
||||
);
|
||||
}
|
||||
if (result.unreadable.length > 0) {
|
||||
warnings.push(
|
||||
`${result.unreadable.length} encrypted wallet record${result.unreadable.length === 1 ? " was" : "s were"} unreadable and omitted.`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@bitlogin/core/vault";
|
||||
import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js";
|
||||
import { saveCachedSession, loadCachedSession, clearCachedSession } from "./sessionCache.js";
|
||||
import { SerialQueue } from "./serialQueue.js";
|
||||
import type { VaultConnectionSummary } from "./protocol.js";
|
||||
import type {
|
||||
WorkerRequest,
|
||||
@@ -85,6 +86,7 @@ let vaultRelayUrls: string[] = [...BUILTIN_VAULT_RELAYS];
|
||||
let discoveryRelayUrls: string[] = [...BUILTIN_DISCOVERY_RELAYS];
|
||||
|
||||
const store = new IndexedDbKeyValueStore();
|
||||
const requestQueue = new SerialQueue();
|
||||
|
||||
function requireUnlocked(): { signer: NostrSigner; everydayPrivateKey: Uint8Array } {
|
||||
if (!session.signer || !session.everydayPrivateKey) {
|
||||
@@ -204,13 +206,19 @@ async function listLiveConnections(): Promise<{
|
||||
vault: VaultSession;
|
||||
connections: DecryptedConnectionRecord[];
|
||||
rollbackWarnings: string[];
|
||||
unreadable: string[];
|
||||
truncated: boolean;
|
||||
quorumMet: boolean;
|
||||
}> {
|
||||
const vault = requireVault();
|
||||
const listed = await vault.listConnections({ relayUrls: vaultRelayUrls, store });
|
||||
return {
|
||||
vault,
|
||||
connections: listed.connections.filter((c) => c.record.state !== "deleted"),
|
||||
rollbackWarnings: listed.rollbackWarnings
|
||||
rollbackWarnings: listed.rollbackWarnings,
|
||||
unreadable: listed.unreadable,
|
||||
truncated: listed.truncated,
|
||||
quorumMet: listed.quorumMet
|
||||
};
|
||||
}
|
||||
|
||||
@@ -496,8 +504,8 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
}
|
||||
|
||||
case "vaultList": {
|
||||
const { connections, rollbackWarnings } = await listLiveConnections();
|
||||
return { connections: connections.map(summarize), rollbackWarnings };
|
||||
const { connections, rollbackWarnings, unreadable, truncated, quorumMet } = await listLiveConnections();
|
||||
return { connections: connections.map(summarize), rollbackWarnings, unreadable, truncated, quorumMet };
|
||||
}
|
||||
|
||||
case "vaultSaveNwc": {
|
||||
@@ -668,7 +676,7 @@ self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
return;
|
||||
}
|
||||
const { id, action, payload } = event.data;
|
||||
handle(action, payload).then(
|
||||
requestQueue.run(() => handle(action, payload)).then(
|
||||
(result) => {
|
||||
const response: WorkerResponse = { id, ok: true, result };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
|
||||
@@ -192,6 +192,12 @@ export interface VaultConnectionSummary {
|
||||
export interface VaultListResult {
|
||||
connections: VaultConnectionSummary[];
|
||||
rollbackWarnings: string[];
|
||||
/** Signed records that could not be decrypted or validated and were omitted. */
|
||||
unreadable: string[];
|
||||
/** True when at least one relay filled the fetch page, so records may be missing. */
|
||||
truncated: boolean;
|
||||
/** True when enough configured relays participated in the read. */
|
||||
quorumMet: boolean;
|
||||
}
|
||||
|
||||
export interface VaultSaveNwcPayload {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SerialQueue } from "./serialQueue.js";
|
||||
|
||||
describe("SerialQueue", () => {
|
||||
it("does not let logout be overwritten by an earlier async login", async () => {
|
||||
const queue = new SerialQueue();
|
||||
let releaseLogin!: () => void;
|
||||
const loginGate = new Promise<void>((resolve) => {
|
||||
releaseLogin = resolve;
|
||||
});
|
||||
let state = "logged-out";
|
||||
|
||||
const login = queue.run(async () => {
|
||||
await loginGate;
|
||||
state = "logged-in";
|
||||
});
|
||||
const logout = queue.run(async () => {
|
||||
state = "logged-out";
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(state).toBe("logged-out");
|
||||
releaseLogin();
|
||||
await Promise.all([login, logout]);
|
||||
expect(state).toBe("logged-out");
|
||||
});
|
||||
|
||||
it("continues after a failed request", async () => {
|
||||
const queue = new SerialQueue();
|
||||
await expect(queue.run(async () => Promise.reject(new Error("failed")))).rejects.toThrow("failed");
|
||||
await expect(queue.run(async () => "next")).resolves.toBe("next");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Runs worker RPC handlers one at a time. Session-changing calls perform relay
|
||||
* and KDF work before adopting state; without ordering, a later logout could
|
||||
* finish first and then be overwritten by the older call.
|
||||
*/
|
||||
export class SerialQueue {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(operation, operation);
|
||||
this.tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
// The demo build helper is plain ESM because the static demo has no TypeScript build.
|
||||
// @ts-expect-error no declaration file is needed for this build-only module.
|
||||
import { renderServiceWorker } from "../../../demo/swBuild.mjs";
|
||||
|
||||
describe("demo service-worker generation", () => {
|
||||
const template =
|
||||
'const CACHE_NAME = "release-__BITLOGIN_BUILD_HASH__";\nconst FILES = ["__BITLOGIN_PRECACHE_MANIFEST__"];';
|
||||
|
||||
it("changes the cache version when any release artifact changes", () => {
|
||||
const first = renderServiceWorker(template, [
|
||||
{ path: "app.js", content: Buffer.from("one") },
|
||||
]);
|
||||
const second = renderServiceWorker(template, [
|
||||
{ path: "app.js", content: Buffer.from("two") },
|
||||
]);
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).not.toContain("__BITLOGIN_");
|
||||
});
|
||||
|
||||
it("includes the complete artifact graph in deterministic order", () => {
|
||||
const output = renderServiceWorker(template, [
|
||||
{ path: "vendor/worker.js", content: Buffer.from("worker") },
|
||||
{ path: "index.html", content: Buffer.from("html") },
|
||||
]);
|
||||
expect(output.indexOf("./index.html")).toBeLessThan(
|
||||
output.indexOf("./vendor/worker.js"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps active clients on one release until the complete successor can activate", () => {
|
||||
const serviceWorker = readFileSync(
|
||||
new URL("../../../demo/public/sw.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(serviceWorker).not.toContain("self.skipWaiting(");
|
||||
expect(serviceWorker).not.toContain("self.clients.claim(");
|
||||
expect(serviceWorker).toContain("navigationForThisRelease");
|
||||
expect(serviceWorker).toContain(
|
||||
"event.respondWith(cacheFirst(event.request))",
|
||||
);
|
||||
});
|
||||
});
|
||||
+21
-1
@@ -1,4 +1,24 @@
|
||||
{
|
||||
"buildCommand": "npm run build -w @bitlogin/core && npm run build -w @bitlogin/widget && npm run build -w @bitlogin/demo",
|
||||
"outputDirectory": "packages/demo/dist"
|
||||
"outputDirectory": "packages/demo/dist",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Content-Security-Policy",
|
||||
"value": "default-src 'none'; script-src 'self'; worker-src 'self'; connect-src 'self' wss:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests"
|
||||
},
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "key": "Referrer-Policy", "value": "no-referrer" },
|
||||
{
|
||||
"key": "Permissions-Policy",
|
||||
"value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
},
|
||||
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "key": "Cross-Origin-Resource-Policy", "value": "same-origin" },
|
||||
{ "key": "X-Frame-Options", "value": "DENY" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user