Skip to main content

binius_circuits/hash_based_sig/
xmss.rs

1// Copyright 2026 The Binius Developers
2// Copyright (c) 2026 leanEthereum
3//! XMSS: a Merkle tree of `2^LOG_LIFETIME` WOTS public-key hashes.
4//!
5//! A signature is a WOTS signature at one epoch plus the authentication path linking that epoch's
6//! leaf to the committed root. Verification recovers the WOTS public key from the chain tips,
7//! hashes it into the leaf, and climbs the path.
8//!
9//! XMSS is stateful: signing twice at one epoch breaks the one-time signature, and so the key.
10
11use std::iter;
12
13use binius_core::Word;
14use binius_frontend::{CircuitBuilder, Wire};
15use rand::CryptoRng;
16
17use super::{
18	DIGEST_LEN, DIGEST_WIRES, Digest, LOG_LIFETIME, MESSAGE_WIRES, Message, PUBLIC_PARAM_LEN,
19	PUBLIC_PARAM_WIRES, PublicParam, RANDOMNESS_WIRES, Randomness, V,
20	hashing::{TWEAK_TYPE_MERKLE, circuit_tweak_hash, tweak_hash},
21	wots::{
22		circuit_recover_public_key, circuit_wots_encode, circuit_wots_public_key_hash,
23		find_randomness_for_wots_encoding, iterate_hash, recover_public_key, wots_encode,
24		wots_public_key_hash,
25	},
26};
27
28/// An XMSS public key.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct XmssPublicKey {
31	pub merkle_root: Digest,
32	pub public_param: PublicParam,
33}
34
35/// An XMSS signature.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct XmssSignature {
38	/// The ground randomness the encoding is drawn from.
39	pub randomness: Randomness,
40	/// Each chain walked as far as its digit.
41	pub chain_tips: [Digest; V],
42	/// Sibling nodes from the leaf up to the root.
43	pub merkle_path: [Digest; LOG_LIFETIME],
44}
45
46/// The wires an [`XmssSignature`] occupies in circuit.
47#[derive(Debug, Clone)]
48pub struct XmssSignatureWires {
49	pub randomness: [Wire; RANDOMNESS_WIRES],
50	pub chain_tips: [[Wire; DIGEST_WIRES]; V],
51	pub merkle_path: [[Wire; DIGEST_WIRES]; LOG_LIFETIME],
52}
53
54impl XmssSignatureWires {
55	/// Allocates the signature as private witness wires.
56	pub fn new_witness(builder: &CircuitBuilder) -> Self {
57		Self {
58			randomness: std::array::from_fn(|_| builder.add_witness()),
59			chain_tips: std::array::from_fn(|_| std::array::from_fn(|_| builder.add_witness())),
60			merkle_path: std::array::from_fn(|_| std::array::from_fn(|_| builder.add_witness())),
61		}
62	}
63
64	/// Populates the wires from a signature.
65	pub fn populate(&self, w: &mut binius_frontend::WitnessFiller<'_>, signature: &XmssSignature) {
66		w.pack_bytes_le(&self.randomness, &signature.randomness);
67		for (wires, tip) in iter::zip(&self.chain_tips, &signature.chain_tips) {
68			w.pack_bytes_le(wires, tip);
69		}
70		for (wires, node) in iter::zip(&self.merkle_path, &signature.merkle_path) {
71			w.pack_bytes_le(wires, node);
72		}
73	}
74}
75
76/// Why a signature failed to verify.
77#[derive(Debug, PartialEq, Eq, Clone, Copy)]
78pub enum XmssVerifyError {
79	/// The randomness does not encode to a valid codeword.
80	InvalidWots,
81	/// The authentication path does not reach the committed root.
82	InvalidMerklePath,
83}
84
85/// A Merkle parent, hashed under the tweak that fixes its level and index.
86///
87/// The level and index place each node in its own hash domain, so a node computed at one position
88/// cannot be replayed at another.
89pub fn merkle_node(
90	public_param: &PublicParam,
91	level: usize,
92	index: u32,
93	left: &Digest,
94	right: &Digest,
95) -> Digest {
96	let mut data = [0u8; 2 * DIGEST_LEN];
97	data[..DIGEST_LEN].copy_from_slice(left);
98	data[DIGEST_LEN..].copy_from_slice(right);
99	tweak_hash(public_param, TWEAK_TYPE_MERKLE, level as u32, index, &data)
100}
101
102/// Climbs an authentication path from a leaf to the root it implies.
103fn climb(
104	public_param: &PublicParam,
105	leaf: &Digest,
106	epoch: u32,
107	merkle_path: &[Digest; LOG_LIFETIME],
108) -> Digest {
109	merkle_path
110		.iter()
111		.enumerate()
112		.fold(*leaf, |current, (level, sibling)| {
113			let is_left = ((epoch >> level) & 1) == 0;
114			let (left, right) = if is_left {
115				(current, *sibling)
116			} else {
117				(*sibling, current)
118			};
119			// The parent sits one level up, at half the index.
120			let parent_index = ((epoch as u64) >> (level + 1)) as u32;
121			merkle_node(public_param, level + 1, parent_index, &left, &right)
122		})
123}
124
125/// Verifies an XMSS signature.
126pub fn xmss_verify(
127	public_key: &XmssPublicKey,
128	message: &Message,
129	signature: &XmssSignature,
130	epoch: u32,
131) -> Result<(), XmssVerifyError> {
132	let encoding = wots_encode(message, epoch, &public_key.public_param, &signature.randomness)
133		.ok_or(XmssVerifyError::InvalidWots)?;
134	let chain_ends =
135		recover_public_key(&signature.chain_tips, &encoding, epoch, &public_key.public_param);
136	let leaf = wots_public_key_hash(&public_key.public_param, epoch, &chain_ends);
137	if climb(&public_key.public_param, &leaf, epoch, &signature.merkle_path)
138		== public_key.merkle_root
139	{
140		Ok(())
141	} else {
142		Err(XmssVerifyError::InvalidMerklePath)
143	}
144}
145
146/// In-circuit form of [`xmss_verify`].
147///
148/// Three checks are stacked:
149/// 1. the randomness encodes to a valid codeword, and the chains walk from their tips to the
150///    Winternitz public key,
151/// 2. those chain ends hash into the Merkle leaf,
152/// 3. the authentication path links that leaf to the committed root.
153///
154/// Every digest is derived from the inputs, so this emits constraints and returns nothing.
155///
156/// # Arguments
157///
158/// - `builder`: circuit builder.
159/// - `public_param`: the signer's public parameter.
160/// - `merkle_root`: the signer's committed root.
161/// - `message`: the 32-byte message.
162/// - `epoch`: the leaf index the signature is at.
163/// - `signature`: the signature's witness wires.
164pub fn circuit_xmss_verify(
165	builder: &CircuitBuilder,
166	public_param: &[Wire; PUBLIC_PARAM_WIRES],
167	merkle_root: &[Wire; DIGEST_WIRES],
168	message: &[Wire; MESSAGE_WIRES],
169	epoch: Wire,
170	signature: &XmssSignatureWires,
171) {
172	// An epoch is a `u32` in the reference, and only its low four bytes reach a tweak. Bounding
173	// it here keeps epochs that agree modulo 2^32 from sharing every tweak in the instance.
174	builder.assert_zero("xmss_epoch_in_range", builder.shr(epoch, LOG_LIFETIME as u32));
175
176	let digits = circuit_wots_encode(builder, public_param, epoch, message, &signature.randomness);
177	let chain_ends =
178		circuit_recover_public_key(builder, public_param, epoch, &signature.chain_tips, &digits);
179	let leaf = circuit_wots_public_key_hash(builder, public_param, epoch, &chain_ends);
180
181	let root = signature
182		.merkle_path
183		.iter()
184		.enumerate()
185		.fold(leaf, |current, (level, sibling)| {
186			// `select` reads its condition's most significant bit, so the epoch's level-th bit is
187			// shifted up to it: set means this node is the right child.
188			let is_right = builder.shl(epoch, (Word::BITS - 1 - level) as u32);
189			let left: [Wire; DIGEST_WIRES] =
190				std::array::from_fn(|k| builder.select(is_right, sibling[k], current[k]));
191			let right: [Wire; DIGEST_WIRES] =
192				std::array::from_fn(|k| builder.select(is_right, current[k], sibling[k]));
193
194			let parent_index = builder.shr(epoch, level as u32 + 1);
195			let payload = [left[0], left[1], right[0], right[1]];
196			circuit_tweak_hash(
197				builder,
198				public_param,
199				TWEAK_TYPE_MERKLE,
200				builder.add_constant_64(level as u64 + 1),
201				parent_index,
202				&payload,
203			)
204		});
205
206	builder.assert_eq_v("xmss_merkle_root", root, *merkle_root);
207}
208
209/// Generates a key and a signature on `message` at `epoch`, for witness generation.
210///
211/// The tree exists only along the authentication path. Its sibling nodes are drawn at random and
212/// the root is whatever climbing the path from the leaf produces, which is what the reference's
213/// secret key does for every node outside the range of epochs it can sign — so a `2^32`-leaf tree
214/// costs 32 hashes here rather than `2^32`.
215///
216/// This is enough to exercise a verifier and no more: there is no secret key to sign with again,
217/// and no second epoch under the same root.
218pub fn generate_signature(
219	rng: &mut impl CryptoRng,
220	message: &Message,
221	epoch: u32,
222) -> (XmssPublicKey, XmssSignature) {
223	let mut public_param = [0u8; PUBLIC_PARAM_LEN];
224	rng.fill_bytes(&mut public_param);
225
226	let (randomness, encoding) =
227		find_randomness_for_wots_encoding(message, epoch, &public_param, rng);
228
229	// A chain's secret preimage walked as far as its digit is what the signature reveals.
230	let chain_tips: [Digest; V] = std::array::from_fn(|i| {
231		let mut pre_image = [0u8; DIGEST_LEN];
232		rng.fill_bytes(&mut pre_image);
233		iterate_hash(&pre_image, encoding[i] as usize, &public_param, epoch, i, 0)
234	});
235
236	let chain_ends = recover_public_key(&chain_tips, &encoding, epoch, &public_param);
237	let leaf = wots_public_key_hash(&public_param, epoch, &chain_ends);
238
239	let merkle_path: [Digest; LOG_LIFETIME] = std::array::from_fn(|_| {
240		let mut node = [0u8; DIGEST_LEN];
241		rng.fill_bytes(&mut node);
242		node
243	});
244	let merkle_root = climb(&public_param, &leaf, epoch, &merkle_path);
245
246	(
247		XmssPublicKey {
248			merkle_root,
249			public_param,
250		},
251		XmssSignature {
252			randomness,
253			chain_tips,
254			merkle_path,
255		},
256	)
257}
258
259#[cfg(test)]
260mod tests {
261	use rand::{Rng, SeedableRng, rngs::StdRng};
262	use rstest::rstest;
263
264	use super::*;
265	use crate::hash_based_sig::MESSAGE_LEN;
266
267	/// Builds the verification circuit, populates it, and returns the result of checking it.
268	fn run(
269		public_key: &XmssPublicKey,
270		message: &Message,
271		signature: &XmssSignature,
272		epoch: u32,
273	) -> Result<(), String> {
274		let b = CircuitBuilder::new();
275		let param_w: [Wire; PUBLIC_PARAM_WIRES] = std::array::from_fn(|_| b.add_inout());
276		let root_w: [Wire; DIGEST_WIRES] = std::array::from_fn(|_| b.add_inout());
277		let message_w: [Wire; MESSAGE_WIRES] = std::array::from_fn(|_| b.add_inout());
278		let epoch_w = b.add_inout();
279		let sig_w = XmssSignatureWires::new_witness(&b);
280
281		circuit_xmss_verify(&b, &param_w, &root_w, &message_w, epoch_w, &sig_w);
282
283		let circuit = b.build();
284		let mut w = circuit.new_witness_filler();
285		w.pack_bytes_le(&param_w, &public_key.public_param);
286		w.pack_bytes_le(&root_w, &public_key.merkle_root);
287		w.pack_bytes_le(&message_w, message);
288		w[epoch_w] = Word::from_u64(epoch as u64);
289		sig_w.populate(&mut w, signature);
290
291		circuit
292			.populate_wire_witness(&mut w)
293			.map_err(|e| format!("populate: {e:?}"))?;
294		circuit
295			.constraint_system()
296			.verify(&w.into_value_vec())
297			.map_err(|e| format!("verify: {e:?}"))
298	}
299
300	fn generate(seed: u64, epoch: u32) -> (XmssPublicKey, Message, XmssSignature) {
301		let mut rng = StdRng::seed_from_u64(seed);
302		let mut message = [0u8; MESSAGE_LEN];
303		rng.fill_bytes(&mut message);
304		let (public_key, signature) = generate_signature(&mut rng, &message, epoch);
305		(public_key, message, signature)
306	}
307
308	#[rstest]
309	#[case::first_epoch(0)]
310	#[case::odd_epoch(1)]
311	#[case::interior_epoch(0x1234_5678)]
312	#[case::last_epoch(u32::MAX)]
313	fn a_generated_signature_verifies(#[case] epoch: u32) {
314		let (public_key, message, signature) = generate(1, epoch);
315		// The native verifier and the circuit must agree that it is valid.
316		xmss_verify(&public_key, &message, &signature, epoch).unwrap();
317		run(&public_key, &message, &signature, epoch).unwrap();
318	}
319
320	#[test]
321	fn a_tampered_path_node_is_rejected() {
322		let (public_key, message, mut signature) = generate(2, 77);
323		signature.merkle_path[0][0] ^= 0xFF;
324		assert_eq!(
325			xmss_verify(&public_key, &message, &signature, 77),
326			Err(XmssVerifyError::InvalidMerklePath)
327		);
328		assert!(run(&public_key, &message, &signature, 77).is_err());
329	}
330
331	#[test]
332	fn a_tampered_root_is_rejected() {
333		let (mut public_key, message, signature) = generate(3, 77);
334		public_key.merkle_root[0] ^= 0xFF;
335		assert!(run(&public_key, &message, &signature, 77).is_err());
336	}
337
338	#[test]
339	fn another_message_is_rejected() {
340		let (public_key, mut message, signature) = generate(4, 77);
341		message[0] ^= 0xFF;
342		assert!(run(&public_key, &message, &signature, 77).is_err());
343	}
344
345	#[test]
346	fn another_epoch_is_rejected() {
347		// Every tweak on the path carries the level and index, and the leaf and chains carry the
348		// epoch, so a signature does not move to a neighbouring leaf.
349		let (public_key, message, signature) = generate(5, 77);
350		assert!(run(&public_key, &message, &signature, 78).is_err());
351	}
352
353	#[test]
354	fn an_epoch_past_the_lifetime_is_rejected() {
355		// Only the low four bytes of an epoch reach a tweak, so epochs 2^32 apart would otherwise
356		// share every hash in the instance.
357		let (public_key, message, signature) = generate(6, 5);
358		let b = CircuitBuilder::new();
359		let param_w: [Wire; PUBLIC_PARAM_WIRES] = std::array::from_fn(|_| b.add_inout());
360		let root_w: [Wire; DIGEST_WIRES] = std::array::from_fn(|_| b.add_inout());
361		let message_w: [Wire; MESSAGE_WIRES] = std::array::from_fn(|_| b.add_inout());
362		let epoch_w = b.add_inout();
363		let sig_w = XmssSignatureWires::new_witness(&b);
364		circuit_xmss_verify(&b, &param_w, &root_w, &message_w, epoch_w, &sig_w);
365
366		let circuit = b.build();
367		let mut w = circuit.new_witness_filler();
368		w.pack_bytes_le(&param_w, &public_key.public_param);
369		w.pack_bytes_le(&root_w, &public_key.merkle_root);
370		w.pack_bytes_le(&message_w, &message);
371		w[epoch_w] = Word::from_u64((1u64 << LOG_LIFETIME) + 5);
372		sig_w.populate(&mut w, &signature);
373
374		assert!(
375			circuit.populate_wire_witness(&mut w).is_err(),
376			"an epoch outside the lifetime must not verify"
377		);
378	}
379
380	#[test]
381	fn the_path_climbs_the_side_its_index_says() {
382		// Leaf 1 is a right child at level 0 and its subtree a left child at level 1, so the
383		// ordering at each level is what the epoch's bits dictate.
384		let pp = [1u8; PUBLIC_PARAM_LEN];
385		let leaf = [2u8; DIGEST_LEN];
386		let sibling0 = [3u8; DIGEST_LEN];
387		let sibling1 = [4u8; DIGEST_LEN];
388		let parent = merkle_node(&pp, 1, 0, &sibling0, &leaf);
389		let grandparent = merkle_node(&pp, 2, 0, &parent, &sibling1);
390
391		let mut path = [[0u8; DIGEST_LEN]; LOG_LIFETIME];
392		path[0] = sibling0;
393		path[1] = sibling1;
394		let mut current = grandparent;
395		for (level, sibling) in path.iter().enumerate().skip(2) {
396			current = merkle_node(&pp, level + 1, 0, &current, sibling);
397		}
398		assert_eq!(climb(&pp, &leaf, 1, &path), current);
399	}
400}