221 lines
10 KiB
JavaScript
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));
|