Skip to main content

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}