rhdl_dsig_scalable_core/ed25519.rs
1#![doc = include_str!("../docs/certificate-bridge.md")]
2//!
3//! ## Where this bridge sits, and why it is so thin
4//!
5//! Every batch of 128 one-time keys needs one Ed25519 signature over its
6//! 64-byte envelope (see [`crate::certificate`]). The Ed25519 math is large
7//! and was already built and tested by a separate project, so DSig reuses
8//! that core unchanged (vendored as `rhdl_ed25519_scalable_core`) instead of
9//! writing its own. This file is only the plug between the two.
10//!
11//! ```text
12//! crate::cert_unit (bookkeeping: tags, ≤ 32 in flight, in-order delivery)
13//! │ CertificateSignerInput: key control + epoch, batch_id, root, tag
14//! ▼
15//! ┌──────────── CertificateSigner (this file, no state of its own) ───────┐
16//! │ certificate_request(): batch_commitment_words() → SignRequest │
17//! │ │ CachedSignIn │
18//! │ ▼ │
19//! │ Ed25519SignCoreSmall (SHA-512, key expansion, scalar multiply) │
20//! └────────┬──────────────────────────────────────────────────────────────┘
21//! │ CachedSignOut: ready, valid pulse, signature, tag, occupancy,
22//! ▼ internal_error (forwarded unchanged)
23//! crate::cert_unit
24//! ```
25//!
26//! Why no registers or queues here? Every extra stage would be a new place
27//! where a request could be lost, duplicated or reordered, and a new piece of
28//! state that a reset could leave half-finished. Keeping the bridge stateless
29//! means all policy (credits, fault latching, tag records, draining) lives in
30//! one place, [`crate::cert_unit`], where it can be reviewed as a whole.
31//!
32//! Figures: the core cost 25,383 CLB LUTs in context on the routed `slices-2`
33//! link (the planner's [`ED25519_LUT`](crate::config::ED25519_LUT)) and
34//! 25,911 LUTs and 248 DSPs inside the certificate unit of build v13 (routed,
35//! measured). It finishes one signature per ~895 cycles with at least 32
36//! requests in flight (Ed25519 project, simulation), far more than the one
37//! certificate per 128 signatures, about 94,000 per second at 12M signatures
38//! per second, that the engine needs.
39#![forbid(unsafe_code)]
40
41use crate::certificate::batch_commitment_words;
42use rhdl::prelude::*;
43use rhdl_ed25519_scalable_core::{CachedSignIn, Ed25519SignCoreSmall, SignRequest, Words512};
44
45/// Unmodified cached-signer output, including unbackpressured completion and fault.
46pub use rhdl_ed25519_scalable_core::CachedSignOut;
47/// Actual Ed25519 seed/public-key word type used at the bridge boundary.
48pub use rhdl_ed25519_scalable_core::ScalarWords;
49
50/// Key-control signals and one complete DSig certificate request.
51///
52/// This is the bundle of wires the certificate unit drives every clock. The
53/// key-control fields (`load_key_valid`, `key_seed`, `clear_key`) manage the
54/// one Ed25519 signing key the core caches; the request fields (`valid`,
55/// `epoch`, `batch_id`, `root`, `tag`) describe one envelope to sign.
56///
57/// A request transfers only on `valid && output.ready`. The caller binds its
58/// full namespace/root and trusted signer identity to a unique outstanding
59/// [`Self::tag`], and reserves completion capacity before asserting `valid`.
60#[derive(Clone, Copy, Debug, PartialEq, Digital)]
61pub struct CertificateSignerInput {
62 /// Accept a secret seed only while `output.load_key_ready` is asserted.
63 ///
64 /// One-bit strobe: high for the clock on which `key_seed` is offered.
65 pub load_key_valid: bool,
66 /// Secret 32-byte Ed25519 seed in four little-endian 64-bit words.
67 ///
68 /// Write-only: the core never outputs it, only the derived public key.
69 pub key_seed: ScalarWords,
70 /// Clear the cached key only after every accepted request has retired.
71 ///
72 /// Keep `valid` and `load_key_valid` low during clear. This is not a cancel
73 /// handshake and does not erase every intermediate pipeline or memory bit.
74 pub clear_key: bool,
75 /// Marks the namespace/root/tag as a pending signing request.
76 ///
77 /// Hold the request fields stable while `valid` is high and
78 /// `output.ready` is low.
79 pub valid: bool,
80 /// Durably allocated epoch, included in the signed DSig envelope.
81 pub epoch: b64,
82 /// Batch identity within the epoch, included in the signed envelope.
83 pub batch_id: b64,
84 /// Completed 128-key Merkle root, in its original 32-byte order.
85 pub root: [b8; 32],
86 /// Opaque 16-bit routing tag; it is outside the signed message.
87 ///
88 /// Never truncate an epoch/batch identity into this field as a substitute
89 /// for a full outstanding-request record. Reject unknown or reused tags.
90 pub tag: b16,
91}
92
93impl Default for CertificateSignerInput {
94 /// All strobes low and all data zero: an idle input that requests nothing.
95 fn default() -> Self {
96 Self {
97 load_key_valid: false,
98 key_seed: ScalarWords { words: [b64(0); 4] },
99 clear_key: false,
100 valid: false,
101 epoch: b64(0),
102 batch_id: b64(0),
103 root: [b8(0); 32],
104 tag: b16(0),
105 }
106 }
107}
108
109/// Pack the actual DSig fixed-64 message into the actual Ed25519 request type.
110///
111/// Calls [`batch_commitment_words`] directly. No host prehash, duplicated
112/// domain/profile constants, endian conversion, or intermediate register is
113/// inserted. The returned tag is routing metadata, not a signed field.
114///
115/// Inputs: 64-bit `epoch` and `batch_id`, the 32-byte `root` and the 16-bit
116/// `tag`. Output: the core's `SignRequest` (eight 64-bit message words plus
117/// the tag). Timing: combinational, zero cycles.
118#[kernel]
119pub fn certificate_request(epoch: b64, batch_id: b64, root: [b8; 32], tag: b16) -> SignRequest {
120 SignRequest {
121 message: Words512 {
122 words: batch_commitment_words(epoch, batch_id, root),
123 },
124 tag,
125 }
126}
127
128/// Direct RHDL composition of one DSig packer and exactly one cached signer.
129///
130/// The sole stateful child is the Ed25519 project's scalable RHDL core,
131/// `Ed25519SignCoreSmall` = `Ed25519SignCore<1, 1, 1, 0>` (vendored in
132/// `vendor/ed25519-scalable-core`; one
133/// fixed-base machine, one SHA-512 ring, one scalar ring, 128 contexts). It
134/// accepts up to 32 requests in flight and may finish them out of order. This
135/// wrapper adds no FIFO, register, controller, admission policy, credit state,
136/// fault latch, or output backpressure. Its output is [`CachedSignOut`] itself.
137///
138/// The caller must reserve every completion slot in advance, latch any
139/// `internal_error` before processing a coincident `valid` pulse, bind the
140/// returned tag to the full namespace/root/signer identity, and drain before
141/// clearing. Quarantine outstanding requests across reset; neither reset nor
142/// clear proves cancellation or grants permission to reuse a DSig allocation.
143/// No bank may be marked certified solely because this wrapper emitted bytes.
144#[derive(Clone, Debug, Default, Synchronous, SynchronousDQ)]
145pub struct CertificateSigner {
146 /// The vendored Ed25519 signing core; the only stateful part.
147 signer: Ed25519SignCoreSmall,
148}
149
150impl SynchronousIO for CertificateSigner {
151 /// Input: key control plus one certificate request.
152 type I = CertificateSignerInput;
153 /// Output: the core's own output, unchanged.
154 type O = CachedSignOut;
155 /// The wiring kernel that runs every clock.
156 type Kernel = certificate_signer_kernel;
157}
158
159/// Stateless wiring for [`CertificateSigner`]; clock/reset reaches its child
160/// through RHDL's synchronous hierarchy, and every child output is forwarded.
161///
162/// In RHDL a component's kernel is called every clock with the inputs `input`
163/// and the children's current outputs `q`, and returns this component's
164/// outputs plus the children's next inputs `d`. Here `d` is the input
165/// mapped field by field (the request packed by [`certificate_request`]) and
166/// the output is `q.signer` as is. Timing: combinational; all latency is the
167/// core's.
168#[kernel]
169pub fn certificate_signer_kernel(
170 _clock_reset: ClockReset,
171 input: CertificateSignerInput,
172 q: CertificateSignerQ,
173) -> (CachedSignOut, CertificateSignerD) {
174 // Next inputs for the core: key control passes through, the request is packed.
175 let d = CertificateSignerD {
176 signer: CachedSignIn {
177 load_key_valid: input.load_key_valid,
178 key_seed: input.key_seed,
179 clear_key: input.clear_key,
180 valid: input.valid,
181 request: certificate_request(input.epoch, input.batch_id, input.root, input.tag),
182 },
183 };
184 // Forward the core's output untouched, faults and all.
185 (q.signer, d)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 /// The kernel forwards every control bit and every child output, even an
193 /// illegal mix, without masking anything.
194 #[test]
195 fn wiring_preserves_coincident_completion_fault_and_control_signals() {
196 let input = CertificateSignerInput {
197 load_key_valid: true,
198 key_seed: ScalarWords {
199 words: [b64(0x81); 4],
200 },
201 clear_key: true,
202 valid: true,
203 epoch: b64(0x8000_0000_0000_0001),
204 batch_id: b64(0xfedc_ba98_7654_3210),
205 root: [b8(0x42); 32],
206 tag: b16(0xf135),
207 };
208 let child = CachedSignOut {
209 load_key_ready: false,
210 key_loaded: true,
211 public_key: ScalarWords {
212 words: [b64(0xab); 4],
213 },
214 ready: true,
215 valid: true,
216 signature: Words512 {
217 words: [b64(0xcd); 8],
218 },
219 tag: b16(0xf135),
220 occupancy: b8(3),
221 internal_error: true,
222 };
223 // Deliberately illegal control coincidence is forwarded, not silently
224 // made safe. This is a combinational wiring test, not a core transaction.
225 let (output, d) = certificate_signer_kernel(
226 clock_reset(clock(false), reset(false)),
227 input,
228 CertificateSignerQ { signer: child },
229 );
230 assert_eq!(output, child);
231 assert!(d.signer.load_key_valid && d.signer.clear_key && d.signer.valid);
232 assert_eq!(d.signer.key_seed, input.key_seed);
233 assert_eq!(
234 d.signer.request,
235 certificate_request(input.epoch, input.batch_id, input.root, input.tag)
236 );
237 }
238}