Compare commits

...
1 Commits
7 changed files with 87 additions and 8986 deletions
-36
View File
@@ -1,36 +0,0 @@
/**
* Build script for the post-quantum crypto bundle.
* Bundles pq-crypto.mjs and all its dependencies into a single
* ESM file that can be loaded directly in the browser.
*
* Usage: node build-pq-bundle.js
*/
const esbuild = require('esbuild');
const path = require('path');
async function build() {
console.log('🔧 Building PQ crypto bundle...');
await esbuild.build({
entryPoints: ['www/js/pq-crypto.mjs'],
bundle: true,
format: 'esm',
target: ['es2020'],
outfile: 'www/pq-crypto.bundle.js',
sourcemap: true,
minify: false, // keep readable for demo
logLevel: 'info',
// Pure JS — no WASM files to handle
define: {
'process.env.NODE_ENV': '"production"'
}
});
console.log('✅ PQ crypto bundle built: www/pq-crypto.bundle.js');
}
build().catch(err => {
console.error('❌ Build failed:', err);
process.exit(1);
});
-351
View File
@@ -1,351 +0,0 @@
/**
* Post-Quantum Crypto Module for Nostr
*
* Provides:
* - BIP39 seed phrase generation
* - NIP-06 key derivation (secp256k1 from seed)
* - PQ key derivation from seed (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
* - PQ signing (ML-DSA, SLH-DSA)
* - NIP-QR event construction
*
* Uses @noble/post-quantum (pure JS, no WASM needed)
*/
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 as sha256Hash, sha512 as sha512Hash } from '@noble/hashes/sha2.js';
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
// ============================================================================
// BIP39 SEED PHRASE
// ============================================================================
/**
* Generate a new 12-word BIP39 mnemonic.
* @returns {string} 12-word seed phrase
*/
export function generateSeedPhrase() {
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
}
/**
* Convert a mnemonic to a 64-byte BIP39 seed (PBKDF2-HMAC-SHA512).
* @param {string} mnemonic - 12/24 word seed phrase
* @param {string} [passphrase=''] - optional BIP39 passphrase
* @returns {Uint8Array} 64-byte seed
*/
export function mnemonicToSeed(mnemonic, passphrase = '') {
if (!validateMnemonic(mnemonic, wordlist)) {
throw new Error('Invalid mnemonic');
}
return mnemonicToSeedSync(mnemonic, passphrase);
}
/**
* Validate a BIP39 mnemonic.
* @param {string} mnemonic
* @returns {boolean}
*/
export function isValidMnemonic(mnemonic) {
return validateMnemonic(mnemonic, wordlist);
}
// ============================================================================
// NIP-06 KEY DERIVATION (secp256k1 from seed)
// ============================================================================
/**
* Derive a secp256k1 keypair from a BIP39 seed using NIP-06.
* Path: m/44'/1237'/0'/0/0
*
* @param {Uint8Array} seed - 64-byte BIP39 seed
* @param {number} [accountIndex=0] - account index
* @returns {{privateKey: Uint8Array, publicKey: Uint8Array}} secp256k1 keypair
*/
export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
const hdKey = HDKey.fromMasterSeed(seed);
const path = `m/44'/1237'/${accountIndex}'/0/0`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error('Failed to derive private key');
}
return {
privateKey: child.privateKey,
publicKey: child.publicKey
};
}
// ============================================================================
// PQ KEY DERIVATION FROM SEED
// ============================================================================
/**
* Derive PQ key seeds from a BIP39 seed using HKDF.
* Each algorithm gets a unique label so keys are independent.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {string} label - algorithm label (e.g. 'nostr-pq-ml-dsa-65')
* @param {number} length - output length in bytes
* @returns {Uint8Array} deterministic seed for PQ keygen
*/
function derivePQSeed(bip39Seed, label, length) {
const info = new TextEncoder().encode(label);
return hkdf(sha512Hash, bip39Seed, undefined, info, length);
}
/**
* Derive all PQ keypairs from a BIP39 seed.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @returns {{
* mlDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
* slhDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
* }}
*/
export function derivePQKeysFromSeed(bip39Seed) {
// ML-DSA-65 needs 32-byte seed
const mlDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-dsa-65', 32);
const mlDsa = ml_dsa65.keygen(mlDsaSeed);
// SLH-DSA-128s needs 48-byte seed (3 * 16 for sk seed, pk seed, etc.)
const slhDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-slh-dsa-128s', 48);
const slhDsa = slh_dsa_sha2_128s.keygen(slhDsaSeed);
// ML-KEM-768 needs 64-byte seed
const mlKemSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-kem-768', 64);
const mlKem = ml_kem768.keygen(mlKemSeed);
return { mlDsa, slhDsa, mlKem };
}
// ============================================================================
// PQ SIGNING
// ============================================================================
/**
* Sign a message with ML-DSA-65.
* @param {Uint8Array} message
* @param {Uint8Array} secretKey
* @returns {Uint8Array} signature
*/
export function signWithMLDSA(message, secretKey) {
return ml_dsa65.sign(message, secretKey);
}
/**
* Verify an ML-DSA-65 signature.
* @param {Uint8Array} signature
* @param {Uint8Array} message
* @param {Uint8Array} publicKey
* @returns {boolean}
*/
export function verifyMLDSA(signature, message, publicKey) {
return ml_dsa65.verify(signature, message, publicKey);
}
/**
* Sign a message with SLH-DSA-128s.
* @param {Uint8Array} message
* @param {Uint8Array} secretKey
* @returns {Uint8Array} signature
*/
export function signWithSLHDSA(message, secretKey) {
return slh_dsa_sha2_128s.sign(message, secretKey);
}
/**
* Verify an SLH-DSA-128s signature.
* @param {Uint8Array} signature
* @param {Uint8Array} message
* @param {Uint8Array} publicKey
* @returns {boolean}
*/
export function verifySLHDSA(signature, message, publicKey) {
return slh_dsa_sha2_128s.verify(signature, message, publicKey);
}
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Convert Uint8Array to base64 string.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function bytesToBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array.
* @param {string} base64
* @returns {Uint8Array}
*/
export function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Convert Uint8Array to hex string.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hex string to Uint8Array.
* @param {string} hex
* @returns {Uint8Array}
*/
export function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
// ============================================================================
// NIP-QR EVENT CONSTRUCTION
// ============================================================================
/**
* Build the NIP-QR event content (the JSON that goes in the event's content field).
*
* The content contains:
* - A link statement
* - All PQ public keys
* - PQ signatures over the statement
* - The ML-KEM public key (no signature — KEM can't sign)
*
* @param {string} npub - The user's Nostr npub (hex pubkey)
* @param {string} successorNpub - The successor's hex pubkey (for Path B), or null for Path A
* @param {{mlDsa: *, slhDsa: *, mlKem: *}} pqKeys - PQ keypairs
* @returns {{statement: string, content: object, statementBytes: Uint8Array}}
*/
export function buildNIPQRContent(npub, successorNpub, pqKeys) {
let statement;
if (successorNpub) {
// Path B: migration from old nsec to seed-derived key
statement = `Identity ${npub} is migrating to successor ${successorNpub}. All PQ keys listed below are derived from the same BIP39 seed as ${successorNpub}. This link is established pre-quantum.`;
} else {
// Path A: direct link (identity already seed-derived)
statement = `Identity ${npub} is linked to the following PQ keys, all derived from the same BIP39 seed. This link is established pre-quantum.`;
}
const statementBytes = new TextEncoder().encode(statement);
// Sign the statement with each PQ signature scheme
const mlDsaSig = signWithMLDSA(statementBytes, pqKeys.mlDsa.secretKey);
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
const content = {
statement,
pq_keys: [
{
algorithm: 'ml-dsa-65',
public_key: bytesToBase64(pqKeys.mlDsa.publicKey),
signature: bytesToBase64(mlDsaSig)
},
{
algorithm: 'slh-dsa-128s',
public_key: bytesToBase64(pqKeys.slhDsa.publicKey),
signature: bytesToBase64(slhDsaSig)
},
{
algorithm: 'ml-kem-768',
public_key: bytesToBase64(pqKeys.mlKem.publicKey),
note: 'KEM key for encryption; ownership asserted by secp256k1 signature over this content'
}
]
};
// If Path B, include successor info
if (successorNpub) {
content.successor_pubkey = successorNpub;
}
return { statement, content, statementBytes };
}
/**
* Verify a NIP-QR event's PQ signatures.
* @param {object} content - The parsed content object
* @returns {{valid: boolean, results: Array}} verification results
*/
export function verifyNIPQRContent(content) {
const results = [];
for (const keyEntry of content.pq_keys) {
if (keyEntry.algorithm === 'ml-kem-768') {
// KEM can't sign — skip verification
results.push({ algorithm: keyEntry.algorithm, valid: true, note: 'KEM (no signature to verify)' });
continue;
}
const pubKey = base64ToBytes(keyEntry.public_key);
const sig = base64ToBytes(keyEntry.signature);
const msg = new TextEncoder().encode(content.statement);
let valid = false;
if (keyEntry.algorithm === 'ml-dsa-65') {
valid = verifyMLDSA(sig, msg, pubKey);
} else if (keyEntry.algorithm === 'slh-dsa-128s') {
valid = verifySLHDSA(sig, msg, pubKey);
}
results.push({ algorithm: keyEntry.algorithm, valid });
}
return {
valid: results.every(r => r.valid),
results
};
}
// ============================================================================
// KEY SIZE INFO (for display)
// ============================================================================
export const PQ_KEY_INFO = {
'ml-dsa-65': {
name: 'ML-DSA-65 (Dilithium)',
publicKeySize: 1952,
signatureSize: 3309,
fips: 'FIPS 204',
type: 'signature'
},
'slh-dsa-128s': {
name: 'SLH-DSA-128s (SPHINCS+)',
publicKeySize: 32,
signatureSize: 7856,
fips: 'FIPS 205',
type: 'signature'
},
'ml-kem-768': {
name: 'ML-KEM-768 (Kyber)',
publicKeySize: 1184,
ciphertextSize: 1088,
fips: 'FIPS 203',
type: 'kem'
}
};
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.94",
"VERSION_NUMBER": "0.7.94",
"BUILD_DATE": "2026-07-12T14:10:59.968Z"
"VERSION": "v0.7.95",
"VERSION_NUMBER": "0.7.95",
"BUILD_DATE": "2026-07-31T10:32:03.777Z"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+84
View File
@@ -441,6 +441,22 @@
<div id="outNip44Decrypted" class="outDivTall clsClipboard">&nbsp;</div>
<div id="btnDecodeNip44" class="divButton btn">Decrypt</div>
</div>
<!-- HMAC-SHA256 D TAG (deterministic opaque NIP-33 d tag from privkey + path) -->
<div class="divTool">
<div class="divSideTitle">HMAC-SHA256 D TAG</div>
<div class="toolLabel">nsec/hex (privkey):</div>
<div id="inpHmacDTagPrivkey" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">Path (e.g. Work/Projects/Secret):</div>
<div id="inpHmacDTagPath" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">Key derivation label (optional, defaults to sovereign-browser/bookmarks-folder-id-v1):</div>
<div id="inpHmacDTagLabel" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">HMAC key (hex, derived from privkey + label):</div>
<div id="outHmacDTagKey" class="outDiv clsClipboard">&nbsp;</div>
<div class="toolLabel">d tag (HMAC-SHA256(hmac_key, path), 64 hex chars):</div>
<div id="outHmacDTag" class="outDiv clsClipboard">&nbsp;</div>
<div id="btnComputeHmacDTag" class="divButton btn">Compute</div>
</div>
</div>
<!-- ================================================================
@@ -1069,6 +1085,71 @@ const versionInfo = await getVersion();
}
};
// HMAC-SHA256 deterministic opaque d tag (for NIP-33 parameterized replaceable
// events where the d tag must be (a) opaque to observers and (b) identical
// across re-publishes so relays replace the prior event. Encryption (NIP-04 /
// NIP-44) cannot be used because both use a random IV/nonce per call, which
// would break replaceability. A MAC is deterministic by construction.
//
// hmac_key = HMAC-SHA256(privkey_bytes, label_utf8)
// d = HMAC-SHA256(hmac_key, path_utf8) → 64 hex chars
//
// The same privkey + label + path always yields the same d tag, so relays
// replace the prior event. Observers see only an opaque hash and learn
// nothing about the path. Used by sovereign_browser for nested bookmark
// folders (NIP-51 kind 30003) where the real path lives inside the
// NIP-44 encrypted content.
const ComputeHmacDTag = async () => {
try {
const privkeyInput = document.getElementById(`inpHmacDTagPrivkey`).innerText.trim();
const path = document.getElementById(`inpHmacDTagPath`).innerText;
let label = document.getElementById(`inpHmacDTagLabel`).innerText.trim();
if (!label) label = 'sovereign-browser/bookmarks-folder-id-v1';
if (!privkeyInput) {
document.getElementById(`outHmacDTagKey`).innerText = 'Error: privkey is required';
document.getElementById(`outHmacDTag`).innerText = '';
return;
}
if (!path) {
document.getElementById(`outHmacDTagKey`).innerText = '';
document.getElementById(`outHmacDTag`).innerText = 'Error: path is required';
return;
}
const privkeyBytes = normalizeKey(privkeyInput, true);
// hmac_key = HMAC-SHA256(privkey, label)
const labelBytes = new TextEncoder().encode(label);
const hmacKeyBuf = await crypto.subtle.importKey(
'raw', privkeyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const hmacKeySig = await crypto.subtle.sign('HMAC', hmacKeyBuf, labelBytes);
const hmacKeyHex = Array.from(new Uint8Array(hmacKeySig))
.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById(`outHmacDTagKey`).innerText = hmacKeyHex;
// d = HMAC-SHA256(hmac_key, path)
const hmacKeyBytes = new Uint8Array(hmacKeySig);
const pathBytes = new TextEncoder().encode(path);
const dKeyBuf = await crypto.subtle.importKey(
'raw', hmacKeyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const dSig = await crypto.subtle.sign('HMAC', dKeyBuf, pathBytes);
const dHex = Array.from(new Uint8Array(dSig))
.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById(`outHmacDTag`).innerText = dHex;
} catch (error) {
console.error('HMAC d-tag error:', error);
document.getElementById(`outHmacDTagKey`).innerText = `Error: ${error.message}`;
document.getElementById(`outHmacDTag`).innerText = '';
}
};
// NSEC to NPUB Conversion
const NsecToNpub = async () => {
try {
@@ -1337,6 +1418,9 @@ const versionInfo = await getVersion();
if (btnEncodeNip44) btnEncodeNip44.addEventListener("click", EncodeNip44);
if (btnDecodeNip44) btnDecodeNip44.addEventListener("click", DecodeNip44);
const btnComputeHmacDTag = document.getElementById(`btnComputeHmacDTag`);
if (btnComputeHmacDTag) btnComputeHmacDTag.addEventListener("click", ComputeHmacDTag);
// NSEC to NPUB
const inpNsecToNpub = document.getElementById(`inpNsecToNpub`);
if (inpNsecToNpub) inpNsecToNpub.addEventListener("input", NsecToNpub);