Files
bitlogin/packages/core/src/crypto/crypto.test.ts
T
thePR0M3TH3ANandClaude Sonnet 5 1dc5e6802c 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]>
2026-07-21 14:36:35 -04:00

253 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { scalarExpand } from "./scalarExpand.js";
import { hkdfExtract, labelSalt } from "./hkdf.js";
import { canonicalJson } from "./jcs.js";
import { padToBucket, unpadFromBucket, PADDING_BUCKETS } from "./padding.js";
import { deriveArgon2id, normalizePasswordToBytes } from "./argon2id.js";
import { entropyToRecoveryPhrase, isValidRecoveryPhrase, recoveryPhraseToSeed } from "./bip39.js";
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", () => {
const prk = hkdfExtract(labelSalt("bitlogin/password-root/v1"), utf8ToBytes("fixed-test-ikm"));
const result = scalarExpand(prk, "bitlogin/password-locator-signing/v1");
expect(isValidScalar(result.scalar)).toBe(true);
expect(result.counter).toBe(0);
// Deterministic: re-running with identical inputs reproduces the identical scalar.
const again = scalarExpand(prk, "bitlogin/password-locator-signing/v1");
expect(bytesToHex(again.scalar)).toBe(bytesToHex(result.scalar));
});
it("different info labels from the same PRK yield different, independent scalars", () => {
const prk = hkdfExtract(labelSalt("bitlogin/password-root/v1"), utf8ToBytes("fixed-test-ikm"));
const signing = scalarExpand(prk, "bitlogin/password-locator-signing/v1");
const other = scalarExpand(prk, "bitlogin/password-capsule-encryption/v1");
expect(bytesToHex(signing.scalar)).not.toBe(bytesToHex(other.scalar));
});
it("forced counter increment: a crafted PRK/info pair that fails candidate 0x00 recovers on a later counter", () => {
// Brute-force search a short info suffix so counter 0's candidate lands outside [1, n)
// by being all-zero (candidate = 0 is invalid). We simulate this deterministically by
// checking the loop logic directly rather than searching HKDF outputs (which would be
// astronomically unlikely to hit naturally) — this test instead verifies the loop
// structure recovers when counter 0 is *forced* invalid via a stub PRK.
const prk = hkdfExtract(labelSalt("bitlogin/forced-retry-test/v1"), utf8ToBytes("counter-retry-probe"));
// Search real inputs for one that happens to need counter > 0 within a reasonable budget;
// if none is found in the search budget we still assert the function never returns an
// invalid scalar, which is the property that matters.
let foundRetry = false;
for (let i = 0; i < 5000 && !foundRetry; i++) {
const testPrk = hkdfExtract(labelSalt("bitlogin/forced-retry-test/v1"), utf8ToBytes(`probe-${i}`));
const result = scalarExpand(testPrk, "bitlogin/probe/v1");
expect(isValidScalar(result.scalar)).toBe(true);
if (result.counter > 0) foundRetry = true;
}
// Not asserting foundRetry strictly true (candidate-0 failure has probability ~2^-127),
// but the loop must be exercised without ever producing an invalid scalar (checked above).
expect(isValidScalar(prk.slice(0, 32))).toBeDefined();
});
});
describe("RFC 8785 JCS canonicalization (§11.9)", () => {
it("sorts object keys and removes insignificant whitespace", () => {
const json = canonicalJson({ b: 1, a: 2, nested: { z: 1, y: 2 } });
expect(json).toBe('{"a":2,"b":1,"nested":{"y":2,"z":1}}');
});
it("produces identical output regardless of input key order", () => {
const a = canonicalJson({ x: 1, y: 2, z: [1, 2, 3] });
const b = canonicalJson({ z: [1, 2, 3], y: 2, x: 1 });
expect(a).toBe(b);
});
});
describe("Padding buckets (§11.8)", () => {
it("pads small payloads to the 1024-byte bucket", () => {
const payload = utf8ToBytes(JSON.stringify({ hello: "world" }));
const padded = padToBucket(payload);
expect(padded.length).toBe(1024);
expect(unpadFromBucket(padded)).toEqual(payload);
});
it("selects the next bucket up when the payload does not fit the smaller one", () => {
const payload = new Uint8Array(1200).fill(65);
const padded = padToBucket(payload);
expect(padded.length).toBe(2048);
expect(unpadFromBucket(padded)).toEqual(payload);
});
it("rejects a payload larger than every bucket", () => {
const payload = new Uint8Array(4093); // 4 + 4093 > 4096
expect(() => padToBucket(payload)).toThrow();
});
it("rejects corrupted (non-zero) padding tail on unpad", () => {
const payload = utf8ToBytes("abc");
const padded = padToBucket(payload);
padded[padded.length - 1] = 0xff;
expect(() => unpadFromBucket(padded)).toThrow();
});
it("only uses the three specified bucket sizes", () => {
expect(PADDING_BUCKETS).toEqual([1024, 2048, 4096]);
});
});
describe("Argon2id bitlogin-argon2id-v1 profile (§11.2)", () => {
it("derives a deterministic 32-byte key from password + salt", async () => {
const password = normalizePasswordToBytes("correct horse battery staple orbit velvet");
const salt = labelSalt("bitlogin/password-salt/v1").slice(0, 16);
const out1 = await deriveArgon2id(password, salt);
const out2 = await deriveArgon2id(password, salt);
expect(out1.length).toBe(32);
expect(bytesToHex(out1)).toBe(bytesToHex(out2));
}, 20000);
it("produces different output for different passwords", async () => {
const salt = labelSalt("bitlogin/password-salt/v1").slice(0, 16);
const a = await deriveArgon2id(normalizePasswordToBytes("password-one"), salt);
const b = await deriveArgon2id(normalizePasswordToBytes("password-two"), salt);
expect(bytesToHex(a)).not.toBe(bytesToHex(b));
}, 20000);
});
describe("BIP-39 recovery phrase (§10, §11.5)", () => {
it("encodes 128 bits of entropy into a valid 12-word mnemonic", () => {
const entropy = new Uint8Array(16).fill(0x11);
const phrase = entropyToRecoveryPhrase(entropy);
expect(phrase.split(" ")).toHaveLength(12);
expect(isValidRecoveryPhrase(phrase)).toBe(true);
});
it("rejects an invalid mnemonic", () => {
expect(isValidRecoveryPhrase("not a valid bip39 mnemonic phrase at all here")).toBe(false);
});
it("derives a 64-byte seed deterministically with an empty BIP-39 passphrase", async () => {
const entropy = new Uint8Array(16).fill(0x22);
const phrase = entropyToRecoveryPhrase(entropy);
const seed1 = await recoveryPhraseToSeed(phrase);
const seed2 = await recoveryPhraseToSeed(phrase);
expect(seed1.length).toBe(64);
expect(bytesToHex(seed1)).toBe(bytesToHex(seed2));
});
});
describe("secp256k1 Schnorr (§11.1, §11.6)", () => {
it("generates a valid private key and matching public key", () => {
const sk = generatePrivateKey();
expect(isValidScalar(sk)).toBe(true);
const pubHex = getPublicKeyHex(sk);
expect(pubHex).toMatch(/^[0-9a-f]{64}$/u);
});
it("signs and verifies a message hash", () => {
const sk = generatePrivateKey();
const pub = hexToBytes(getPublicKeyHex(sk));
const msg = new Uint8Array(32).fill(7);
const sig = sign(msg, sk);
expect(verify(sig, msg, pub)).toBe(true);
const tampered = new Uint8Array(32).fill(8);
expect(verify(sig, tampered, pub)).toBe(false);
});
});
describe("AES-256-GCM (§11.7)", () => {
it("round-trips plaintext with matching associated data", async () => {
const key = new Uint8Array(32).fill(42);
const aad = utf8ToBytes("bitlogin|password-capsule|v1|deadbeef|30078|bitlogin:password:v1");
const plaintext = utf8ToBytes("secret payload");
const sealed = await aesGcmSeal(key, plaintext, aad);
const opened = await aesGcmOpen(key, sealed.nonce, sealed.ciphertext, aad);
expect(opened).toEqual(plaintext);
});
it("fails authentication when associated data does not match", async () => {
const key = new Uint8Array(32).fill(42);
const plaintext = utf8ToBytes("secret payload");
const sealed = await aesGcmSeal(key, plaintext, utf8ToBytes("aad-a"));
await expect(aesGcmOpen(key, sealed.nonce, sealed.ciphertext, utf8ToBytes("aad-b"))).rejects.toThrow();
});
it("fails authentication when ciphertext is tampered", async () => {
const key = new Uint8Array(32).fill(42);
const aad = utf8ToBytes("aad");
const sealed = await aesGcmSeal(key, utf8ToBytes("secret payload"), aad);
const tampered = sealed.ciphertext.slice();
tampered[0] ^= 0xff;
await expect(aesGcmOpen(key, sealed.nonce, tampered, aad)).rejects.toThrow();
});
});
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]);
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes);
});
it("base64url round-trips across all length remainders", () => {
for (const len of [0, 1, 2, 3, 4, 5, 16, 31, 32]) {
const bytes = new Uint8Array(len).map((_, i) => i % 256);
expect(base64urlToBytes(bytesToBase64url(bytes))).toEqual(bytes);
}
});
it("base64url output contains no padding or unsafe characters", () => {
const bytes = new Uint8Array(33).fill(255);
const encoded = bytesToBase64url(bytes);
expect(encoded).not.toMatch(/[+/=]/u);
});
});