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

2585 lines
105 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)
*
* Derivation schemes (see plans/v2-hardened-derivation.md):
*
* V2 (default) — per-algorithm coin types in the unregistered SLIP-44
* 102XXX' range, all-hardened below coin type. PQ keys are NOT under the
* Nostr coin branch (1237'), so no compromise of the Nostr subtree (even a
* coin-level xpub leak + quantum) can reach them. Coin types 102003'–102005'
* match n_signer/the Rust signer; 102006'–102007' are this project's
* allocations for ML-DSA-44 and Falcon-512.
* ML-DSA-44 m/44'/102006'/0'/0'/0' (32-byte seed)
* ML-DSA-65 m/44'/102003'/0'/0'/0' (32-byte seed)
* SLH-DSA-128s m/44'/102004'/0'/0'/0' + /1' (48-byte seed)
* Falcon-512 m/44'/102007'/0'/0'/0' + /1' (48-byte seed)
* ML-KEM-768 m/44'/102005'/0'/0'/0' + /1' (64-byte seed)
*
* V1 (legacy, retained for recovery only) — all keys under
* m/44'/1237'/0'/0/ with non-hardened leaf children:
* 0 — secp256k1 (NIP-06 standard)
* 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, base64 as scureBase64 } 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';
import { DEFAULT_POLICY, knownAlgorithms, isMandatorySignature, isKem } from './nip-qr-policy.mjs';
// ============================================================================
// BIP32 DERIVATION PATHS
// ============================================================================
/**
* Versioned PQ derivation schemes.
*
* V2 (default): per-algorithm coin types (102XXX' range), all-hardened below
* the coin type. Each algorithm gets its own coin branch, so a leak of any
* one branch's extended key compromises exactly one algorithm, and no leak
* within the Nostr coin branch (1237') can reach PQ keys at all.
*
* V1 (legacy): all PQ seeds at non-hardened children 1–8 under the NIP-06
* account 0 change level. Retained ONLY so v1-era seeds can recover their
* v1 keys; never used for new derivations. See audit F-M3.
*
* `children` maps each algorithm to either:
* - v1: an array of child indices under the shared base path
* - v2: { coin, indices } — hardened children under m/44'/coin'/0'/0'
*/
const PQ_DERIVATION_SCHEMES = {
v1: {
version: 1,
base: "m/44'/1237'/0'/0",
hardenedLeaves: false,
children: {
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8],
},
},
v2: {
version: 2,
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] },
},
},
};
/**
* The derivation scheme version used for new key derivations and announced
* in the kind 1 event via the `derivation_scheme` tag.
*/
export const PQ_DERIVATION_SCHEME_VERSION = 2;
/**
* Resolve a scheme identifier ('v1' | 'v2' | 1 | 2) to its definition.
* @param {string|number} scheme
* @returns {object} scheme definition
*/
function resolveScheme(scheme) {
const key = typeof scheme === 'number' ? `v${scheme}` : scheme;
const def = PQ_DERIVATION_SCHEMES[key];
if (!def) {
throw new Error(`Unknown derivation scheme: ${scheme}. Supported: 'v1', 'v2'`);
}
return def;
}
/**
* Full derivation path for one algorithm under a scheme (for display/tests).
* @param {string} alg - algorithm key in scheme.children
* @param {string|number} scheme
* @returns {string} e.g. "m/44'/102003'/0'/0'/0'" (v2) or "m/44'/1237'/0'/0/2" (v1)
*/
export function pqDerivationPath(alg, scheme = 'v2') {
const def = resolveScheme(scheme);
const child = def.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (def.version === 1) {
return child.map(i => `${def.base}/${i}`).join(' + ');
}
return child.indices.map(i => `m/44'/${child.coin}'/0'/0'/${i}'`).join(' + ');
}
// 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 an explicit full path.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {string} path - full derivation path, e.g. "m/44'/102003'/0'/0'/0'"
* @returns {Uint8Array} 32-byte private key
*/
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error(`Failed to derive private key at path ${path}`);
}
return child.privateKey;
}
/**
* Resolve the full derivation paths for one algorithm under a scheme.
*
* v1: children are indices under the shared base m/44'/1237'/0'/0/
* v2: children are hardened indices under m/44'/<coin>'/0'/0'
*
* @param {object} schemeDef - resolved scheme definition
* @param {string} alg - algorithm key in scheme.children
* @returns {string[]} full paths (one per child index)
*/
function schemePathsFor(schemeDef, alg) {
const child = schemeDef.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (schemeDef.version === 1) {
return child.map(idx => `${schemeDef.base}/${idx}`);
}
return child.indices.map(i => `m/44'/${child.coin}'/0'/0'/${i}'`);
}
/**
* 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 {string[]} paths - full child paths to derive
* @param {number} requiredLength - required seed length
* @returns {Uint8Array} seed bytes
*/
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
// Single child — 32 bytes
const seed = deriveBIP32Child(bip39Seed, paths[0]);
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 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;
}
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.
*
* V2 (default) — per-algorithm coin types, all-hardened (see module header).
* V1 (legacy) — non-hardened children under m/44'/1237'/0'/0/. Pass 'v1'
* ONLY to recover keys for a v1-era seed; never for new derivations.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {string|number} [scheme='v2'] - 'v1' | 'v2' | 1 | 2
* @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, scheme = 'v2') {
const schemeDef = resolveScheme(scheme);
// ML-DSA-44 (32-byte seed)
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'mlDsa44'), PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
// ML-DSA-65 (32-byte seed)
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'mlDsa65'), PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
// SLH-DSA-128s (48-byte seed, two children concatenated)
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'slhDsa'), PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
// Falcon-512 (48-byte seed, two children concatenated)
const falconSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'falcon512'), PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
// ML-KEM-768 (64-byte seed, two children concatenated)
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, '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.
*
* F-D5: strict base64 decoding. The previous implementation used `atob`, which
* is lenient (tolerates whitespace and missing padding in some engines), so two
* implementations could disagree on whether a tag is "malformed". This strict
* decoder rejects whitespace, missing padding, and non-base64 characters
* uniformly, removing the interop ambiguity.
*
* @param {string} base64 - strict base64 string (with padding, no whitespace)
* @returns {Uint8Array}
* @throws {Error} if the input is empty, contains whitespace, has missing
* padding, or contains non-base64 characters
*/
export function base64ToBytes(base64) {
if (typeof base64 !== 'string' || base64.length === 0) {
throw new Error('base64ToBytes: expected a non-empty base64 string');
}
// @scure/base's base64.decode is strict: rejects whitespace, missing
// padding, and non-base64 characters. It throws on malformed input.
return scureBase64.decode(base64);
}
/**
* 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;
// G56-19: Require exact NIP-01 shape — id must be present and 64 lowercase hex
if (typeof event.id !== 'string' || !/^[0-9a-f]{64}$/.test(event.id)) 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 / G56-19: Verify event.id matches the computed hash (id is now required)
if (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
* (0–9999 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()
* @param {string|number} [derivationScheme='v2'] - scheme the keys were derived with
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number, statementBytes: Uint8Array}}
*/
export function buildKind1Announcement(hexPubkey, blockHeight, pqKeys, derivationScheme = 'v2') {
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 and anchored to the Bitcoin blockchain via OpenTimestamps.
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/quantum-prep/verify.html?npub=${npub}
Created at: https://laantungir.net/quantum-prep/`;
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);
// G56-17: Removed unverified block_height tag — the OTS proof is the real anchor
const tags = [
['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)]
];
// Informational metadata: which derivation scheme produced these keys.
// Absent tag = v1 (legacy events predate the tag). Not evidence — signature
// verification is path-agnostic — so verifiers treat unknown values as
// display-only.
const schemeVersion = resolveScheme(derivationScheme).version;
if (schemeVersion >= 2) {
tags.push(['derivation_scheme', String(schemeVersion)]);
}
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).
*
* **Legacy (v0) digest.** This hashes `JSON.stringify(signedEvent)`, which is
* key-insertion-order dependent and therefore NOT reproducible across
* implementations or across re-serialization. Retained only for verifying
* events published before the canonical digest (F-D1) was introduced.
*
* New code MUST use [`canonicalEventDigest()`](#canonicaleventdigest) instead.
*
* @param {object} signedEvent - The fully signed kind 1 event (with id and sig)
* @returns {string} hex SHA-256 hash
* @deprecated Use canonicalEventDigest() for new events. This remains only to
* verify legacy v0 proof carriers under a clearly-labeled legacy path.
*/
export function hashFullEvent(signedEvent) {
const json = JSON.stringify(signedEvent);
return bytesToHex(sha256(new TextEncoder().encode(json)));
}
/**
* Canonical OTS digest version supported by this implementation.
*
* The proof carrier's `digest_version` tag MUST match a version the verifier
* supports. Unknown versions fail closed. This makes a future digest change
* detectable rather than silently breaking verification.
*/
export const CANONICAL_DIGEST_VERSION = 1;
/**
* Compute the canonical OTS digest of a fully signed kind 1 event (F-D1).
*
* **Why canonical:** the OTS proof commits to a hash of the kind 1 event, and
* that hash MUST be reproducible by any conforming implementation decades from
* now — including non-JS implementations and verifiers that re-fetch the event
* from a relay (which may return it with different object key order). Hashing
* `JSON.stringify(event)` is NOT reproducible because object key order is
* engine-dependent.
*
* **Construction (F-D1, version 1):** the digest is SHA-256 of the NIP-01
* serialization array extended with the `id` and `sig` fields:
*
* ```
* sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))
* ```
*
* This reuses the already-canonical NIP-01 array form (the same form every
* Nostr implementation reproduces to compute event IDs), extended with the two
* fields that complete a signed event. Array element order is fixed by
* construction, so the only residual formatting ambiguity is number/string
* serialization, which is already implicit in NIP-01 event-ID computation and
* is well-defined for the integer/string field types used here.
*
* @param {object} signedEvent - The fully signed kind 1 event. MUST have
* `pubkey`, `created_at`, `kind`, `tags`, `content`, `id`, and `sig`.
* @returns {string} hex SHA-256 hash (64 lowercase hex chars)
* @throws {Error} if any required field is missing or wrongly typed
*/
export function canonicalEventDigest(signedEvent) {
if (!signedEvent || typeof signedEvent !== 'object') {
throw new Error('canonicalEventDigest: expected an event object');
}
const required = ['pubkey', 'created_at', 'kind', 'tags', 'content', 'id', 'sig'];
for (const field of required) {
if (!(field in signedEvent)) {
throw new Error(`canonicalEventDigest: missing field '${field}'`);
}
}
if (typeof signedEvent.pubkey !== 'string' || typeof signedEvent.id !== 'string' ||
typeof signedEvent.sig !== 'string' || typeof signedEvent.content !== 'string') {
throw new Error('canonicalEventDigest: pubkey, id, sig, content must be strings');
}
if (typeof signedEvent.created_at !== 'number' || typeof signedEvent.kind !== 'number') {
throw new Error('canonicalEventDigest: created_at and kind must be numbers');
}
if (!Array.isArray(signedEvent.tags)) {
throw new Error('canonicalEventDigest: tags must be an array');
}
const serialized = JSON.stringify([
0,
signedEvent.pubkey,
signedEvent.created_at,
signedEvent.kind,
signedEvent.tags,
signedEvent.content,
signedEvent.id,
signedEvent.sig
]);
return bytesToHex(sha256(new TextEncoder().encode(serialized)));
}
/**
* 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 canonical digest of the kind 1 event>'] — what was timestamped (F-D1)
* - ['digest_version', '<version>'] — canonical digest version (F-D1, currently '1')
* - ['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 = {}) {
// F-D1: use the canonical (cross-implementation-reproducible) digest, not
// the legacy key-order-dependent hashFullEvent(). The sha256 tag records
// what was submitted to OpenTimestamps; the digest_version tag records
// which canonicalization produced it so a future change is detectable.
const fullHash = canonicalEventDigest(kind1Event);
const otsStatus = options.otsStatus || 'pending';
const tags = [
['e', kind1Event.id],
['sha256', fullHash],
['digest_version', String(CANONICAL_DIGEST_VERSION)],
['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} proofCarrierContent - The proof carrier content (JSON string of kind 1 event)
* @param {Array} proofCarrierTags - 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(proofCarrierContent, proofCarrierTags, options = {}) {
// F-D3: versioned, explicit algorithm policy. Defaults to POLICY_V1.
// Unknown algorithms are 'ignored' (valid: null) — neither credit nor
// failure — matching the NIP's "verify whichever subset they support"
// while still requiring the mandatory set to be present and valid.
const policy = options.policy || DEFAULT_POLICY;
const results = [];
const errors = [];
const { expectedAuthor, outerPubkey } = options;
// G56-07: reject oversized content before any parsing/decoding work
if (typeof proofCarrierContent === 'string' && proofCarrierContent.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(proofCarrierContent);
} 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(proofCarrierTags) ? proofCarrierTags : []).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 the canonical digest of the kind 1 event.
//
// F-D1: the canonical digest (canonicalEventDigest) is cross-implementation-
// reproducible. The legacy hashFullEvent() is key-order-dependent and is only
// used to verify pre-F-D1 (v0) proof carriers that carry no digest_version tag.
// A v0 match is reported with a 'legacy digest' note and is NOT sufficient for
// validForMigration (see digestVersionValid below).
const canonicalHash = canonicalEventDigest(kind1Event);
const legacyHash = hashFullEvent(kind1Event);
const sha256Tags = (Array.isArray(proofCarrierTags) ? proofCarrierTags : []).filter(t => Array.isArray(t) && t[0] === 'sha256');
const digestVersionTags = (Array.isArray(proofCarrierTags) ? proofCarrierTags : []).filter(t => Array.isArray(t) && t[0] === 'digest_version');
let sha256Valid = false;
let digestVersionValid = false;
let digestIsLegacy = false;
// F-D1: digest_version tag must be present exactly once and match a supported
// version. Unknown versions fail closed. Missing tag => legacy v0 event.
if (digestVersionTags.length > 1) {
pushResult('digest_version', false, `duplicate digest_version tags (${digestVersionTags.length})`);
} else if (digestVersionTags.length === 1) {
const dv = digestVersionTags[0][1];
const dvNum = Number(dv);
if (dv === undefined || dv === null || dv === '' || !Number.isInteger(dvNum)) {
pushResult('digest_version', false, `malformed digest_version tag: '${dv}'`);
} else if (dvNum !== CANONICAL_DIGEST_VERSION) {
pushResult('digest_version', false, `unsupported digest_version ${dvNum} (supported: ${CANONICAL_DIGEST_VERSION})`);
} else {
digestVersionValid = true;
pushResult('digest_version', true, `v${dvNum} (supported)`);
}
} else {
// No digest_version tag => legacy v0 event. Acceptable for inspection but
// not for validForMigration.
digestIsLegacy = true;
pushResult('digest_version', false, 'missing — legacy v0 event (not valid for migration under F-D1)');
}
if (sha256Tags.length !== 1) {
pushResult('sha256 (kind 1 event digest)', false, sha256Tags.length === 0 ? 'tag missing' : `duplicate sha256 tags (${sha256Tags.length})`);
} else {
const sha256Tag = sha256Tags[0];
if (sha256Tag[1]) {
const tagVal = sha256Tag[1].toLowerCase();
if (digestVersionValid) {
// F-D1: verify against the canonical digest
sha256Valid = (tagVal === canonicalHash.toLowerCase());
} else if (digestIsLegacy) {
// Legacy v0: verify against the old key-order-dependent hash so
// historical events still inspect correctly. Marked legacy.
sha256Valid = (tagVal === legacyHash.toLowerCase());
}
}
const expectedHash = digestVersionValid ? canonicalHash : legacyHash;
pushResult('sha256 (kind 1 event digest)', sha256Valid,
sha256Valid
? `matches (${expectedHash.substring(0, 16)}...${digestIsLegacy ? ' [legacy v0]' : ''})`
: `mismatch: tag=${(sha256Tag[1] || '').substring(0, 16)}... computed=${expectedHash.substring(0, 16)}...`);
}
// 5. Verify PQ signatures with strict algorithm policy (G56-01 fix)
//
// F-D3: the mandatory/KEM sets come from the versioned policy object.
// Missing signatures on signature algorithms are FAILURES, not silent
// KEM-style successes. Unknown algorithms are 'ignored' (valid: null) —
// neither credit nor failure — matching the NIP's "verify whichever
// subset they support" while still requiring the mandatory set.
const MANDATORY_SIGNATURE_ALGORITHMS = policy.mandatorySignatureAlgorithms;
const KEM_ALGORITHMS = policy.kemAlgorithms;
const KNOWN_ALGORITHMS = knownAlgorithms(policy);
const POLICY_VERSION = policy.version;
// 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;
// F-D3: Unknown algorithms are 'ignored' (valid: null) — neither
// credit nor failure. They do NOT flip the legacy `valid` flag and do
// NOT count toward policySufficient. This matches the NIP's "verify
// whichever subset they support" while keeping the mandatory set strict.
if (!KNOWN_ALGORITHMS.has(algorithm)) {
results.push({ algorithm, valid: null, note: 'unknown algorithm (ignored — not counted as valid evidence or as a failure)' });
continue;
}
const isKEM = isKem(algorithm, policy);
const isSignature = isMandatorySignature(algorithm, policy);
// 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
//
// F-D1: validForMigration additionally requires digestVersionValid, so a
// legacy v0 event (no digest_version tag) can be inspected but cannot be
// accepted as a migration-valid authorization. This prevents old
// key-order-dependent digests from being trusted as canonical anchors.
// F-D3: ignored algorithms (valid: null) do not flip the legacy `valid`
// flag. Only entries with an explicit boolean valid value count.
const allChecksPassed = results.every(r => r.valid === true || r.valid === null);
const validForMigration = kind1SigValid && eTagValid && sha256Valid && digestVersionValid &&
pqProofsValid && policySufficient && identityBound;
const policyVersion = POLICY_VERSION;
return {
valid: allChecksPassed,
results,
kind1Event,
fullHash: digestVersionValid ? canonicalHash : legacyHash,
canonicalHash,
legacyHash,
digestVersionValid,
digestIsLegacy,
sha256Valid,
eTagValid,
pqProofsValid,
policySufficient,
policyVersion,
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, canonicalBitcoinHeight: null, canonicalBitcoinTime: null, canonicalDigestVersion: null, canonicalTrustMode: 'none', allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ['No candidates provided'] };
}
if (!kind1Event || !kind1Event.id) {
return { canonical: null, canonicalBitcoinHeight: null, canonicalBitcoinTime: null, canonicalDigestVersion: null, canonicalTrustMode: 'none', allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ['No kind 1 event provided'] };
}
const expectedKind1Id = kind1Event.id.toLowerCase();
// F-D1: accept either the canonical (v1) digest or the legacy v0 hash.
// New v1 carriers carry a digest_version tag and commit to canonicalHash;
// legacy v0 carriers commit to legacyHash. A candidate is accepted if its
// sha256 tag matches either, but only v1 candidates are eligible to be
// selected as canonical (see the confirmed/pending sort below).
const expectedCanonicalHash = canonicalEventDigest(kind1Event).toLowerCase();
const expectedLegacyHash = 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;
}
// F-D1: Check sha256 tag matches the kind 1 event digest. Accept either
// the canonical (v1) digest or the legacy v0 hash, so historical proof
// carriers remain selectable for inspection. The digest_version tag
// determines which hash is expected; a v1 carrier MUST match the
// canonical hash, a v0 carrier (no digest_version) MUST match the legacy
// hash. Mismatched carriers are rejected.
const sha256Tag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'sha256');
const dvTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'digest_version');
const dvVal = dvTag ? Number(dvTag[1]) : NaN;
const isV1 = Number.isInteger(dvVal) && dvVal === CANONICAL_DIGEST_VERSION;
const isLegacy = !dvTag;
const tagVal = sha256Tag && sha256Tag[1] ? sha256Tag[1].toLowerCase() : null;
const matchesV1 = isV1 && tagVal === expectedCanonicalHash;
const matchesLegacy = isLegacy && tagVal === expectedLegacyHash;
if (!tagVal || (!matchesV1 && !matchesLegacy)) {
errors.push(`candidate ${candidate.id || '?'}: sha256 tag does not match kind 1 event digest`);
continue;
}
// F-D1: only v1 candidates are eligible to be selected as the canonical
// migration root. Legacy v0 candidates pass the filter for inspection
// but are excluded from the confirmed/pending selection pools below.
candidate._fDigestIsLegacy = !isV1;
// 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'], isLegacy: candidate._fDigestIsLegacy });
continue;
}
let otsBytes;
try {
otsBytes = base64ToBytes(otsTag[1]);
} catch (e) {
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`], isLegacy: candidate._fDigestIsLegacy });
continue;
}
// F-D1: bind the OTS proof to the digest this carrier actually commits
// to — canonical hash for v1, legacy hash for v0.
const expectedDigestForCandidate = candidate._fDigestIsLegacy ? expectedLegacyHash : expectedCanonicalHash;
try {
const verifyResult = await verifyOtsProof(otsBytes, expectedDigestForCandidate);
if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) {
confirmedCandidates.push({
event: candidate,
bitcoinHeight: verifyResult.bitcoinAttestations[0].height,
bitcoinTime: verifyResult.bitcoinAttestations[0].time,
errors: [],
isLegacy: candidate._fDigestIsLegacy
});
} else {
pendingCandidates.push({
event: candidate,
bitcoinHeight: null,
errors: verifyResult.errors,
isLegacy: candidate._fDigestIsLegacy
});
}
} catch (e) {
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`], isLegacy: candidate._fDigestIsLegacy });
}
}
// F-D1: legacy v0 candidates are inspectable but cannot be selected as the
// canonical migration root. Filter them out of the selection pools.
const confirmedV1 = confirmedCandidates.filter(c => !c.isLegacy);
const pendingV1 = pendingCandidates.filter(c => !c.isLegacy);
const legacyConfirmed = confirmedCandidates.filter(c => c.isLegacy);
const legacyPending = pendingCandidates.filter(c => c.isLegacy);
if (legacyConfirmed.length > 0 || legacyPending.length > 0) {
errors.push(`${legacyConfirmed.length + legacyPending.length} legacy v0 candidate(s) retained for inspection only (not eligible for canonical selection)`);
}
// Step 5: Among confirmed v1 candidates, select earliest Bitcoin block height.
// F-D1: only v1 candidates are eligible; legacy v0 candidates are reported
// in confirmedCandidates/pendingCandidates for inspection but cannot be
// selected as canonical.
if (confirmedV1.length > 0) {
confirmedV1.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: confirmedV1[0].event,
canonicalBitcoinHeight: confirmedV1[0].bitcoinHeight,
canonicalBitcoinTime: confirmedV1[0].bitcoinTime,
canonicalDigestVersion: CANONICAL_DIGEST_VERSION,
allCandidates: candidates,
confirmedCandidates,
pendingCandidates,
errors
};
}
// Step 6: No confirmed v1 candidates — return the best pending v1 one
// (earliest created_at, tie-break by event ID). F-D1/L-2: a pending-only
// selection is NOT a trust decision; callers must label it as such.
if (pendingV1.length > 0) {
pendingV1.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: pendingV1[0].event,
canonicalBitcoinHeight: null,
canonicalBitcoinTime: null,
canonicalDigestVersion: CANONICAL_DIGEST_VERSION,
canonicalTrustMode: 'pending-only',
allCandidates: candidates,
confirmedCandidates,
pendingCandidates,
errors
};
}
return {
canonical: null,
canonicalBitcoinHeight: null,
canonicalBitcoinTime: null,
canonicalDigestVersion: null,
canonicalTrustMode: 'none',
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)',
coinType: 102006,
derivationPath: "m/44'/102006'/0'/0'/0'"
},
'ml-dsa-65': {
name: 'ML-DSA-65 (Dilithium)',
publicKeySize: 1952,
signatureSize: 3309,
fips: 'FIPS 204',
type: 'signature',
securityLevel: 'Category 3 (~AES-192)',
coinType: 102003,
derivationPath: "m/44'/102003'/0'/0'/0'"
},
'slh-dsa-128s': {
name: 'SLH-DSA-128s (SPHINCS+)',
publicKeySize: 32,
signatureSize: 7856,
fips: 'FIPS 205',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, hash-based)',
coinType: 102004,
derivationPath: "m/44'/102004'/0'/0'/0' + /1'"
},
'falcon-512': {
name: 'Falcon-512',
publicKeySize: 897,
signatureSize: 666,
fips: 'FIPS 206 (draft)',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, lattice-based)',
coinType: 102007,
derivationPath: "m/44'/102007'/0'/0'/0' + /1'"
},
'ml-kem-768': {
name: 'ML-KEM-768 (Kyber)',
publicKeySize: 1184,
ciphertextSize: 1088,
fips: 'FIPS 203',
type: 'kem',
securityLevel: 'Category 3 (~AES-192)',
coinType: 102005,
derivationPath: "m/44'/102005'/0'/0'/0' + /1'"
}
};
/**
* Derivation path for an algorithm id (as used in event tags / PQ_KEY_INFO
* keys) under a given scheme. Defaults to v2.
*
* @param {string} algorithmId - e.g. 'ml-dsa-44'
* @param {string|number} [scheme='v2']
* @returns {string} display path
*/
export function derivationPathForAlgorithm(algorithmId, scheme = 'v2') {
const algKey = {
'ml-dsa-44': 'mlDsa44',
'ml-dsa-65': 'mlDsa65',
'slh-dsa-128s': 'slhDsa',
'falcon-512': 'falcon512',
'ml-kem-768': 'mlKem',
}[algorithmId];
if (!algKey) throw new Error(`Unknown algorithm id: ${algorithmId}`);
return pqDerivationPath(algKey, scheme);
}
// ============================================================================
// 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
// 83dfe30d2ef90c8e = 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');
}
// ============================================================================
// PROOF ARCHIVE (F-D2: anchor availability / deletion resistance)
// ============================================================================
//
// A self-contained archive package a user can store offline and later feed to
// a verifier, so the genuine proof carrier remains verifiable even if relays
// delete it (NIP-09) or churn it. See the "Data availability" section of
// nip_proposal.md.
/**
* Build a self-contained proof archive JSON object.
*
* The archive contains everything a verifier needs to validate the link
* without contacting any relay: the signed kind 1 event, the proof carrier
* event, the OTS proof bytes, the canonical digest, and the digest version.
*
* @param {object} kind1Event - The fully signed kind 1 announcement event
* @param {object} proofCarrierEvent - The fully signed kind 9999 proof carrier
* @param {Uint8Array} otsProof - The detached .ots proof bytes
* @returns {string} JSON string of the archive (suitable for download)
*/
export function buildProofArchive(kind1Event, proofCarrierEvent, otsProof) {
if (!kind1Event || typeof kind1Event !== 'object') {
throw new Error('buildProofArchive: kind1Event must be an object');
}
if (!proofCarrierEvent || typeof proofCarrierEvent !== 'object') {
throw new Error('buildProofArchive: proofCarrierEvent must be an object');
}
if (!(otsProof instanceof Uint8Array)) {
throw new Error('buildProofArchive: otsProof must be a Uint8Array');
}
// Strip any non-NIP-01 helper fields (e.g. statementBytes from
// buildKind1Announcement) so the archive contains only the serializable,
// normative event fields. This also ensures deepEqual against a re-derived
// event works in tests and that the archive is byte-stable.
const kind1Clean = {
id: kind1Event.id,
pubkey: kind1Event.pubkey,
created_at: kind1Event.created_at,
kind: kind1Event.kind,
tags: kind1Event.tags,
content: kind1Event.content,
sig: kind1Event.sig
};
const archive = {
archiveType: 'nostr-pq-link-proof',
archiveVersion: 1,
createdAt: Math.floor(Date.now() / 1000),
kind1Event: kind1Clean,
proofCarrierEvent,
otsProofBase64: bytesToBase64(otsProof),
canonicalDigest: canonicalEventDigest(kind1Event),
digestVersion: CANONICAL_DIGEST_VERSION
};
return JSON.stringify(archive, null, 2);
}
/**
* Parse and validate a proof archive JSON string.
*
* Returns the structured archive on success, or throws on malformed input.
* This does NOT verify the cryptography — it only validates structure. Callers
* must run verifyNIPQRContent() and verifyOtsProof() on the extracted fields
* before trusting the archive.
*
* @param {string} archiveJson - The archive JSON string
* @returns {{archiveType: string, archiveVersion: number, createdAt: number, kind1Event: object, proofCarrierEvent: object, otsProof: Uint8Array, canonicalDigest: string, digestVersion: number}}
* @throws {Error} if the archive is malformed or has the wrong type/version
*/
export function parseProofArchive(archiveJson) {
if (typeof archiveJson !== 'string' || archiveJson.length === 0) {
throw new Error('parseProofArchive: expected a non-empty JSON string');
}
if (archiveJson.length > MAX_EVENT_JSON_SIZE * 4) {
throw new Error('parseProofArchive: archive exceeds maximum size');
}
let archive;
try {
archive = JSON.parse(archiveJson);
} catch (e) {
throw new Error(`parseProofArchive: invalid JSON: ${e.message}`);
}
if (!archive || typeof archive !== 'object') {
throw new Error('parseProofArchive: archive is not an object');
}
if (archive.archiveType !== 'nostr-pq-link-proof') {
throw new Error(`parseProofArchive: wrong archiveType '${archive.archiveType}'`);
}
if (archive.archiveVersion !== 1) {
throw new Error(`parseProofArchive: unsupported archiveVersion ${archive.archiveVersion}`);
}
if (!archive.kind1Event || typeof archive.kind1Event !== 'object') {
throw new Error('parseProofArchive: missing or invalid kind1Event');
}
if (!archive.proofCarrierEvent || typeof archive.proofCarrierEvent !== 'object') {
throw new Error('parseProofArchive: missing or invalid proofCarrierEvent');
}
if (typeof archive.otsProofBase64 !== 'string' || archive.otsProofBase64.length === 0) {
throw new Error('parseProofArchive: missing or invalid otsProofBase64');
}
let otsProof;
try {
otsProof = base64ToBytes(archive.otsProofBase64);
} catch (e) {
throw new Error(`parseProofArchive: otsProofBase64 decode failed: ${e.message}`);
}
return {
archiveType: archive.archiveType,
archiveVersion: archive.archiveVersion,
createdAt: archive.createdAt,
kind1Event: archive.kind1Event,
proofCarrierEvent: archive.proofCarrierEvent,
otsProof,
canonicalDigest: archive.canonicalDigest,
digestVersion: archive.digestVersion
};
}