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

+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).