Skip to main content

rhdl_dsig_scalable_core/
certificate.rs

1//! Versioned, domain-separated Ed25519 batch authentication.
2//!
3//! The Ed25519 implementation's fixed-64 request signs these exact bytes, not a
4//! host-prehashed replacement. Eight little-endian 64-bit words map directly to
5//! its `Words512` message; the 16-bit hardware transaction tag is not signed and
6//! must never be used as the cryptographic batch identity.
7//!
8//! # The problem
9//!
10//! A one-time key is only useful if a verifier can tell it really belongs to
11//! the signer. Signing each of millions of keys with Ed25519 would cost as
12//! much as signing the messages directly. DSig instead groups 128 keys into a
13//! *batch*, builds a Merkle tree over them (see
14//! [`BatchTree`](crate::reference::BatchTree)) and signs only the tree root,
15//! once per batch, with Ed25519.
16//!
17//! # The decision: one fixed 64-byte envelope
18//!
19//! What exactly gets signed must be unambiguous, or a signature made for one
20//! purpose could be replayed for another. So the signed message is a fixed
21//! 64-byte *envelope*: a domain string, a profile version, the batch size,
22//! the never-reused namespace `(epoch, batch_id)`, and the root.
23//!
24//! ```text
25//!  byte  0        8        12       16        24          32                64
26//!        ┌────────┬────────┬────────┬─────────┬───────────┬─────────────────┐
27//!        │DSIGFP01│profile │ batch  │  epoch  │ batch_id  │   Merkle root   │
28//!        │ ASCII  │ u32 LE │ size LE│ u64 LE  │  u64 LE   │    32 bytes     │
29//!        │        │  = 1   │ = 128  │         │           │                 │
30//!        └────────┴────────┴────────┴─────────┴───────────┴─────────────────┘
31//!  word     0          1             2          3          4    5    6    7
32//! ```
33//!
34//! 64 bytes is exactly eight 64-bit words, which is the fixed message size of
35//! the Ed25519 hardware core's `Words512` request. So the hardware never has
36//! to hash or reshuffle the envelope before signing: [`batch_commitment_words`]
37//! packs it straight into those eight words.
38//!
39//! # Two views of the same bytes
40//!
41//! * Hardware: [`batch_commitment_words`] is an RHDL `#[kernel]` that the
42//!   certificate unit ([`crate::cert_unit`]) and the bridge
43//!   ([`crate::ed25519`]) call.
44//! * Software: [`BatchCommitment`] and [`BatchCertificate`] are the host-side
45//!   oracle used by [`crate::reference`] and the host tools. A test checks
46//!   that both views produce the same words for every root bit.
47//!
48//! The 16-bit routing tag that travels next to the request in hardware is
49//! not part of these bytes. It only says which outstanding request a result
50//! belongs to.
51
52use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
53use rhdl::prelude::*;
54
55use crate::{BATCH_KEYS, Digest};
56
57/// Domain occupying certificate bytes 0 through 7.
58///
59/// The fixed ASCII label `DSIGFP01`, so this envelope can never be
60/// mistaken for any other 64-byte message signed by the same Ed25519 key.
61pub const DOMAIN: [u8; 8] = *b"DSIGFP01";
62/// Version/profile identifier for the corrected base-four construction.
63///
64/// Bytes 8..12, little endian. "Corrected" refers to the complement checksum
65/// in [`crate::reference::digits_from_xof`]. A verifier for another profile
66/// rejects these envelopes instead of misreading them.
67///
68/// Profile 1 signs 16-byte messages (`DSIG_MESSAGE_BYTES=16`, the default
69/// and the measured profile); profile 2 is the same construction over
70/// 64-byte messages. Since the profile is inside the certified envelope, a
71/// key of one profile can never pass for the other.
72pub const PROFILE_ID: u32 = if crate::MESSAGE_BYTES == 16 { 1 } else { 2 };
73/// Envelope word 1: profile (bytes 8..12) and batch size 128 (bytes 12..16).
74const PROFILE_WORD: b64 = b64(0x0000_0080_0000_0000 | PROFILE_ID as u128);
75
76/// Build the Ed25519 fixed-64 message in RHDL, without hashing on the host.
77///
78/// The background scheduler supplies a completed 128-key Merkle root and its
79/// durably allocated namespace. The result maps directly to the Ed25519 core's
80/// `SignRequest.message.words`. This adapter is pure packing: it neither signs
81/// nor validates a certificate, and it does not authorize cache admission.
82///
83/// Inputs: `epoch` and `batch_id` (64 bits each), `root` (32 bytes, in hash
84/// output order). Output: eight 64-bit words; word `i` holds envelope bytes
85/// `8i..8i + 8` in little-endian order. Timing: purely combinational (no
86/// registers, zero cycles of latency); it is wires plus constants.
87#[kernel]
88#[allow(clippy::needless_range_loop)]
89pub fn batch_commitment_words(epoch: b64, batch_id: b64, root: [b8; 32]) -> [b64; 8] {
90    // The eight output words, filled in envelope order.
91    let mut words = [b64(0); 8];
92    // ASCII DSIGFP01, then little-endian profile and batch_size=128.
93    words[0] = b64(0x3130_5046_4749_5344);
94    words[1] = PROFILE_WORD;
95    words[2] = epoch;
96    words[3] = batch_id;
97    // Words 4..8: the root, eight bytes per word, byte 0 in the low bits.
98    // RHDL needs literal loop bounds, hence the fixed 0..4 and 0..8.
99    for word in 0..4 {
100        // Accumulates one 64-bit word from eight root bytes.
101        let mut value = b64(0);
102        for byte in 0..8 {
103            // Widen the byte to 64 bits and shift it to its lane.
104            value = value | (root[8 * word + byte].resize::<64>() << (8 * byte) as u128);
105        }
106        words[word + 4] = value;
107    }
108    words
109}
110
111/// Unambiguous 64-byte message authenticated by the background Ed25519 core.
112///
113/// Only the variable fields are stored; domain, profile and batch size are
114/// constants added by [`BatchCommitment::to_bytes`].
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub struct BatchCommitment {
117    /// Durably allocated, never-reused epoch under this signing identity.
118    pub epoch: u64,
119    /// Monotonic batch number within the epoch.
120    pub batch_id: u64,
121    /// BLAKE3 Merkle root of exactly 128 one-time public-key commitments.
122    pub root: Digest,
123}
124
125impl BatchCommitment {
126    /// Encode domain8, profile4, batch-size4, epoch8, batch-id8, root32.
127    ///
128    /// The number after each field name is its size in bytes; all integers
129    /// are little endian.
130    #[must_use]
131    pub fn to_bytes(self) -> [u8; 64] {
132        let mut bytes = [0; 64];
133        bytes[..8].copy_from_slice(&DOMAIN);
134        bytes[8..12].copy_from_slice(&PROFILE_ID.to_le_bytes());
135        bytes[12..16].copy_from_slice(&(BATCH_KEYS as u32).to_le_bytes());
136        bytes[16..24].copy_from_slice(&self.epoch.to_le_bytes());
137        bytes[24..32].copy_from_slice(&self.batch_id.to_le_bytes());
138        bytes[32..].copy_from_slice(&self.root);
139        bytes
140    }
141
142    /// Parse only this exact profile and batch size; reject cross-profile data.
143    #[must_use]
144    pub fn from_bytes(bytes: &[u8; 64]) -> Option<Self> {
145        if bytes[..8] != DOMAIN
146            || bytes[8..12] != PROFILE_ID.to_le_bytes()
147            || bytes[12..16] != (BATCH_KEYS as u32).to_le_bytes()
148        {
149            return None;
150        }
151        Some(Self {
152            epoch: u64::from_le_bytes(bytes[16..24].try_into().ok()?),
153            batch_id: u64::from_le_bytes(bytes[24..32].try_into().ok()?),
154            root: bytes[32..64].try_into().ok()?,
155        })
156    }
157
158    /// Map directly to the Ed25519 fixed-64 core's little-endian words.
159    #[must_use]
160    ///
161    /// Software twin of [`batch_commitment_words`]; tests compare the two.
162    pub fn to_ed25519_words(self) -> [u64; 8] {
163        let bytes = self.to_bytes();
164        core::array::from_fn(|word| {
165            u64::from_le_bytes(bytes[word * 8..word * 8 + 8].try_into().expect("word"))
166        })
167    }
168}
169
170/// Public batch commitment and its Ed25519 signature.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct BatchCertificate {
173    /// Exact message signed, including domain/profile and lifetime namespace.
174    pub commitment: BatchCommitment,
175    /// Canonical Ed25519 `R || S` bytes.
176    pub signature: [u8; 64],
177}
178
179impl BatchCertificate {
180    /// Software oracle for the hardware certificate service, not hardware work.
181    ///
182    /// Signs `commitment.to_bytes()` with `ed25519-dalek`. Ed25519 is
183    /// deterministic, so the hardware core must produce the same 64 bytes.
184    #[must_use]
185    pub fn sign_reference(commitment: BatchCommitment, key: &SigningKey) -> Self {
186        Self {
187            signature: key.sign(&commitment.to_bytes()).to_bytes(),
188            commitment,
189        }
190    }
191
192    /// Authenticate against a caller-supplied trusted signer identity.
193    ///
194    /// Uses dalek's `verify_strict`, which also rejects weak (small-order)
195    /// keys and signature points. The caller supplies the trusted key; the
196    /// certificate does not carry one, so it cannot vouch for itself.
197    #[must_use]
198    pub fn verify_strict(&self, trusted_signer: &VerifyingKey) -> bool {
199        trusted_signer
200            .verify_strict(
201                &self.commitment.to_bytes(),
202                &Signature::from_bytes(&self.signature),
203            )
204            .is_ok()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    /// For each of the 256 root bits set alone, the RHDL kernel and the byte
213    /// serializer give identical words.
214    #[test]
215    fn rhdl_message_packing_matches_ed25519_fixed64_bytes() {
216        for bit in 0..256 {
217            let mut root = [0u8; 32];
218            root[bit / 8] = 1 << (bit % 8);
219            let commitment = BatchCommitment {
220                epoch: 0x0123_4567_89ab_cdef,
221                batch_id: 0xfedc_ba98_7654_3210,
222                root,
223            };
224            let packed = batch_commitment_words(
225                b64(commitment.epoch as u128),
226                b64(commitment.batch_id as u128),
227                root.map(|byte| b8(byte as u128)),
228            );
229            assert_eq!(
230                packed.map(|word| word.raw() as u64),
231                commitment.to_ed25519_words()
232            );
233        }
234    }
235
236    /// Sign, verify, reject a wrong key, round-trip the bytes, and show that
237    /// flipping any one bit of the envelope breaks either parsing (bytes
238    /// 0..16, the constants) or verification (bytes 16..64).
239    #[test]
240    fn fixed64_certificate_binds_every_field() {
241        let signing = SigningKey::from_bytes(&[19; 32]);
242        let commitment = BatchCommitment {
243            epoch: 0x0102_0304_0506_0708,
244            batch_id: 42,
245            root: [0x69; 32],
246        };
247        let certificate = BatchCertificate::sign_reference(commitment, &signing);
248        assert!(certificate.verify_strict(&signing.verifying_key()));
249        assert!(!certificate.verify_strict(&SigningKey::from_bytes(&[20; 32]).verifying_key()));
250        let encoded = commitment.to_bytes();
251        assert_eq!(BatchCommitment::from_bytes(&encoded), Some(commitment));
252        let words = commitment.to_ed25519_words();
253        for (i, word) in words.iter().enumerate() {
254            assert_eq!(&encoded[i * 8..i * 8 + 8], &word.to_le_bytes());
255        }
256        for i in 0..64 {
257            let mut changed = encoded;
258            changed[i] ^= 1;
259            if let Some(commitment) = BatchCommitment::from_bytes(&changed) {
260                assert!(
261                    !BatchCertificate {
262                        commitment,
263                        ..certificate.clone()
264                    }
265                    .verify_strict(&signing.verifying_key())
266                );
267            } else {
268                assert!(i < 16);
269            }
270        }
271    }
272}