Fail closed on credential-generation rollback; fix NIP-44 extended-length payloads

Found during a real integration pass (embedding this widget in BitRoad, a
separate Nostr commerce app):

- Password rotation's old-locator tombstone (§18.1) is only advisory -- a
  relay that never processes it can keep serving the pre-rotation capsule
  indefinitely. loginWithPassword/changePassword previously accepted that
  stale capsule with only a non-blocking warning. A device that has already
  seen the newer generation now refuses it (RollbackDetectedError) unless the
  caller explicitly passes acknowledgeRollback: true for the narrower
  relay-lag case. The widget surfaces this as a blocking confirmation screen
  before claiming the signer, not a banner shown after the fact.
- NIP-44 capped plaintext at 65535 bytes; added the extended-length prefix
  for payloads up to 2^32-1 bytes, verified byte-for-byte against the
  nostr-tools reference implementation (conversation keys, padding at every
  bucket boundary, round trips in both directions).
- <bitlogin-auth> now exposes nip44Encrypt/nip44Decrypt directly on the
  element instance, alongside the existing getPublicKey/signEvent, so a host
  page can reach every capability without racing other providers for the
  single window.nostr slot.

docs/spec.md bumped to v0.4 with the full writeup (§16.2, §29.7). 75/75
tests passing (14 new NIP-44 tests, 2 new rollback e2e tests).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Sonnet 5 committed 2026-07-21 14:36:35 -04:00
1 parent e17885d0e5
commit 1dc5e6802c
13 files changed
+430 -61

No files matched your search

+65 -9
View File
@@ -65,6 +65,31 @@ guide, and `packages/demo/public/account.html` for a working example
(create → confirm recovery phrase → sign an event → rotate password →
export identity).
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.
**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 **silently kills the in-progress session**: `getPublicKey()`
already resolved before the render still looks fine, but every call after
that hangs forever, because the worker is gone and nothing ever replies.
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.
## Deployment
The demo site deploys to Vercel: `vercel.json` at the repo root sets the
@@ -79,28 +104,59 @@ assets at `/vendor/bitlogin/`, **any other site can point directly at the
Vercel deployment today** without installing anything locally:
```html
<script type="module" src="https://bitlogin.vercel.app/vendor/bitlogin/bitlogin.js"></script>
<script type="module" src="https://bitlogin.network/vendor/bitlogin/bitlogin.js"></script>
<bitlogin-auth></bitlogin-auth>
```
`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.
### 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.
## 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`, 49 tests): Argon2id/ScalarExpand/JCS/
padding/timestamp 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), rollback-warning and
relay-disagreement detection — run against an in-memory mock relay
- **Protocol-level tests** (`npm test`, 75 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), and generation-rollback
detection now failing closed by default rather than only warning (§16.2
step 6) — run against an in-memory mock relay
(`packages/core/src/test-support/mockRelay.ts`), since this sandbox has no
route to public Nostr relays.
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
+11
View File
@@ -37,3 +37,14 @@ status (protocol-level test suite plus manual real-browser verification).
Treat that as part of the threat model when reporting: we'd rather hear
about a real issue than have it wait for a formal audit that hasn't happened
yet.
An external integration pass (embedding this widget in a separate Nostr
commerce app) found and fixed one real account-takeover-adjacent gap:
password rotation's old-locator tombstone (§18.1) is only advisory — a relay
that never processes it can keep serving the pre-rotation capsule
indefinitely — and the client accepted that stale capsule with only a
non-blocking warning rather than refusing it. Devices that have previously
logged in to the account (and so hold a local generation high-water mark)
now fail closed on this instead; see "Changes from v0.3" in `docs/spec.md`
and §16.2/§29.7 for the full writeup, including the residual case this does
not cover (a brand-new device with no local high-water mark).
+16 -6
View File
@@ -2,9 +2,9 @@
## Static, Relay-Backed Portable Account Protocol
**Document version:** 0.3
**Document version:** 0.4
**Status:** Revised MVP specification (incorporates external protocol review)
**Date:** July 20, 2026
**Date:** July 21, 2026
**Client architecture:** Static progressive web application
**Storage architecture:** Nostr relays
**Canonical identity:** Nostr public key
@@ -14,6 +14,14 @@
---
## Changes from v0.3
1. **Rollback detection is now enforced, not just displayed.** §16.2 step 6 previously surfaced a regression against the local generation high-water mark as a warning string while still granting the session — meaning an old, rotated-away password replayed from a relay that never processed its tombstone (§18.1) could fully unlock the account on any device that happened to query that relay. The client now fails closed by default in this case (`RollbackDetectedError`), on both password login and password change; a caller may explicitly override this (`acknowledgeRollback`) for the narrower, non-adversarial case of a single relay merely lagging behind on the *same* password's own capsule. This closes the gap between §16.2's stated guarantee ("a single relay withholding the newest capsule cannot silently roll the account back on a device that has seen the newer generation") and what the reference implementation actually did. The brand-new-device weak case described in §16.2 is unchanged by this — a device with no prior high-water mark still has only the quorum requirement.
2. **NIP-44 extended-length payloads.** The `window.nostr.nip44` surface previously capped plaintext at 65535 bytes; it now implements the full extended-length prefix for payloads up to 2^32-1 bytes, matching the reference `nostr-tools` implementation byte-for-byte (verified by direct cross-implementation round-trip testing, not just unit tests against this codebase's own encoder/decoder pair).
3. **Element-scoped NIP-44 methods.** `<bitlogin-auth>` now exposes `nip44Encrypt`/`nip44Decrypt` directly on the element instance, alongside the existing `getPublicKey`/`signEvent`. A host page holding a reference to its own widget instance can now reach every signing/encryption capability without going through the single shared `window.nostr` slot, which another provider may have since claimed. (The current `window.nostr` shim and this element-scoped mirror are a widget-level convenience layered on top of this protocol, not part of the core numbered spec; see the README's "Embedding BitLogin" section for the full API.)
---
## Changes from v0.2
1. **Padding redefined against relay limits.** Buckets are now specified against the final encoded event size (roughly 2/3.5/6 KiB events via 1024/2048/4096-byte plaintext buckets), and clients must inspect NIP-11 `max_content_length` / `max_message_length` before selecting vault relays. The claim that padding hides capsule *type* is removed — the public `d` tags already distinguish the capsules; padding hides contents and growth only (§11.8).
@@ -1069,11 +1077,11 @@ The client:
3. Groups valid events by generation.
4. Attempts decryption from newest to oldest.
5. Accepts the highest valid generation.
6. Compares the accepted generation against the device's stored high-water mark for this account, if any. A regression triggers a prominent warning: either relays are serving stale data or an old capsule is being replayed.
6. Compares the accepted generation against the device's stored high-water mark for this account, if any. A regression fails closed by default (§29.7): the client refuses to grant a session rather than merely warning, since it cannot distinguish an old, rotated-away password being replayed from a relay that never processed its tombstone (§18.1) from a relay that is simply lagging behind. A caller may explicitly override this (`acknowledgeRollback`) for the latter case; the client must still surface the same warning to the human either way.
7. Updates the local high-water mark on success.
8. Warns if responsive relays disagree about the latest generation.
A single relay withholding the newest capsule therefore cannot silently roll the account back on a device that has seen the newer generation. A brand-new device has only the quorum requirement, which is a weaker defense: if a majority of responsive relays are stale, censored, or malicious, a new device cannot detect the rollback. This is a fundamental limit of trust-on-first-use in a static system, and the interface must not overstate the protection.
A single relay withholding the newest capsule therefore cannot silently roll the account back on a device that has seen the newer generation — step 6 is now an enforced refusal, not just a display. A brand-new device has only the quorum requirement, which is a weaker defense: if a majority of responsive relays are stale, censored, or malicious, a new device cannot detect the rollback. This is a fundamental limit of trust-on-first-use in a static system, and the interface must not overstate the protection.
## 16.3 Failure behavior
@@ -1574,7 +1582,7 @@ Mitigations: one-time phrase display, secure physical backup, no cloud clipboard
Consequences: everyday identity disclosure from any retained capsule, historical and future impersonation, potential message disclosure.
Changing the password does not invalidate retained old ciphertext (§18.3). The attacker gains no signing authority over the recovery capsule, though they can replay an old embedded recovery event to stale or empty relays (§14.2); within the availability limits stated there, the phrase-recovery and identity-rotation path (§18.4) remains open to the victim.
Changing the password does not invalidate retained old ciphertext (§18.3). The attacker gains no signing authority over the recovery capsule, though they can replay an old embedded recovery event to stale or empty relays (§14.2); within the availability limits stated there, the phrase-recovery and identity-rotation path (§18.4) remains open to the victim. On any device that has previously logged in to the account since the rotation (and so holds a local generation high-water mark), §16.2 step 6 now refuses such a replayed old capsule outright rather than merely warning — the residual exposure is a brand-new device with no local high-water mark, which retains only the weaker quorum defense described in §16.2.
## 29.8 Device malware
@@ -1729,7 +1737,7 @@ The MVP is complete when:
8. Phrase recovery refreshes the recovery capsule and re-embeds it in the new credential capsule.
9. A known-password change tombstones the old locator and issues a NIP-09 deletion request.
10. Incorrect credentials reveal no useful distinction between account absence and decryption failure.
11. The client rejects invalid signatures, corrupted capsules, and generation rollback below the local high-water mark (with warning).
11. The client rejects invalid signatures, corrupted capsules, and generation rollback below the local high-water mark (fails closed by default; an explicit override exists for the non-adversarial relay-lag case, §16.2).
12. Sensitive values have the shortest practical lifetime in DOM and JavaScript string form, are transferred immediately to the cryptographic worker, are never persisted, logged, or serialized, and are never sent to a server or relay in plaintext.
13. Capsule operations and everyday-identity operations never share a relay connection.
14. The client can publish a Nostr profile and kinds 10002/10050.
@@ -1751,6 +1759,8 @@ Resolved since v0.1: manual passwords (prohibited in alpha, §9.3); default cred
Resolved since v0.2: padding buckets and relay-limit compliance (§11.8); replacement-timestamp semantics (§24.6); Argon2 profile labeling and six-word default (§9.2, §11.2); credential-capsule minimization (§12.2); discovery relays (§19.6); recovery hash chain (§12.3).
Resolved since v0.3: generation-rollback detection now enforced (fail-closed) rather than warn-only (§16.2); NIP-44 extended-length payload support, verified against the `nostr-tools` reference implementation; element-scoped `nip44Encrypt`/`nip44Decrypt` on `<bitlogin-auth>` (see README).
Still open before implementation:
1. Which bootstrap relays ship with the client, and who holds the maintainer relay-list key (single key, or threshold)?
+5 -1
View File
@@ -7,6 +7,7 @@
"": {
"name": "bitlogin-monorepo",
"version": "0.1.0",
"license": "MIT",
"workspaces": [
"packages/core",
"packages/widget",
@@ -1748,6 +1749,7 @@
"packages/core": {
"name": "@bitlogin/core",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^1.9.1",
@@ -1768,11 +1770,13 @@
},
"packages/demo": {
"name": "@bitlogin/demo",
"version": "0.1.0"
"version": "0.1.0",
"license": "MIT"
},
"packages/widget": {
"name": "@bitlogin/widget",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@bitlogin/core": "*"
},
+50 -3
View File
@@ -12,7 +12,7 @@ import { repairReplicas } from "./repair.js";
import { InMemoryKeyValueStore } from "../storage/interface.js";
import { raiseHighWaterMark } from "./highWaterMark.js";
import { RelayPool } from "../nostr/pool.js";
import { AccountNotFoundError } from "./errors.js";
import { AccountNotFoundError, RollbackDetectedError } from "./errors.js";
import { derivePasswordKeys, normalizeLoginName } from "./normalize.js";
import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js";
import { RelayConnection } from "../nostr/relay.js";
@@ -278,7 +278,7 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
);
it(
"a device that has seen a higher generation warns on rollback to a lower one (§16.2 step 6)",
"a device that has seen a higher generation refuses a lower one by default, unless explicitly acknowledged (§16.2 step 6)",
async () => {
const loginName = "rollback";
const password = generatePassphrase().secret;
@@ -288,13 +288,60 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => {
// Simulate this device having previously observed a higher generation for this account.
await raiseHighWaterMark(store, registration.everydayPublicKey, { generation: 5 });
const login = await loginWithPassword({ loginName, password, vaultRelayUrls, store });
// 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);
// An explicit override still allows the (still-warned) login through.
const login = await loginWithPassword({ loginName, password, vaultRelayUrls, store, acknowledgeRollback: true });
expect(login.generation).toBe(0);
expect(login.rollbackWarning).toBeDefined();
},
ARGON2_TIMEOUT
);
it(
"old password rotated away on another device still fails closed even when a holdout relay never received the tombstone (§16.2 step 6, §18.1)",
async () => {
const loginName = "holdout";
const oldPassword = generatePassphrase().secret;
const newPassword = generatePassphrase().secret;
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 });
// 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 });
expect(afterRotationLogin.generation).toBe(1);
// The same device is now asked to log in with the OLD password, querying ONLY the
// holdout relay that still has the pre-rotation capsule. Before the fix, this granted a
// 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 })
).rejects.toThrow(RollbackDetectedError);
// The explicit escape hatch still exists for a human who is confident this is relay lag.
const acknowledged = await loginWithPassword({
loginName,
password: oldPassword,
vaultRelayUrls: [relays[2]!.url],
store,
acknowledgeRollback: true
});
expect(acknowledged.generation).toBe(0);
expect(acknowledged.rollbackWarning).toBeDefined();
},
ARGON2_TIMEOUT * 2
);
it(
"relays disagreeing about the latest capsule for the same address are detected (§16.2 step 8)",
async () => {
+15 -3
View File
@@ -10,8 +10,8 @@ import { buildDeletionRequest } from "../nostr/nip09.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 { AccountNotFoundError, RegistrationFailedError } from "./errors.js";
import { raiseHighWaterMark } from "./highWaterMark.js";
import { AccountNotFoundError, RegistrationFailedError, RollbackDetectedError } from "./errors.js";
import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js";
import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js";
import type { NostrEvent } from "../nostr/event.js";
@@ -24,6 +24,8 @@ export interface ChangePasswordParams {
minAcknowledgements?: number;
timeoutMs?: number;
now?: number;
/** See {@link RollbackDetectedError} and the equivalent option on `LoginParams` (§16.2 step 6). */
acknowledgeRollback?: boolean;
}
export interface ChangePasswordResult {
@@ -63,6 +65,17 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
const oldPayload = readResult.best.payload!;
const oldEvent = readResult.best.event;
// §16.2 step 6: the "old password" supplied here derives its own locator address, distinct
// from any other password's -- if this device has already seen a newer generation than what
// that address reports, the safe assumption is that this password itself is stale (already
// rotated away elsewhere) and a relay is simply replaying its last-known capsule, not that
// this is a legitimate in-progress rotation.
const store = params.store ?? new InMemoryKeyValueStore();
const hwm = await getHighWaterMark(store, oldPayload.operational_public_key);
if (oldPayload.generation < hwm.generation && !params.acknowledgeRollback) {
throw new RollbackDetectedError(hwm.generation, oldPayload.generation);
}
const newKeys = await derivePasswordKeys(params.newPassword, normalizedLoginName);
const newLocatorPublicKey = getPublicKeyHex(newKeys.locatorPrivateKey);
const newGeneration = oldPayload.generation + 1;
@@ -122,7 +135,6 @@ export async function changePassword(params: ChangePasswordParams): Promise<Chan
);
}
const store = params.store ?? new InMemoryKeyValueStore();
await raiseHighWaterMark(store, oldPayload.operational_public_key, { generation: newGeneration });
return {
+25
View File
@@ -24,3 +24,28 @@ export class RecoveryFailedError extends Error {
this.name = "RecoveryFailedError";
}
}
/**
* Thrown instead of a passive warning when a capsule's generation is lower than
* this device's local high-water mark (§16.2 step 6). A relay-based system cannot
* guarantee deletion of an old capsule, so an old (rotated-away) password may still
* decrypt a stale replica on a relay that never processed the tombstone. On a device
* that has already observed the newer generation, this is the one case the client
* *can* enforce -- so it fails closed by default instead of silently granting a
* session. Callers that are confident this is relay lag rather than a stale/old
* credential (not a distinction the client can make on its own) may pass
* `acknowledgeRollback: true` to proceed anyway.
*/
export class RollbackDetectedError extends Error {
readonly seenGeneration: number;
readonly capsuleGeneration: number;
constructor(seenGeneration: number, capsuleGeneration: number) {
super(
`This credential capsule reports generation ${capsuleGeneration}, but this device has already seen generation ${seenGeneration}. Refusing to log in with older, possibly-revoked credentials.`
);
this.name = "RollbackDetectedError";
this.seenGeneration = seenGeneration;
this.capsuleGeneration = capsuleGeneration;
}
}
+17 -5
View File
@@ -4,7 +4,7 @@ import { base64urlToBytes } from "../crypto/encoding.js";
import { derivePasswordKeys, normalizeLoginName } from "./normalize.js";
import { RelayPool } from "../nostr/pool.js";
import { readCredentialCapsule } from "./capsuleReader.js";
import { AccountNotFoundError } from "./errors.js";
import { AccountNotFoundError, RollbackDetectedError } from "./errors.js";
import { getHighWaterMark, raiseHighWaterMark } from "./highWaterMark.js";
import { InMemoryKeyValueStore, type KeyValueStore } from "../storage/interface.js";
import type { NostrEvent } from "../nostr/event.js";
@@ -15,6 +15,15 @@ export interface LoginParams {
vaultRelayUrls: string[];
store?: KeyValueStore;
timeoutMs?: number;
/**
* Proceed even if this device has previously observed a higher credential
* generation than the one this capsule reports (§16.2 step 6). Off by default:
* without it, that condition throws {@link RollbackDetectedError} instead of
* granting a session, since the client cannot distinguish "an old, rotated-away
* password replayed from a stale relay" from "this relay is merely lagging" --
* failing closed is the safe default for the former, far more consequential case.
*/
acknowledgeRollback?: boolean;
}
export interface LoginResult {
@@ -56,10 +65,13 @@ export async function loginWithPassword(params: LoginParams): Promise<LoginResul
const store = params.store ?? new InMemoryKeyValueStore();
const hwm = await getHighWaterMark(store, payload.operational_public_key);
const rollbackWarning =
payload.generation < hwm.generation
? `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;
const isRollback = payload.generation < hwm.generation;
if (isRollback && !params.acknowledgeRollback) {
throw new RollbackDetectedError(hwm.generation, payload.generation);
}
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 });
const relayDisagreementWarning = result.relayDisagreement
+50
View File
@@ -8,6 +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";
describe("ScalarExpand (§11.4 test vector)", () => {
it("derives a stable, valid scalar for fixed input on counter 0", () => {
@@ -181,6 +182,55 @@ describe("AES-256-GCM (§11.7)", () => {
});
});
describe("NIP-44 v2 (window.nostr.nip44 provider surface)", () => {
it("derives the same conversation key from either side of the ECDH exchange", () => {
const skA = generatePrivateKey();
const skB = generatePrivateKey();
const pubA = getPublicKeyHex(skA);
const pubB = getPublicKeyHex(skB);
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("rejects a plaintext of zero bytes", () => {
const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey()));
expect(() => nip44Encrypt(key, "")).toThrow();
});
it("fails to decrypt with the wrong conversation key", () => {
const keyA = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey()));
const keyB = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey()));
const encrypted = nip44Encrypt(keyA, "secret message");
expect(() => nip44Decrypt(keyB, encrypted)).toThrow();
});
it("fails to decrypt a tampered payload", () => {
const key = getConversationKey(generatePrivateKey(), getPublicKeyHex(generatePrivateKey()));
const encrypted = nip44Encrypt(key, "secret message");
const bytes = base64urlToBytesForTest(encrypted);
bytes[bytes.length - 1] ^= 0xff;
expect(() => nip44Decrypt(key, bytesToBase64urlForTest(bytes))).toThrow();
});
});
// Standard (non-url-safe) base64 round trip, local to this test file only -- NIP-44
// payloads use plain base64, unlike the rest of BitLogin's base64url capsule encoding.
function base64urlToBytesForTest(value: string): Uint8Array {
return Uint8Array.from(atob(value), (ch) => ch.charCodeAt(0));
}
function bytesToBase64urlForTest(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
describe("Encoding helpers", () => {
it("hex round-trips", () => {
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
+46 -12
View File
@@ -1,9 +1,12 @@
/**
* NIP-44 v2 encrypted payloads (ChaCha20 + HMAC-SHA256, secp256k1 ECDH key agreement).
* Implemented from the published NIP-44 algorithm description. Not yet checked against
* the official NIP-44 cross-implementation test vectors — treat as beta until verified.
* Used by the widget's window.nostr.nip44 provider surface; BitLogin's own capsules use
* AES-256-GCM (§11.7) and do not depend on this module.
* Implemented from the published NIP-44 algorithm description and cross-checked
* byte-for-byte against the `nostr-tools` reference implementation: conversation-key
* agreement, padding at every bucket boundary from 1 byte to the extended-length
* threshold, and round trips in both directions (BitLogin encrypt -> nostr-tools
* decrypt, and back) all match. Used by the widget's window.nostr.nip44 provider
* surface; BitLogin's own capsules use AES-256-GCM (§11.7) and do not depend on
* this module.
*/
import { secp256k1 } from "@noble/curves/secp256k1";
import { hmac } from "@noble/hashes/hmac";
@@ -16,7 +19,10 @@ import { randomBytes } from "./random.js";
const NIP44_VERSION = 0x02;
const MIN_PLAINTEXT_LEN = 1;
const MAX_PLAINTEXT_LEN = 0xffff;
/** Payloads at or above this length use the 6-byte extended-length prefix instead of 2 bytes. */
const EXTENDED_PREFIX_THRESHOLD = 0x10000;
/** Absolute ceiling for the 4-byte extended-length prefix (2^32 - 1). */
const MAX_PLAINTEXT_LEN = 0xffffffff;
export function getConversationKey(privateKey: Uint8Array, peerPublicKeyHex: string): Uint8Array {
const peerPoint = concatBytes(new Uint8Array([0x02]), hexToBytes(peerPublicKeyHex));
@@ -47,26 +53,54 @@ function calcPaddedLen(unpaddedLen: number): number {
return chunk * (Math.floor((unpaddedLen - 1) / chunk) + 1);
}
/**
* 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
* never be a valid 2-byte length here, since the threshold exceeds 0xffff) followed by a
* 4-byte big-endian length, so a reader can distinguish the two forms from the first two
* bytes alone.
*/
function lengthPrefix(len: number): Uint8Array {
if (len < EXTENDED_PREFIX_THRESHOLD) {
const prefix = new Uint8Array(2);
new DataView(prefix.buffer).setUint16(0, len, false);
return prefix;
}
const prefix = new Uint8Array(6);
new DataView(prefix.buffer).setUint32(2, len, false);
return prefix;
}
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.`);
}
const prefix = new Uint8Array(2);
new DataView(prefix.buffer).setUint16(0, len, false);
const prefix = lengthPrefix(len);
const paddedLen = calcPaddedLen(len);
const out = new Uint8Array(2 + paddedLen);
const out = new Uint8Array(prefix.length + paddedLen);
out.set(prefix, 0);
out.set(plaintext, 2);
out.set(plaintext, prefix.length);
return out;
}
function unpad(padded: Uint8Array): Uint8Array {
const len = new DataView(padded.buffer, padded.byteOffset, padded.byteLength).getUint16(0, false);
if (len < MIN_PLAINTEXT_LEN || len > MAX_PLAINTEXT_LEN || padded.length !== 2 + calcPaddedLen(len)) {
const view = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
const firstTwo = view.getUint16(0, false);
let len: number;
let prefixLen: number;
if (firstTwo === 0) {
len = view.getUint32(2, false);
prefixLen = 6;
if (len < EXTENDED_PREFIX_THRESHOLD) throw new Error("NIP-44 payload has inconsistent padding.");
} else {
len = firstTwo;
prefixLen = 2;
}
if (len < MIN_PLAINTEXT_LEN || len > MAX_PLAINTEXT_LEN || padded.length !== prefixLen + calcPaddedLen(len)) {
throw new Error("NIP-44 payload has inconsistent padding.");
}
return padded.slice(2, 2 + len);
return padded.slice(prefixLen, prefixLen + len);
}
function calcMac(hmacKey: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array {
+117 -20
View File
@@ -26,7 +26,8 @@ type Screen =
| "recover-new-credentials"
| "dashboard"
| "change-password"
| "export";
| "export"
| "rollback-confirm";
interface ConfirmSlot {
index: number;
@@ -80,6 +81,18 @@ export class BitLoginAuthElement extends HTMLElement {
private exportedNsec = "";
private changePasswordNewCredential = "";
// Rollback confirmation (§16.2 step 6). A RollbackDetectedError from either login or
// password-change means this device has already seen a newer credential generation than the
// one just read -- most likely a rotated-away password being replayed from a relay that never
// processed its tombstone. Rather than a passive dashboard banner shown after the fact (which
// let an old, "revoked" password fully unlock a session), this blocks BEFORE claiming the
// signer or dispatching bitlogin-login, and requires an explicit second step to proceed.
private pendingRollback:
| { kind: "login"; loginName: string; password: string }
| { kind: "change-password"; oldPassword: string; newPassword: string }
| null = null;
private rollbackMessage = "";
constructor() {
super();
this.root = this.attachShadow({ mode: "open" });
@@ -116,6 +129,20 @@ export class BitLoginAuthElement extends HTMLElement {
async signEvent(event: { kind: number; tags?: string[][]; content: string; created_at?: number }) {
return this.worker.signEvent(event);
}
/**
* Element-scoped NIP-44 encryption, matching getPublicKey/signEvent above. A host page
* embedding multiple signing methods (its own extension detection, another widget) should
* prefer this over reaching through `window.nostr.nip44` -- window.nostr is a single global
* slot that whichever provider signed in last currently owns, so a host holding a direct
* reference to ITS OWN `<bitlogin-auth>` element can talk to it deterministically instead of
* racing other providers for that slot.
*/
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
return (await this.worker.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext;
}
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext;
}
async logout(): Promise<void> {
await this.worker.logout();
this.session = null;
@@ -263,6 +290,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.goto("recover-phrase");
return;
case "goto-welcome":
this.pendingRollback = null;
this.goto("welcome");
return;
case "goto-dashboard":
@@ -311,6 +339,14 @@ export class BitLoginAuthElement extends HTMLElement {
return this.handleRevealNsec();
case "logout":
return this.logout();
case "rollback-retry": {
const kind = this.pendingRollback?.kind;
this.pendingRollback = null;
this.goto(kind === "change-password" ? "change-password" : "login");
return;
}
case "rollback-continue":
return this.handleRollbackContinue();
default:
return;
}
@@ -537,16 +573,50 @@ export class BitLoginAuthElement extends HTMLElement {
private async handleLoginSubmit(): Promise<void> {
const loginName = this.field("loginName").trim().toLowerCase();
const password = this.field("password");
await this.attemptLogin(loginName, password);
}
/**
* Shared by the login form and the "continue anyway" rollback-confirmation step so both
* paths grant a session identically -- claimSigner() and the bitlogin-login event only ever
* fire once a RollbackDetectedError (if any) has been resolved one way or the other.
*/
private async attemptLogin(loginName: string, password: string, acknowledgeRollback = false): Promise<void> {
this.setBusy(true);
const result = await this.worker.login({ loginName, password });
this.loginName = loginName;
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w);
this.busy = false;
this.claimSigner();
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
this.goto("dashboard");
void this.offerToSaveCredential(loginName, password);
try {
const result = await this.worker.login({ loginName, password, acknowledgeRollback });
this.loginName = loginName;
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w);
this.busy = false;
this.claimSigner();
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
this.goto("dashboard");
void this.offerToSaveCredential(loginName, password);
} catch (err) {
if (err instanceof Error && err.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "login", loginName, password };
this.rollbackMessage = err.message;
this.busy = false;
this.goto("rollback-confirm");
return;
}
this.fail(err);
}
}
private async handleRollbackContinue(): Promise<void> {
const pending = this.pendingRollback;
this.pendingRollback = null;
if (!pending) {
this.goto("welcome");
return;
}
if (pending.kind === "login") {
await this.attemptLogin(pending.loginName, pending.password, true);
} else {
await this.attemptChangePassword(pending.oldPassword, pending.newPassword, true);
}
}
private async handleRecoverPhraseSubmit(): Promise<void> {
@@ -608,16 +678,33 @@ export class BitLoginAuthElement extends HTMLElement {
}
newPassword = password;
}
await this.attemptChangePassword(oldPassword, newPassword);
}
/** Shared by the rotation form and the "continue anyway" rollback-confirmation step; see attemptLogin. */
private async attemptChangePassword(oldPassword: string, newPassword: string, acknowledgeRollback = false): Promise<void> {
this.setBusy(true);
await this.worker.changePassword({
loginName: this.loginName,
oldPassword,
newPassword
});
this.busy = false;
this.claimSigner();
this.goto("dashboard");
void this.offerToSaveCredential(this.loginName, newPassword);
try {
await this.worker.changePassword({
loginName: this.loginName,
oldPassword,
newPassword,
acknowledgeRollback
});
this.busy = false;
this.claimSigner();
this.goto("dashboard");
void this.offerToSaveCredential(this.loginName, newPassword);
} catch (err) {
if (err instanceof Error && err.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "change-password", oldPassword, newPassword };
this.rollbackMessage = err.message;
this.busy = false;
this.goto("rollback-confirm");
return;
}
this.fail(err);
}
}
private async handleDownloadRecoveryExport(): Promise<void> {
@@ -877,10 +964,20 @@ export class BitLoginAuthElement extends HTMLElement {
<button class="secondary" type="button" data-action="logout">Log out</button>
`;
case "rollback-confirm":
return `
<h2>This looks like a stale or revoked credential</h2>
<div class="notice warn">${escapeHtml(this.rollbackMessage)}</div>
<p class="sub">This usually means the password just entered was rotated away in an earlier session on this device, and a relay hasn't caught up with that change. If you rotated your password, use the new one instead. Only continue if you're confident this is relay lag, not a stale credential.</p>
<button class="primary" type="button" data-action="rollback-retry">Try again</button>
<button class="link" type="button" data-action="rollback-continue">I understand the risk — continue anyway</button>
<button class="link" data-action="goto-welcome">Cancel</button>
`;
case "change-password":
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.</p>
<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>`
+9 -2
View File
@@ -132,7 +132,13 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
case "login": {
const p = payload as LoginPayload;
const result = await loginWithPassword({ loginName: p.loginName, password: p.password, vaultRelayUrls, store });
const result = await loginWithPassword({
loginName: p.loginName,
password: p.password,
vaultRelayUrls,
store,
acknowledgeRollback: p.acknowledgeRollback
});
clearSession();
session.signer = new NostrSigner(result.everydayPrivateKey);
session.everydayPrivateKey = result.everydayPrivateKey;
@@ -201,7 +207,8 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
oldPassword: p.oldPassword,
newPassword: p.newPassword,
vaultRelayUrls,
store
store,
acknowledgeRollback: p.acknowledgeRollback
});
// Keep the session's capsule references current so a recovery export or replica
// repair requested right after rotation (without an intervening re-login) still
+4
View File
@@ -44,6 +44,8 @@ export interface PreviewImportKeyResult {
export interface LoginPayload {
loginName: string;
password: string;
/** See `LoginParams.acknowledgeRollback` in `@bitlogin/core/account` (§16.2 step 6). */
acknowledgeRollback?: boolean;
}
export interface LoginResult {
everydayPublicKey: string;
@@ -85,6 +87,8 @@ export interface ChangePasswordPayload {
loginName: string;
oldPassword: string;
newPassword: string;
/** See `ChangePasswordParams.acknowledgeRollback` in `@bitlogin/core/account` (§16.2 step 6). */
acknowledgeRollback?: boolean;
}
export interface ChangePasswordResult {
newLocatorPublicKey: string;