Files
nostr_quantum_preparation/test/vectors/generate-vectors.mjs
T

221 lines
10 KiB
JavaScript

/**
* Conformance vector generator (F-D6).
*
* Computes the pinned values for the cross-implementation conformance vectors
* from the fixed BIP39 test mnemonic, and writes them to test/vectors/.
*
* Run: node test/vectors/generate-vectors.mjs
*
* A second implementation (Rust/Python/Go) can be validated against the
* generated JSON files without reading the JS source.
*/
import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { schnorr } from '@noble/curves/secp256k1.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { ml_dsa44, ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
import { falcon512 } from '@noble/post-quantum/falcon.js';
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VECTORS_DIR = __dirname;
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
slhDsa: 48,
falcon512: 48,
mlKem: 64,
};
// V1 (legacy): non-hardened children under the NIP-06 account 0 change level.
const V1_PATHS = {
mlDsa44: ["m/44'/1237'/0'/0/1"],
mlDsa65: ["m/44'/1237'/0'/0/2"],
slhDsa: ["m/44'/1237'/0'/0/3", "m/44'/1237'/0'/0/4"],
falcon512: ["m/44'/1237'/0'/0/5", "m/44'/1237'/0'/0/6"],
mlKem: ["m/44'/1237'/0'/0/7", "m/44'/1237'/0'/0/8"],
};
// V2 (default): per-algorithm coin types in the unregistered SLIP-44 102XXX'
// range, all-hardened below the coin type. See plans/v2-hardened-derivation.md.
const V2_PATHS = {
mlDsa44: ["m/44'/102006'/0'/0'/0'"],
mlDsa65: ["m/44'/102003'/0'/0'/0'"],
slhDsa: ["m/44'/102004'/0'/0'/0'", "m/44'/102004'/0'/0'/1'"],
falcon512: ["m/44'/102007'/0'/0'/0'", "m/44'/102007'/0'/0'/1'"],
mlKem: ["m/44'/102005'/0'/0'/0'", "m/44'/102005'/0'/0'/1'"],
};
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const child = hdKey.derive(path);
if (!child.privateKey) throw new Error(`Failed to derive at ${path}`);
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
return deriveBIP32Child(bip39Seed, paths[0]);
} else {
let combined = new Uint8Array(0);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
combined = newCombined;
}
return combined.slice(0, requiredLength);
}
}
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
if (!validateMnemonic(MNEMONIC, wordlist)) {
throw new Error('Test mnemonic failed validation');
}
const seed = mnemonicToSeedSync(MNEMONIC, '');
// secp256k1
const secpHd = HDKey.fromMasterSeed(seed).derive("m/44'/1237'/0'/0/0");
const secpPub = secpHd.publicKey;
// PQ keys — v1 (legacy) paths
const mlDsa44Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(seed, V1_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(seed, V1_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(seed, V1_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
// PQ keys — v2 (per-algorithm coin types, all hardened)
const v2MlDsa44Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const v2MlDsa44Keys = ml_dsa44.keygen(v2MlDsa44Seed);
const v2MlDsa65Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const v2MlDsa65Keys = ml_dsa65.keygen(v2MlDsa65Seed);
const v2SlhDsaSeed = derivePQSeedFromBIP32(seed, V2_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const v2SlhDsaKeys = slh_dsa_sha2_128s.keygen(v2SlhDsaSeed);
const v2FalconSeed = derivePQSeedFromBIP32(seed, V2_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const v2FalconKeys = falcon512.keygen(v2FalconSeed);
const v2MlKemSeed = derivePQSeedFromBIP32(seed, V2_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const v2MlKemKeys = ml_kem768.keygen(v2MlKemSeed);
const vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 1,
description: 'Pins the BIP32 truncation rule and PQ keygen determinism: a fixed BIP39 mnemonic must produce the same five PQ public keys on any conforming implementation.',
mnemonic: MNEMONIC,
bip39SeedHex: bytesToHex(seed),
derivationBasePath: "m/44'/1237'/0'/0/",
derivedPublicKeys: {
secp256k1: {
derivationPath: "m/44'/1237'/0'/0/0",
publicKeyHex: bytesToHex(secpPub)
},
'ml-dsa-44': {
derivationPath: "m/44'/1237'/0'/0/1",
publicKeyHex: bytesToHex(mlDsa44Keys.publicKey)
},
'ml-dsa-65': {
derivationPath: "m/44'/1237'/0'/0/2",
publicKeyHex: bytesToHex(mlDsa65Keys.publicKey)
},
'slh-dsa-128s': {
derivationPath: "m/44'/1237'/0'/0/3 + m/44'/1237'/0'/0/4 (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(slhDsaKeys.publicKey)
},
'falcon-512': {
derivationPath: "m/44'/1237'/0'/0/5 + m/44'/1237'/0'/0/6 (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(falconKeys.publicKey)
},
'ml-kem-768': {
derivationPath: "m/44'/1237'/0'/0/7 + m/44'/1237'/0'/0/8 (concatenated, all 64 bytes used)",
publicKeyHex: bytesToHex(mlKemKeys.publicKey)
}
},
notes: [
'The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.',
'A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.',
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.',
'LEGACY: this v1 vector pins the pre-v2 derivation (non-hardened children under m/44\'/1237\'/0\'/0/). It must never change — v1 seed recovery depends on it. New derivations use seed-to-pubkeys.v2.json.'
]
};
const outPath = join(VECTORS_DIR, 'seed-to-pubkeys.v1.json');
writeFileSync(outPath, JSON.stringify(vector, null, 2) + '\n');
console.log(`Wrote ${outPath}`);
console.log('secp256k1 pubkey:', bytesToHex(secpPub));
console.log('ml-dsa-44 pubkey (v1):', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v1):', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v1):', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v1):', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey (v1):', bytesToHex(mlKemKeys.publicKey));
// ── V2 vector: per-algorithm coin types, all hardened ────────────────────────
const v2Vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 2,
derivationScheme: 2,
description: 'V2 hardened derivation: per-algorithm coin types in the unregistered SLIP-44 102XXX range, all-hardened below the coin type. PQ keys are outside the Nostr coin branch (1237\'), so no compromise of the Nostr subtree can reach them (audit F-M3).',
mnemonic: MNEMONIC,
bip39SeedHex: bytesToHex(seed),
coinTypes: {
'ml-dsa-44': 102006,
'ml-dsa-65': 102003,
'slh-dsa-128s': 102004,
'falcon-512': 102007,
'ml-kem-768': 102005,
},
derivedPublicKeys: {
'ml-dsa-44': {
derivationPath: "m/44'/102006'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa44Keys.publicKey)
},
'ml-dsa-65': {
derivationPath: "m/44'/102003'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa65Keys.publicKey)
},
'slh-dsa-128s': {
derivationPath: "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2SlhDsaKeys.publicKey)
},
'falcon-512': {
derivationPath: "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2FalconKeys.publicKey)
},
'ml-kem-768': {
derivationPath: "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1' (concatenated, all 64 bytes used)",
publicKeyHex: bytesToHex(v2MlKemKeys.publicKey)
}
},
notes: [
'Same truncation rule as v1: two hardened children concatenated, first 48 bytes for 48-byte seeds, all 64 for 64-byte seeds.',
'Coin types 102003\u2013102005 match n_signer / the Rust signer; 102006\u2013102007 are this project\u2019s allocations for ML-DSA-44 and Falcon-512.',
'Falcon-512 is per-library: its keygen is rejection-sampling-based with no universal seed interface, so this vector pins @noble/post-quantum\u2019s behavior specifically.',
'The secp256k1 identity key is unchanged: NIP-06 m/44\'/1237\'/0\'/0/0 (see the v1 vector).'
]
};
const v2OutPath = join(VECTORS_DIR, 'seed-to-pubkeys.v2.json');
writeFileSync(v2OutPath, JSON.stringify(v2Vector, null, 2) + '\n');
console.log(`Wrote ${v2OutPath}`);
console.log('ml-dsa-44 pubkey (v2):', bytesToHex(v2MlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v2):', bytesToHex(v2MlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v2):', bytesToHex(v2SlhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v2):', bytesToHex(v2FalconKeys.publicKey));
console.log('ml-kem-768 pubkey (v2):', bytesToHex(v2MlKemKeys.publicKey));