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