mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
The element was the only way to sign, so a host that renders its own UI, a background job, or a headless test couldn't sign without mounting <bitlogin-auth>. Add @bitlogin/widget/headless: createHeadlessSigner() → a DOM-free wrapper over the crypto worker exposing restore, getPublicKey, signEvent, nip44Encrypt/Decrypt, isUnlocked, logout, destroy. It restores the cached session and signs with no element and no window.nostr. It deliberately cannot interactively log in (that needs the password UI): restore() returns null when nothing is cached, at which point the host mounts <bitlogin-auth> once; after that the headless signer works across reloads. New bitlogin-headless.js build entry + @bitlogin/widget/headless export (also re-exported from @bitlogin/widget). Covered by headless.test.ts with a scripted worker; README gains "Signing without the widget (headless)". Addresses the "headless session-restore API" item of the Tessera integration feedback (docs/TODO.md). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
522 lines
27 KiB
Markdown
522 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 reports `detail.method` (`'bitlogin' | 'nip07' | 'nip46'`) and
|
|
`detail.capabilities`, and `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` 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, 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. 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 } | 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 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` 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: { publicKey?: string; method; capabilities }
|
|
console.log(e.detail.method, e.detail.capabilities.nip44);
|
|
});
|
|
await el?.getPublicKey(); // typed element methods
|
|
|
|
import { readCachedSession } from "@bitlogin/widget/session";
|
|
const who = await readCachedSession(); // { pubkey, npub, name? } | null
|
|
```
|
|
|
|
Exported types include `BitLoginAuthElement`, the `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.
|