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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-31 13:54:36 -04:00
1 parent efd994c4de
commit 7d2209d706
56 files changed
+3597 -1318

No files matched your search

+15
View File
@@ -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
+54
View File
@@ -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 }}
+37 -25
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+489 -101
View File
@@ -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,
);
});
+89 -34
View File
@@ -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
View File
@@ -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;
}
+156 -49
View File
@@ -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
View File
@@ -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";
+31 -11
View File
@@ -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 };
}
+253 -123
View File
@@ -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);
}
}
+124 -26
View File
@@ -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));
});
+9 -9
View File
@@ -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
+138 -59
View File
@@ -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 };
}
+31 -12
View File
@@ -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();
+25 -2
View File
@@ -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);
+47 -40
View File
@@ -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
}
+47 -5
View File
@@ -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];
}
+1
View File
@@ -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";
+345 -20
View File
@@ -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);
+107 -18
View File
@@ -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;
}
+26
View File
@@ -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);
}
+14 -6
View File
@@ -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)
+40 -1
View File
@@ -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 {
+33
View File
@@ -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();
});
});
+46 -20
View File
@@ -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);
}
+62 -27
View File
@@ -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,
};
}
}
+65 -57
View File
@@ -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,
};
}
+1 -1
View File
@@ -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). */
+230 -54
View File
@@ -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([]);
}
});
});
+160 -39
View File
@@ -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
View File
@@ -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}`);
+2 -15
View File
@@ -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>
+6
View File
@@ -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"));
+1 -1
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("./sw.js").catch(() => {});
}
+3 -10
View File
@@ -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>
+3 -10
View File
@@ -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>
+6 -5
View File
@@ -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
View File
@@ -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));
});
+17
View File
@@ -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
View File
@@ -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;
}
+12 -4
View File
@@ -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);
+6
View File
@@ -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");
});
});
+17
View File
@@ -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
View File
@@ -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" }
]
}
]
}