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}