Files
nostr_quantum_preparation/test/pq-crypto.test.mjs
T

1550 lines
71 KiB
JavaScript

/**
* Test suite for pq-crypto.mjs
*
* Run with: node --test test/
*
* Covers:
* - BIP39 seed phrase generation and validation
* - BIP32 → PQ seed derivation (determinism)
* - PQ keygen determinism (same seed → same pubkeys)
* - PQ sign → verify round-trips (all 4 signature schemes)
* - PQ verify rejection (tampered message, tampered sig, wrong pubkey)
* - NIP-01 event id computation
* - verifyNostrEvent id check (F-H2)
* - hexToBytes input validation (F-L3)
* - OTS parser (parseOtsFile) against vendored example proofs
* - OTS verifier (verifyOtsProof) F-C3 target-digest binding
*/
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { schnorr } from '@noble/curves/secp256k1.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const m = await import('../www/js/pq-crypto.mjs');
const policy = await import('../www/js/nip-qr-policy.mjs');
const vectorsDir = join(__dirname, 'vectors');
// ============================================================================
// BIP39 SEED PHRASE
// ============================================================================
describe('BIP39 seed phrase', () => {
test('generateSeedPhrase defaults to a valid 24-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase();
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
const words = mnemonic.split(' ');
assert.equal(words.length, 24, 'default should be 24 words');
});
test('generateSeedPhrase(128) produces a valid 12-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase(128);
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhrase rejects invalid entropyBits', () => {
assert.throws(() => m.generateSeedPhrase(64), /entropyBits must be 128/);
assert.throws(() => m.generateSeedPhrase(192), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy defaults to a 24-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 24, 'default should be 24 words');
});
test('generateSeedPhraseWithEntropy(128) produces a 12-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy, 128);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhraseWithEntropy rejects invalid entropyBits', () => {
const userEntropy = new TextEncoder().encode('test entropy');
assert.throws(() => m.generateSeedPhraseWithEntropy(userEntropy, 64), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy produces different mnemonics for different CSPRNG draws', () => {
// Note: generateSeedPhraseWithEntropy mixes CSPRNG + user entropy, so it is
// NOT deterministic (the CSPRNG part changes each call). This is by design.
// We test that it produces valid mnemonics, not determinism.
const userEntropy = new TextEncoder().encode('test entropy');
const m1 = m.generateSeedPhraseWithEntropy(userEntropy);
const m2 = m.generateSeedPhraseWithEntropy(userEntropy);
assert.ok(m.isValidMnemonic(m1), 'first mnemonic should be valid');
assert.ok(m.isValidMnemonic(m2), 'second mnemonic should be valid');
// They should differ (CSPRNG component differs)
assert.notEqual(m1, m2, 'different CSPRNG draws should produce different mnemonics');
});
test('mnemonicToSeed rejects invalid mnemonic', () => {
assert.throws(() => m.mnemonicToSeed('invalid mnemonic phrase here'), /Invalid mnemonic/);
});
test('mnemonicToSeed produces 64-byte seed', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
assert.equal(seed.length, 64, 'BIP39 seed should be 64 bytes');
});
});
// ============================================================================
// BIP32 → PQ SEED DERIVATION (DETERMINISM)
// ============================================================================
// Use the well-known BIP39 test vector mnemonic (all "abandon" words)
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
describe('BIP32 → PQ seed derivation', () => {
let seed;
before(() => {
seed = m.mnemonicToSeed(TEST_MNEMONIC);
});
test('deriveSecp256k1FromSeed produces a keypair', () => {
const kp = m.deriveSecp256k1FromSeed(seed);
assert.ok(kp.privateKey, 'should have privateKey');
assert.ok(kp.publicKey, 'should have publicKey');
assert.equal(kp.privateKey.length, 32, 'privateKey should be 32 bytes');
});
test('derivePQKeysFromSeed is deterministic (same seed → same pubkeys)', () => {
const keys1 = m.derivePQKeysFromSeed(seed);
const keys2 = m.derivePQKeysFromSeed(seed);
for (const algo of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
assert.deepEqual(keys1[algo].publicKey, keys2[algo].publicKey, `${algo} pubkey should be deterministic`);
}
});
test('different seeds produce different PQ keys', () => {
// Use a 24-word test vector (different from the 12-word TEST_MNEMONIC)
const seed2 = m.mnemonicToSeed('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art');
const keys1 = m.derivePQKeysFromSeed(seed);
const keys2 = m.derivePQKeysFromSeed(seed2);
assert.notDeepEqual(keys1.mlDsa65.publicKey, keys2.mlDsa65.publicKey, 'different seeds should produce different keys');
});
test('all 5 PQ keypairs are produced', () => {
const keys = m.derivePQKeysFromSeed(seed);
for (const algo of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
assert.ok(keys[algo], `${algo} should be present`);
assert.ok(keys[algo].publicKey, `${algo} should have publicKey`);
assert.ok(keys[algo].secretKey, `${algo} should have secretKey`);
}
});
});
// ============================================================================
// PQ SIGN → VERIFY ROUND-TRIPS
// ============================================================================
describe('PQ sign → verify round-trips', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const keys = m.derivePQKeysFromSeed(seed);
const message = new TextEncoder().encode('Hello, post-quantum Nostr!');
const schemes = [
{ name: 'ML-DSA-44', sign: m.signWithMLDSA44, verify: m.verifyMLDSA44, key: 'mlDsa44' },
{ name: 'ML-DSA-65', sign: m.signWithMLDSA65, verify: m.verifyMLDSA65, key: 'mlDsa65' },
{ name: 'SLH-DSA-128s', sign: m.signWithSLHDSA, verify: m.verifySLHDSA, key: 'slhDsa' },
{ name: 'Falcon-512', sign: m.signWithFalcon, verify: m.verifyFalcon, key: 'falcon512' },
];
for (const scheme of schemes) {
describe(scheme.name, () => {
test('valid signature verifies', () => {
const sig = scheme.sign(message, keys[scheme.key].secretKey);
assert.ok(sig && sig.length > 0, 'signature should be non-empty');
assert.ok(scheme.verify(sig, message, keys[scheme.key].publicKey), 'valid signature should verify');
});
test('tampered message fails verification', () => {
const sig = scheme.sign(message, keys[scheme.key].secretKey);
const tamperedMsg = new TextEncoder().encode('Tampered message!');
assert.equal(scheme.verify(sig, tamperedMsg, keys[scheme.key].publicKey), false, 'tampered message should fail');
});
test('tampered signature fails verification', () => {
const sig = scheme.sign(message, keys[scheme.key].secretKey);
const tamperedSig = new Uint8Array(sig);
tamperedSig[0] ^= 0xff; // flip first byte
assert.equal(scheme.verify(tamperedSig, message, keys[scheme.key].publicKey), false, 'tampered signature should fail');
});
});
}
});
// ============================================================================
// NIP-01 EVENT ID AND verifyNostrEvent (F-H2)
// ============================================================================
describe('NIP-01 event id and verifyNostrEvent', () => {
test('computeEventId produces a 32-byte hex id', () => {
const event = {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test'
};
const id = m.computeEventId(event);
assert.equal(id.length, 64, 'event id should be 64 hex chars (32 bytes)');
assert.match(id, /^[0-9a-f]{64}$/, 'should be valid hex');
});
test('verifyNostrEvent rejects wrong id (F-H2)', () => {
// Build a minimal event, sign it with a real key, then tamper the id
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for id check'
};
const id = m.computeEventId(event);
// We can't sign without a Schnorr signer here, but we can test that
// verifyNostrEvent returns false for a wrong id even without a valid sig
// (it should fail on the id check before reaching sig verification)
const wrongIdEvent = { ...event, id: '0'.repeat(64), sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(wrongIdEvent), false, 'wrong id should fail verification');
});
// G56-19: missing event id must be rejected (id is now required, not optional)
test('G56-19: verifyNostrEvent rejects missing id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for missing id check'
};
// No id field at all — must be rejected
const noIdEvent = { ...event, sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(noIdEvent), false, 'missing id should fail verification');
});
// G56-19: malformed id (not 64 hex) must be rejected
test('G56-19: verifyNostrEvent rejects malformed id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
id: 'short',
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for malformed id check',
sig: '0'.repeat(128)
};
assert.equal(m.verifyNostrEvent(event), false, 'malformed id should fail verification');
});
});
// ============================================================================
// hexToBytes INPUT VALIDATION (F-L3)
// ============================================================================
describe('hexToBytes input validation (F-L3)', () => {
test('valid hex converts correctly', () => {
const bytes = m.hexToBytes('deadbeef');
assert.deepEqual(Array.from(bytes), [0xde, 0xad, 0xbe, 0xef]);
});
test('empty string throws', () => {
assert.throws(() => m.hexToBytes(''), /non-empty/);
});
test('odd-length hex throws', () => {
assert.throws(() => m.hexToBytes('abc'), /odd-length/);
});
test('invalid hex characters throw', () => {
assert.throws(() => m.hexToBytes('xy12'), /invalid hex/);
});
});
// ============================================================================
// OTS PARSER (parseOtsFile)
// ============================================================================
describe('OTS parser (parseOtsFile)', () => {
const examplesDir = join(__dirname, '..', 'resources', 'javascript-opentimestamps', 'examples');
test('isDetachedOtsFile true on valid .ots file', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
assert.equal(m.isDetachedOtsFile(ots), true);
});
test('isDetachedOtsFile false on random bytes', () => {
const random = new Uint8Array(100);
assert.equal(m.isDetachedOtsFile(random), false);
});
test('parseOtsFile on hello-world.txt.ots → Bitcoin attestation at height 358391', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
const parsed = m.parseOtsFile(ots);
assert.ok(parsed, 'should parse successfully');
assert.equal(parsed.fileHashOp, 'sha256');
assert.equal(parsed.targetDigest.length, 32, 'SHA-256 target digest should be 32 bytes');
const bitcoinAtts = parsed.attestations.filter(a => a.type === 'bitcoin');
assert.equal(bitcoinAtts.length, 1, 'should have 1 Bitcoin attestation');
assert.equal(bitcoinAtts[0].height, 358391, 'should be block 358391');
});
test('parseOtsFile on incomplete.txt.ots → pending attestation', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
const parsed = m.parseOtsFile(ots);
assert.ok(parsed);
const pendingAtts = parsed.attestations.filter(a => a.type === 'pending');
assert.ok(pendingAtts.length > 0, 'should have at least 1 pending attestation');
assert.ok(pendingAtts[0].uri, 'pending attestation should have a URI');
});
test('parseOtsFile on two-calendars.txt.ots → 2 pending attestations', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'two-calendars.txt.ots')));
const parsed = m.parseOtsFile(ots);
assert.ok(parsed);
const pendingAtts = parsed.attestations.filter(a => a.type === 'pending');
assert.equal(pendingAtts.length, 2, 'should have 2 pending attestations');
});
test('isOtsConfirmed true on hello-world (has Bitcoin attestation)', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
assert.equal(m.isOtsConfirmed(ots), true);
});
test('isOtsConfirmed false on incomplete (pending only)', () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
assert.equal(m.isOtsConfirmed(ots), false);
});
});
// ============================================================================
// OTS VERIFIER (verifyOtsProof) — F-C3 TARGET-DIGEST BINDING
// ============================================================================
describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
const examplesDir = join(__dirname, '..', 'resources', 'javascript-opentimestamps', 'examples');
test('hello-world.txt.ots verifies against real Bitcoin block', async () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
const result = await m.verifyOtsProof(ots);
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
assert.equal(result.bitcoinAttestations.length, 1);
assert.equal(result.bitcoinAttestations[0].height, 358391);
// G56-04: trustMode must be present and indicate explorer-checked trust
assert.ok(result.trustMode, 'trustMode field must be present');
assert.ok(['multi-explorer-checked', 'single-explorer-checked'].includes(result.trustMode),
`trustMode should be explorer-checked, got: ${result.trustMode}`);
});
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
const wrongDigest = '0'.repeat(64);
const result = await m.verifyOtsProof(ots, wrongDigest);
assert.equal(result.verified, false);
assert.ok(result.errors.some(e => e.includes('Target digest mismatch')), 'should report digest mismatch');
});
test('F-C3: correct expected digest passes', async () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
const parsed = m.parseOtsFile(ots);
const correctDigest = m.bytesToHex(parsed.targetDigest);
const result = await m.verifyOtsProof(ots, correctDigest);
assert.equal(result.verified, true);
});
test('pending proof (no Bitcoin attestation) does not verify', async () => {
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
const result = await m.verifyOtsProof(ots);
assert.equal(result.verified, false);
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
// G56-04: trustMode should be 'structural-only' when no Bitcoin attestation is verified
assert.equal(result.trustMode, 'structural-only', 'pending proof should have structural-only trust mode');
});
});
// ============================================================================
// G56-01: STRICT PQ ALGORITHM POLICY (verifyNIPQRContent)
// ============================================================================
//
// These tests verify that the verifier enforces a mandatory algorithm
// policy and does NOT accept events with missing PQ signatures, missing
// signature fields, unknown algorithms treated as valid, or duplicate
// algorithms.
describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
// Use a fixed key for signing test events
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
// Helper: sign a kind 1 event with a real Schnorr signature
async function signedKind1(content, tags) {
const event = {
pubkey: pkHex,
created_at: 1700000000,
kind: 1,
tags: tags || [],
content: content || 'test attestation'
};
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
// Helper: build wrapper tags for a given kind 1 event.
// F-D1: use the canonical digest and include the digest_version tag so the
// verifier treats the carrier as a v1 event (valid for migration). A helper
// wrapperTagsLegacy() below produces v0 tags for legacy-path tests.
function wrapperTags(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.canonicalEventDigest(kind1Event)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
}
// Helper: build legacy v0 wrapper tags (no digest_version, legacy hash) for
// testing the legacy inspection path.
function wrapperTagsLegacy(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.hashFullEvent(kind1Event)]
];
}
// Helper: build a complete valid kind 1 event with all 5 PQ algorithms
async function validKind1WithAllPQKeys() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
// buildKind1Announcement returns an unsigned template — sign it
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
test('valid event with all 5 PQ algorithms passes strict policy', async () => {
const kind1 = await validKind1WithAllPQKeys();
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.policySufficient, true, 'policy should be sufficient with all 5 algorithms');
assert.equal(result.pqProofsValid, true, 'all PQ proofs should verify');
assert.equal(result.validForMigration, true, 'should be valid for migration');
});
test('G56-01: event with NO algorithm tags fails policy', async () => {
const kind1 = await signedKind1('attestation with no PQ keys', []);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.policySufficient, false, 'policy must fail with no algorithms');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
assert.ok(result.errors.some(e => e.includes('missing mandatory')), 'should report missing mandatory algorithms');
});
test('G56-01: signature algorithm with missing signature field fails', async () => {
// Build a kind 1 with ml-dsa-44 tag but no signature field
const kind1 = await signedKind1('attestation with missing sig', [
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(1312))], // no 4th field
]);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.pqProofsValid, false, 'missing signature must fail PQ proof');
assert.equal(result.policySufficient, false, 'policy must fail');
assert.equal(result.validForMigration, false);
// The ml-dsa-44 result should be invalid
const mlDsa44Result = result.results.find(r => r.algorithm === 'ml-dsa-44');
assert.ok(mlDsa44Result, 'should have an ml-dsa-44 result');
assert.equal(mlDsa44Result.valid, false, 'ml-dsa-44 with no signature must be invalid');
});
test('G56-01: each signature algorithm with missing signature fails', async () => {
const sigAlgs = ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512'];
for (const alg of sigAlgs) {
const keyLen = { 'ml-dsa-44': 1312, 'ml-dsa-65': 1952, 'slh-dsa-128s': 32, 'falcon-512': 897 }[alg];
const kind1 = await signedKind1(`test ${alg} missing sig`, [
['algorithm', alg, m.bytesToBase64(new Uint8Array(keyLen))],
]);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const algResult = result.results.find(r => r.algorithm === alg);
assert.ok(algResult, `should have a result for ${alg}`);
assert.equal(algResult.valid, false, `${alg} with no signature must be invalid`);
assert.equal(result.pqProofsValid, false, `${alg} missing sig must fail PQ proofs`);
}
});
test('F-D3: unknown algorithm is ignored (valid: null), not counted as evidence or failure', async () => {
const kind1 = await signedKind1('attestation with unknown alg', [
['algorithm', 'future-pq-scheme', m.bytesToBase64(new Uint8Array(64))],
]);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const unknownResult = result.results.find(r => r.algorithm === 'future-pq-scheme');
assert.ok(unknownResult, 'should have a result for the unknown algorithm');
// F-D3: unknown algorithms are 'ignored' (valid: null) — neither valid
// evidence nor a failure. They do not flip the legacy `valid` flag.
assert.equal(unknownResult.valid, null, 'unknown algorithm must be ignored (valid: null), not false');
assert.ok(unknownResult.note.includes('ignored'), 'note should mention ignored');
// Policy must still fail because the mandatory algorithms are missing.
assert.equal(result.policySufficient, false, 'policy must fail with only an unknown algorithm (mandatory set missing)');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('G56-01: duplicate algorithm tags fail', async () => {
const kind1 = await validKind1WithAllPQKeys();
// Duplicate the ml-dsa-44 tag
const dupTag = kind1.tags.find(t => t[1] === 'ml-dsa-44');
kind1.tags.push([...dupTag]);
// Re-sign because tags changed
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.policySufficient, false, 'duplicate algorithms must fail policy');
assert.ok(result.errors.some(e => e.includes('duplicate')), 'should report duplicate');
});
test('G56-01: malformed base64 in public key fails closed (does not throw)', async () => {
const kind1 = await signedKind1('attestation with malformed base64', [
['algorithm', 'ml-dsa-44', '%%%not-valid-base64%%%', m.bytesToBase64(new Uint8Array(2420))],
]);
const tags = wrapperTags(kind1);
// Must not throw — must return a structured failure
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.pqProofsValid, false, 'malformed base64 must fail PQ proofs');
assert.equal(result.validForMigration, false);
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
assert.ok(algResult, 'should have an ml-dsa-44 result');
assert.equal(algResult.valid, false, 'malformed base64 must be invalid');
});
test('G56-01: malformed base64 in signature fails closed (does not throw)', async () => {
const kind1 = await signedKind1('attestation with malformed sig base64', [
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(1312)), '%%%not-valid-base64%%%'],
]);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.pqProofsValid, false, 'malformed signature base64 must fail');
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
assert.equal(algResult.valid, false, 'malformed signature must be invalid');
});
test('G56-01: wrong public key length fails', async () => {
const kind1 = await signedKind1('attestation with wrong key length', [
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(100)), m.bytesToBase64(new Uint8Array(2420))],
]);
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
assert.equal(algResult.valid, false, 'wrong key length must be invalid');
assert.equal(result.pqProofsValid, false);
});
test('G56-01: non-array wrapper tags fail closed (does not throw)', async () => {
const kind1 = await validKind1WithAllPQKeys();
// Pass a non-array for wrapper tags — must not throw
const result = m.verifyNIPQRContent(JSON.stringify(kind1), {});
assert.equal(result.validForMigration, false);
assert.equal(result.eTagValid, false);
});
test('G56-01: result has structured fields', async () => {
const kind1 = await validKind1WithAllPQKeys();
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.ok('pqProofsValid' in result, 'should have pqProofsValid field');
assert.ok('policySufficient' in result, 'should have policySufficient field');
assert.ok('validForMigration' in result, 'should have validForMigration field');
assert.ok('errors' in result, 'should have errors field');
assert.ok(Array.isArray(result.errors), 'errors should be an array');
});
});
// ============================================================================
// F-D1: CANONICAL OTS DIGEST (Fable5 H-1)
// ============================================================================
//
// The OTS target digest MUST be cross-implementation-reproducible. The legacy
// hashFullEvent() is key-order-dependent; canonicalEventDigest() hashes the
// NIP-01 serialization array extended with id and sig, which is canonical.
describe('F-D1: canonical OTS digest', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function signedKind1() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
test('canonicalEventDigest is deterministic for a fixed event', async () => {
const kind1 = await signedKind1();
const d1 = m.canonicalEventDigest(kind1);
const d2 = m.canonicalEventDigest(kind1);
assert.equal(d1, d2, 'same event must produce the same canonical digest');
assert.match(d1, /^[0-9a-f]{64}$/, 'digest must be 64 lowercase hex chars');
});
test('F-D1: canonical digest is invariant under object key reordering', async () => {
// A relay or second implementation may return the same event with different
// object key order. The canonical digest MUST be identical. The legacy
// hashFullEvent would NOT be (this is the H-1 regression test).
const kind1 = await signedKind1();
const canonicalOriginal = m.canonicalEventDigest(kind1);
// Reorder keys deliberately (sig first, id last, etc.)
const reordered = {
sig: kind1.sig,
content: kind1.content,
tags: kind1.tags,
kind: kind1.kind,
created_at: kind1.created_at,
pubkey: kind1.pubkey,
id: kind1.id
};
const canonicalReordered = m.canonicalEventDigest(reordered);
assert.equal(canonicalOriginal, canonicalReordered,
'canonical digest must NOT depend on object key order');
// And the legacy hash MUST differ (demonstrating why the fix is needed)
const legacyOriginal = m.hashFullEvent(kind1);
const legacyReordered = m.hashFullEvent(reordered);
assert.notEqual(legacyOriginal, legacyReordered,
'legacy hashFullEvent is key-order-dependent (this is the bug F-D1 fixes)');
});
test('F-D1: re-serialization invariance (JSON.parse then re-digest)', async () => {
const kind1 = await signedKind1();
const digestBefore = m.canonicalEventDigest(kind1);
const reparsed = JSON.parse(JSON.stringify(kind1));
const digestAfter = m.canonicalEventDigest(reparsed);
assert.equal(digestBefore, digestAfter,
'parse -> re-digest must yield the same canonical digest');
});
test('F-D1: buildProofCarrier emits digest_version tag and canonical sha256', async () => {
const kind1 = await signedKind1();
const otsProof = new Uint8Array([0x00]); // dummy proof bytes
const carrier = m.buildProofCarrier(kind1, otsProof);
const dvTag = carrier.tags.find(t => t[0] === 'digest_version');
const shaTag = carrier.tags.find(t => t[0] === 'sha256');
assert.ok(dvTag, 'proof carrier must have a digest_version tag');
assert.equal(dvTag[1], String(m.CANONICAL_DIGEST_VERSION),
`digest_version must be ${m.CANONICAL_DIGEST_VERSION}`);
assert.equal(shaTag[1], m.canonicalEventDigest(kind1),
'sha256 tag must be the canonical digest, not the legacy hash');
});
test('F-D1: v1 carrier with all PQ keys is validForMigration', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, true, 'digest_version should be valid');
assert.equal(result.digestIsLegacy, false, 'should not be legacy');
assert.equal(result.sha256Valid, true, 'sha256 should match canonical digest');
assert.equal(result.validForMigration, true, 'v1 carrier should be valid for migration');
});
test('F-D1: legacy v0 carrier (no digest_version) is NOT validForMigration', async () => {
const kind1 = await signedKind1();
// v0 tags: legacy hash, no digest_version
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'v0 must not have a valid digest_version');
assert.equal(result.digestIsLegacy, true, 'should be flagged legacy');
assert.equal(result.sha256Valid, true, 'legacy sha256 should still match for inspection');
assert.equal(result.validForMigration, false, 'legacy v0 must NOT be valid for migration');
});
test('F-D1: unknown digest_version fails closed', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', '99'] // unsupported future version
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'unknown version must fail closed');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
assert.ok(result.errors.some(e => e.includes('unsupported digest_version')),
'should report unsupported digest_version');
});
test('F-D1: missing digest_version is treated as legacy v0', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
// no digest_version tag
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestIsLegacy, true, 'missing tag => legacy v0');
assert.equal(result.digestVersionValid, false, 'legacy is not valid for migration');
});
test('F-D1: duplicate digest_version tags fail', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'duplicate digest_version must fail');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('F-D1: v1 carrier with wrong sha256 (legacy hash instead of canonical) fails', async () => {
const kind1 = await signedKind1();
// Claim v1 but put the legacy hash in the sha256 tag
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, true, 'version tag itself is valid');
assert.equal(result.sha256Valid, false, 'sha256 must match canonical, not legacy, for v1');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('F-D1: canonicalEventDigest rejects missing/invalid fields', () => {
assert.throws(() => m.canonicalEventDigest(null), /expected an event object/);
assert.throws(() => m.canonicalEventDigest({}), /missing field/);
// created_at as a string => "must be numbers"
assert.throws(() => m.canonicalEventDigest({
pubkey: 'x', created_at: 'notanumber', kind: 1, tags: [], content: 'x', id: 'x', sig: 'x'
}), /must be numbers/);
// pubkey as a number => "must be strings"
assert.throws(() => m.canonicalEventDigest({
pubkey: 1, created_at: 1, kind: 1, tags: [], content: 'x', id: 'x', sig: 'x'
}), /must be strings/);
// tags as a string => "tags must be an array"
assert.throws(() => m.canonicalEventDigest({
pubkey: 'x', created_at: 1, kind: 1, tags: 'notarray', content: 'x', id: 'x', sig: 'x'
}), /tags must be an array/);
});
});
// ============================================================================
// F-D2: PROOF ARCHIVE (Fable5 H-2 — anchor availability / deletion resistance)
// ============================================================================
describe('F-D2: proof archive', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function makeSignedKind1AndCarrier() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const kind1 = m.buildKind1Announcement(pkHex, 958927, keys);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
const otsProof = new Uint8Array([0x00, 0x01, 0x02, 0x03]); // dummy proof
const carrier = m.buildProofCarrier(kind1, otsProof);
carrier.id = m.computeEventId(carrier);
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
return { kind1, carrier, otsProof };
}
test('F-D2: buildProofArchive produces a parseable archive with correct fields', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const parsed = m.parseProofArchive(archiveJson);
assert.equal(parsed.archiveType, 'nostr-pq-link-proof');
assert.equal(parsed.archiveVersion, 1);
// kind1 from buildKind1Announcement carries a non-serializable statementBytes
// Uint8Array; compare the JSON-serializable NIP-01 fields instead.
const kind1Serializable = { ...kind1 };
delete kind1Serializable.statementBytes;
assert.deepEqual(parsed.kind1Event, kind1Serializable);
assert.deepEqual(parsed.proofCarrierEvent, carrier);
assert.deepEqual(parsed.otsProof, otsProof);
assert.equal(parsed.canonicalDigest, m.canonicalEventDigest(kind1));
assert.equal(parsed.digestVersion, m.CANONICAL_DIGEST_VERSION);
});
test('F-D2: archive round-trips through JSON.parse and re-verification', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
// Simulate offline storage and re-load
const reloaded = m.parseProofArchive(archiveJson);
// The extracted kind 1 must verify against the extracted carrier
const result = m.verifyNIPQRContent(
JSON.stringify(reloaded.kind1Event),
reloaded.proofCarrierEvent.tags
);
assert.equal(result.sha256Valid, true, 'extracted sha256 should match');
assert.equal(result.digestVersionValid, true, 'extracted digest_version should be valid');
assert.equal(result.validForMigration, true, 'archive should verify end-to-end without any relay');
});
test('F-D2: parseProofArchive rejects wrong archiveType', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const tampered = JSON.parse(archiveJson);
tampered.archiveType = 'not-the-right-type';
assert.throws(() => m.parseProofArchive(JSON.stringify(tampered)), /wrong archiveType/);
});
test('F-D2: parseProofArchive rejects unsupported archiveVersion', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const tampered = JSON.parse(archiveJson);
tampered.archiveVersion = 99;
assert.throws(() => m.parseProofArchive(JSON.stringify(tampered)), /unsupported archiveVersion/);
});
test('F-D2: parseProofArchive rejects malformed input', () => {
assert.throws(() => m.parseProofArchive(''), /non-empty JSON string/);
assert.throws(() => m.parseProofArchive('not json'), /invalid JSON/);
assert.throws(() => m.parseProofArchive('{}'), /wrong archiveType/);
assert.throws(() => m.parseProofArchive('{"archiveType":"nostr-pq-link-proof","archiveVersion":1}'), /missing or invalid kind1Event/);
});
test('F-D2: buildProofArchive rejects invalid inputs', () => {
assert.throws(() => m.buildProofArchive(null, {}, new Uint8Array()), /kind1Event must be an object/);
assert.throws(() => m.buildProofArchive({}, null, new Uint8Array()), /proofCarrierEvent must be an object/);
assert.throws(() => m.buildProofArchive({}, {}, 'notbytes'), /otsProof must be a Uint8Array/);
});
});
// ============================================================================
// F-D3: ALGORITHM POLICY RECONCILIATION (Fable5 M-1)
// ============================================================================
//
// The mandatory set is versioned and explicit. Unknown algorithms are
// 'ignored' (valid: null) — neither credit nor failure. A custom policy can
// drop a broken scheme (the NIP's revocation path).
describe('F-D3: algorithm policy reconciliation', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function validKind1WithAllPQKeys() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
function wrapperTags(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.canonicalEventDigest(kind1Event)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
}
test('F-D3: default policy is POLICY_V1 and result reports policyVersion', async () => {
const kind1 = await validKind1WithAllPQKeys();
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.policyVersion, 1, 'default policy version should be 1');
assert.equal(result.policySufficient, true, 'all mandatory algorithms present');
});
test('F-D3: extra unknown algorithm alongside valid mandatory set still passes', async () => {
const kind1 = await validKind1WithAllPQKeys();
// Add an unknown algorithm tag alongside the valid mandatory set
kind1.tags.push(['algorithm', 'future-pq-scheme', m.bytesToBase64(new Uint8Array(64))]);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const unknownResult = result.results.find(r => r.algorithm === 'future-pq-scheme');
assert.ok(unknownResult, 'should have a result for the unknown algorithm');
assert.equal(unknownResult.valid, null, 'unknown algorithm should be ignored (valid: null)');
// The mandatory set is still complete and valid, so policy passes.
assert.equal(result.policySufficient, true, 'policy should pass with valid mandatory set + ignored unknown');
assert.equal(result.validForMigration, true, 'should be valid for migration');
});
test('F-D3: custom policy dropping a scheme allows revocation-path event', async () => {
// Simulate a future POLICY_V2 that drops falcon-512 (because it was broken).
// An event that omits falcon-512 should pass under POLICY_V2 but fail under V1.
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
// Remove the falcon-512 tag (revocation scenario)
event.tags = event.tags.filter(t => !(Array.isArray(t) && t[1] === 'falcon-512'));
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
const tags = wrapperTags(event);
// Under default V1 policy: falcon-512 is mandatory, so policy fails
const resultV1 = m.verifyNIPQRContent(JSON.stringify(event), tags);
assert.equal(resultV1.policySufficient, false, 'V1 policy must fail without falcon-512');
assert.ok(resultV1.errors.some(e => e.includes('missing mandatory algorithm: falcon-512')),
'should report missing falcon-512');
// Under a custom V2 policy that drops falcon-512: policy passes
const POLICY_V2 = {
version: 2,
mandatorySignatureAlgorithms: ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s'],
kemAlgorithms: ['ml-kem-768']
};
const resultV2 = m.verifyNIPQRContent(JSON.stringify(event), tags, { policy: POLICY_V2 });
assert.equal(resultV2.policyVersion, 2, 'should report custom policy version 2');
assert.equal(resultV2.policySufficient, true, 'V2 policy should pass without falcon-512');
assert.equal(resultV2.validForMigration, true, 'should be valid for migration under V2');
});
test('F-D3: policy module exports match the expected V1 set', () => {
assert.deepEqual([...policy.POLICY_V1.mandatorySignatureAlgorithms],
['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512']);
assert.deepEqual([...policy.POLICY_V1.kemAlgorithms], ['ml-kem-768']);
assert.equal(policy.POLICY_V1.version, 1);
assert.equal(policy.isMandatorySignature('ml-dsa-44', policy.POLICY_V1), true);
assert.equal(policy.isMandatorySignature('ml-kem-768', policy.POLICY_V1), false);
assert.equal(policy.isKem('ml-kem-768', policy.POLICY_V1), true);
assert.equal(policy.isKem('ml-dsa-44', policy.POLICY_V1), false);
assert.ok(policy.knownAlgorithms(policy.POLICY_V1).has('falcon-512'));
assert.ok(!policy.knownAlgorithms(policy.POLICY_V1).has('future-scheme'));
});
});
// ============================================================================
// F-D5: STRICT BASE64 DECODING (Fable5 L-3)
// ============================================================================
describe('F-D5: strict base64 decoding', () => {
test('F-D5: valid base64 round-trips', () => {
const bytes = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); // "hello"
const b64 = m.bytesToBase64(bytes);
const decoded = m.base64ToBytes(b64);
assert.deepEqual(decoded, bytes, 'round-trip should preserve bytes');
});
test('F-D5: empty string throws', () => {
assert.throws(() => m.base64ToBytes(''), /non-empty base64 string/);
});
test('F-D5: missing padding throws', () => {
// "aGVsbG8" is "hello" without the trailing '=' padding
assert.throws(() => m.base64ToBytes('aGVsbG8'), /padding|base64|invalid/);
});
test('F-D5: whitespace in base64 throws', () => {
assert.throws(() => m.base64ToBytes('aGVsbG8=\n'), /padding|base64|invalid|whitespace/);
});
test('F-D5: non-base64 characters throw', () => {
assert.throws(() => m.base64ToBytes('not-base64!@#'), /base64|invalid/);
});
test('F-D5: non-string input throws', () => {
assert.throws(() => m.base64ToBytes(null), /non-empty base64 string/);
assert.throws(() => m.base64ToBytes(123), /non-empty base64 string/);
assert.throws(() => m.base64ToBytes(undefined), /non-empty base64 string/);
});
test('F-D5: whitespace-padded ots tag is rejected by base64ToBytes and verifyOtsProof', async () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const kind1 = m.buildKind1Announcement(pkHex, 958927, keys);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
// Build a carrier, then tamper the ots tag to have trailing whitespace
const carrier = m.buildProofCarrier(kind1, new Uint8Array([0x00, 0x01]));
const otsTag = carrier.tags.find(t => t[0] === 'ots');
const tamperedOts = otsTag[1] + '\n'; // append whitespace
// F-D5: base64ToBytes must reject the whitespace-padded ots tag
assert.throws(() => m.base64ToBytes(tamperedOts), /padding|base64|invalid|whitespace/);
// F-D5: verifyOtsProof (which decodes the ots tag) must fail closed on the tampered tag
const sha256Tag = carrier.tags.find(t => t[0] === 'sha256');
const verifyResult = await m.verifyOtsProof(m.base64ToBytes(otsTag[1]), sha256Tag ? sha256Tag[1] : undefined).catch(() => null);
// Either it throws (caught) or returns verified:false — either way, not verified
if (verifyResult) {
assert.equal(verifyResult.verified, false, 'verifyOtsProof must not verify a tampered ots tag');
}
});
});
// ============================================================================
// Tests for the new non-replaceable proof carrier event kind, append-only
// upgrades, and the selectCanonicalProofCarrier function.
describe('G56-02: non-replaceable proof carrier (kind 9999)', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function signedKind1(content, tags) {
const event = {
pubkey: pkHex,
created_at: 1700000000,
kind: 1,
tags: tags || [],
content: content || 'test attestation'
};
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
async function validKind1WithAllPQKeys() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
async function signedProofCarrier(kind1Event, otsProof, options = {}) {
const template = m.buildProofCarrier(kind1Event, otsProof, options);
template.id = m.computeEventId(template);
template.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(template.id), sk));
return template;
}
test('NIP_QR_KIND is 9999 (non-replaceable range)', () => {
assert.equal(m.NIP_QR_KIND, 9999, 'kind should be 9999 (0-9999 non-replaceable range)');
});
test('buildProofCarrier includes ots_status tag', async () => {
const kind1 = await validKind1WithAllPQKeys();
const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' });
assert.equal(carrier.kind, 9999);
const otsStatusTag = carrier.tags.find(t => t[0] === 'ots_status');
assert.ok(otsStatusTag, 'should have ots_status tag');
assert.equal(otsStatusTag[1], 'pending');
});
test('buildProofCarrier includes upgrade_of tag when provided', async () => {
const kind1 = await validKind1WithAllPQKeys();
const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), {
otsStatus: 'confirmed',
upgradeOf: 'abc123'
});
const upgradeTag = carrier.tags.find(t => t[0] === 'upgrade_of');
assert.ok(upgradeTag, 'should have upgrade_of tag');
assert.equal(upgradeTag[1], 'abc123');
});
test('buildUpgradedEvent creates append-only event with upgrade_of', async () => {
const kind1 = await validKind1WithAllPQKeys();
const pendingCarrier = await signedProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' });
const upgraded = m.buildUpgradedEvent(pendingCarrier, new Uint8Array(200));
// Should be a new event, not a replacement
assert.equal(upgraded.kind, 9999);
const otsStatusTag = upgraded.tags.find(t => t[0] === 'ots_status');
assert.equal(otsStatusTag[1], 'confirmed', 'upgraded event should have ots_status confirmed');
const upgradeTag = upgraded.tags.find(t => t[0] === 'upgrade_of');
assert.ok(upgradeTag, 'should have upgrade_of tag');
assert.equal(upgradeTag[1], pendingCarrier.id, 'upgrade_of should reference original event ID');
// e and sha256 tags should be the same as the original
const eTag = upgraded.tags.find(t => t[0] === 'e');
const origETag = pendingCarrier.tags.find(t => t[0] === 'e');
assert.equal(eTag[1], origETag[1], 'e tag should be preserved');
});
test('selectCanonicalProofCarrier returns null for empty candidates', async () => {
const kind1 = await validKind1WithAllPQKeys();
const result = await m.selectCanonicalProofCarrier([], kind1);
assert.equal(result.canonical, null);
});
test('selectCanonicalProofCarrier rejects wrong e tag', async () => {
const kind1 = await validKind1WithAllPQKeys();
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
// Tamper the e tag
carrier.tags = carrier.tags.map(t => t[0] === 'e' ? ['e', '0'.repeat(64)] : t);
carrier.id = m.computeEventId(carrier);
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
assert.equal(result.canonical, null);
assert.ok(result.errors.some(e => e.includes('e tag')), 'should report e tag mismatch');
});
test('selectCanonicalProofCarrier rejects wrong sha256 tag', async () => {
const kind1 = await validKind1WithAllPQKeys();
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
// Tamper the sha256 tag
carrier.tags = carrier.tags.map(t => t[0] === 'sha256' ? ['sha256', '0'.repeat(64)] : t);
carrier.id = m.computeEventId(carrier);
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
assert.equal(result.canonical, null);
assert.ok(result.errors.some(e => e.includes('sha256')), 'should report sha256 mismatch');
});
test('selectCanonicalProofCarrier rejects wrong expected author', async () => {
const kind1 = await validKind1WithAllPQKeys();
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
const otherKey = '0'.repeat(64);
const result = await m.selectCanonicalProofCarrier([carrier], kind1, otherKey);
assert.equal(result.canonical, null);
assert.ok(result.errors.some(e => e.includes('expected author')), 'should report author mismatch');
});
test('F-D4/L-2: pending-only selection is labeled canonicalTrustMode=pending-only', async () => {
const kind1 = await validKind1WithAllPQKeys();
// A carrier with a dummy OTS proof (no Bitcoin attestation) => pending-only
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
assert.ok(result.canonical, 'should select the pending candidate as canonical (best available)');
assert.equal(result.canonicalBitcoinHeight, null, 'no Bitcoin height for pending');
assert.equal(result.canonicalTrustMode, 'pending-only',
'pending-only selection MUST be labeled canonicalTrustMode=pending-only so callers do not treat it as a trust decision');
});
test('F-D4/L-2: no candidates yields canonicalTrustMode=none', async () => {
const kind1 = await validKind1WithAllPQKeys();
const result = await m.selectCanonicalProofCarrier([], kind1);
assert.equal(result.canonical, null);
assert.equal(result.canonicalTrustMode, 'none');
});
});
// ============================================================================
// G56-03: IDENTITY BINDING (outer.pubkey === embedded.pubkey === expectedAuthor)
// ============================================================================
describe('G56-03: identity binding (verifyNIPQRContent)', () => {
const skA = new Uint8Array(32);
skA[31] = 1;
const pkA = m.bytesToHex(schnorr.getPublicKey(skA));
const skB = new Uint8Array(32);
skB[31] = 2;
const pkB = m.bytesToHex(schnorr.getPublicKey(skB));
async function signedKind1(pubkey, sk, content, tags) {
const event = {
pubkey,
created_at: 1700000000,
kind: 1,
tags: tags || [],
content: content || 'test attestation'
};
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
test('G56-03: matching identities pass identity binding', async () => {
const kind1 = await signedKind1(pkA, skA, 'test', []);
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
outerPubkey: pkA,
expectedAuthor: pkA
});
assert.equal(result.identityBound, true, 'matching identities should pass');
});
test('G56-03: mismatched outer/embedded identity fails', async () => {
// Kind 1 signed by A, but outer pubkey is B
const kind1 = await signedKind1(pkA, skA, 'test', []);
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
outerPubkey: pkB
});
assert.equal(result.identityBound, false, 'mismatched outer/embedded should fail');
assert.equal(result.validForMigration, false, 'should not be valid for migration');
});
test('G56-03: mismatched expected author fails', async () => {
const kind1 = await signedKind1(pkA, skA, 'test', []);
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
expectedAuthor: pkB
});
assert.equal(result.identityBound, false, 'mismatched expected author should fail');
assert.equal(result.validForMigration, false);
});
test('G56-03: no identity options → identityBound is true (backward compatible)', async () => {
const kind1 = await signedKind1(pkA, skA, 'test', []);
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.identityBound, true, 'without options, identity binding should pass');
});
});
// ============================================================================
// G56-07: Fuzz / property tests — untrusted input must never throw
// ============================================================================
describe('G56-07: verifyNostrEvent never throws on malformed input', () => {
const malformedInputs = [
null,
undefined,
'string',
42,
true,
[],
{},
{ id: 'abc' },
{ pubkey: 'x', sig: 'y', created_at: 'not-a-number', kind: 1, tags: [], content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 'not-a-number', tags: [], content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: 'not-an-array', content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: [], content: 123 },
{ pubkey: 123, sig: 'y', created_at: 1, kind: 1, tags: [], content: '' },
{ pubkey: 'x', sig: 123, created_at: 1, kind: 1, tags: [], content: '' },
];
for (const input of malformedInputs) {
test(`verifyNostrEvent returns false for ${JSON.stringify(input)?.substring(0, 50)}`, () => {
assert.equal(m.verifyNostrEvent(input), false);
});
}
test('verifyNostrEvent returns false for random-shaped objects (fuzz)', () => {
const types = [null, true, false, 0, 1, 'x', '', [], {}, { id: 1 }, { tags: 'x' }];
for (let i = 0; i < 500; i++) {
const obj = {};
const keys = ['id', 'pubkey', 'sig', 'created_at', 'kind', 'tags', 'content', 'extra'];
for (const k of keys) {
if (Math.random() < 0.6) obj[k] = types[Math.floor(Math.random() * types.length)];
}
assert.equal(m.verifyNostrEvent(obj), false, `threw or passed for iteration ${i}`);
}
});
});
describe('G56-07: verifyNIPQRContent never throws on arbitrary input', () => {
function randomJson() {
const types = [null, true, false, 0, 'x', [], {}, { id: 1 }, { tags: 'not-array' }];
return types[Math.floor(Math.random() * types.length)];
}
test('random content + tags never throw and never pass (fuzz)', () => {
for (let i = 0; i < 1000; i++) {
const content = Math.random() < 0.5
? JSON.stringify(randomJson())
: 'not-json-at-' + i;
const tags = Math.random() < 0.5
? randomJson()
: (Math.random() < 0.5 ? [['algorithm', 'x']] : 'not-an-array');
let result;
assert.doesNotThrow(() => {
result = m.verifyNIPQRContent(content, tags);
}, `threw on iteration ${i}`);
assert.equal(result.validForMigration, false, `passed on iteration ${i}`);
}
});
test('null / undefined / wrong-typed content returns structured failure', () => {
for (const content of [null, undefined, 42, true, {}]) {
let result;
assert.doesNotThrow(() => {
result = m.verifyNIPQRContent(content, []);
});
assert.equal(result.validForMigration, false);
}
});
test('oversized content is rejected before parsing', () => {
const huge = 'x'.repeat(70 * 1024); // > MAX_EVENT_JSON_SIZE (64 KiB)
const result = m.verifyNIPQRContent(huge, []);
assert.equal(result.validForMigration, false);
assert.ok(result.errors.some(e => e.includes('maximum size')), 'should report size error');
});
});
describe('G56-07: parseOtsFile never throws on random/truncated/oversized bytes', () => {
test('random bytes never throw (fuzz)', () => {
for (let i = 0; i < 2000; i++) {
const len = Math.floor(Math.random() * 2048);
const bytes = new Uint8Array(len);
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
let result;
assert.doesNotThrow(() => { result = m.parseOtsFile(bytes); }, `threw on iteration ${i}, len=${len}`);
// result is either null or a valid parse object — never throws
if (result !== null) {
assert.ok(typeof result === 'object' && 'fileHashOp' in result, 'should be null or valid object');
}
}
});
test('non-Uint8Array inputs return null without throwing', () => {
for (const input of [null, undefined, 'string', 42, true, [], {}, new Int8Array(10)]) {
assert.equal(m.parseOtsFile(input), null);
}
});
test('oversized input returns null without parsing', () => {
const huge = new Uint8Array(2 * 1024 * 1024); // 2 MiB > MAX_OTS_PROOF_SIZE (1 MiB)
assert.equal(m.parseOtsFile(huge), null);
});
test('truncated valid-prefix bytes never throw', () => {
// OTS magic header as a starting point
const magic = m.hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
for (let cut = 0; cut <= magic.length; cut++) {
const truncated = magic.slice(0, cut);
let result;
assert.doesNotThrow(() => { result = m.parseOtsFile(truncated); });
// short truncations return null (too short); longer ones may parse partially or return null
assert.ok(result === null || typeof result === 'object');
}
});
});
describe('G56-07: isOtsConfirmed never throws on bad input', () => {
test('non-Uint8Array and random bytes never throw', () => {
for (const input of [null, undefined, 'string', 42, [], {}]) {
assert.doesNotThrow(() => m.isOtsConfirmed(input));
}
for (let i = 0; i < 200; i++) {
const len = Math.floor(Math.random() * 512);
const bytes = new Uint8Array(len);
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
assert.doesNotThrow(() => m.isOtsConfirmed(bytes), `threw on iteration ${i}`);
}
});
});
// ============================================================================
// G56-05: Signer output validation
// ============================================================================
describe('G56-05: validateSignerOutput', () => {
const sk = new Uint8Array(32);
sk[31] = 5;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
const otherSk = new Uint8Array(32);
otherSk[31] = 9;
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
// Helper: create a valid template + correctly signed event
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
const template = {
pubkey: pkHex,
created_at: 1700000000,
kind: 1,
tags,
content
};
const event = { ...template };
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return { template, event };
}
test('valid signer output passes validation', () => {
const { template, event } = makeValidSignedEvent();
const result = m.validateSignerOutput(event, template, pkHex);
assert.equal(result.id, event.id);
assert.equal(result.sig, event.sig);
});
test('signer changes content → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, content: 'tampered content' };
// Recompute id+sig so the tampered event is internally consistent
// (a real malicious signer would do this, but the content must still match the template)
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
});
test('signer changes tags → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
});
test('signer changes created_at → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, created_at: 9999999999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
});
test('signer changes kind → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, kind: 9999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
});
test('signer returns wrong pubkey → rejected', () => {
const { template, event } = makeValidSignedEvent();
// Sign with a different key, use that key's pubkey
const tampered = { ...template, pubkey: otherPkHex };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
});
test('signer returns wrong ID → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: '0'.repeat(64) };
// sig is valid for the real id, not for '0'.repeat(64)
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
});
test('signer returns invalid signature → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'f'.repeat(128) };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
});
test('signer returns extra field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, extra: 'malicious' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
});
test('signer returns missing field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const { sig, ...noSig } = event;
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
});
test('signer returns non-object → rejected', () => {
const { template } = makeValidSignedEvent();
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
});
test('signer returns malformed id (not 64 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
});
test('signer returns malformed sig (not 128 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
});
test('no expectedPubkey → pubkey check skipped', () => {
const { template, event } = makeValidSignedEvent();
// Should pass without expectedPubkey (backward compatible)
const result = m.validateSignerOutput(event, template);
assert.equal(result.id, event.id);
});
test('tag element value changed → rejected', () => {
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
const tampered = { ...event, tags: [['t', 'changed']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
});
});
// ============================================================================
// F-D6: CROSS-IMPLEMENTATION CONFORMANCE VECTORS (Fable5 F-6)
// ============================================================================
//
// Pinned test vectors that a second implementation (Rust/Python/Go) can be
// validated against without reading the JS source. The vector files live in
// test/vectors/ and are generated by test/vectors/generate-vectors.mjs.
describe('F-D6: cross-implementation conformance vectors', () => {
test('F-D6: seed-to-pubkeys vector is loadable and well-formed', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
let vec;
try {
vec = JSON.parse(readFileSync(vecPath, 'utf8'));
} catch (e) {
assert.fail(`Could not load ${vecPath}: ${e.message}`);
}
assert.equal(vec.vectorType, 'nostr-pq-link-seed-to-pubkeys');
assert.equal(vec.vectorVersion, 1);
assert.equal(typeof vec.mnemonic, 'string');
assert.equal(typeof vec.bip39SeedHex, 'string');
assert.ok(vec.derivedPublicKeys, 'should have derivedPublicKeys');
for (const alg of ['secp256k1', 'ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512', 'ml-kem-768']) {
assert.ok(vec.derivedPublicKeys[alg], `should have ${alg} entry`);
assert.equal(typeof vec.derivedPublicKeys[alg].publicKeyHex, 'string');
assert.ok(vec.derivedPublicKeys[alg].publicKeyHex.length > 0, `${alg} publicKeyHex should be non-empty`);
}
});
test('F-D6: implementation reproduces the pinned seed-to-pubkeys vector', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
// Verify the BIP39 seed matches
assert.equal(m.bytesToHex(seed), vec.bip39SeedHex, 'BIP39 seed should match the vector');
// Derive all keys and compare against the pinned hex values
const secpKp = m.deriveSecp256k1FromSeed(seed);
assert.equal(m.bytesToHex(secpKp.publicKey), vec.derivedPublicKeys.secp256k1.publicKeyHex,
'secp256k1 pubkey should match the pinned vector');
const pqKeys = m.derivePQKeysFromSeed(seed);
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned vector');
});
});