Files
bitlogin/README.md
T
Cursor AgentandthePR0M3TH3AN f5b2f78da1 Add pubkey identity alias and colon-style DOM event aliases
Host identity surfaces now always expose pubkey (lowercase hex), with
publicKey kept as a deprecated alias of the same value. Every existing
hyphenated widget event also fires a bubbling, composed bitlogin:<verb>
equivalent immediately after, without changing old dispatch so a listener
on the hyphen name cannot fire twice.

Co-authored-by: thePR0M3TH3AN <[email protected]>
2026-10-08 19:14:25 +00:00

533 lines
27 KiB
Markdown

# BitLogin
login for normies.
A static, relay-backed portable account protocol: a familiar login name and
password that unlocks a portable Nostr identity, plus an independent 12-word
recovery phrase. Encrypted account material lives on public Nostr relays —
no account server, no password-reset email, no database.
This repo implements the protocol from `docs/spec.md` (v0.6) as a modular
system any static site can embed for login and Nostr signing.
## Layout
```
packages/
core/ @bitlogin/core — protocol implementation: crypto, capsules, Nostr
relay I/O, account flows. Framework- and DOM-free,
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 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,
identity export).
```
## Quick start
```bash
npm install # installs all three workspaces
npm test # runs @bitlogin/core's test suite (vitest)
npm run build # builds core, then widget
npm run dev:demo # assembles + serves the demo at :4173
```
`@bitlogin/demo`'s `npm run build` copies the built widget into
`packages/demo/dist/vendor/bitlogin/` alongside the static HTML — the result
in `packages/demo/dist/` is a complete, ready-to-host static site.
## Embedding BitLogin in your own static site
```html
<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"
>
</bitlogin-auth>
```
`bitlogin.js` and `cryptoWorker.js` (plus the shared chunk they both import)
must be deployed side by side — the main script resolves the worker's URL at
runtime relative to its own location, so this works from any path, not just
the site root.
The widget is a complete login surface, not just the password flow: behind
"More sign-in options" it also offers a NIP-07 signer extension (when one is
detected), a NIP-46 remote signer (bunker:// paste or a scannable
nostrconnect QR for phone signers like Amber), a passkey (WebAuthn PRF —
zero site setup, the passkey deterministically derives the account
credential), §SF10 key import, and phrase recovery. The `bitlogin:login`
event (hyphenated `bitlogin-login` still fires, deprecated) reports
`detail.pubkey` (lowercase hex; `detail.publicKey` is a deprecated alias
with the same value), `detail.method` (`'bitlogin' | 'nip07' | 'nip46'`),
and `detail.capabilities`. `window.bitlogin.activeMethod()` /
`activeSession()` answer session state for every method. See
`docs/login-methods.md` and `docs/passkey-login.md`.
Once a user signs in, `window.nostr` is installed with the de facto NIP-07
shape (`getPublicKey`, `signEvent`, `getRelays`, `nip44.encrypt/decrypt`), so
existing Nostr web apps work against it unmodified — routed to whichever
signer backs the session (the worker for password/passkey accounts, the
worker-held NIP-46 client for remote-signer sessions; NIP-07 sessions leave
the slot to the extension that owns it). Private keys are held in
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
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
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
shipped. See `packages/demo/public/docs.html` for the full integration
guide, and `packages/demo/public/account.html` for a working example
(create → confirm recovery phrase → sign an event → rotate password →
export identity).
**Sessions persist across a page reload.** After a successful sign-in, the
everyday private key is cached in IndexedDB for that origin, so a later
reload restores the session and fires `bitlogin:login` (and the
deprecated `bitlogin-login`) automatically — your existing listener
doesn't need to distinguish "just typed" from "silently restored." This
trades "nothing persists locally" for "the
decrypted key sits in this browser's IndexedDB until logout," the same
tradeoff a NIP-07 extension already makes. Calling `logout()` (or the
element's dashboard "Log out" button) clears it.
**Reading "who's signed in" without loading the widget.** A multi-page site
often wants a persistent "Signed in as …" indicator in the header on *every*
page, but only needs the full widget on the pages where a user actually signs
in or manages their account. Loading `bitlogin.js` (plus the worker and wasm)
on every page just to read the pubkey is a lot of bytes for a label. The
`@bitlogin/widget/session` entry is a ~1 KB, dependency-free reader for exactly
this:
```html
<script type="module">
import { readCachedSession } from "/vendor/bitlogin/bitlogin-session.js";
const who = await readCachedSession(); // { pubkey, publicKey, npub, name? } | null
if (who) header.textContent = `Signed in as ${who.name ?? who.npub.slice(0, 12)}…`;
</script>
```
It reads a cleartext, **public-only** record (pubkey / npub / optional display
name — never key material) that the widget writes beside its encrypted session
and clears on logout, so it can't report a session that has ended. The returned
object also includes `publicKey` as a deprecated alias of `pubkey`. It cannot
sign: when the user clicks "sign in" or "manage account," load the full
`<bitlogin-auth>` then and defer the heavy cost until it's needed. The same
call is also on the full widget as `window.bitlogin.readCachedSession()`, so
pages that already load `bitlogin.js` can use it without a second import.
Deploy `bitlogin-session.js` alongside `bitlogin.js`.
**Signing without the widget (headless).** `readCachedSession` reads identity
but can't sign. When a host needs to *sign* programmatically — a background job,
a framework rendering its own account UI, a headless test — and doesn't want the
custom element, use the headless signer. It runs the same crypto worker without
any DOM:
```javascript
import { createHeadlessSigner } from "@bitlogin/widget/headless";
const signer = await createHeadlessSigner();
const who = await signer.restore(); // { pubkey, publicKey } | null (null → no cached session)
if (who) {
const event = await signer.signEvent({ kind: 1, content: "gm", tags: [] });
const pubkey = await signer.getPublicKey();
// also: nip44Encrypt / nip44Decrypt / isUnlocked / logout / destroy
}
```
It deliberately can't *log in* (that needs the password UI): `restore()` returns
null when no session is cached, at which point mount `<bitlogin-auth>` once so
the user signs in — after that the headless signer works across reloads from the
cached session. Deploy `bitlogin-headless.js` next to `cryptoWorker.js` and the
shared chunks, same as `bitlogin.js`.
The `<bitlogin-auth>` element also mirrors `window.nostr` directly on the
element instance (`getPublicKey`, `signEvent`, `nip44Encrypt`, `nip44Decrypt`)
— prefer this over `window.nostr` when your host page holds a reference to
its own widget instance, since `window.nostr` is a single global slot that
whichever provider signed in last currently owns.
**Wallet connections (Connection Vault, reveal mode).** An app can ask the
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",
});
if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
```
The widget owns the whole ceremony: sign-in first if needed, a one-tap
approval when the account already has a connection bound to this origin
(that's the new-phone moment the vault exists for), or a guided import —
Bitcoin Connect's wallet chooser (loaded lazily; it never downloads unless
the flow opens) or a pasted URI — stored as an encrypted vault record and
restored on every device. Stated plainly: this is **reveal mode**. The
requesting origin receives the full bearer credential and everything its
wallet-side budget allows; an embedded same-origin widget cannot broker
(`docs/connection-vault.md` §12.3), so it does not pretend to. Encourage
users toward budget-capped connections — the wallet's own budget is the real
security boundary. Users manage and revoke grants from the dashboard's
"Wallet connections" screen. A `bitlogin:connection-granted` event (and the
deprecated `bitlogin-connection-granted`) fires on the element after each
share. Hosts using this flow need one extra CSP line,
`style-src-attr 'unsafe-inline'` (Bitcoin Connect's components set inline
style attributes; a hash cannot cover those).
**Integration gotcha:** the element's `disconnectedCallback` terminates its
crypto Web Worker (that's the correct cleanup when the element is genuinely
being removed). If your host app re-renders its own DOM by resetting an
ancestor's `innerHTML` — a common pattern in hand-rolled UI without a
framework's reconciler — and the widget happens to be a descendant of that
subtree, this **kills the in-progress session**: `getPublicKey()` that
already resolved before the render still looks fine, but every call after
that fails. As of the unmount guardrail, that failure is now loud rather
than silent — the element logs a `console.error` on disconnect-while-active,
and every later `signEvent`/`getPublicKey` **rejects** with a message naming
the unmount as the cause, instead of hanging forever. It's still a broken
session, though; the fix below is unchanged.
Mount `<bitlogin-auth>` into a container that your own re-renders never
replace (toggle visibility with the `hidden` attribute instead of removing
it from the DOM), not into a template string that gets thrown away and
rebuilt. See `packages/demo/public/account.html` for a working single-page
example. The BitRoad project (a sibling Nostr commerce app that embeds
BitLogin as a sign-in option) hit exactly this bug during integration and
fixed it by giving the element a permanent, never-replaced mount point in
its static shell HTML, only toggling `hidden` — see its
`src/nostr/bitloginAdapter.mjs` and the `#bitloginMount` container in its
`index.html` for a worked example of the pattern.
**Moving the element is not a safe workaround, either.** A same-document
move — `someContainer.appendChild(mount)`, `slot.replaceWith(mount)`, or
similar, to relocate a statically-declared mount point into a
dynamically-rendered slot — still fires `disconnectedCallback` (removal)
and then `connectedCallback` (insertion) on `<bitlogin-auth>`, exactly like
a genuine remove-and-recreate. The worker still gets terminated, and
because `connectedCallback` doesn't construct a new `WorkerClient` (that
only happens once, in the constructor), the element is left reusing a
now-dead worker: every subsequent message — `configure`, `login`,
everything — fails, since `postMessage` on a terminated `Worker` is a
no-op. The client now marks itself torn-down on `terminate()`, so those
calls **reject immediately** with a clear teardown message (and the removal
logged a `console.error`) rather than hanging forever with no error — but
the element is still stuck on a dead worker, so this is a diagnosis, not a
recovery. BitUnlock hit exactly this while trying
to work around the innerHTML gotcha above: it kept `#bitloginMount` static
but used `replaceWith()` to relocate it into a per-step placeholder,
believing a move (vs. removal) would spare the worker. It doesn't. The fix
is the same as above — never move it either; keep it in one fixed DOM
position for its entire lifetime and let CSS/layout put other content
around it.
**Required CSP allowance:** password hashing (Argon2id, via `hash-wasm`)
compiles a WebAssembly module inside the crypto worker. A `script-src` that
doesn't include `'wasm-unsafe-eval'` (or the broader `'unsafe-eval'`) blocks
`WebAssembly.compile()` outright — every action that touches a password
(`register`, `login`, `recover`, `changePassword`) fails with a CSP error.
If your site sets its own `script-src`, add `'wasm-unsafe-eval'` to it:
```
script-src 'self' 'wasm-unsafe-eval';
```
This is easy to miss because the failure is scoped to the crypto worker —
the rest of the page (and even a plain main-thread `WebAssembly.compile()`
check) can work fine while every BitLogin auth action still fails.
**Another integration gotcha, now fixed:** some NIP-07 browser extensions
install `window.nostr` as a non-configurable, non-writable property
specifically to stop another script from overwriting it. `claimSigner()`
(called automatically after every sign-in) used to do a plain
`window.nostr = ...` assignment with no guard, so on a page where such an
extension is active, that line threw `TypeError: Cannot assign to read only
property 'nostr' of object '#<Window>'` — and since it ran synchronously
before the widget dispatched `bitlogin:login` / `bitlogin-login` or moved to the dashboard
screen, this **froze account creation/import at the final "verify your
recovery phrase" step**, with that raw TypeError as the only (confusing)
feedback. `claimSigner()` now catches this, returns `false` instead of
throwing, and every caller proceeds to complete sign-in regardless — a host
page using this element's own methods (as documented above) was never
actually affected by the failed `window.nostr` takeover anyway. The widget's
own dashboard now also shows a plain-language warning in this case ("Another
Nostr signer (browser extension) is active in this browser and couldn't be
replaced...") using the same warning banner already shown for rollback and
relay-disagreement conditions.
### TypeScript
The package ships type declarations (`@bitlogin/widget` and
`@bitlogin/widget/session`), generated from source so they can't drift.
Registering the widget augments the DOM types, so integrators get real
autocomplete instead of reading the demo:
```ts
import "@bitlogin/widget"; // registers <bitlogin-auth> and its types
const el = document.querySelector("bitlogin-auth"); // typed as BitLoginAuthElement
el?.addEventListener("bitlogin:login", (e) => {
// e.detail is typed: { pubkey?: string; publicKey?: string; method; capabilities }
console.log(e.detail.pubkey, e.detail.method, e.detail.capabilities.nip44);
});
await el?.getPublicKey(); // typed element methods
import { readCachedSession } from "@bitlogin/widget/session";
const who = await readCachedSession(); // { pubkey, publicKey, npub, name? } | null
```
Prefer the colon event names (`bitlogin:login`, `bitlogin:logout`, …): they
bubble and compose across the widget's shadow root, so a host can listen on
`document`. The original hyphenated names (`bitlogin-login`, …) still fire
unchanged (non-bubbling) and are marked `@deprecated`. `publicKey` is a
deprecated alias of `pubkey` with the identical lowercase-hex value.
Exported types include `BitLoginAuthElement`, the `bitlogin-*` /
`bitlogin:*` `...EventDetail` shapes and `BitLoginEventMap`,
`PublicSessionInfo`, `SignerMethod`/`SignerCapabilities`, `BitLoginConfig`,
and the `window.bitlogin` (`BitLoginGlobal`) interface. Run `npm run build`
(or `build:types`) to emit them into `dist-types/`.
### Theming
`<bitlogin-auth>` renders in a shadow root, but its colors, radius, font, and
max-width are all read from CSS custom properties on `:host` — and custom
properties inherit through the shadow boundary, so a host page can override
every one of them from ordinary light-DOM CSS (a rule targeting the element,
or an inline style), without touching the widget's internals:
```css
bitlogin-auth {
--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.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;
override to fill a wider container */
}
```
All of these have defaults (a light theme, with a `prefers-color-scheme:
dark` media-query fallback and a `data-theme="dark"|"light"` attribute
override for host pages with their own theme toggle), so overriding none of
them is a valid choice — the widget just uses its own look. See BitRoad's
`bitlogin-auth { ... }` rule in `src/styles.css` for a real integration that
maps every one of these to its own design tokens.
**Structural theming with `::part()`.** Custom properties cover colors,
radius, font, and width — reach for them first. When you need to restyle
_structure_ the tokens don't expose (padding, borders, box-shadow, a
different button shape), the widget exposes a stable set of shadow parts you
can target from light-DOM CSS:
```css
bitlogin-auth::part(card) { box-shadow: 0 8px 30px rgba(0,0,0,.12); border: 1px solid #e5e7eb; }
bitlogin-auth::part(button) { border-radius: 8px; font-weight: 600; }
bitlogin-auth::part(button-primary) { text-transform: uppercase; letter-spacing: .02em; }
bitlogin-auth::part(input) { border-width: 2px; }
bitlogin-auth::part(notice-warn) { border-left: 3px solid currentColor; }
```
The exposed parts (kept stable as a contract):
| Part | Element |
| --- | --- |
| `card` | the outer widget panel |
| `button`, `button-primary`, `button-secondary` | action buttons (both variants also carry the generic `button`) |
| `link` | link-style buttons and inline links |
| `input`, `checkbox` | form fields |
| `heading` | screen headings |
| `text` | secondary / hint text |
| `notice`, `notice-warn`, `notice-info`, `notice-error` | inline message boxes (variants also carry the generic `notice`) |
Parts are an escape hatch for matching a host design system without the
widget looking pasted in; they don't expose the widget's internal layout or
let a host reach past these named anchors.
## Deployment
The demo site deploys to Vercel: `vercel.json` at the repo root sets the
build command (core → widget → demo) and output directory, so importing this
repo at [vercel.com/new](https://vercel.com/new) needs no manual
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.
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="/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.
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
Vercel doesn't manage DNS for a domain unless its nameservers are delegated
to Vercel; with third-party DNS (this project's domains sit on Cloudflare
nameservers, registrar-agnostic), add these records at whichever provider
actually serves the zone:
```text
A bitlogin.network 76.76.21.21
A www.bitlogin.network 76.76.21.21
```
Run `vercel domains inspect bitlogin.network` (from a machine with the
Vercel CLI linked to this project) to confirm the exact records currently
expected — Vercel occasionally changes its recommended anycast IP, and that
command always reflects the live requirement rather than whatever's written
here. If the DNS host proxies through something like Cloudflare, set the
record to DNS-only (grey cloud) rather than proxied — a proxying layer in
front of Vercel's own edge can interfere with certificate issuance and
routing.
## Updating your integration
There's no npm package or auto-updating CDN release yet (see "Deployment"
above) — each site vendors its own copy of the built widget, so **updates to
this repo do not automatically reach a site that already integrated
BitLogin.** This is deliberate, not an oversight: a shared script every site
loads live would mean a single bad or compromised deploy here breaks (or
worse, compromises) every consuming site at once, with no per-site review
window. Since this widget handles private key crypto, that trade is worth
the manual step.
Check `CHANGELOG.md` periodically (or whenever a site reports an auth issue)
for entries relevant to your integration — each one says whether it applies
to sites using the default built-in relay list vs. a custom one, and similar
integration-relevant conditions. To pull in a fix or update:
```bash
# from your site's repo, with a sibling ../bitlogin checkout pulled to the
# commit you want (git log / CHANGELOG.md to pick one)
node scripts/build-bitlogin-widget.mjs
# or, if bitlogin isn't checked out as a sibling directory:
BITLOGIN_ENTRY=/path/to/bitlogin node scripts/build-bitlogin-widget.mjs
```
This rebuilds `@bitlogin/core`/`@bitlogin/widget` if needed and copies the
built `bitlogin.js`/`cryptoWorker.js`/shared-chunk output verbatim into your
site's `vendor/bitlogin/` (or `src/vendor/bitlogin/`), alongside a
`vendor/bitlogin.integrity.json` recording the exact file hashes vendored.
Diff that file before and after to confirm what actually changed, then
commit, test, and deploy through your site's normal process — the whole
point of vendoring is that this is a reviewed step, not an automatic one.
## What's implemented
This build covers the protocol's Phase 0 (cryptographic core + test vectors)
and the load-bearing parts of Phase 1 (account MVP), verified two ways:
- **Protocol-level tests** (`npm test`, 120 tests): Argon2id/ScalarExpand/JCS/
padding/timestamp/NIP-44 unit tests with the spec's exact byte layouts
(§11), and full end-to-end scenarios — create → clean-device login, phrase
recovery → new credentials → clean-device login, password change with
mandatory tombstone + NIP-09 deletion, relay-loss → replica repair
(including keyless recovery-event rebroadcast), generation-rollback
detection failing closed by default rather than only warning (§16.2 step
6), initial profile/relay-list publication never overwriting an existing
kind 0/10002/10050 event when the everyday identity is an imported nsec
(§15.8, §28.1), and registration/password-rotation refusing to overwrite
another account already registered at the same locator address (§15.6,
§18.1) — run against an in-memory mock relay
(`packages/core/src/test-support/mockRelay.ts`), since this sandbox has no
route to public Nostr relays. NIP-44 has additionally been cross-tested
directly against the `nostr-tools` reference implementation (conversation
keys, padding at every bucket boundary, and round trips in both directions)
during the BitRoad integration — see "Changes from v0.3" in `docs/spec.md`.
- **Real-browser verification**: the same flows were driven end-to-end in
actual Chromium against the built widget, confirming the crypto worker,
IndexedDB storage, and `window.nostr` provider work together outside the
test harness.
Implemented: registration (§15), any-device password login with rollback and
relay-disagreement detection (§16), phrase recovery with recovery-capsule
refresh (§17), known-password change with mandatory tombstone + NIP-09
deletion (§18), replica repair (§24.4), recovery export (§19.5), NIP-19
nsec/npub encoding, importing an existing Nostr `nsec` as the everyday
identity (§28.1, §SF10 — with a preview-before-commit UI step), a NIP-44 v2
implementation for the `window.nostr.nip44` surface, bootstrap/discovery
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). (NIP-07 and NIP-46
sign-in, anticipated by §26.4, shipped 2026-08-04 — see
`docs/login-methods.md`; NIP-46 credential *persistence* stays gated on
the external vault review.)
- NIP-49 `ncryptsec` export (§28.2) — deliberately left out rather than
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 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).
## Development notes
- Crypto primitives use audited libraries: `@noble/curves` (secp256k1
Schnorr), `@noble/hashes` (SHA-256, HKDF, HMAC), `@noble/ciphers`
(ChaCha20 for NIP-44), `@scure/bip39` (BIP-39), native WebCrypto
(AES-256-GCM), and `hash-wasm` (Argon2id).
- The Argon2id profile (`bitlogin-argon2id-v1`) uses parallelism 1 rather
than RFC 9106's recommended 4, for reasons documented in
`packages/core/src/crypto/argon2id.ts` (§11.2 of the spec).
- Passphrases are generated from the EFF long Diceware wordlist
(`diceware-wordlist-en-eff`), matching the spec's ~12.9-bits/word,
six-word/~77-bit default (§9.2).
## Contributing
MIT licensed — see `LICENSE`. See `CONTRIBUTING.md` for the dev workflow,
mock-relay testing (no route to public relays in most sandboxes/CI), and
the spec-first process for anything touching wire format or crypto. Found
a security issue? See `SECURITY.md` rather than opening a public issue.