rhdl_dsig_scalable_core/slice_model.rs
1//! Bit-exact software model of every compression a slice issues.
2//!
3//! # Why this file exists
4//!
5//! The hardware never calls "BLAKE3 of this message". It feeds one 64-byte
6//! *compression* at a time into its BLAKE3 lane, and it has to choose every
7//! chaining value, counter, length and flag itself. One wrong flag bit gives
8//! a different hash, and a key that no verifier accepts. The high-level
9//! software [`reference`](crate::reference) cannot help locate such a bug,
10//! because it calls a BLAKE3 library that hides those details.
11//!
12//! So this file sits in between. It rebuilds a whole key out of the same
13//! single compressions the hardware issues, written as plain Rust, one
14//! function per kind of request. A test proves the result equals the
15//! reference. After that, this file is the readable specification of what
16//! the RTL must send, and RTL tests can compare against it request by request.
17//!
18//! ```text
19//! reference (BLAKE3 library) ══ test: equal ══ slice_model (single compressions)
20//! ║ RTL tests: equal, request by request
21//! slice_factory / slice_signer (hardware)
22//! ```
23//!
24//! # What it models
25//!
26//! Each function below returns the exact BLAKE3 request (chaining value,
27//! block, counter, length, flags) that one [`Op`](crate::slice_schedule::Op)
28//! sends into a slice's BLAKE3 lane. [`model_key`] builds a complete one-time
29//! key using only these requests, [`compress`], [`dsig_chain`] and the
30//! [`packer`](crate::packer) stream layout. A test proves the result equals
31//! the high-level [`reference`](crate::reference), so:
32//!
33//! * the flags, counters and lengths in this file are the *specification* the
34//! RTL must follow, one function per op, and
35//! * RTL tests can compare against [`model_key`] request by request.
36//!
37//! ```text
38//! host: prefix_cv = BLAKE3-derive-key(context) ∘ (master ‖ signer) [once per run]
39//! Seed cv=prefix_cv block=epoch‖batch‖index (20 B) CHUNK_END|ROOT|DERIVE_KEY_MATERIAL
40//! PkNonce cv=IV block=LE32(0x5eed)‖seed (36 B) single-block root
41//! Nonce cv=IV block=LE32(0xc0ca)‖seed (36 B) single-block root
42//! Xof(t) cv=IV block=seed (32 B), counter t single-block root
43//! Chunk0(j) cv=IV or CV block=stream[64j..] counter 0 START on j=0, END on j=15
44//! Chunk1(m) cv=IV or CV block=stream[1024+64m..] ctr 1 START on m=0, END on m=3 (24 B)
45//! Parent cv=IV block=CV0‖CV1 PARENT|ROOT → pk_hash
46//! Merkle cv=IV block=left‖right single-block root
47//! Message cv=IV block=pk_hash‖nonce‖message single-block root → digits
48//! ```
49//!
50//! Per key that is 1 seed + 2 nonces + 20 XOF + 20 commitment blocks + 1
51//! parent = 44 requests (the test checks this count). `Merkle` and `Message`
52//! are not part of one key: they serve the subtree and the signer.
53//!
54//! # The story of one key, in data
55//!
56//! ```text
57//! seed ──▶ pk_nonce, nonce
58//! └───▶ XOF 20 × 64 B = 1,280 B ──▶ 68 chain secrets of 18 B (1,224 B used)
59//! position 0 ──Haraka──▶ position 1 ──▶ position 2 ──▶ position 3 (chain end)
60//! (all four positions of all 68 chains go to the key cache)
61//! pk_nonce ‖ 68 chain ends = 1,240 B ──▶ chunk 0 (16 blocks) + chunk 1 (4 blocks)
62//! ──▶ Parent(CV0, CV1) = pk_hash (the Merkle leaf)
63//! ```
64//!
65//! 1,240 bytes is more than one 1,024-byte BLAKE3 chunk, so BLAKE3 hashes
66//! it as two chunks and one parent node. That is standard BLAKE3; the model
67//! just spells it out.
68//!
69//! The master seed never enters the FPGA: the trusted host derives
70//! [`prefix_cv`], the chaining value after the first 64 material bytes, and
71//! loads that instead. Both are equally secret.
72
73use crate::blake3_core::{
74 CHUNK_END, CHUNK_START, DERIVE_KEY_CONTEXT, DERIVE_KEY_MATERIAL, IV, PARENT, ROOT,
75 SINGLE_BLOCK_ROOT, compress, decode_block, encode_words,
76};
77use crate::haraka::dsig_chain;
78use crate::{CHAINS, Digest, ELEMENT_BYTES, Nonce, POSITIONS, SEED_DERIVATION_CONTEXT};
79
80/// One BLAKE3 compression request, exactly as the lane receives it.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct Request {
83 /// Input chaining value or key words.
84 pub cv: [u32; 8],
85 /// Zero-padded block as little-endian words.
86 pub block: [u32; 16],
87 /// Chunk counter, or XOF output-block number for root outputs.
88 pub counter: u64,
89 /// Meaningful bytes in `block`.
90 pub block_len: u32,
91 /// BLAKE3 domain flags.
92 pub flags: u32,
93}
94
95impl Request {
96 /// Run the request through the reference compression function.
97 ///
98 /// Returns all 16 output words; callers keep the first 8 as a chaining
99 /// value, or use all 16 as 64 bytes of root output.
100 #[must_use]
101 pub const fn compress(&self) -> [u32; 16] {
102 compress(
103 self.cv,
104 self.block,
105 self.counter,
106 self.block_len,
107 self.flags,
108 )
109 }
110}
111
112/// Zero-pad up to 64 bytes and convert to 16 little-endian words.
113fn block_of(bytes: &[u8]) -> [u32; 16] {
114 let mut padded = [0; 64];
115 padded[..bytes.len()].copy_from_slice(bytes);
116 decode_block(padded)
117}
118
119/// First 8 output words: the chaining value of a compression.
120fn cv_of(words: [u32; 16]) -> [u32; 8] {
121 core::array::from_fn(|i| words[i])
122}
123
124/// First 32 output bytes: a 32-byte hash (seed, `pk_hash`, Merkle node).
125fn bytes32(words: [u32; 16]) -> [u8; 32] {
126 let bytes = encode_words(words);
127 core::array::from_fn(|i| bytes[i])
128}
129
130/// BLAKE3 derive-key context key for [`SEED_DERIVATION_CONTEXT`].
131///
132/// BLAKE3's key-derivation mode first hashes a context string into a key,
133/// then hashes the key material under that key. This is the first half.
134#[must_use]
135pub fn context_key() -> [u32; 8] {
136 let context = SEED_DERIVATION_CONTEXT.as_bytes();
137 assert!(context.len() <= 64, "single-block context");
138 cv_of(compress(
139 IV,
140 block_of(context),
141 0,
142 context.len() as u32,
143 CHUNK_START | CHUNK_END | ROOT | DERIVE_KEY_CONTEXT,
144 ))
145}
146
147/// Host-side: chaining value after hashing `master ‖ signer` as key material.
148///
149/// The key material is `master (32 B) ‖ signer identity (32 B) ‖ epoch ‖
150/// batch ‖ index`. Its first 64 bytes are the same for every key of a run,
151/// so the host compresses them once here; the FPGA continues from this
152/// value with [`seed_request`].
153#[must_use]
154pub fn prefix_cv(master: &[u8; 32], signer: &[u8; 32]) -> [u32; 8] {
155 let mut first = [0; 64];
156 first[..32].copy_from_slice(master);
157 first[32..].copy_from_slice(signer);
158 cv_of(compress(
159 context_key(),
160 decode_block(first),
161 0,
162 64,
163 CHUNK_START | DERIVE_KEY_MATERIAL,
164 ))
165}
166
167/// The batch namespace a slice works in.
168///
169/// Together with a key index, these three values name exactly one one-time
170/// key. A fresh, durably allocated epoch per run is what keeps keys from
171/// being regenerated (and reused) after a restart.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub struct Namespace {
174 /// From [`prefix_cv`]; secret.
175 pub prefix_cv: [u32; 8],
176 /// Durably allocated epoch.
177 pub epoch: u64,
178 /// Batch within the epoch.
179 pub batch_id: u64,
180}
181
182/// `Seed`: the second (final) material block, `epoch ‖ batch ‖ index`.
183///
184/// 8 + 8 + 4 = 20 bytes, little-endian. The output's first 32 bytes are the
185/// key's one-time seed.
186#[must_use]
187pub fn seed_request(ns: &Namespace, index: u32) -> Request {
188 let mut material = [0; 20];
189 material[..8].copy_from_slice(&ns.epoch.to_le_bytes());
190 material[8..16].copy_from_slice(&ns.batch_id.to_le_bytes());
191 material[16..].copy_from_slice(&index.to_le_bytes());
192 Request {
193 cv: ns.prefix_cv,
194 block: block_of(&material),
195 counter: 0,
196 block_len: 20,
197 flags: CHUNK_END | ROOT | DERIVE_KEY_MATERIAL,
198 }
199}
200
201/// Prefix of the public `pk_nonce` derivation.
202///
203/// A different 4-byte prefix per purpose ("domain separation") makes the two
204/// nonces unrelated even though both come from the same seed.
205pub const PK_NONCE_PREFIX: u32 = 0x5eed;
206/// Prefix of the signer-private message nonce derivation.
207pub const NONCE_PREFIX: u32 = 0xc0ca;
208
209/// `PkNonce` / `Nonce`: hash `LE32(prefix) ‖ seed`, keep 16 bytes.
210#[must_use]
211pub fn nonce_request(seed: &[u8; 32], prefix: u32) -> Request {
212 // 4-byte prefix then the 32-byte seed: 36 bytes.
213 let mut bytes = [0; 36];
214 bytes[..4].copy_from_slice(&prefix.to_le_bytes());
215 bytes[4..].copy_from_slice(seed);
216 Request {
217 cv: IV,
218 block: block_of(&bytes),
219 counter: 0,
220 block_len: 36,
221 flags: SINGLE_BLOCK_ROOT,
222 }
223}
224
225/// `Xof(t)`: output block `t` of `BLAKE3-XOF(seed)`.
226///
227/// BLAKE3's extendable output: the same root compression run with counter
228/// `t = 0, 1, 2, …` gives 64 fresh bytes each time. 20 blocks give the
229/// 1,224 bytes of chain secrets (68 × 18).
230#[must_use]
231pub fn xof_request(seed: &[u8; 32], block: u8) -> Request {
232 Request {
233 cv: IV,
234 block: block_of(seed),
235 counter: u64::from(block),
236 block_len: 32,
237 flags: SINGLE_BLOCK_ROOT,
238 }
239}
240
241/// `Chunk0(j)` (`chunk = 0`) or `Chunk1(m)` (`chunk = 1`) of the commitment.
242///
243/// `cv` is IV for the first block of a chunk and the previous block's
244/// chaining value otherwise; `word` is the packer's output for this block.
245///
246/// `bytes` is the block length (64, or 24 for the last block), `first` /
247/// `last` set the `CHUNK_START` / `CHUNK_END` flags. No `ROOT` flag: the two
248/// chunks are joined by [`parent_request`].
249#[must_use]
250pub fn commitment_request(
251 cv: [u32; 8],
252 word: &[u8; 64],
253 bytes: u32,
254 chunk: u64,
255 first: bool,
256 last: bool,
257) -> Request {
258 Request {
259 cv,
260 block: decode_block(*word),
261 counter: chunk,
262 block_len: bytes,
263 flags: if first { CHUNK_START } else { 0 } | if last { CHUNK_END } else { 0 },
264 }
265}
266
267/// `Parent`: combine the two chunk chaining values into `pk_hash`.
268#[must_use]
269pub fn parent_request(cv0: [u32; 8], cv1: [u32; 8]) -> Request {
270 // Block = CV of chunk 0 (8 words) ‖ CV of chunk 1 (8 words).
271 let mut block = [0; 16];
272 block[..8].copy_from_slice(&cv0);
273 block[8..].copy_from_slice(&cv1);
274 Request {
275 cv: IV,
276 block,
277 counter: 0,
278 block_len: 64,
279 flags: PARENT | ROOT,
280 }
281}
282
283/// `Merkle`: one batch-tree parent, `BLAKE3(left ‖ right)`.
284///
285/// The same request the [subtree builder](crate::slice_merkle) and the
286/// [cert unit](crate::cert_unit) issue for every inner tree node.
287#[must_use]
288pub fn merkle_request(left: &Digest, right: &Digest) -> Request {
289 let mut bytes = [0; 64];
290 bytes[..32].copy_from_slice(left);
291 bytes[32..].copy_from_slice(right);
292 Request {
293 cv: IV,
294 block: decode_block(bytes),
295 counter: 0,
296 block_len: 64,
297 flags: SINGLE_BLOCK_ROOT,
298 }
299}
300
301/// `Message`: salted digit hash for a 16-byte message.
302///
303/// `pk_hash (32 B) ‖ private nonce (16 B) ‖ message (16 B)` fills one block
304/// exactly. Its 64 output bytes become the 68 digits
305/// ([`digits_kernel`](crate::slice_signer::digits_kernel) in hardware).
306#[must_use]
307pub fn message_request(pk_hash: &Digest, nonce: &Nonce, message: &[u8; crate::MESSAGE_BYTES]) -> Request {
308 let mut bytes = [0; 64];
309 bytes[..32].copy_from_slice(pk_hash);
310 bytes[32..48].copy_from_slice(nonce);
311 bytes[48..].copy_from_slice(&message[..16]);
312 Request {
313 cv: IV,
314 block: decode_block(bytes),
315 counter: 0,
316 block_len: 64,
317 // Profile 2 continues in [`message_tail_request`].
318 flags: if crate::MESSAGE_BYTES == 16 { SINGLE_BLOCK_ROOT } else { CHUNK_START },
319 }
320}
321
322/// Profile 2's second digest block: message bytes 16..64 (48 bytes),
323/// chained from the first block's chaining value `cv` (its output words
324/// 0..8), `CHUNK_END | ROOT`. Its output is the 64-byte XOF.
325#[must_use]
326pub fn message_tail_request(cv: [u32; 8], message: &[u8; crate::MESSAGE_BYTES]) -> Request {
327 let mut bytes = [0; 64];
328 bytes[..crate::MESSAGE_BYTES - 16].copy_from_slice(&message[16..]);
329 Request {
330 cv,
331 block: decode_block(bytes),
332 counter: 0,
333 block_len: (crate::MESSAGE_BYTES - 16) as u32,
334 flags: CHUNK_END | ROOT,
335 }
336}
337
338/// The message digest (64 XOF words) as the slice computes it: one block in
339/// profile 1, two chained blocks in profile 2.
340#[must_use]
341pub fn message_digest(pk_hash: &Digest, nonce: &Nonce, message: &[u8; crate::MESSAGE_BYTES]) -> [u32; 16] {
342 let first = message_request(pk_hash, nonce, message).compress();
343 if crate::MESSAGE_BYTES == 16 {
344 return first;
345 }
346 message_tail_request(first[..8].try_into().unwrap(), message).compress()
347}
348
349/// Everything a slice produces for one key, built only from the requests above.
350#[derive(Clone, Debug, PartialEq, Eq)]
351pub struct ModelKey {
352 /// One-time seed (secret).
353 pub seed: [u8; 32],
354 /// Public chain salt.
355 pub pk_nonce: Nonce,
356 /// Signer-private message nonce.
357 pub nonce: Nonce,
358 /// Cache rows: `nodes[position][chain]`.
359 ///
360 /// Exactly what the four [cache banks](crate::cache_bank) hold for this
361 /// key: bank `p` stores `nodes[p]`.
362 pub nodes: [[[u8; ELEMENT_BYTES]; CHAINS]; POSITIONS],
363 /// Endpoint commitment, the Merkle leaf.
364 pub pk_hash: Digest,
365 /// Every request issued for this key, in [`Op`](crate::slice_schedule::Op) order.
366 pub requests: Vec<Request>,
367}
368
369/// The 1,240-byte commitment stream split into the packer's 20 blocks.
370///
371/// Returns `(bytes, length)` per block: 19 blocks of 64 bytes and a final
372/// block of 24. This is the same split the hardware
373/// [packer](crate::packer) makes with a 16-byte prefix.
374#[must_use]
375pub fn commitment_blocks(
376 pk_nonce: &Nonce,
377 ends: &[[u8; ELEMENT_BYTES]; CHAINS],
378) -> Vec<(Vec<u8>, u32)> {
379 // pk_nonce (16 B) then the 68 chain ends (18 B each).
380 let mut stream = pk_nonce.to_vec();
381 for end in ends {
382 stream.extend_from_slice(end);
383 }
384 stream
385 .chunks(64)
386 .map(|c| (c.to_vec(), c.len() as u32))
387 .collect()
388}
389
390/// Build key `index` of the namespace purely from slice requests.
391///
392/// Every BLAKE3 call goes through `issue`, which records the request and
393/// returns its output, so `requests` is the complete list in order.
394#[must_use]
395pub fn model_key(ns: &Namespace, index: u32) -> ModelKey {
396 let mut requests = Vec::new();
397 // Record a request, then run it.
398 let mut issue = |request: Request| {
399 requests.push(request);
400 request.compress()
401 };
402 let seed = bytes32(issue(seed_request(ns, index)));
403 // First 16 output bytes (a nonce).
404 let first16 = |words: [u32; 16]| -> Nonce {
405 let bytes = encode_words(words);
406 core::array::from_fn(|i| bytes[i])
407 };
408 let pk_nonce = first16(issue(nonce_request(&seed, PK_NONCE_PREFIX)));
409 let nonce = first16(issue(nonce_request(&seed, NONCE_PREFIX)));
410 // 20 XOF blocks = 1,280 bytes; the first 68 × 18 are the chain secrets.
411 let mut xof = Vec::new();
412 for block in 0..20 {
413 xof.extend_from_slice(&encode_words(issue(xof_request(&seed, block))));
414 }
415 // Walk every chain: position 0 is the secret, each next position is one
416 // salted Haraka chain step of the previous one.
417 let mut nodes = [[[0; ELEMENT_BYTES]; CHAINS]; POSITIONS];
418 for chain in 0..CHAINS {
419 nodes[0][chain].copy_from_slice(&xof[ELEMENT_BYTES * chain..ELEMENT_BYTES * (chain + 1)]);
420 for depth in 0..POSITIONS - 1 {
421 nodes[depth + 1][chain] = dsig_chain(&nodes[depth][chain], &pk_nonce, chain, depth);
422 }
423 }
424 // Hash pk_nonce ‖ chain ends (position 3) as BLAKE3 chunks 0 and 1.
425 let blocks = commitment_blocks(&pk_nonce, &nodes[POSITIONS - 1]);
426 assert_eq!(blocks.len(), 20);
427 // Running chaining value of each chunk, starting from IV.
428 let mut chunk_cv = [IV, IV];
429 for (n, (bytes, length)) in blocks.iter().enumerate() {
430 // Blocks 0..16 are chunk 0 (16 blocks), 16..20 are chunk 1 (4 blocks).
431 let (chunk, within, size) = if n < 16 { (0, n, 16) } else { (1, n - 16, 4) };
432 let mut word = [0; 64];
433 word[..bytes.len()].copy_from_slice(bytes);
434 let request = commitment_request(
435 chunk_cv[chunk],
436 &word,
437 *length,
438 chunk as u64,
439 within == 0,
440 within == size - 1,
441 );
442 chunk_cv[chunk] = cv_of(issue(request));
443 }
444 // Join the two chunks: this is the key's public commitment.
445 let pk_hash = bytes32(issue(parent_request(chunk_cv[0], chunk_cv[1])));
446 ModelKey {
447 seed,
448 pk_nonce,
449 nonce,
450 nodes,
451 pk_hash,
452 requests,
453 }
454}
455
456/// Batch root from 128 leaves using only [`merkle_request`].
457///
458/// Hashes pairs level by level until one node is left. In hardware the
459/// lower levels are done by each slice's subtree builder and the top levels
460/// by the cert unit; the result must be the same.
461#[must_use]
462pub fn model_root(leaves: &[Digest]) -> Digest {
463 let mut level = leaves.to_vec();
464 while level.len() > 1 {
465 level = level
466 .chunks(2)
467 .map(|pair| bytes32(merkle_request(&pair[0], &pair[1]).compress()))
468 .collect();
469 }
470 level[0]
471}
472
473/// Proof that the request-level model equals the high-level reference.
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::reference::{PreparedBatch, message_digits};
478 use ed25519_dalek::SigningKey;
479
480 /// Build all 128 keys of one batch from requests; compare a sample of
481 /// keys field by field, the message digits, and the batch root with the
482 /// reference.
483 #[test]
484 fn slice_requests_reproduce_the_reference_batch_exactly() {
485 // Fixed test secrets; never used outside tests.
486 let master = [0x42; 32];
487 let signer = SigningKey::from_bytes(&[7; 32]);
488 let identity = signer.verifying_key().to_bytes();
489 let (epoch, batch_id) = (5, 9);
490 let mut batch = PreparedBatch::new(master, epoch, batch_id, &signer);
491 let ns = Namespace {
492 prefix_cv: prefix_cv(&master, &identity),
493 epoch,
494 batch_id,
495 };
496 let mut leaves = Vec::new();
497 for index in 0..128 {
498 let model = model_key(&ns, index);
499 assert_eq!(
500 model.requests.len(),
501 44,
502 "seed, 2 nonces, 20 XOF, 21 commitment"
503 );
504 leaves.push(model.pk_hash);
505 // Check a spread of keys in full (every 37th, plus the last).
506 if index % 37 == 0 || index == 127 {
507 let key = batch.take_key_for_device(index as usize).unwrap();
508 assert_eq!(model.pk_hash, key.public().pk_hash, "pk_hash {index}");
509 assert_eq!(model.pk_nonce, key.public().pk_nonce, "pk_nonce {index}");
510 assert_eq!(model.nonce, key.signing_nonce_for_device());
511 assert_eq!(&model.nodes, key.cache_for_device(), "cache {index}");
512 // The message slot reproduces the reference digit hash.
513 let message: [u8; crate::MESSAGE_BYTES] = core::array::from_fn(|i| index as u8 ^ (i as u8 * 3));
514 let xof = encode_words(message_digest(&model.pk_hash, &model.nonce, &message));
515 assert_eq!(
516 crate::reference::digits_from_xof(&xof),
517 message_digits(&model.pk_hash, &model.nonce, &message)
518 );
519 }
520 }
521 assert_eq!(model_root(&leaves), batch.certificate().commitment.root);
522 }
523}