Files
nostr_quantum_preparation/www/js/pq-crypto.mjs
T

2133 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Post-Quantum Crypto Module for Nostr
*
* Provides:
* - BIP39 seed phrase generation
* - NIP-06 key derivation (secp256k1 from seed via BIP32)
* - PQ key derivation from BIP32 HD wallet paths (5 algorithms)
* - PQ signing (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512)
* - NIP-QR event construction
*
* Uses @noble/post-quantum (pure JS, no WASM needed)
*
* BIP32 Derivation Paths (all under m/44'/1237'/0'/0/):
* 0 — secp256k1 (NIP-06 standard, Account #2)
* 1 — ML-DSA-44 (32-byte seed)
* 2 — ML-DSA-65 (32-byte seed)
* 3+4 — SLH-DSA-128s (48-byte seed, two 32-byte children concatenated)
* 5+6 — Falcon-512 (48-byte seed, two 32-byte children concatenated)
* 7+8 — ML-KEM-768 (64-byte seed, two 32-byte children concatenated)
*/
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, entropyToMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { bech32 } from '@scure/base';
import { schnorr } from '@noble/curves/secp256k1.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { ripemd160, sha1 } from '@noble/hashes/legacy.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';
// ============================================================================
// BIP32 DERIVATION PATHS
// ============================================================================
/**
* BIP32 derivation paths for all keys.
*
* Base path: m/44'/1237'/0'/0/ (NIP-06 account 0, change 0)
* Child indices:
* 0 — secp256k1 (NIP-06 standard)
* 1 — ML-DSA-44
* 2 — ML-DSA-65
* 3, 4 — SLH-DSA-128s (two children, concatenated for 48-byte seed)
* 5, 6 — Falcon-512 (two children, concatenated for 48-byte seed)
* 7, 8 — ML-KEM-768 (two children, concatenated for 64-byte seed)
*/
const PQ_DERIVATION_PATHS = {
secp256k1: [0], // 32 bytes (standard NIP-06)
mlDsa44: [1], // 32 bytes
mlDsa65: [2], // 32 bytes
slhDsa: [3, 4], // 64 bytes concatenated, take first 48
falcon512: [5, 6], // 64 bytes concatenated, take first 48
mlKem: [7, 8], // 64 bytes concatenated
};
// Seed lengths required by each algorithm's keygen()
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
slhDsa: 48,
falcon512: 48,
mlKem: 64,
};
// ============================================================================
// BIP39 SEED PHRASE
// ============================================================================
/**
* Generate a new BIP39 mnemonic.
*
* Defaults to 24 words (256 bits of entropy) so the recovery root is not the
* weakest link in a system whose PQ algorithms target NIST Category 1+.
* Pass `128` for `entropyBits` to generate a 12-word phrase (e.g. for testing).
*
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} BIP39 seed phrase (24 words by default)
*/
export function generateSeedPhrase(entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
return generateMnemonic(wordlist, entropyBits);
}
/**
* Generate a seed phrase using both CSPRNG entropy and user-provided entropy.
*
* This mixes the browser's crypto.getRandomValues() with user-supplied entropy
* (e.g. from mouse movements or keyboard input), so even if the CSPRNG is
* compromised, the seed remains unpredictable to an attacker who doesn't have
* the user's entropy.
*
* Defaults to 24 words (256 bits). Pass `128` for `entropyBits` to generate a
* 12-word phrase.
*
* @param {Uint8Array} userEntropy - Additional entropy from the user (any length)
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} A valid BIP39 mnemonic (24 words by default)
*/
export function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
const entropyBytes = entropyBits / 8; // 16 or 32
// Get CSPRNG entropy of the requested size
const csprngBytes = new Uint8Array(entropyBytes);
crypto.getRandomValues(csprngBytes);
// Combine CSPRNG entropy with user entropy via SHA-256
const combined = concatBytes(csprngBytes, userEntropy);
const hash = sha256(combined);
// Use the first `entropyBytes` bytes of the hash as the entropy
const finalEntropy = hash.slice(0, entropyBytes);
return entropyToMnemonic(finalEntropy, wordlist);
}
/**
* 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);
}
// ============================================================================
// BIP32 KEY DERIVATION
// ============================================================================
/**
* Derive a BIP32 child private key at a given path.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {number[]} childIndices - child indices under m/44'/1237'/0'/0/
* @returns {Uint8Array} 32-byte private key
*/
function deriveBIP32Child(bip39Seed, childIndices) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join('/')}`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error(`Failed to derive private key at path ${path}`);
}
return child.privateKey;
}
/**
* Derive a seed of the required length from BIP32 child keys.
*
* For 32-byte seeds: derive one child, use its 32-byte private key.
* For 48-byte seeds: derive two children, concatenate (64 bytes), take first 48.
* For 64-byte seeds: derive two children, concatenate (64 bytes).
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {number[]} childIndices - child indices to derive
* @param {number} requiredLength - required seed length
* @returns {Uint8Array} seed bytes
*/
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
// Single child — 32 bytes
const seed = deriveBIP32Child(bip39Seed, childIndices);
if (seed.length !== requiredLength) {
throw new Error(`Seed length mismatch: got ${seed.length}, expected ${requiredLength}`);
}
return seed;
} else {
// Multiple children — concatenate and truncate
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
combined = newCombined;
}
if (combined.length < requiredLength) {
throw new Error(`Combined seed too short: got ${combined.length}, expected ${requiredLength}`);
}
return combined.slice(0, requiredLength);
}
}
// ============================================================================
// 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 BIP32 PATHS
// ============================================================================
/**
* Derive all PQ keypairs from a BIP39 seed using BIP32 derivation paths.
*
* Paths (under m/44'/1237'/0'/0/):
* 1 — ML-DSA-44
* 2 — ML-DSA-65
* 3+4 — SLH-DSA-128s
* 5+6 — Falcon-512
* 7+8 — ML-KEM-768
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @returns {{
* mlDsa44: {publicKey: Uint8Array, secretKey: Uint8Array},
* mlDsa65: {publicKey: Uint8Array, secretKey: Uint8Array},
* slhDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
* falcon512: {publicKey: Uint8Array, secretKey: Uint8Array},
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
* }}
*/
export function derivePQKeysFromSeed(bip39Seed) {
// ML-DSA-44 (32-byte seed, path child 1)
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
// ML-DSA-65 (32-byte seed, path child 2)
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
// SLH-DSA-128s (48-byte seed, paths children 3+4 concatenated)
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
// Falcon-512 (48-byte seed, paths children 5+6 concatenated)
const falconSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
// ML-KEM-768 (64-byte seed, paths children 7+8 concatenated)
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
return {
mlDsa44: mlDsa44Keys,
mlDsa65: mlDsa65Keys,
slhDsa: slhDsaKeys,
falcon512: falconKeys,
mlKem: mlKemKeys
};
}
// ============================================================================
// PQ SIGNING
// ============================================================================
/**
* Sign a message with ML-DSA-44.
*/
export function signWithMLDSA44(message, secretKey) {
return ml_dsa44.sign(message, secretKey);
}
/**
* Verify an ML-DSA-44 signature.
*/
export function verifyMLDSA44(signature, message, publicKey) {
return ml_dsa44.verify(signature, message, publicKey);
}
/**
* Sign a message with ML-DSA-65.
*/
export function signWithMLDSA65(message, secretKey) {
return ml_dsa65.sign(message, secretKey);
}
/**
* Verify an ML-DSA-65 signature.
*/
export function verifyMLDSA65(signature, message, publicKey) {
return ml_dsa65.verify(signature, message, publicKey);
}
/**
* Sign a message with SLH-DSA-128s.
*/
export function signWithSLHDSA(message, secretKey) {
return slh_dsa_sha2_128s.sign(message, secretKey);
}
/**
* Verify an SLH-DSA-128s signature.
*/
export function verifySLHDSA(signature, message, publicKey) {
return slh_dsa_sha2_128s.verify(signature, message, publicKey);
}
/**
* Sign a message with Falcon-512.
*/
export function signWithFalcon(message, secretKey) {
return falcon512.sign(message, secretKey);
}
/**
* Verify a Falcon-512 signature.
*/
export function verifyFalcon(signature, message, publicKey) {
return falcon512.verify(signature, message, publicKey);
}
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Convert Uint8Array to base64 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.
*/
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.
*/
export function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hex string to Uint8Array.
* F-L3: Validates input format and length.
* @param {string} hex - hex string (must have even length, hex chars only)
* @returns {Uint8Array}
* @throws {Error} if input is malformed
*/
export function hexToBytes(hex) {
if (typeof hex !== 'string' || hex.length === 0) {
throw new Error('hexToBytes: expected non-empty hex string');
}
if (hex.length % 2 !== 0) {
throw new Error(`hexToBytes: odd-length hex string (${hex.length} chars)`);
}
if (!/^[0-9a-fA-F]*$/.test(hex)) {
throw new Error('hexToBytes: invalid hex characters');
}
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;
}
/**
* Convert a hex pubkey to npub (bech32 encoding).
* @param {string} hexPubkey - 32-byte hex pubkey
* @returns {string} npub1...
*/
export function hexToNpub(hexPubkey) {
const bytes = hexToBytes(hexPubkey);
return bech32.encode('npub', bech32.toWords(bytes));
}
/**
* Verify a Nostr event's secp256k1 Schnorr signature (NIP-01).
* Checks both the signature validity AND that event.id matches the computed hash.
*
* G56-07: This function accepts untrusted input (pasted JSON, relay events) and
* must never throw on malformed data. It returns `false` for any structurally
* invalid event.
*
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
* @returns {boolean} true if signature is valid and id matches; false otherwise (never throws)
*/
export function verifyNostrEvent(event) {
try {
// Structural validation — fail closed on wrong shape
if (!event || typeof event !== 'object') return false;
if (typeof event.pubkey !== 'string' || typeof event.sig !== 'string') return false;
if (typeof event.created_at !== 'number') return false;
if (typeof event.kind !== 'number') return false;
if (!Array.isArray(event.tags)) return false;
if (typeof event.content !== 'string') return false;
// Build the event hash (NIP-01 serialization)
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex(hash);
// F-H2: Verify event.id matches the computed hash
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
return false;
}
const sig = hexToBytes(event.sig);
const pubkey = hexToBytes(event.pubkey);
return schnorr.verify(sig, hash, pubkey);
} catch (e) {
// G56-07: never throw on untrusted malformed input
return false;
}
}
/**
* Validate the output of a NIP-07 signer's signEvent() call (G56-05).
*
* This ensures a malicious or buggy signer cannot substitute different content,
* tags, pubkey, or an invalid signature. It verifies that the signed event
* matches the template exactly (for the semantic fields), that the pubkey
* equals the expected identity, that the computed ID matches, and that the
* Schnorr signature is valid.
*
* Returns the validated signed event object (the signer's exact object, not a
* reconstruction) on success, or throws on any mismatch.
*
* @param {object} signedEvent - The object returned by window.nostr.signEvent()
* @param {object} template - The unsigned template that was passed to signEvent()
* @param {string} expectedPubkey - The hex pubkey of the authenticated identity
* @returns {object} the validated signed event
* @throws {Error} if any field is missing, mismatched, or the signature is invalid
*/
export function validateSignerOutput(signedEvent, template, expectedPubkey) {
if (!signedEvent || typeof signedEvent !== 'object') {
throw new Error('validateSignerOutput: signer returned non-object');
}
// 1. Check all required fields are present and correctly typed
const required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
for (const field of required) {
if (!(field in signedEvent)) {
throw new Error(`validateSignerOutput: missing field '${field}'`);
}
}
if (typeof signedEvent.id !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
throw new Error('validateSignerOutput: id must be 64 lowercase hex chars');
}
if (typeof signedEvent.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
throw new Error('validateSignerOutput: pubkey must be 64 lowercase hex chars');
}
if (typeof signedEvent.sig !== 'string' || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
throw new Error('validateSignerOutput: sig must be 128 lowercase hex chars');
}
if (typeof signedEvent.created_at !== 'number') {
throw new Error('validateSignerOutput: created_at must be a number');
}
if (typeof signedEvent.kind !== 'number') {
throw new Error('validateSignerOutput: kind must be a number');
}
if (!Array.isArray(signedEvent.tags)) {
throw new Error('validateSignerOutput: tags must be an array');
}
if (typeof signedEvent.content !== 'string') {
throw new Error('validateSignerOutput: content must be a string');
}
// 2. Verify no template field was changed by the signer
if (signedEvent.kind !== template.kind) {
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
}
if (signedEvent.content !== template.content) {
throw new Error('validateSignerOutput: content was changed by signer');
}
if (signedEvent.created_at !== template.created_at) {
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
}
// Deep-compare tags (order-sensitive, as tag order affects the event ID)
if (signedEvent.tags.length !== template.tags.length) {
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
}
for (let i = 0; i < template.tags.length; i++) {
const tTag = template.tags[i];
const sTag = signedEvent.tags[i];
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
}
for (let j = 0; j < tTag.length; j++) {
if (sTag[j] !== tTag[j]) {
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
}
}
}
// 3. Verify the pubkey matches the expected identity
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
}
// 4. Recompute the event ID and verify it matches
const computedId = computeEventId(signedEvent);
if (signedEvent.id !== computedId) {
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
}
// 5. Verify the Schnorr signature
const hash = sha256(new TextEncoder().encode(JSON.stringify([
0,
signedEvent.pubkey,
signedEvent.created_at,
signedEvent.kind,
signedEvent.tags,
signedEvent.content
])));
const sig = hexToBytes(signedEvent.sig);
const pubkey = hexToBytes(signedEvent.pubkey);
if (!schnorr.verify(sig, hash, pubkey)) {
throw new Error('validateSignerOutput: invalid Schnorr signature');
}
// 6. Reject extra unexpected fields (allow only the 7 NIP-01 fields)
const allowedFields = new Set(['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
for (const key of Object.keys(signedEvent)) {
if (!allowedFields.has(key)) {
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
}
}
return signedEvent;
}
// ============================================================================
// NIP-QR EVENT CONSTRUCTION
// ============================================================================
/**
* NIP-QR proof carrier event kind. Kind 9999 is a non-replaceable event
* (09999 range). Each publication is permanent — relays cannot replace it.
* Upgrades (e.g. pending → confirmed OTS proof) publish a NEW kind 9999 event
* with an `upgrade_of` tag referencing the previous one. Clients fetch all
* kind 9999 events for a pubkey and select the one with the earliest valid
* Bitcoin anchor.
*/
export const NIP_QR_KIND = 9999;
/**
* Build the kind 1 announcement event (unsigned template).
*
* This is a regular Nostr text note (kind 1) that serves as a public announcement
* of the post-quantum key migration. The content is human-readable text with
* newlines. The PQ public keys and signatures go in the tags.
*
* Each PQ key signs the content text (the attestation statement).
*
* Tag format: ['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']
* For ML-KEM (KEM, can't sign): ['algorithm', 'ml-kem-768', '<base64 pubkey>']
* Also: ['block_height', '<height>']
*
* @param {string} hexPubkey - The user's Nostr hex pubkey (Account #1)
* @param {number} blockHeight - Current Bitcoin block height for pre-quantum anchoring
* @param {object} pqKeys - PQ keypairs from derivePQKeysFromSeed()
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number, statementBytes: Uint8Array}}
*/
export function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
const npub = hexToNpub(hexPubkey);
// Human-readable attestation statement (signed by each PQ key)
// Uses newlines for nice display in Nostr clients
const content = `I am signaling that the post-quantum public keys listed in the tags of this event were generated by me and I hold the private keys. I may use these keys in the future as successors to my current Nostr identity.
My current identity:
npub: ${npub}
hex: ${hexPubkey}
This attestation is established pre-quantum at Bitcoin block height ${blockHeight}.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
Falcon-512 (FIPS 206 draft, NIST Level 1)
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/post-quantum/verify.html
Created at: https://laantungir.net/post-quantum/`;
const statementBytes = new TextEncoder().encode(content);
// Sign the content text with each PQ signature scheme
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
const mlDsa65Sig = signWithMLDSA65(statementBytes, pqKeys.mlDsa65.secretKey);
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
// Tags: block height + each PQ key's pubkey and signature
const tags = [
['block_height', String(blockHeight)],
['algorithm', 'ml-dsa-44', bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
['algorithm', 'ml-dsa-65', bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
['algorithm', 'slh-dsa-128s', bytesToBase64(pqKeys.slhDsa.publicKey), bytesToBase64(slhDsaSig)],
['algorithm', 'falcon-512', bytesToBase64(pqKeys.falcon512.publicKey), bytesToBase64(falconSig)],
['algorithm', 'ml-kem-768', bytesToBase64(pqKeys.mlKem.publicKey)]
];
return {
kind: 1,
content,
tags,
pubkey: hexPubkey,
created_at: Math.floor(Date.now() / 1000),
statementBytes
};
}
/**
* Compute the NIP-01 event ID for an event (before signing).
* The event ID is SHA-256 of JSON.stringify([0, pubkey, created_at, kind, tags, content]).
*
* @param {object} event - The event template (must have pubkey, created_at, kind, tags, content)
* @returns {string} hex event ID
*/
export function computeEventId(event) {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
return bytesToHex(sha256(new TextEncoder().encode(serialized)));
}
/**
* Compute the SHA-256 hash of the full signed event JSON (including id and sig).
* This is the hash that gets submitted to OpenTimestamps — it covers everything:
* content, tags, PQ signatures, AND the secp256k1 signature.
*
* @param {object} signedEvent - The fully signed kind 1 event (with id and sig)
* @returns {string} hex SHA-256 hash
*/
export function hashFullEvent(signedEvent) {
const json = JSON.stringify(signedEvent);
return bytesToHex(sha256(new TextEncoder().encode(json)));
}
/**
* Build the kind 9999 proof carrier event (unsigned template).
*
* This event wraps the kind 1 announcement and carries the OpenTimestamps proof.
* The content is the full kind 1 event as JSON (so verifiers don't need to fetch
* it from relays). The tags reference the kind 1 event ID and contain the OTS proof.
*
* Kind 9999 is non-replaceable. Each publication is permanent. Upgrades publish
* a new event with `upgrade_of` referencing the previous one.
*
* Tags:
* - ['e', '<kind1_event_id>'] — reference to the kind 1 announcement
* - ['sha256', '<hex hash of full kind 1 event JSON>'] — what was timestamped
* - ['ots', '<base64 .ots proof>'] — the OpenTimestamps proof
* - ['ots_status', 'pending'|'confirmed'] — quick UI indicator
* - ['upgrade_of', '<previous_proof_carrier_event_id>'] — only on upgrade events
*
* @param {object} kind1Event - The fully signed kind 1 event
* @param {Uint8Array} otsProof - The OTS proof bytes (pending or confirmed)
* @param {object} [options] - Optional parameters
* @param {string} [options.otsStatus='pending'] - 'pending' or 'confirmed'
* @param {string} [options.upgradeOf] - Previous proof carrier event ID (for upgrades)
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number}}
*/
export function buildProofCarrier(kind1Event, otsProof, options = {}) {
const fullHash = hashFullEvent(kind1Event);
const otsStatus = options.otsStatus || 'pending';
const tags = [
['e', kind1Event.id],
['sha256', fullHash],
['ots', bytesToBase64(otsProof)],
['ots_status', otsStatus]
];
if (options.upgradeOf) {
tags.push(['upgrade_of', options.upgradeOf]);
}
return {
kind: NIP_QR_KIND,
content: JSON.stringify(kind1Event),
tags,
pubkey: kind1Event.pubkey,
created_at: Math.floor(Date.now() / 1000)
};
}
/**
* Backward-compatible alias for buildProofCarrier.
* @deprecated Use buildProofCarrier() instead.
*/
export function buildKind11112Wrapper(kind1Event, otsProof) {
return buildProofCarrier(kind1Event, otsProof);
}
/**
* Build an upgraded proof carrier event (append-only, non-replacing).
*
* Instead of replacing the original event, this builds a NEW kind 9999 event
* with the upgraded OTS proof. The new event references the original via the
* `upgrade_of` tag. Both events remain on relays permanently.
*
* The `e` and `sha256` tags stay the same — they reference the same kind 1
* announcement. Only the `ots` proof and `ots_status` change.
*
* @param {object} originalEvent - The original proof carrier event (pending)
* @param {Uint8Array} upgradedOtsProof - The upgraded/confirmed OTS proof bytes
* @returns {{kind: number, created_at: number, tags: Array, content: string, pubkey: string}}
*/
export function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
// Extract the kind 1 event from the original content to rebuild tags cleanly
let kind1Event;
try {
kind1Event = JSON.parse(originalEvent.content);
} catch (e) {
// If we can't parse, fall back to copying tags (shouldn't happen in practice)
const tags = originalEvent.tags
.filter(t => t[0] !== 'ots' && t[0] !== 'ots_status' && t[0] !== 'upgrade_of')
.map(t => [...t]);
tags.push(['ots', bytesToBase64(upgradedOtsProof)]);
tags.push(['ots_status', 'confirmed']);
tags.push(['upgrade_of', originalEvent.id]);
return {
kind: NIP_QR_KIND,
created_at: Math.floor(Date.now() / 1000),
tags,
content: originalEvent.content,
pubkey: originalEvent.pubkey
};
}
// Build a clean new proof carrier with the upgraded proof
return buildProofCarrier(kind1Event, upgradedOtsProof, {
otsStatus: 'confirmed',
upgradeOf: originalEvent.id
});
}
/**
* Verify a NIP-QR proof carrier event's content and tags.
*
* The proof carrier content is the full kind 1 event as JSON. This function:
* 1. Parses the kind 1 event from the content
* 2. Verifies the kind 1 secp256k1 signature
* 3. Verifies the 'e' tag matches the kind 1 event ID
* 4. Verifies the 'sha256' tag matches SHA-256 of the full kind 1 event JSON
* 5. Verifies each PQ signature in the kind 1 tags against the content text
* 6. (G56-03) If expectedAuthor is provided, verifies identity binding:
* the proof carrier pubkey, embedded kind 1 pubkey, and expected author
* must all be equal.
*
* @param {string} kind11112Content - The proof carrier content (JSON string of kind 1 event)
* @param {Array} kind11112Tags - The proof carrier tags array
* @param {object} [options] - Optional parameters
* @param {string} [options.expectedAuthor] - Expected author pubkey (hex) for G56-03 identity binding
* @param {string} [options.outerPubkey] - The proof carrier event's pubkey (for G56-03 identity binding)
* @returns {{
* valid: boolean,
* results: Array,
* kind1Event: object|null,
* fullHash: string|null,
* sha256Valid: boolean,
* eTagValid: boolean,
* pqProofsValid: boolean,
* policySufficient: boolean,
* validForMigration: boolean,
* identityBound: boolean,
* errors: Array
* }}
*
* `valid` is retained for backward compatibility and means "every individual
* check that ran passed". `validForMigration` is the strict policy-gated
* result: it is true only when the secp signature, e tag, sha256 tag, all
* present PQ proofs, the mandatory algorithm policy, AND identity binding
* are all satisfied.
*/
export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
const results = [];
const errors = [];
const { expectedAuthor, outerPubkey } = options;
// G56-07: reject oversized content before any parsing/decoding work
if (typeof kind11112Content === 'string' && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
return {
results: [{ algorithm: 'event', valid: false, note: 'content exceeds maximum size' }],
kind1Event: null, fullHash: null, sha256Valid: false, eTagValid: false,
pqProofsValid: false, policySufficient: false, identityBound: false,
validForMigration: false, errors: ['content exceeds maximum size']
};
}
// --- Helper: push a result entry and track failures ---
function pushResult(algorithm, valid, note) {
results.push({ algorithm, valid, note });
if (!valid) errors.push(`${algorithm}: ${note || 'INVALID'}`);
}
// --- Helper: safe base64 decode (G56-07: fail closed, never throw) ---
function safeBase64ToBytes(b64, label) {
if (typeof b64 !== 'string' || b64.length === 0) {
throw new Error(`${label}: missing or empty`);
}
return base64ToBytes(b64); // may throw on malformed input
}
// 1. Parse the kind 1 event from the kind 11112 content
let kind1Event;
try {
kind1Event = JSON.parse(kind11112Content);
} catch (e) {
return {
valid: false,
results: [{ algorithm: 'kind 1 content', valid: false, note: 'Content is not valid JSON' }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ['Content is not valid JSON']
};
}
if (!kind1Event || typeof kind1Event !== 'object' ||
!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content ||
!Array.isArray(kind1Event.tags) || kind1Event.kind !== 1) {
return {
valid: false,
results: [{ algorithm: 'kind 1 content', valid: false, note: 'Embedded event is not a valid kind 1 event' }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ['Embedded event is not a valid kind 1 event']
};
}
// 2. Verify the kind 1 secp256k1 signature
let kind1SigValid = false;
try {
kind1SigValid = verifyNostrEvent(kind1Event);
} catch (e) {
kind1SigValid = false;
errors.push(`secp256k1 (kind 1): ${e.message}`);
}
pushResult('secp256k1 (kind 1 announcement)', kind1SigValid, kind1SigValid ? 'valid' : 'INVALID');
// 2b. G56-03: Identity binding check
//
// The proof carrier pubkey, the embedded kind 1 pubkey, and (if
// provided) the expected author must all be equal. Without this,
// identity A can wrap identity B's announcement and the verifier
// would say "valid" for both.
let identityBound = true;
const embeddedPubkey = kind1Event.pubkey.toLowerCase();
if (outerPubkey && outerPubkey.toLowerCase() !== embeddedPubkey) {
identityBound = false;
errors.push(`identity binding: outer pubkey ${outerPubkey.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
}
if (expectedAuthor && expectedAuthor.toLowerCase() !== embeddedPubkey) {
identityBound = false;
errors.push(`identity binding: expected author ${expectedAuthor.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
}
pushResult(
'identity binding (outer = embedded = author)',
identityBound,
identityBound ? 'all identities match' : 'identity mismatch'
);
// 3. Verify the 'e' tag matches the kind 1 event ID
const computedKind1Id = computeEventId(kind1Event);
const eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'e');
let eTagValid = false;
if (eTags.length !== 1) {
pushResult('e tag (kind 1 event ID)', false, eTags.length === 0 ? 'tag missing' : `duplicate e tags (${eTags.length})`);
} else {
const eTag = eTags[0];
if (eTag[1]) {
eTagValid = (eTag[1].toLowerCase() === computedKind1Id.toLowerCase());
// F-L4: Also check that kind1Event.id (if present) matches the computed id
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
eTagValid = false;
}
}
pushResult('e tag (kind 1 event ID)', eTagValid,
eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` :
`mismatch: e tag=${(eTag[1] || '').substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...`);
}
// 4. Verify the 'sha256' tag matches SHA-256 of the full kind 1 event JSON
const fullHash = hashFullEvent(kind1Event);
const sha256Tags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'sha256');
let sha256Valid = false;
if (sha256Tags.length !== 1) {
pushResult('sha256 (full kind 1 event hash)', false, sha256Tags.length === 0 ? 'tag missing' : `duplicate sha256 tags (${sha256Tags.length})`);
} else {
const sha256Tag = sha256Tags[0];
if (sha256Tag[1]) {
sha256Valid = (sha256Tag[1].toLowerCase() === fullHash.toLowerCase());
}
pushResult('sha256 (full kind 1 event hash)', sha256Valid,
sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` :
`mismatch: tag=${(sha256Tag[1] || '').substring(0, 16)}... computed=${fullHash.substring(0, 16)}...`);
}
// 5. Verify PQ signatures with strict algorithm policy (G56-01 fix)
//
// Policy: the kind 1 event MUST contain exactly one tag for each
// mandatory signature algorithm and exactly one tag for the KEM
// algorithm. Missing signatures on signature algorithms are FAILURES,
// not silent KEM-style successes. Unknown algorithms are reported as
// ignored (not valid evidence, not a failure unless duplicates of a
// known algorithm are present).
const MANDATORY_SIGNATURE_ALGORITHMS = ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512'];
const KEM_ALGORITHMS = ['ml-kem-768'];
const KNOWN_ALGORITHMS = new Set([...MANDATORY_SIGNATURE_ALGORITHMS, ...KEM_ALGORITHMS]);
// Expected public-key byte lengths for each known algorithm
const EXPECTED_PUBKEY_LENGTHS = {
'ml-dsa-44': 1312,
'ml-dsa-65': 1952,
'slh-dsa-128s': 32,
'falcon-512': 897,
'ml-kem-768': 1184,
};
const msg = new TextEncoder().encode(kind1Event.content);
// Collect all algorithm tags, grouped by algorithm ID
const algorithmTags = []; // [{algorithm, pubKeyBase64, sigBase64, tag}]
const algorithmCounts = {}; // algorithm -> count
for (const tag of kind1Event.tags) {
if (!Array.isArray(tag) || tag[0] !== 'algorithm') continue;
const algorithm = tag[1];
if (typeof algorithm !== 'string') {
pushResult('algorithm tag', false, 'algorithm identifier is not a string');
continue;
}
algorithmCounts[algorithm] = (algorithmCounts[algorithm] || 0) + 1;
algorithmTags.push({
algorithm,
pubKeyBase64: tag[2],
sigBase64: tag[3],
tag
});
}
// Check for duplicates of known algorithms
for (const alg of KNOWN_ALGORITHMS) {
if (algorithmCounts[alg] > 1) {
pushResult(alg, false, `duplicate algorithm tags (${algorithmCounts[alg]})`);
}
}
// Verify each known algorithm tag
let pqProofsValid = true; // all present PQ proofs verify
for (const entry of algorithmTags) {
const { algorithm, pubKeyBase64, sigBase64 } = entry;
// Unknown algorithms: report as ignored, not valid evidence
if (!KNOWN_ALGORITHMS.has(algorithm)) {
pushResult(algorithm, false, 'unknown algorithm (ignored — not counted as valid evidence)');
continue;
}
const isKEM = KEM_ALGORITHMS.includes(algorithm);
const isSignature = MANDATORY_SIGNATURE_ALGORITHMS.includes(algorithm);
// Validate public key presence and decode
let pubKey;
try {
pubKey = safeBase64ToBytes(pubKeyBase64, `${algorithm} public key`);
} catch (e) {
pushResult(algorithm, false, `public key decode failed: ${e.message}`);
pqProofsValid = false;
continue;
}
// Validate public key length
const expectedLen = EXPECTED_PUBKEY_LENGTHS[algorithm];
if (pubKey.length !== expectedLen) {
pushResult(algorithm, false, `public key length mismatch: got ${pubKey.length}, expected ${expectedLen}`);
pqProofsValid = false;
continue;
}
if (isKEM) {
// KEM: no signature to verify. Report as present and valid
// (the pubkey is authorized by the secp signature over the
// event). This is NOT a cryptographic proof of possession.
pushResult(algorithm, true, 'KEM public key present (no signature — authorized by secp event signature)');
continue;
}
// Signature algorithm: signature is MANDATORY
if (!sigBase64) {
pushResult(algorithm, false, 'missing signature (signature algorithms MUST have a signature field)');
pqProofsValid = false;
continue;
}
let sig;
try {
sig = safeBase64ToBytes(sigBase64, `${algorithm} signature`);
} catch (e) {
pushResult(algorithm, false, `signature decode failed: ${e.message}`);
pqProofsValid = false;
continue;
}
// Verify the PQ signature
let valid = false;
try {
if (algorithm === 'ml-dsa-44') {
valid = verifyMLDSA44(sig, msg, pubKey);
} else if (algorithm === 'ml-dsa-65') {
valid = verifyMLDSA65(sig, msg, pubKey);
} else if (algorithm === 'slh-dsa-128s') {
valid = verifySLHDSA(sig, msg, pubKey);
} else if (algorithm === 'falcon-512') {
valid = verifyFalcon(sig, msg, pubKey);
}
} catch (e) {
pushResult(algorithm, false, `verification threw: ${e.message}`);
pqProofsValid = false;
continue;
}
pushResult(algorithm, valid, valid ? 'valid' : 'INVALID signature');
if (!valid) pqProofsValid = false;
}
// 6. Check mandatory algorithm policy (G56-01 core fix)
//
// policySufficient is true only when every mandatory signature
// algorithm AND the KEM algorithm are present exactly once and all
// present PQ proofs verify.
const policyErrors = [];
for (const alg of MANDATORY_SIGNATURE_ALGORITHMS) {
const count = algorithmCounts[alg] || 0;
if (count === 0) {
policyErrors.push(`missing mandatory algorithm: ${alg}`);
} else if (count > 1) {
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
}
}
for (const alg of KEM_ALGORITHMS) {
const count = algorithmCounts[alg] || 0;
if (count === 0) {
policyErrors.push(`missing KEM algorithm: ${alg}`);
} else if (count > 1) {
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
}
}
const policySufficient = policyErrors.length === 0 && pqProofsValid;
if (policyErrors.length > 0) {
for (const e of policyErrors) errors.push(e);
}
// 7. Compute final results
const allChecksPassed = results.every(r => r.valid);
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound;
return {
valid: allChecksPassed,
results,
kind1Event,
fullHash,
sha256Valid,
eTagValid,
pqProofsValid,
policySufficient,
validForMigration,
identityBound,
errors
};
}
// ============================================================================
// CANONICAL PROOF CARRIER SELECTION (G56-02)
// ============================================================================
//
// When multiple proof carrier events exist for the same pubkey (e.g. a
// pending one and a later confirmed one, or multiple migration attempts),
// the client must select the one with the earliest valid Bitcoin anchor.
// This implements the "earliest valid anchor wins" rule from the NIP proposal.
/**
* Select the canonical proof carrier from an array of candidates.
*
* This function:
* 1. Filters candidates whose `e` tag matches the kind 1 event ID
* 2. Filters candidates whose `sha256` tag matches the kind 1 event hash
* 3. Filters candidates with a valid secp256k1 signature
* 4. For each remaining candidate, verifies its OTS proof against the
* expected digest (the sha256 tag)
* 5. Among candidates with a verified Bitcoin attestation, selects the
* one with the earliest block height (deterministic tie-break by
* event ID lexicographically)
* 6. If no candidate has a confirmed Bitcoin anchor, returns the best
* pending candidate
*
* @param {Array} candidates - Array of proof carrier events (kind 9999)
* @param {object} kind1Event - The kind 1 announcement event they reference
* @param {string} [expectedAuthor] - Expected author pubkey (hex) for identity binding
* @returns {Promise<{canonical: object|null, allCandidates: Array, confirmedCandidates: Array, pendingCandidates: Array, errors: Array}>}
*/
export async function selectCanonicalProofCarrier(candidates, kind1Event, expectedAuthor) {
const errors = [];
if (!Array.isArray(candidates) || candidates.length === 0) {
return { canonical: null, allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ['No candidates provided'] };
}
if (!kind1Event || !kind1Event.id) {
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ['No kind 1 event provided'] };
}
const expectedKind1Id = kind1Event.id.toLowerCase();
const expectedHash = hashFullEvent(kind1Event).toLowerCase();
// Step 1-3: Filter candidates by e tag, sha256 tag, and secp signature
const validCandidates = [];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
if (candidate.kind !== NIP_QR_KIND) {
errors.push(`candidate ${candidate.id || '?'}: wrong kind ${candidate.kind}`);
continue;
}
// Check e tag matches kind 1 event ID
const eTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'e');
if (!eTag || !eTag[1] || eTag[1].toLowerCase() !== expectedKind1Id) {
errors.push(`candidate ${candidate.id || '?'}: e tag does not match kind 1 event ID`);
continue;
}
// Check sha256 tag matches kind 1 event hash
const sha256Tag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'sha256');
if (!sha256Tag || !sha256Tag[1] || sha256Tag[1].toLowerCase() !== expectedHash) {
errors.push(`candidate ${candidate.id || '?'}: sha256 tag does not match kind 1 event hash`);
continue;
}
// Check secp signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(candidate);
} catch (e) {
secpValid = false;
}
if (!secpValid) {
errors.push(`candidate ${candidate.id || '?'}: invalid secp256k1 signature`);
continue;
}
// G56-03: Check identity binding if expectedAuthor is provided
if (expectedAuthor && candidate.pubkey && candidate.pubkey.toLowerCase() !== expectedAuthor.toLowerCase()) {
errors.push(`candidate ${candidate.id || '?'}: pubkey does not match expected author`);
continue;
}
validCandidates.push(candidate);
}
if (validCandidates.length === 0) {
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors };
}
// Step 4: Verify OTS proof for each valid candidate
const confirmedCandidates = [];
const pendingCandidates = [];
for (const candidate of validCandidates) {
const otsTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'ots');
if (!otsTag || !otsTag[1]) {
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: ['no ots tag'] });
continue;
}
let otsBytes;
try {
otsBytes = base64ToBytes(otsTag[1]);
} catch (e) {
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`] });
continue;
}
try {
const verifyResult = await verifyOtsProof(otsBytes, expectedHash);
if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) {
confirmedCandidates.push({
event: candidate,
bitcoinHeight: verifyResult.bitcoinAttestations[0].height,
bitcoinTime: verifyResult.bitcoinAttestations[0].time,
errors: []
});
} else {
pendingCandidates.push({
event: candidate,
bitcoinHeight: null,
errors: verifyResult.errors
});
}
} catch (e) {
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`] });
}
}
// Step 5: Among confirmed candidates, select earliest Bitcoin block height
if (confirmedCandidates.length > 0) {
confirmedCandidates.sort((a, b) => {
// Primary sort: lowest Bitcoin block height
if (a.bitcoinHeight !== b.bitcoinHeight) {
return a.bitcoinHeight - b.bitcoinHeight;
}
// Tie-break: lowest event ID lexicographically (deterministic)
return (a.event.id || '').localeCompare(b.event.id || '');
});
return {
canonical: confirmedCandidates[0].event,
canonicalBitcoinHeight: confirmedCandidates[0].bitcoinHeight,
canonicalBitcoinTime: confirmedCandidates[0].bitcoinTime,
allCandidates: candidates,
confirmedCandidates,
pendingCandidates,
errors
};
}
// Step 6: No confirmed candidates — return the best pending one
// (earliest created_at, tie-break by event ID)
if (pendingCandidates.length > 0) {
pendingCandidates.sort((a, b) => {
if (a.event.created_at !== b.event.created_at) {
return a.event.created_at - b.event.created_at;
}
return (a.event.id || '').localeCompare(b.event.id || '');
});
return {
canonical: pendingCandidates[0].event,
canonicalBitcoinHeight: null,
canonicalBitcoinTime: null,
allCandidates: candidates,
confirmedCandidates: [],
pendingCandidates,
errors
};
}
return {
canonical: null,
canonicalBitcoinHeight: null,
canonicalBitcoinTime: null,
allCandidates: candidates,
confirmedCandidates: [],
pendingCandidates: [],
errors
};
}
// ============================================================================
// KEY SIZE INFO (for display)
// ============================================================================
export const PQ_KEY_INFO = {
'ml-dsa-44': {
name: 'ML-DSA-44 (Dilithium)',
publicKeySize: 1312,
signatureSize: 2420,
fips: 'FIPS 204',
type: 'signature',
securityLevel: 'Category 2 (~AES-128)',
derivationPath: "m/44'/1237'/0'/0/1"
},
'ml-dsa-65': {
name: 'ML-DSA-65 (Dilithium)',
publicKeySize: 1952,
signatureSize: 3309,
fips: 'FIPS 204',
type: 'signature',
securityLevel: 'Category 3 (~AES-192)',
derivationPath: "m/44'/1237'/0'/0/2"
},
'slh-dsa-128s': {
name: 'SLH-DSA-128s (SPHINCS+)',
publicKeySize: 32,
signatureSize: 7856,
fips: 'FIPS 205',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, hash-based)',
derivationPath: "m/44'/1237'/0'/0/3+4"
},
'falcon-512': {
name: 'Falcon-512',
publicKeySize: 897,
signatureSize: 666,
fips: 'FIPS 206 (draft)',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, lattice-based)',
derivationPath: "m/44'/1237'/0'/0/5+6"
},
'ml-kem-768': {
name: 'ML-KEM-768 (Kyber)',
publicKeySize: 1184,
ciphertextSize: 1088,
fips: 'FIPS 203',
type: 'kem',
securityLevel: 'Category 3 (~AES-192)',
derivationPath: "m/44'/1237'/0'/0/7+8"
}
};
// ============================================================================
// OPENTIMESTAMPS (NIP-03)
// ============================================================================
// Calendar servers to submit to. We submit to ALL of them in parallel and
// merge the returned fragments into a single .ots proof with multiple
// attestation branches. This provides redundancy (if one calendar goes
// offline, others still provide a path to Bitcoin confirmation) and faster
// confirmation (whichever calendar gets the hash into a Bitcoin block first
// wins). Servers are ordered by observed uptime/reliability.
const OTS_CALENDAR_SERVERS = [
'https://alice.btc.calendar.opentimestamps.org',
'https://bob.btc.calendar.opentimestamps.org',
'https://a.pool.opentimestamps.org',
'https://b.pool.opentimestamps.org',
'https://ots.btc.catallaxy.com',
];
// Detached .ots file prefix:
// magic header (31 bytes) + version 1 varuint + SHA-256 operation tag (0x08).
const OTS_DETACHED_PREFIX = hexToBytes(
'004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294' +
'01' +
'08'
);
function concatBytes(...arrays) {
const length = arrays.reduce((sum, bytes) => sum + bytes.length, 0);
const result = new Uint8Array(length);
let offset = 0;
for (const bytes of arrays) {
result.set(bytes, offset);
offset += bytes.length;
}
return result;
}
/**
* Check whether bytes begin with the detached .ots magic header.
* @param {Uint8Array} otsBytes
* @returns {boolean}
*/
export function isDetachedOtsFile(otsBytes) {
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_DETACHED_PREFIX.length) return false;
for (let i = 0; i < OTS_DETACHED_PREFIX.length; i++) {
if (otsBytes[i] !== OTS_DETACHED_PREFIX[i]) return false;
}
return true;
}
/**
* Submit a hash to a single OpenTimestamps calendar server.
* Returns the raw timestamp fragment bytes (NOT a full .ots file).
*
* @param {string} server - Calendar server base URL
* @param {Uint8Array} hashBytes - 32-byte SHA-256 hash to timestamp
* @returns {Promise<Uint8Array>} timestamp fragment bytes
*/
async function submitToCalendar(server, hashBytes) {
const response = await fetch(`${server}/digest`, {
method: 'POST',
body: hashBytes,
headers: {
'Accept': 'application/vnd.opentimestamps.v1',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (!response.ok) {
throw new Error(`Calendar ${server} returned HTTP ${response.status}`);
}
const fragmentBuffer = await response.arrayBuffer();
return new Uint8Array(fragmentBuffer);
}
/**
* Submit a hash to OpenTimestamps for timestamping.
*
* Submits to ALL configured calendar servers in parallel and merges the
* returned fragments into a single detached .ots file with multiple
* attestation branches. This provides:
* - Redundancy: if one calendar goes offline, others still provide a path
* to Bitcoin confirmation.
* - Faster confirmation: whichever calendar gets the hash into a Bitcoin
* block first wins.
* - Stronger proof: multiple attestations are harder to forge.
*
* The .ots timestamp tree format supports multiple parallel attestations via
* the 0xff continuation marker. Each calendar's fragment is a self-contained
* timestamp subtree (ops + pending attestation). We join them at the top
* level: [0xff][fragment1][0xff][fragment2]...[fragmentN].
*
* @param {string} eventIdHex - The Nostr event id (hex string, 32 bytes)
* @returns {Promise<Uint8Array>} pending .ots file bytes with multiple calendar attestations
*/
export async function timestampEvent(eventIdHex) {
const hashBytes = hexToBytes(eventIdHex);
// Submit to all calendar servers in parallel
const results = await Promise.allSettled(
OTS_CALENDAR_SERVERS.map(server => submitToCalendar(server, hashBytes))
);
// Collect successful fragments
const fragments = [];
const succeeded = [];
const failed = [];
results.forEach((result, i) => {
const server = OTS_CALENDAR_SERVERS[i];
if (result.status === 'fulfilled' && result.value && result.value.length > 0) {
fragments.push(result.value);
succeeded.push(server);
console.log(`[ots] Calendar ${server} returned fragment (${result.value.length} bytes)`);
} else {
const reason = result.status === 'rejected' ? result.reason.message : 'empty response';
failed.push(`${server}: ${reason}`);
console.warn(`[ots] Calendar ${server} failed: ${reason}`);
}
});
if (fragments.length === 0) {
throw new Error(`All OpenTimestamps calendar servers failed (${failed.join('; ')})`);
}
// Merge fragments into a single .ots file.
//
// The detached .ots file format is:
// [magic header][version][hash op tag][32-byte digest][timestamp tree]
//
// The timestamp tree for a single attestation is just the fragment bytes.
// For multiple attestations, we use 0xff continuation markers to join them:
// [0xff][fragment1][0xff][fragment2]...[fragmentN]
//
// The 0xff byte means "another attestation/op follows for this timestamp
// node." The last fragment has no 0xff prefix. This is the same format the
// reference `ots-cli stamp` tool produces when stamping with multiple
// calendars.
const FF = new Uint8Array([0xff]);
const treeParts = [];
for (let i = 0; i < fragments.length; i++) {
if (i < fragments.length - 1) {
treeParts.push(FF, fragments[i]);
} else {
treeParts.push(fragments[i]);
}
}
const timestampTree = concatBytes(...treeParts);
const otsBytes = concatBytes(OTS_DETACHED_PREFIX, hashBytes, timestampTree);
console.log(`[ots] Built merged .ots file (${otsBytes.length} bytes) from ${fragments.length}/${OTS_CALENDAR_SERVERS.length} calendars (${succeeded.join(', ')})`);
if (failed.length > 0) {
console.warn(`[ots] ${failed.length} calendar(s) failed: ${failed.join('; ')}`);
}
return otsBytes;
}
/**
* Upgrade a detached .ots file using the same-origin standards-based helper.
*
* @param {Uint8Array} otsBytes - The pending or partially upgraded .ots bytes
* @returns {Promise<{proof: Uint8Array, changed: boolean, confirmed: boolean, detail: string}>}
*/
export async function upgradeOts(otsBytes) {
const upgradeUrl = (typeof window !== 'undefined' && window.location)
? `${window.location.origin}/ots-upgrade`
: 'https://laantungir.net/ots-upgrade';
const response = await fetch(upgradeUrl, {
method: 'POST',
body: otsBytes,
headers: {
'Content-Type': 'application/octet-stream',
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`OTS upgrade helper returned HTTP ${response.status}`);
}
const result = await response.json();
if (!result.proof) {
throw new Error(result.error || 'OTS upgrade helper returned no proof');
}
return {
proof: base64ToBytes(result.proof),
changed: Boolean(result.changed),
confirmed: Boolean(result.confirmed),
detail: String(result.stderr || result.stdout || '').trim()
};
}
/**
* Check if an .ots file contains a Bitcoin attestation tag.
*
* NOTE: This is a quick structural check (parses the OTS op stream to find
* attestation tags), NOT a cryptographic verification. It only tells you that
* a Bitcoin attestation *appears* in the proof. To cryptographically verify
* that the attestation is valid (Merkle path + block header), use
* `verifyOtsProof()` instead.
*
* @param {Uint8Array} otsBytes - The .ots file bytes
* @returns {boolean} true if the .ots file structurally contains a Bitcoin attestation
*/
export function isOtsConfirmed(otsBytes) {
try {
const parsed = parseOtsFile(otsBytes);
if (!parsed) return false;
return parsed.attestations.some(a => a.type === 'bitcoin');
} catch (e) {
// Fall back to the legacy byte-pattern search if parsing fails.
// This handles malformed proofs gracefully but is NOT a verification.
const bitcoinTag = hexToBytes('0588960d73d71901');
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
for (let j = 0; j < bitcoinTag.length; j++) {
if (otsBytes[i + j] !== bitcoinTag[j]) continue outer;
}
return true;
}
return false;
}
}
// ============================================================================
// OTS BINARY FORMAT PARSER
// ============================================================================
//
// The OTS detached file format (binary):
// magic header (31 bytes) + version varuint + file hash op tag + file digest
// + timestamp tree (recursive: tag/attestation or op + result + subtree)
//
// Operation tags:
// 0x00 = attestation (followed by 8-byte attestation tag + varbytes payload)
// 0xff = continuation marker (more ops/attestations follow for this timestamp)
// 0x08 = SHA-256
// 0x02 = SHA-1
// 0x03 = RIPEMD160
// 0xf0 = append (varbytes arg)
// 0xf1 = prepend (varbytes arg)
// 0xf2 = reverse
//
// Attestation tags (8 bytes):
// 0588960d73d71901 = BitcoinBlockHeaderAttestation (payload: varuint height)
// 06869a0d73d71b45 = LitecoinBlockHeaderAttestation
// 83df830d1c9d4a51 = PendingAttestation (payload: varbytes URI)
/**
* Binary stream reader for OTS deserialization.
*/
class OtsReader {
constructor(bytes) {
this.bytes = bytes;
this.pos = 0;
}
readByte() {
if (this.pos >= this.bytes.length) throw new Error('OTS: unexpected end of data');
return this.bytes[this.pos++];
}
readBytes(n) {
if (this.pos + n > this.bytes.length) throw new Error('OTS: unexpected end of data');
const out = this.bytes.slice(this.pos, this.pos + n);
this.pos += n;
return out;
}
// G56-13: Maximum varuint value and encoding length to prevent overflow
static MAX_VARUINT = 0xffffffff; // 32-bit max — OTS heights/lengths won't exceed this
static MAX_VARUINT_BYTES = 5; // 5 bytes is enough for 32-bit values
readVaruint() {
// G56-13: Use safe arithmetic instead of bitwise ops (which are signed 32-bit)
let value = 0;
let shift = 0;
let bytesRead = 0;
let b;
do {
b = this.readByte();
bytesRead++;
if (bytesRead > OtsReader.MAX_VARUINT_BYTES) {
throw new Error('OTS: varuint encoding too long');
}
value += (b & 0x7f) * Math.pow(2, shift);
shift += 7;
if (value > OtsReader.MAX_VARUINT) {
throw new Error(`OTS: varuint value ${value} exceeds maximum ${OtsReader.MAX_VARUINT}`);
}
} while (b & 0x80);
return value;
}
readVarbytes(maxLen = 4096) {
const len = this.readVaruint();
if (len > maxLen) throw new Error(`OTS: varbytes length ${len} exceeds max ${maxLen}`);
return this.readBytes(len);
}
remaining() {
return this.bytes.length - this.pos;
}
}
// OTS magic header (31 bytes)
const OTS_MAGIC = hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
// G56-07: Maximum sizes for untrusted inputs. Reject larger inputs before any
// parsing or cryptographic work to prevent denial-of-service via oversized data.
const MAX_OTS_PROOF_SIZE = 1 << 20; // 1 MiB — generous for heavily upgraded proofs
const MAX_EVENT_JSON_SIZE = 1 << 16; // 64 KiB — NIP-QR events are typically 30-50 KiB
// Attestation tags
const BITCOIN_ATTESTATION_TAG = hexToBytes('0588960d73d71901');
const LITECOIN_ATTESTATION_TAG = hexToBytes('06869a0d73d71b45');
const PENDING_ATTESTATION_TAG = hexToBytes('83dfe30d2ef90c8e');
function bytesEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
/**
* Parse the OTS detached timestamp file.
*
* @param {Uint8Array} otsBytes
* @returns {{fileHashOp: string, targetDigest: Uint8Array, attestations: Array, timestamp: object}|null}
* attestations: [{type: 'bitcoin'|'litecoin'|'pending'|'unknown', height?: number, uri?: string, digest: Uint8Array}]
* The `digest` in each attestation is the computed merkle root / commitment
* that the attestation covers (after walking the op tree from the target).
*/
export function parseOtsFile(otsBytes) {
// G56-07: fail closed on untrusted input — never throw
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
// G56-07: reject oversized proofs before any parsing work
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
try {
const reader = new OtsReader(otsBytes);
// 1. Magic header
const magic = reader.readBytes(OTS_MAGIC.length);
if (!bytesEqual(magic, OTS_MAGIC)) return null;
// 2. Version (varuint) — we support major version 1
const version = reader.readVaruint();
if (version !== 1) return null;
// 3. File hash operation tag (1 byte)
const hashOpTag = reader.readByte();
let fileHashOp = 'unknown';
let digestLen = 32;
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
else { return null; } // unsupported hash op
// 4. File digest (the target — what was timestamped)
const targetDigest = reader.readBytes(digestLen);
// 5. Timestamp tree
const attestations = [];
// G56-13: pass operation/branch budget to prevent DoS
const budget = { operations: 0, branches: 0 };
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
return { fileHashOp, targetDigest, attestations };
} catch (e) {
// G56-07: never throw on malformed/truncated untrusted input
return null;
}
}
/**
* Recursively parse a timestamp node, collecting attestations with their
* computed commitment digests (the result of walking the op tree).
*
* The OTS timestamp format for a node (per the reference implementation):
* Read a byte.
* While that byte is 0xff (continuation marker):
* Read the next byte as the actual tag; process it.
* Read another byte.
* Process the final (non-0xff) tag.
*
* So a node is a sequence of one or more entries. 0xff prefixes all entries
* except the last one. Each entry is either:
* - 0x00 = attestation (8-byte tag + varbytes payload)
* - other = operation tag; apply op to current msg → result; recurse into subtree with result
*/
// G56-13: Budget limits for the OTS parser
const OTS_MAX_OPERATIONS = 10000; // total operations across the entire proof
const OTS_MAX_BRANCHES = 1000; // total continuation (0xff) branches
function _parseTimestamp(reader, msg, attestations, depth, budget) {
// The OTS timestamp tree can be deeply nested, especially after many
// upgrade cycles (each upgrade can add new branches/operations). The
// reference Python implementation doesn't impose a low limit here.
// 512 is generous enough for heavily upgraded proofs while still
// guarding against pathological/malicious inputs.
if (depth > 512) throw new Error('OTS: recursion limit exceeded');
let tag = reader.readByte();
while (tag === 0xff) {
// G56-13: track branch count
if (budget) {
budget.branches++;
if (budget.branches > OTS_MAX_BRANCHES) {
throw new Error(`OTS: branch count exceeds maximum ${OTS_MAX_BRANCHES}`);
}
}
// Continuation: read the actual tag, process it, then read the next byte
const current = reader.readByte();
_processTimestampEntry(reader, current, msg, attestations, depth, budget);
tag = reader.readByte();
}
// Process the final (non-0xff) entry
_processTimestampEntry(reader, tag, msg, attestations, depth, budget);
}
/**
* Process a single timestamp entry (attestation or operation+subtree).
*/
function _processTimestampEntry(reader, tag, msg, attestations, depth, budget) {
// G56-13: track total operations
if (budget) {
budget.operations++;
if (budget.operations > OTS_MAX_OPERATIONS) {
throw new Error(`OTS: operation count exceeds maximum ${OTS_MAX_OPERATIONS}`);
}
}
if (tag === 0x00) {
// Attestation
const attTag = reader.readBytes(8);
const payload = reader.readVarbytes(8192);
_classifyAttestation(attTag, payload, msg, attestations);
} else {
// Operation — apply to msg, then recurse into the subtree
const result = _applyOp(reader, tag, msg);
_parseTimestamp(reader, result, attestations, depth + 1, budget);
}
}
/**
* Apply an OTS operation to a message, returning the result.
* May read additional bytes from the reader (for binary ops with args).
*/
function _applyOp(reader, tag, msg) {
if (tag === 0x08) {
// SHA-256
return sha256(msg);
} else if (tag === 0x02) {
// SHA-1
return sha1(msg);
} else if (tag === 0x03) {
// RIPEMD160
return ripemd160(msg);
} else if (tag === 0xf0) {
// Append
const arg = reader.readVarbytes(4096);
return concatBytes(msg, arg);
} else if (tag === 0xf1) {
// Prepend
const arg = reader.readVarbytes(4096);
return concatBytes(arg, msg);
} else if (tag === 0xf2) {
// Reverse
return msg.slice().reverse();
} else {
throw new Error(`OTS: unknown operation tag 0x${tag.toString(16)}`);
}
}
/**
* Classify an attestation and add it to the attestations list.
*/
function _classifyAttestation(tag, payload, digest, attestations) {
if (bytesEqual(tag, BITCOIN_ATTESTATION_TAG)) {
// Payload: varuint height
const r = new OtsReader(payload);
const height = r.readVaruint();
attestations.push({ type: 'bitcoin', height, digest });
} else if (bytesEqual(tag, LITECOIN_ATTESTATION_TAG)) {
const r = new OtsReader(payload);
const height = r.readVaruint();
attestations.push({ type: 'litecoin', height, digest });
} else if (bytesEqual(tag, PENDING_ATTESTATION_TAG)) {
const r = new OtsReader(payload);
const uri = new TextDecoder().decode(r.readVarbytes(1024));
attestations.push({ type: 'pending', uri, digest });
} else {
attestations.push({ type: 'unknown', tag, digest });
}
}
// ============================================================================
// OTS CRYPTOGRAPHIC VERIFICATION
// ============================================================================
/**
* Bitcoin block explorer API providers.
*
* G56-04: These are trusted public APIs, NOT a Bitcoin light-client. The block
* headers they return are NOT independently verified against PoW, difficulty, or
* chain linkage. We cross-check both providers and fail on disagreement to make
* coordinated false responses harder, but this is still "multi-explorer-checked"
* trust, not "header-chain-verified" or trustless.
*/
const BITCOIN_API_PROVIDERS = [
{ name: 'blockstream', blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
{ name: 'mempool', blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` },
];
/**
* Fetch a Bitcoin block header (merkle root + timestamp) from public explorer APIs.
*
* G56-04: This is NOT lite-client verification. We trust the APIs for the block
* header data. To make coordinated false responses harder, we query ALL providers
* and require them to agree on the merkle root. If they disagree, we fail.
*
* Trust mode: 'multi-explorer-checked' if all queried providers agree.
* This is stronger than single-provider trust but is NOT trustless — a
* compromise of both providers could still falsify a result.
*
* @param {number} height - Bitcoin block height
* @returns {Promise<{merkleroot: string, time: number, height: number, trustMode: string, providers: string[]}>}
* @throws {Error} if providers disagree or all fail
*/
async function fetchBitcoinBlockHeader(height) {
const results = [];
for (const provider of BITCOIN_API_PROVIDERS) {
try {
// 1. Get block hash from height
const hashResp = await fetch(provider.blockHash(height), { headers: { 'Accept': 'text/plain' } });
if (!hashResp.ok) continue;
const blockHash = (await hashResp.text()).trim();
if (!blockHash || blockHash.length !== 64) continue;
// 2. Get block header info (merkle_root, timestamp)
const blockResp = await fetch(provider.block(blockHash), { headers: { 'Accept': 'application/json' } });
if (!blockResp.ok) continue;
const blockData = await blockResp.json();
if (!blockData.merkle_root && !blockData.merkleroot) continue;
if (!blockData.timestamp && !blockData.time) continue;
results.push({
provider: provider.name,
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
time: blockData.timestamp || blockData.time,
height
});
} catch (e) {
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
continue;
}
}
if (results.length === 0) {
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
}
// G56-04: Cross-check — if multiple providers responded, they must agree
if (results.length > 1) {
const firstRoot = results[0].merkleroot;
for (let i = 1; i < results.length; i++) {
if (results[i].merkleroot !== firstRoot) {
const providerList = results.map(r => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(', ');
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
}
}
}
// Determine trust mode based on how many providers agreed
const trustMode = results.length >= 2
? 'multi-explorer-checked'
: 'single-explorer-checked';
return {
merkleroot: results[0].merkleroot,
time: results[0].time,
height,
trustMode,
providers: results.map(r => r.provider)
};
}
/**
* Verify an OTS proof against an expected target digest.
*
* G56-04: This performs Merkle-path verification against block headers obtained
* from public explorer APIs. The block headers are NOT independently verified
* against Bitcoin PoW/difficulty/chain-linkage — they are trusted from the APIs.
* The `trustMode` field in the result indicates the trust level:
* - 'multi-explorer-checked': multiple providers agreed on the merkle root
* - 'single-explorer-checked': only one provider responded
* - 'structural-only': no Bitcoin attestation was verified (pending/none)
*
* Steps:
* 1. Parses the OTS file (op stream, not byte-pattern search)
* 2. Checks the proof's target digest equals the expected digest (F-C3 fix)
* 3. Walks the op tree to compute the commitment at each attestation
* 4. For Bitcoin attestations: fetches the block header (cross-checked across
* providers) and checks the computed merkle root matches
*
* @param {Uint8Array} otsBytes - The .ots file bytes
* @param {string} [expectedDigestHex] - The expected target digest (hex). If
* provided, the proof's target digest must match this exactly (F-C3 binding).
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, trustMode: string, errors: Array}>}
* - verified: true if at least one Bitcoin attestation's merkle root matches
* - targetDigest: hex of the proof's target digest
* - attestations: all attestations found (parsed)
* - bitcoinAttestations: verified Bitcoin attestations with block time + height
* - trustMode: 'multi-explorer-checked' | 'single-explorer-checked' | 'structural-only'
* - errors: list of error messages (e.g., digest mismatch, pending only)
*/
export async function verifyOtsProof(otsBytes, expectedDigestHex) {
const errors = [];
// 1. Parse the OTS file
let parsed;
try {
parsed = parseOtsFile(otsBytes);
} catch (e) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: [`Failed to parse OTS file: ${e.message}`] };
}
if (!parsed) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: ['Invalid OTS file format'] };
}
const targetDigestHex = bytesToHex(parsed.targetDigest);
// 2. Bind the target digest to the expected digest (F-C3 fix)
if (expectedDigestHex) {
const expected = expectedDigestHex.toLowerCase();
const actual = targetDigestHex.toLowerCase();
if (expected !== actual) {
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
}
// 3. Find Bitcoin attestations and verify them
const bitcoinAttestations = parsed.attestations.filter(a => a.type === 'bitcoin');
const pendingAttestations = parsed.attestations.filter(a => a.type === 'pending');
if (bitcoinAttestations.length === 0) {
if (pendingAttestations.length > 0) {
errors.push('Proof contains only pending attestations (not yet confirmed on Bitcoin)');
} else {
errors.push('Proof contains no Bitcoin attestations');
}
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
// 4. Verify each Bitcoin attestation against the actual Bitcoin block header
// G56-04: block headers are cross-checked across providers; trustMode is
// propagated from fetchBitcoinBlockHeader.
const verified = [];
let trustMode = 'structural-only';
for (const att of bitcoinAttestations) {
try {
const blockHeader = await fetchBitcoinBlockHeader(att.height);
// Track the strongest trust mode seen across all attestations
if (blockHeader.trustMode === 'multi-explorer-checked') {
trustMode = 'multi-explorer-checked';
} else if (blockHeader.trustMode === 'single-explorer-checked' && trustMode === 'structural-only') {
trustMode = 'single-explorer-checked';
}
// The attestation's digest is the merkle root (after walking the op tree).
// Bitcoin OTS stores the digest in reversed byte order (little-endian).
const computedMerkleRoot = bytesToHex(att.digest.slice().reverse());
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
verified.push({
height: att.height,
time: blockHeader.time,
merkleroot: blockHeader.merkleroot
});
} else {
errors.push(`Bitcoin attestation for block ${att.height}: merkle root mismatch (computed ${computedMerkleRoot.substring(0, 16)}..., block has ${blockHeader.merkleroot.substring(0, 16)}...)`);
}
} catch (e) {
errors.push(`Could not verify Bitcoin attestation for block ${att.height}: ${e.message}`);
}
}
return {
verified: verified.length > 0,
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
/**
* Store OTS workflow data in localStorage.
* Existing metadata is preserved unless explicitly overwritten.
*
* G56-12: If metadata includes `proofCarrierEvent` and/or `kind1Event`, the complete
* event JSON is persisted so resume can validate bindings without relying solely
* on relay fetches.
*
* @param {string} eventId - The NIP-QR event id
* @param {Uint8Array} otsBytes - Current detached .ots bytes
* @param {object} metadata - Workflow metadata to merge
*/
export function savePendingOts(eventId, otsBytes, metadata = {}) {
let existing = {};
try {
existing = JSON.parse(localStorage.getItem('pq-pending-ots') || '{}');
} catch (e) {
existing = {};
}
const data = {
...existing,
...metadata,
eventId,
ots: bytesToBase64(otsBytes),
timestamp: existing.timestamp || Date.now(),
updatedAt: Date.now()
};
// G56-12: persist complete event JSON if provided
if (metadata.proofCarrierEvent) {
data.proofCarrierEventJson = JSON.stringify(metadata.proofCarrierEvent);
}
if (metadata.kind1Event) {
data.kind1EventJson = JSON.stringify(metadata.kind1Event);
}
localStorage.setItem('pq-pending-ots', JSON.stringify(data));
}
/**
* Load OTS workflow data from localStorage.
* @returns {{eventId: string, ots: Uint8Array, timestamp: number, pendingPublished: boolean, confirmedPublished: boolean}|null}
*/
export function loadPendingOts() {
const data = localStorage.getItem('pq-pending-ots');
if (!data) return null;
try {
const parsed = JSON.parse(data);
return {
...parsed,
eventId: parsed.eventId,
ots: base64ToBytes(parsed.ots),
timestamp: parsed.timestamp,
pendingPublished: Boolean(parsed.pendingPublished),
confirmedPublished: Boolean(parsed.confirmedPublished)
};
} catch (e) {
return null;
}
}
/**
* Clear pending OTS data from localStorage.
*/
export function clearPendingOts() {
localStorage.removeItem('pq-pending-ots');
}