rhdl_dsig_scalable_core/reference.rs
1//! Complete byte-oriented reference for the explicitly versioned DSig profile.
2//!
3//! This is a software oracle and a trusted preload path, not synthesizable
4//! hardware or sustained FPGA key-generation evidence. A [`PreparedKey`](crate::reference::PreparedKey) is
5//! consumed by signing, is deliberately not `Clone`, and wipes its cached secret
6//! nodes on drop. The caller must still persist epoch/batch allocation before
7//! deriving seeds: reconstructing the same seed after reset defeats one-time
8//! security. Do not expose precomputed chain caches outside a trusted boundary.
9//!
10//! # Why this module exists
11//!
12//! Hardware is hard to debug. When the engine writes a 1,642-byte signature to
13//! HBM, how do you know that every byte is right? You need an answer key. This
14//! module is that answer key: a plain, slow, easy-to-read Rust implementation
15//! of the whole DSig profile, from seed to transferable signature. Every
16//! hardware block is checked against it bit for bit (see
17//! [`crate::slice_model`] and the tests in `tests/`), and the host tool
18//! `dsig_engine_host` uses it to verify what the card produced.
19//!
20//! It is written for clarity, not speed. It uses byte arrays instead of RHDL
21//! `Bits`, and the `blake3` and `ed25519-dalek` crates instead of hardware
22//! lanes. Nothing here is synthesized.
23//!
24//! # The story of one signature, in software
25//!
26//! ```text
27//! master seed ─┐
28//! signer key ─┼─ one_time_seed() ──▶ 32-byte seed per key (×128 per batch)
29//! epoch,batch ─┤ (BLAKE3 derive_key, SEED_DERIVATION_CONTEXT)
30//! key index ─┘
31//! │
32//! ▼
33//! PreparedKey::from_seed()
34//! pk_nonce, nonce (BLAKE3 XOF, prefixes 0x5eed / 0xc0ca)
35//! nodes[0][0..68] (BLAKE3 XOF of the seed: chain secrets)
36//! nodes[1..=3] (3 chain steps per chain: dsig_chain)
37//! pk_hash (endpoint_commitment of nodes[3])
38//! │
39//! 128 pk_hash ──▶ BatchTree::new() ──▶ root
40//! │
41//! ▼
42//! BatchCommitment { epoch, batch_id, root } ── Ed25519 ──▶ BatchCertificate
43//! │
44//! message ──▶ message_digits() ──▶ 68 base-4 digits (64 message + 4 checksum)
45//! │
46//! ▼
47//! PreparedKey::sign(): reveal nodes[digit][chain] for each chain
48//! │
49//! ▼
50//! DsigSignature = WOTS part + key index + 7 siblings + certificate
51//! │
52//! ▼
53//! to_bytes(): 1,642 bytes on the wire
54//! ```
55//!
56//! # Words you will meet
57//!
58//! * **W-OTS+** (Winternitz one-time signature): each of the 68 chains starts
59//! at a secret node and is hashed forward. Signing a base-4 digit `d`
60//! reveals the node at depth `d`; a verifier hashes it the remaining
61//! `3 − d` steps and must land on the public endpoint.
62//! * **Checksum digits**: the last 4 digits encode the sum of `3 − digit`
63//! over the 64 message digits. An attacker can only hash *forward*, which
64//! raises digits; that lowers the checksum, which they cannot do. So no
65//! forgery comes for free from a seen signature.
66//! * **Nonce** (salt): a 16-byte random value mixed into hashes so that two
67//! keys never share chain inputs.
68//! * **Merkle tree**: a binary tree of hashes. Its 128 leaves are the keys'
69//! `pk_hash` values; its root stands for all 128 at once, so one Ed25519
70//! signature can vouch for all of them.
71//!
72//! # Wire layout of a signature
73//!
74//! | Bytes | Field |
75//! |---|---|
76//! | 0..64 | [`BatchCommitment`] (see [`crate::certificate`]) |
77//! | 64..128 | Ed25519 signature `R ‖ S` |
78//! | 128..130 | key index, little endian |
79//! | 130..162 | `pk_hash` |
80//! | 162..178 | `pk_nonce` |
81//! | 178..194 | message `nonce` |
82//! | 194..418 | 7 Merkle siblings × 32 bytes, bottom up |
83//! | 418..1642 | 68 chain nodes × 18 bytes, in chain order |
84//!
85//! # Security rules this module enforces (and the ones it cannot)
86//!
87//! * A one-time key signs at most once. [`PreparedKey::sign`] takes `self` by
88//! value, so Rust's ownership rules stop a second call. [`PreparedBatch`]
89//! stores keys as `Option` and `take`s them, so a repeated index returns
90//! `None`.
91//! * Secrets are wiped. Secret nodes, nonces, seeds and the master seed are
92//! zeroized as soon as they are no longer needed; the master seed is never
93//! stored in [`PreparedBatch`].
94//! * What it cannot enforce: that `(epoch, batch_id)` was never used before.
95//! Rebuilding a batch from the same master seed and namespace recreates the
96//! same keys, which would let one key sign two messages. The host must
97//! durably allocate namespaces first; [`crate::lease`] does that.
98//!
99//! # Neighbours
100//!
101//! * [`crate::certificate`] defines the signed 64-byte batch envelope.
102//! * [`crate::haraka`] provides [`dsig_chain`], one chain step in the
103//! compiled-in chain hash (Haraka-512 unless a `chain-*` feature says
104//! otherwise).
105//! * [`crate::slice_model`] reuses [`message_digits`] and [`PreparedBatch`]
106//! to predict the hardware cycle by cycle.
107//! * [`crate::lease`] wraps [`PreparedBatch`] with never-reused batch ranges.
108
109use ed25519_dalek::{SigningKey, VerifyingKey};
110use zeroize::Zeroize;
111
112use crate::{
113 BATCH_KEYS, CHAINS, Digest, ELEMENT_BYTES, Element, MERKLE_HEIGHT, MESSAGE_DIGITS, Nonce,
114 POSITIONS,
115 certificate::{BatchCertificate, BatchCommitment},
116 haraka::dsig_chain,
117};
118
119/// Four stored positions for every chain; the last row contains public nodes.
120///
121/// Indexed `[depth][chain]`: `depth` 0 is the secret start of a chain, 1 and
122/// 2 are intermediate nodes, 3 is the public endpoint. Each entry is an
123/// 18-byte [`Element`]. 4 × 68 × 18 = 4,896 bytes per key. Storing every
124/// depth (instead of only the secret) is what lets signing be a table lookup
125/// with no hashing; the hardware key cache ([`crate::cache_bank`]) stores the
126/// same 4 × 68 nodes per key for the same reason.
127pub type ChainCache = [[Element; CHAINS]; POSITIONS];
128/// Self-standing signature wire length, with no transport padding.
129///
130/// 64 (commitment) + 64 (Ed25519) + 2 (index) + 32 + 16 + 16 (public key
131/// hash, public salt, message salt) + 7 × 32 (Merkle path) + 68 × 18 (chain
132/// nodes) = 1,642 bytes. The hardware pads each one to a 2 KiB frame slot in
133/// HBM; this constant is the unpadded length.
134pub const SIGNATURE_WIRE_BYTES: usize = 1642;
135
136/// Public metadata for one cached one-time key.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct KeyPublic {
139 /// Salt used in all of this key's Haraka chains (16 bytes).
140 ///
141 /// Public: a verifier needs it to recompute the chains.
142 pub pk_nonce: Nonce,
143 /// BLAKE3 commitment to public salt and 68 chain endpoints (32 bytes).
144 ///
145 /// This is the key's public identity and the Merkle leaf for its batch.
146 pub pk_hash: Digest,
147}
148
149/// Single-use secret cache. Signing consumes this object before returning data.
150///
151/// It is not `Clone` on purpose: a copy would be a second chance to sign
152/// with the same one-time key. Dropping it zeroizes the secret parts.
153pub struct PreparedKey {
154 /// Public salt and endpoint commitment; safe to share.
155 public: KeyPublic,
156 /// Secret 16-byte message salt, disclosed only inside the signature.
157 nonce: Nonce,
158 /// All four depths of all 68 chains. Rows 0 to 2 are secret.
159 nodes: ChainCache,
160}
161
162impl Drop for PreparedKey {
163 /// Wipe the secret chain nodes and the message salt so they do not
164 /// linger in freed memory.
165 fn drop(&mut self) {
166 self.nodes.zeroize();
167 self.nonce.zeroize();
168 }
169}
170
171impl PreparedKey {
172 /// Prepare a fresh key from an exclusively allocated random 256-bit seed.
173 ///
174 /// The seed must never be reused, including across resets or processes.
175 /// The four-byte salt prefixes retain the upstream x86 little-endian
176 /// convention. The signature checksum and certificate format do not.
177 ///
178 /// Steps: derive two 16-byte salts from the seed, expand the seed into 68
179 /// secret chain starts, hash each chain three steps forward, then commit
180 /// to the public salt and the 68 endpoints.
181 #[must_use]
182 pub fn from_seed(mut seed: [u8; 32]) -> Self {
183 // Helper: BLAKE3(prefix ‖ seed) read as a 16-byte XOF output. The
184 // prefix separates the two salts so they are independent.
185 let nonce_for = |prefix: u32| {
186 let mut hasher = blake3::Hasher::new();
187 hasher.update(&prefix.to_le_bytes());
188 hasher.update(&seed);
189 let mut nonce = [0; 16];
190 let mut reader = hasher.finalize_xof();
191 reader.fill(&mut nonce);
192 reader.zeroize();
193 hasher.zeroize();
194 nonce
195 };
196 // Public chain salt (prefix 0x5eed) and secret message salt (prefix 0xc0ca).
197 let pk_nonce = nonce_for(0x5eed);
198 let nonce = nonce_for(0xc0ca);
199 // nodes[depth][chain], filled row by row below.
200 let mut nodes = [[[0; ELEMENT_BYTES]; CHAINS]; POSITIONS];
201 // Row 0: the 68 secret chain starts, read in order from one BLAKE3
202 // XOF stream of the seed (68 × 18 = 1,224 bytes).
203 let mut expansion = blake3::Hasher::new();
204 expansion.update(&seed);
205 let mut reader = expansion.finalize_xof();
206 for element in &mut nodes[0] {
207 reader.fill(element);
208 }
209 // Wipe the XOF state: it could regenerate the secrets.
210 reader.zeroize();
211 expansion.zeroize();
212 // Rows 1 to 3: each node is one chain step of the node above it. The
213 // step is salted with pk_nonce and tweaked by (chain, depth), so no
214 // two steps anywhere hash the same input.
215 for depth in 0..POSITIONS - 1 {
216 for chain in 0..CHAINS {
217 nodes[depth + 1][chain] = dsig_chain(&nodes[depth][chain], &pk_nonce, chain, depth);
218 }
219 }
220 // The seed is no longer needed; wipe this copy.
221 seed.zeroize();
222 // Row 3 holds the public endpoints; commit to them.
223 let pk_hash = endpoint_commitment(&pk_nonce, &nodes[POSITIONS - 1]);
224 Self {
225 public: KeyPublic { pk_nonce, pk_hash },
226 nonce,
227 nodes,
228 }
229 }
230
231 /// Public metadata to send through the authenticated key-admission plane.
232 #[must_use]
233 pub const fn public(&self) -> KeyPublic {
234 self.public
235 }
236
237 /// Signer-private message nonce for the trusted hardware loader only.
238 ///
239 /// Do not distribute this with pre-certified public keys. It must remain
240 /// unpredictable until the one-time key is irreversibly bound to a message;
241 /// early disclosure weakens the 128-bit salted-digest chosen-message model.
242 #[must_use]
243 pub const fn signing_nonce_for_device(&self) -> Nonce {
244 self.nonce
245 }
246
247 /// Borrow secret cache bytes for trusted device loading. Never log them.
248 #[must_use]
249 pub const fn cache_for_device(&self) -> &ChainCache {
250 &self.nodes
251 }
252
253 /// Consume this key and reveal only message-selected nodes.
254 ///
255 /// Takes `self` by value, so this key cannot sign again. For chain `i`
256 /// with digit `d`, the revealed node is `nodes[d][i]`.
257 #[must_use]
258 pub fn sign(self, message: &[u8]) -> WotsSignature {
259 // 68 base-4 digits, each 0..=3, one per chain.
260 let digits = message_digits(&self.public.pk_hash, &self.nonce, message);
261 WotsSignature {
262 public: self.public,
263 nonce: self.nonce,
264 elements: core::array::from_fn(|i| self.nodes[usize::from(digits[i])][i]),
265 }
266 }
267}
268
269/// Public salt plus endpoint commitment, exactly ordered as upstream DSig.
270///
271/// `BLAKE3(pk_nonce ‖ endpoint[0] ‖ … ‖ endpoint[67])`: 16 + 68 × 18 = 1,240
272/// input bytes, which is why the summary in [`crate`] counts 21 BLAKE3
273/// compressions for the endpoint commitment. Signer and verifier both call
274/// this; a match proves the revealed nodes lead to this key's endpoints.
275#[must_use]
276pub fn endpoint_commitment(pk_nonce: &Nonce, endpoints: &[Element; CHAINS]) -> Digest {
277 let mut hash = blake3::Hasher::new();
278 hash.update(pk_nonce);
279 for endpoint in endpoints {
280 hash.update(endpoint);
281 }
282 *hash.finalize().as_bytes()
283}
284
285/// Extract low two bits from each XOF byte and append the complement checksum.
286///
287/// Input: 64 XOF bytes. Output: 68 digits in 0..=3. Digits 0..64 are the low
288/// two bits of each byte. The checksum is `Σ (3 − digit)` (at most 64 × 3 =
289/// 192, which fits in four base-4 digits) and is written little endian into
290/// digits 64..68. The hardware signer ([`crate::slice_signer`]) does the same
291/// split.
292#[must_use]
293pub fn digits_from_xof(xof: &[u8; 64]) -> [u8; CHAINS] {
294 let mut digits = [0; CHAINS];
295 // Sum of "steps left to the endpoint" over the 64 message digits.
296 let mut checksum = 0_u16;
297 for i in 0..MESSAGE_DIGITS {
298 digits[i] = xof[i] & 3;
299 checksum += u16::from(3 - digits[i]);
300 }
301 // Four base-4 checksum digits, least significant first.
302 for i in 0..4 {
303 digits[MESSAGE_DIGITS + i] = ((checksum >> (2 * i)) & 3) as u8;
304 }
305 digits
306}
307
308/// Salted BLAKE3-XOF message mapping; 64 low-bit digits encode 128 digest bits.
309///
310/// `BLAKE3-XOF(pk_hash ‖ nonce ‖ message)`, 64 bytes, then
311/// [`digits_from_xof`]. Binding `pk_hash` ties the digest to one key; the
312/// secret `nonce` keeps the digits unpredictable until the signature is out.
313#[must_use]
314pub fn message_digits(pk_hash: &Digest, nonce: &Nonce, message: &[u8]) -> [u8; CHAINS] {
315 let mut hash = blake3::Hasher::new();
316 hash.update(pk_hash);
317 hash.update(nonce);
318 hash.update(message);
319 // Only the low 2 bits of each of these 64 bytes are used.
320 let mut xof = [0; 64];
321 hash.finalize_xof().fill(&mut xof);
322 digits_from_xof(&xof)
323}
324
325/// WOTS component only; its public-key hash is not itself a trusted identity.
326///
327/// Anyone can make a key and a matching WOTS signature. Trust comes from the
328/// Merkle path and the Ed25519 certificate in [`DsigSignature`].
329#[derive(Clone, Debug, Eq, PartialEq)]
330pub struct WotsSignature {
331 /// Salts and claimed public commitment.
332 pub public: KeyPublic,
333 /// Message salt disclosed only after the key is bound to this message.
334 pub nonce: Nonce,
335 /// The 68 selected 18-byte nodes, in chain order.
336 pub elements: [Element; CHAINS],
337}
338
339impl WotsSignature {
340 /// Verify the hash-chain component, without authenticating the claimed key.
341 ///
342 /// Recompute the digits, hash each revealed node forward to depth 3, and
343 /// compare the commitment with the claimed `pk_hash`.
344 #[must_use]
345 pub fn verify_hash_component(&self, message: &[u8]) -> bool {
346 let digits = message_digits(&self.public.pk_hash, &self.nonce, message);
347 // Start from the revealed nodes; after the loop these are endpoints.
348 let mut endpoints = self.elements;
349 for chain in 0..CHAINS {
350 for depth in usize::from(digits[chain])..POSITIONS - 1 {
351 endpoints[chain] =
352 dsig_chain(&endpoints[chain], &self.public.pk_nonce, chain, depth);
353 }
354 }
355 endpoint_commitment(&self.public.pk_nonce, &endpoints) == self.public.pk_hash
356 }
357}
358
359/// Fixed 128-leaf BLAKE3 tree authenticating public-key commitments.
360///
361/// Stored as a flat array in heap order: node 0 is the root, node `i` has
362/// children `2i + 1` and `2i + 2`, and the 128 leaves sit at 127..255.
363///
364/// ```text
365/// 0 (root)
366/// / \
367/// 1 2
368/// / \ / \
369/// ... ... ... ... 7 levels
370/// 127 128 ... 254 leaves = pk_hash of keys 0..127
371/// ```
372#[derive(Clone)]
373pub struct BatchTree {
374 /// 255 digests: 127 inner nodes followed by 128 leaves.
375 nodes: [Digest; BATCH_KEYS * 2 - 1],
376}
377
378/// One Merkle parent: `BLAKE3(left ‖ right)`, 64 input bytes, one BLAKE3
379/// compression. The order matters: swapping children gives a different root.
380fn parent_hash(left: &Digest, right: &Digest) -> Digest {
381 let mut hash = blake3::Hasher::new();
382 hash.update(left);
383 hash.update(right);
384 *hash.finalize().as_bytes()
385}
386
387impl BatchTree {
388 /// Construct a tree of exactly one complete certificate batch.
389 #[must_use]
390 pub fn new(leaves: &[Digest; BATCH_KEYS]) -> Self {
391 let mut nodes = [[0; 32]; BATCH_KEYS * 2 - 1];
392 // Leaves go at the end of the heap array.
393 nodes[BATCH_KEYS - 1..].copy_from_slice(leaves);
394 // Walk inner nodes from the last to the root, so both children of
395 // each node are already computed.
396 for i in (0..BATCH_KEYS - 1).rev() {
397 nodes[i] = parent_hash(&nodes[i * 2 + 1], &nodes[i * 2 + 2]);
398 }
399 Self { nodes }
400 }
401
402 /// Batch commitment presented to the Ed25519 background service.
403 #[must_use]
404 pub const fn root(&self) -> Digest {
405 self.nodes[0]
406 }
407
408 /// Bottom-up sibling path; invalid indices are rejected instead of masked.
409 ///
410 /// Returns the 7 sibling digests a verifier needs to rebuild the root from
411 /// leaf `index` (0..128).
412 #[must_use]
413 pub fn proof(&self, index: usize) -> Option<[Digest; MERKLE_HEIGHT]> {
414 if index >= BATCH_KEYS {
415 return None;
416 }
417 // Heap position of the leaf.
418 let mut node = BATCH_KEYS - 1 + index;
419 let mut proof = [[0; 32]; MERKLE_HEIGHT];
420 for sibling in &mut proof {
421 // In heap order a left child is odd and a right child is even;
422 // the sibling is the neighbour on the other side.
423 *sibling = self.nodes[if node % 2 == 0 { node - 1 } else { node + 1 }];
424 // Move up to the parent.
425 node = (node - 1) / 2;
426 }
427 Some(proof)
428 }
429}
430
431/// Recover a root from a public-key commitment and checked leaf index.
432///
433/// Bit `level` of `index` says whether the running hash is a left (0) or
434/// right (1) child at that level. Returns `None` for an index of 128 or more.
435#[must_use]
436pub fn root_from_proof(
437 leaf: Digest,
438 index: usize,
439 proof: &[Digest; MERKLE_HEIGHT],
440) -> Option<Digest> {
441 if index >= BATCH_KEYS {
442 return None;
443 }
444 // The running hash, from the leaf up to the root.
445 let mut root = leaf;
446 for (level, sibling) in proof.iter().enumerate() {
447 root = if (index >> level) & 1 == 0 {
448 parent_hash(&root, sibling)
449 } else {
450 parent_hash(sibling, &root)
451 };
452 }
453 Some(root)
454}
455
456/// Transferable signature: hash-based component, inclusion proof, certificate.
457#[derive(Clone, Debug, Eq, PartialEq)]
458pub struct DsigSignature {
459 /// One-time hash-based message signature.
460 pub wots: WotsSignature,
461 /// Leaf position within the fixed-size authenticated batch (0..128).
462 pub key_index: u16,
463 /// Seven bottom-up siblings linking this one-time key to the batch root.
464 pub proof: [Digest; MERKLE_HEIGHT],
465 /// Background Ed25519 certificate, retained for uncached verifiers.
466 pub certificate: BatchCertificate,
467}
468
469impl DsigSignature {
470 /// Full cold-path verification, bound to a caller's trusted Ed25519 identity.
471 ///
472 /// "Cold" means nothing is cached: this does one Ed25519 verification
473 /// plus the hash work. [`VerifiedBatch::verify`] is the warm path.
474 #[must_use]
475 pub fn verify(&self, message: &[u8], trusted_signer: &VerifyingKey) -> bool {
476 self.certificate.verify_strict(trusted_signer)
477 && self.verify_authenticated_hash_component(message)
478 }
479
480 /// The hash-only part of verification: the Merkle path must lead from
481 /// `pk_hash` to the certified root, and the WOTS part must check out.
482 /// Private because it is only safe after the certificate is trusted.
483 fn verify_authenticated_hash_component(&self, message: &[u8]) -> bool {
484 root_from_proof(
485 self.wots.public.pk_hash,
486 usize::from(self.key_index),
487 &self.proof,
488 ) == Some(self.certificate.commitment.root)
489 && self.wots.verify_hash_component(message)
490 }
491
492 /// Stable wire layout, independent of Rust/C++ padding or native word size.
493 #[must_use]
494 ///
495 /// The byte offsets are listed in the module docs' wire layout table.
496 pub fn to_bytes(&self) -> [u8; SIGNATURE_WIRE_BYTES] {
497 let mut bytes = [0; SIGNATURE_WIRE_BYTES];
498 // Write cursor into `bytes`.
499 let mut position = 0;
500 // Append one field and advance the cursor.
501 let mut put = |part: &[u8]| {
502 bytes[position..position + part.len()].copy_from_slice(part);
503 position += part.len();
504 };
505 put(&self.certificate.commitment.to_bytes());
506 put(&self.certificate.signature);
507 put(&self.key_index.to_le_bytes());
508 put(&self.wots.public.pk_hash);
509 put(&self.wots.public.pk_nonce);
510 put(&self.wots.nonce);
511 for sibling in &self.proof {
512 put(sibling);
513 }
514 for element in &self.wots.elements {
515 put(element);
516 }
517 debug_assert_eq!(position, SIGNATURE_WIRE_BYTES);
518 bytes
519 }
520
521 /// Parse exactly one corrected-profile signature. Parsing never authenticates.
522 ///
523 /// Returns `None` for a wrong length, a foreign domain/profile/batch size
524 /// or a key index of 128 or more. Call [`DsigSignature::verify`] after.
525 #[must_use]
526 pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
527 if bytes.len() != SIGNATURE_WIRE_BYTES {
528 return None;
529 }
530 // Bytes 0..64: rejects any other domain, profile or batch size.
531 let commitment = BatchCommitment::from_bytes(bytes[..64].try_into().ok()?)?;
532 let certificate = BatchCertificate {
533 commitment,
534 signature: bytes[64..128].try_into().ok()?,
535 };
536 // Bytes 128..130: must name one of the 128 leaves.
537 let key_index = u16::from_le_bytes(bytes[128..130].try_into().ok()?);
538 if usize::from(key_index) >= BATCH_KEYS {
539 return None;
540 }
541 let public = KeyPublic {
542 pk_hash: bytes[130..162].try_into().ok()?,
543 pk_nonce: bytes[162..178].try_into().ok()?,
544 };
545 // Bytes 178..194: the message salt.
546 let nonce = bytes[178..194].try_into().ok()?;
547 // Bytes 194..418: seven 32-byte siblings.
548 let mut proof = [[0; 32]; MERKLE_HEIGHT];
549 for (i, sibling) in proof.iter_mut().enumerate() {
550 sibling.copy_from_slice(&bytes[194 + i * 32..194 + (i + 1) * 32]);
551 }
552 // Bytes 418..1642: sixty-eight 18-byte chain nodes.
553 let mut elements = [[0; ELEMENT_BYTES]; CHAINS];
554 for (i, element) in elements.iter_mut().enumerate() {
555 element.copy_from_slice(&bytes[418 + i * ELEMENT_BYTES..418 + (i + 1) * ELEMENT_BYTES]);
556 }
557 Some(Self {
558 wots: WotsSignature {
559 public,
560 nonce,
561 elements,
562 },
563 key_index,
564 proof,
565 certificate,
566 })
567 }
568}
569
570/// Authenticated background cache entry; no unchecked public constructor.
571///
572/// Why: Ed25519 verification is the expensive part of checking a signature.
573/// All 128 signatures of a batch share one certificate, so a verifier checks
574/// it once, keeps a `VerifiedBatch`, and then checks each signature with hash
575/// work only. The only way to build one is [`VerifiedBatch::authenticate`].
576pub struct VerifiedBatch {
577 /// The trusted Ed25519 public key (32 bytes) the certificate verified under.
578 signer: [u8; 32],
579 /// The exact certificate that verified.
580 certificate: BatchCertificate,
581}
582
583impl VerifiedBatch {
584 /// Cache a certificate only after strict Ed25519 verification succeeds.
585 #[must_use]
586 pub fn authenticate(
587 certificate: &BatchCertificate,
588 trusted_signer: &VerifyingKey,
589 ) -> Option<Self> {
590 certificate.verify_strict(trusted_signer).then(|| Self {
591 signer: trusted_signer.to_bytes(),
592 certificate: certificate.clone(),
593 })
594 }
595
596 /// Fast-path eligibility binds the full certificate and trusted identity.
597 #[must_use]
598 pub fn can_verify_fast(
599 &self,
600 signature: &DsigSignature,
601 trusted_signer: &VerifyingKey,
602 ) -> bool {
603 self.signer == trusted_signer.to_bytes() && self.certificate == signature.certificate
604 }
605
606 /// Verify without a repeated Ed25519 operation; cache misses fail closed.
607 #[must_use]
608 pub fn verify(
609 &self,
610 signature: &DsigSignature,
611 message: &[u8],
612 trusted_signer: &VerifyingKey,
613 ) -> bool {
614 self.can_verify_fast(signature, trusted_signer)
615 && signature.verify_authenticated_hash_component(message)
616 }
617}
618
619/// Prepare and certify a reference batch; repeated namespace allocation is unsafe.
620///
621/// One batch = 128 one-time keys, their Merkle tree and one Ed25519
622/// certificate. It holds no master seed. Use it through [`crate::lease`],
623/// which makes sure each `(epoch, batch_id)` is used once.
624pub struct PreparedBatch {
625 /// The 128 keys; `None` once a key has signed or gone to a device.
626 keys: [Option<PreparedKey>; BATCH_KEYS],
627 /// Merkle tree over the keys' `pk_hash` values.
628 tree: BatchTree,
629 /// Ed25519 certificate over the tree root and namespace.
630 certificate: BatchCertificate,
631}
632
633/// Derive the 32-byte seed of one one-time key.
634///
635/// `BLAKE3 derive_key(SEED_DERIVATION_CONTEXT, master ‖ signer ‖ epoch ‖
636/// batch_id ‖ index)` over 32 + 32 + 8 + 8 + 4 = 84 bytes, little endian.
637/// Every input that names the key goes in, so two signers, two namespaces or
638/// two indices never share a seed. The derivation context string,
639/// [`crate::SEED_DERIVATION_CONTEXT`], is frozen and shared with the hardware
640/// builders.
641fn one_time_seed(
642 master: &[u8; 32],
643 signer: &[u8; 32],
644 epoch: u64,
645 batch_id: u64,
646 index: u32,
647) -> [u8; 32] {
648 // The 84-byte input, wiped right after hashing because it holds the master seed.
649 let mut material = [0; 84];
650 material[..32].copy_from_slice(master);
651 material[32..64].copy_from_slice(signer);
652 material[64..72].copy_from_slice(&epoch.to_le_bytes());
653 material[72..80].copy_from_slice(&batch_id.to_le_bytes());
654 material[80..].copy_from_slice(&index.to_le_bytes());
655 let mut hasher = blake3::Hasher::new_derive_key(crate::SEED_DERIVATION_CONTEXT);
656 hasher.update(&material);
657 material.zeroize();
658 let mut seed = [0; 32];
659 let mut reader = hasher.finalize_xof();
660 reader.fill(&mut seed);
661 reader.zeroize();
662 hasher.zeroize();
663 seed
664}
665
666impl PreparedBatch {
667 /// Software bootstrap/oracle. Persist a fresh `(epoch, batch_id)` first.
668 ///
669 /// Derives 128 seeds, prepares 128 keys, builds the tree and signs the
670 /// commitment with `signer`. The master seed is zeroized before return.
671 #[must_use]
672 pub fn new(mut master_seed: [u8; 32], epoch: u64, batch_id: u64, signer: &SigningKey) -> Self {
673 // The Ed25519 public key is mixed into every seed.
674 let signer_identity = signer.verifying_key().to_bytes();
675 let keys: [Option<PreparedKey>; BATCH_KEYS] = core::array::from_fn(|index| {
676 let mut seed = one_time_seed(
677 &master_seed,
678 &signer_identity,
679 epoch,
680 batch_id,
681 index as u32,
682 );
683 let prepared = PreparedKey::from_seed(seed);
684 seed.zeroize();
685 Some(prepared)
686 });
687 master_seed.zeroize();
688 // Leaf i is key i's public commitment.
689 let leaves = core::array::from_fn(|i| keys[i].as_ref().expect("new key").public.pk_hash);
690 let tree = BatchTree::new(&leaves);
691 let certificate = BatchCertificate::sign_reference(
692 BatchCommitment {
693 epoch,
694 batch_id,
695 root: tree.root(),
696 },
697 signer,
698 );
699 Self {
700 keys,
701 tree,
702 certificate,
703 }
704 }
705
706 /// Certificate to pre-verify at expected recipients before online traffic.
707 #[must_use]
708 pub const fn certificate(&self) -> &BatchCertificate {
709 &self.certificate
710 }
711
712 /// Burn ownership before returning a key to a hardware loader.
713 ///
714 /// The slot becomes `None`, so this batch can never sign with that key
715 /// again, even if the device later fails.
716 pub fn take_key_for_device(&mut self, index: usize) -> Option<PreparedKey> {
717 self.keys.get_mut(index)?.take()
718 }
719
720 /// Finish a hardware-produced WOTS signature without recreating a secret key.
721 ///
722 /// The full signature must still be independently verified before use.
723 #[must_use]
724 ///
725 /// Returns `None` if `index` is out of range or `wots` is not the key at
726 /// that leaf.
727 pub fn attach_certificate(&self, index: usize, wots: WotsSignature) -> Option<DsigSignature> {
728 let proof = self.tree.proof(index)?;
729 // Refuse a WOTS part whose key is not leaf `index` of this tree.
730 if root_from_proof(wots.public.pk_hash, index, &proof) != Some(self.tree.root()) {
731 return None;
732 }
733 Some(DsigSignature {
734 wots,
735 key_index: index as u16,
736 proof,
737 certificate: self.certificate.clone(),
738 })
739 }
740
741 /// Consume exactly one unused key; a repeated index returns `None`.
742 pub fn sign(&mut self, index: usize, message: &[u8]) -> Option<DsigSignature> {
743 let key = self.keys.get_mut(index)?.take()?;
744 self.attach_certificate(index, key.sign(message))
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 /// Same master seed, different signer, epoch, batch or index: different seeds; and the input layout is exactly the documented 84 bytes.
753 #[test]
754 fn identical_master_material_is_separated_by_signer_and_namespace() {
755 let master = [0x37; 32];
756 let a = SigningKey::from_bytes(&[0x31; 32])
757 .verifying_key()
758 .to_bytes();
759 let b = SigningKey::from_bytes(&[0x32; 32])
760 .verifying_key()
761 .to_bytes();
762 let seed = one_time_seed(&master, &a, 11, 29, 17);
763 assert_ne!(seed, one_time_seed(&master, &b, 11, 29, 17));
764 assert_ne!(seed, one_time_seed(&master, &a, 12, 29, 17));
765 assert_ne!(seed, one_time_seed(&master, &a, 11, 30, 17));
766 assert_ne!(seed, one_time_seed(&master, &a, 11, 29, 18));
767 let mut material = [0; 84];
768 material[..32].copy_from_slice(&master);
769 material[32..64].copy_from_slice(&a);
770 material[64..72].copy_from_slice(&11u64.to_le_bytes());
771 material[72..80].copy_from_slice(&29u64.to_le_bytes());
772 material[80..].copy_from_slice(&17u32.to_le_bytes());
773 assert_eq!(
774 seed,
775 blake3::derive_key(crate::SEED_DERIVATION_CONTEXT, &material)
776 );
777 }
778
779 /// The complement checksum falls when a digit rises, so forward-only hashing cannot forge; a raw-sum checksum would not.
780 #[test]
781 fn complement_checksum_blocks_the_upstream_monotone_counterexample() {
782 let a = [1; 64];
783 let mut b = a;
784 b[0] = 2;
785 let da = digits_from_xof(&a);
786 let db = digits_from_xof(&b);
787 assert_eq!(&da[64..], &[0, 0, 0, 2]);
788 assert_eq!(&db[64..], &[3, 3, 3, 1]);
789 assert!(da.iter().zip(db).any(|(before, after)| after < *before));
790 // Structural fixture only: raw-sum upstream digits would permit this
791 // forward-only transformation. No message preimage attack is claimed.
792 let upstream_a = [0_u8, 0, 0, 1];
793 let upstream_b = [1_u8, 0, 0, 1];
794 assert!(upstream_a.iter().zip(upstream_b).all(|(x, y)| y >= *x));
795 assert_eq!(&digits_from_xof(&[0; 64])[64..], &[0, 0, 0, 3]);
796 assert_eq!(&digits_from_xof(&[255; 64])[64..], &[0, 0, 0, 0]);
797 }
798
799 /// A WOTS signature verifies for its message and fails for another message or a flipped node bit.
800 #[test]
801 fn one_time_chain_signature_round_trip_and_tamper_rejection() {
802 let signature = PreparedKey::from_seed([0x45; 32]).sign(b"sixteen byte msg");
803 assert!(signature.verify_hash_component(b"sixteen byte msg"));
804 assert!(!signature.verify_hash_component(b"different message"));
805 for index in [0, 32, 64, 67] {
806 let mut bad = signature.clone();
807 bad.elements[index][0] ^= 1;
808 assert!(!bad.verify_hash_component(b"sixteen byte msg"));
809 }
810 }
811
812 /// All 128 keys sign once, verify cold and warm, round-trip the wire format, refuse reuse and reject tampering.
813 #[test]
814 fn complete_batch_is_transferable_cached_and_single_use() {
815 let signer = SigningKey::from_bytes(&[0x29; 32]);
816 let trusted = signer.verifying_key();
817 let wrong = SigningKey::from_bytes(&[0x28; 32]).verifying_key();
818 let mut batch = PreparedBatch::new([0x61; 32], 17, 40, &signer);
819 let cached =
820 VerifiedBatch::authenticate(batch.certificate(), &trusted).expect("certificate");
821 assert!(VerifiedBatch::authenticate(batch.certificate(), &wrong).is_none());
822 for index in 0..BATCH_KEYS {
823 let message = (index as u128).to_le_bytes();
824 let signature = batch.sign(index, &message).expect("unused key");
825 assert!(signature.verify(&message, &trusted));
826 assert!(cached.verify(&signature, &message, &trusted));
827 assert!(!cached.can_verify_fast(&signature, &wrong));
828 assert!(batch.sign(index, &message).is_none());
829 assert_eq!(
830 DsigSignature::from_bytes(&signature.to_bytes()),
831 Some(signature.clone())
832 );
833 let mut bad = signature.clone();
834 bad.proof[0][0] ^= 1;
835 assert!(!bad.verify(&message, &trusted));
836 let mut bad = signature.clone();
837 bad.certificate.commitment.batch_id += 1;
838 assert!(!cached.can_verify_fast(&bad, &trusted));
839 assert!(!bad.verify(&message, &trusted));
840 let mut bad = signature.clone();
841 bad.key_index = 128;
842 assert!(!bad.verify(&message, &trusted));
843 assert!(DsigSignature::from_bytes(&bad.to_bytes()).is_none());
844 }
845 assert!(batch.sign(128, b"bad index").is_none());
846 }
847
848 /// Every leaf's proof rebuilds the root; a wrong index or index 128 is rejected.
849 #[test]
850 fn merkle_all_positions_and_invalid_indices() {
851 let leaves = core::array::from_fn(|i| *blake3::hash(&(i as u64).to_le_bytes()).as_bytes());
852 let tree = BatchTree::new(&leaves);
853 for (i, leaf) in leaves.iter().enumerate() {
854 let proof = tree.proof(i).expect("in range");
855 assert_eq!(root_from_proof(*leaf, i, &proof), Some(tree.root()));
856 assert_ne!(root_from_proof(*leaf, i ^ 1, &proof), Some(tree.root()));
857 }
858 assert!(tree.proof(128).is_none());
859 assert!(root_from_proof(leaves[0], 128, &[[0; 32]; 7]).is_none());
860 }
861}