Skip to main content

openentropy_core/sources/quantum/
qcicada_source.rs

1//! QCicadaSource — Crypta Labs QCicada USB QRNG.
2//!
3//! Reads true quantum random bytes from a QCicada USB device via the `qcicada`
4//! crate. The hardware generates entropy from photonic shot noise (LED +
5//! photodiode), providing full 8 bits/byte of quantum randomness per NIST
6//! SP 800-90B.
7//!
8//! **On-device conditioning**: The QCicada handles its own conditioning internally.
9//! No additional conditioning should be applied by the pool when using this source
10//! alone. The device supports three modes:
11//! - `raw` — health-tested noise after on-device filtering (default)
12//! - `sha256` — NIST SP 800-90B SHA-256 conditioning on-device
13//! - `samples` — raw ADC readings from the photodiode, no processing
14//!
15//! Configuration is via environment variables:
16//! - `QCICADA_MODE` — post-processing mode: `raw`, `sha256`, or `samples` (default: `raw`)
17//! - `QCICADA_POST_PROCESS` — legacy alias for `QCICADA_MODE`
18//! - `QCICADA_PORT` — serial port path (auto-detected if unset)
19//! - `QCICADA_TIMEOUT` — connection timeout in ms (default: 5000)
20
21use std::sync::{Arc, Mutex, OnceLock};
22
23use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
24
25/// Thread-safe override for QCicada mode, set by the CLI before source discovery.
26/// Checked by `QCicadaConfig::default()` before falling back to env vars.
27/// This avoids the need for `unsafe { std::env::set_var(...) }`.
28pub static QCICADA_CLI_MODE: OnceLock<String> = OnceLock::new();
29
30static QCICADA_INFO: SourceInfo = SourceInfo {
31    name: "qcicada",
32    description: "Crypta Labs QCicada USB QRNG \u{2014} quantum shot noise",
33    physics: "Photonic shot noise from an LED/photodiode pair inside the QCicada USB device. \
34              Photon emission and detection are inherently quantum processes governed by Poisson \
35              statistics. The device digitises photodiode current fluctuations to produce true \
36              quantum random numbers at full entropy (8 bits/byte) per NIST SP 800-90B.",
37    category: SourceCategory::Quantum,
38    platform: Platform::Any,
39    requirements: &[Requirement::QCicada],
40    entropy_rate_estimate: 8.0,
41    composite: false,
42    is_fast: false, // USB serial init can take up to timeout_ms (default 5s)
43};
44
45trait QCicadaDevice: Send {
46    fn set_postprocess(&mut self, mode: qcicada::PostProcess) -> Result<(), qcicada::QCicadaError>;
47    fn start_continuous_fresh(&mut self) -> Result<(), qcicada::QCicadaError>;
48    fn read_continuous(&mut self, n: usize) -> Result<Vec<u8>, qcicada::QCicadaError>;
49    fn stop(&mut self) -> Result<(), qcicada::QCicadaError>;
50}
51
52impl QCicadaDevice for qcicada::QCicada {
53    fn set_postprocess(&mut self, mode: qcicada::PostProcess) -> Result<(), qcicada::QCicadaError> {
54        Self::set_postprocess(self, mode)
55    }
56
57    fn start_continuous_fresh(&mut self) -> Result<(), qcicada::QCicadaError> {
58        Self::start_continuous_fresh(self).map(|_| ())
59    }
60
61    fn read_continuous(&mut self, n: usize) -> Result<Vec<u8>, qcicada::QCicadaError> {
62        Self::read_continuous(self, n)
63    }
64
65    fn stop(&mut self) -> Result<(), qcicada::QCicadaError> {
66        Self::stop(self)
67    }
68}
69
70type DeviceHandle = Box<dyn QCicadaDevice>;
71type DeviceOpener =
72    dyn Fn(&QCicadaConfig, qcicada::PostProcess) -> Option<DeviceHandle> + Send + Sync;
73
74fn configure_device(device: &mut impl QCicadaDevice, mode: qcicada::PostProcess) -> Option<()> {
75    // Preserve prior behavior: best-effort mode set, then require a fresh
76    // continuous-mode start before the source is considered open.
77    let _ = device.set_postprocess(mode);
78    device.start_continuous_fresh().ok()?;
79    Some(())
80}
81
82fn default_device_opener(
83    config: &QCicadaConfig,
84    mode: qcicada::PostProcess,
85) -> Option<DeviceHandle> {
86    let timeout = std::time::Duration::from_millis(config.timeout_ms);
87    let port_str = config.port.as_deref();
88    let mut qrng = match qcicada::QCicada::open(port_str, Some(timeout)) {
89        Ok(q) => q,
90        Err(_) => return None,
91    };
92
93    configure_device(&mut qrng, mode)?;
94
95    Some(Box::new(qrng))
96}
97
98/// Configuration for the QCicada QRNG device.
99pub struct QCicadaConfig {
100    /// Serial port path (e.g. `/dev/tty.usbmodem*`). `None` means auto-detect.
101    pub port: Option<String>,
102    /// Connection timeout in milliseconds.
103    pub timeout_ms: u64,
104    /// Post-processing mode: `"raw"`, `"sha256"`, or `"samples"`.
105    pub post_process: String,
106}
107
108impl Default for QCicadaConfig {
109    fn default() -> Self {
110        let port = std::env::var("QCICADA_PORT").ok();
111        let timeout_ms = std::env::var("QCICADA_TIMEOUT")
112            .ok()
113            .and_then(|s| s.parse().ok())
114            .unwrap_or(5000);
115        let post_process = QCICADA_CLI_MODE
116            .get()
117            .cloned()
118            .or_else(|| std::env::var("QCICADA_MODE").ok())
119            .or_else(|| std::env::var("QCICADA_POST_PROCESS").ok())
120            .unwrap_or_else(|| "raw".into());
121        Self {
122            port,
123            timeout_ms,
124            post_process,
125        }
126    }
127}
128
129/// Entropy source backed by the QCicada USB QRNG hardware.
130pub struct QCicadaSource {
131    pub config: QCicadaConfig,
132    device: Mutex<Option<DeviceHandle>>,
133    available: Mutex<Option<bool>>,
134    /// Runtime-mutable mode, initialised from config.post_process.
135    mode: Mutex<String>,
136    opener: Arc<DeviceOpener>,
137}
138
139impl Default for QCicadaSource {
140    fn default() -> Self {
141        let config = QCicadaConfig::default();
142        let mode = config.post_process.clone();
143        Self {
144            config,
145            device: Mutex::new(None),
146            available: Mutex::new(None),
147            mode: Mutex::new(mode),
148            opener: Arc::new(default_device_opener),
149        }
150    }
151}
152
153impl QCicadaSource {
154    #[cfg(test)]
155    fn with_opener(config: QCicadaConfig, opener: Arc<DeviceOpener>) -> Self {
156        let mode = config.post_process.clone();
157        Self {
158            config,
159            device: Mutex::new(None),
160            available: Mutex::new(None),
161            mode: Mutex::new(mode),
162            opener,
163        }
164    }
165
166    /// Parse the current runtime mode into the crate enum.
167    fn post_process_mode(&self) -> qcicada::PostProcess {
168        let mode = self.mode.lock().unwrap_or_else(|e| e.into_inner());
169        match mode.as_str() {
170            "sha256" => qcicada::PostProcess::Sha256,
171            "samples" => qcicada::PostProcess::RawSamples,
172            _ => qcicada::PostProcess::RawNoise,
173        }
174    }
175
176    fn stop_device(device: &mut Option<DeviceHandle>) {
177        if let Some(qrng) = device.as_mut() {
178            let _ = qrng.stop();
179        }
180        *device = None;
181    }
182
183    /// Try to open the QCicada device, set post-processing mode, and switch the
184    /// hardware into fresh-start continuous mode so the first read discards
185    /// queued bytes and subsequent reads do not drain the device's static
186    /// one-shot `ready_bytes` buffer.
187    fn try_open(&self) -> Option<DeviceHandle> {
188        (self.opener)(&self.config, self.post_process_mode())
189    }
190}
191
192impl Drop for QCicadaSource {
193    fn drop(&mut self) {
194        let device = self.device.get_mut().unwrap_or_else(|e| e.into_inner());
195        Self::stop_device(device);
196    }
197}
198
199impl EntropySource for QCicadaSource {
200    fn info(&self) -> &SourceInfo {
201        &QCICADA_INFO
202    }
203
204    fn is_available(&self) -> bool {
205        let mut cached = self.available.lock().unwrap_or_else(|e| e.into_inner());
206        // Positive result is stable (device was found, assume it stays).
207        // Negative result is re-checked each call (device may be hot-plugged).
208        if *cached == Some(true) {
209            return true;
210        }
211        let avail = !qcicada::discover_devices().is_empty();
212        if avail {
213            *cached = Some(true);
214        }
215        avail
216    }
217
218    fn collect(&self, n_samples: usize) -> Vec<u8> {
219        // USB serial has limited throughput, so read in moderate chunks. With
220        // continuous mode active this is just a serial read, not a fresh START
221        // command each time, which avoids the device's static one-shot buffer.
222        const CHUNK_SIZE: usize = 8192;
223
224        let mut guard = self.device.lock().unwrap_or_else(|e| e.into_inner());
225
226        // Lazy-init: open device on first call.
227        if guard.is_none() {
228            *guard = self.try_open();
229            if guard.is_none() {
230                // USB serial devices need settle time after handle release.
231                std::thread::sleep(std::time::Duration::from_millis(500));
232                *guard = self.try_open();
233            }
234        }
235
236        if guard.is_none() {
237            return Vec::new();
238        }
239
240        let mut result = Vec::with_capacity(n_samples);
241        let mut remaining = n_samples;
242
243        while remaining > 0 {
244            let chunk = remaining.min(CHUNK_SIZE);
245            let read_result = guard.as_mut().unwrap().read_continuous(chunk);
246            match read_result {
247                Ok(bytes) => {
248                    if bytes.is_empty() {
249                        break;
250                    }
251                    remaining -= bytes.len();
252                    result.extend_from_slice(&bytes);
253                }
254                Err(_) => {
255                    // Device error — reconnect, restart continuous mode, retry once.
256                    Self::stop_device(&mut guard);
257                    std::thread::sleep(std::time::Duration::from_millis(300));
258                    *guard = self.try_open();
259                    match guard.as_mut().map(|q| q.read_continuous(chunk)) {
260                        Some(Ok(bytes)) => {
261                            if bytes.is_empty() {
262                                break;
263                            }
264                            remaining -= bytes.len();
265                            result.extend_from_slice(&bytes);
266                        }
267                        _ => break,
268                    }
269                }
270            }
271        }
272
273        result
274    }
275
276    fn set_config(&self, key: &str, value: &str) -> Result<(), String> {
277        if key != "mode" {
278            return Err(format!("unknown config key: {key}"));
279        }
280        match value {
281            "raw" | "sha256" | "samples" => {}
282            _ => {
283                return Err(format!(
284                    "invalid mode: {value} (expected raw|sha256|samples)"
285                ));
286            }
287        }
288        *self.mode.lock().unwrap_or_else(|e| e.into_inner()) = value.to_string();
289
290        // Restart the device on the next collect() so the new mode is applied
291        // before continuous reads resume.
292        Self::stop_device(&mut self.device.lock().unwrap_or_else(|e| e.into_inner()));
293        Ok(())
294    }
295
296    fn config_options(&self) -> Vec<(&'static str, String)> {
297        vec![(
298            "mode",
299            self.mode.lock().unwrap_or_else(|e| e.into_inner()).clone(),
300        )]
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::collections::VecDeque;
308    use std::sync::atomic::{AtomicUsize, Ordering};
309
310    #[derive(Default)]
311    struct FakeDeviceState {
312        set_postprocess_calls: Vec<qcicada::PostProcess>,
313        start_continuous_fresh_calls: usize,
314        read_requests: Vec<usize>,
315        stop_calls: usize,
316    }
317
318    struct FakeDevice {
319        state: Arc<Mutex<FakeDeviceState>>,
320        scripted_reads: VecDeque<Result<Vec<u8>, qcicada::QCicadaError>>,
321    }
322
323    impl QCicadaDevice for FakeDevice {
324        fn set_postprocess(
325            &mut self,
326            mode: qcicada::PostProcess,
327        ) -> Result<(), qcicada::QCicadaError> {
328            self.state
329                .lock()
330                .unwrap_or_else(|e| e.into_inner())
331                .set_postprocess_calls
332                .push(mode);
333            Ok(())
334        }
335
336        fn start_continuous_fresh(&mut self) -> Result<(), qcicada::QCicadaError> {
337            self.state
338                .lock()
339                .unwrap_or_else(|e| e.into_inner())
340                .start_continuous_fresh_calls += 1;
341            Ok(())
342        }
343
344        fn read_continuous(&mut self, n: usize) -> Result<Vec<u8>, qcicada::QCicadaError> {
345            self.state
346                .lock()
347                .unwrap_or_else(|e| e.into_inner())
348                .read_requests
349                .push(n);
350            self.scripted_reads
351                .pop_front()
352                .unwrap_or_else(|| Ok(vec![0; n]))
353        }
354
355        fn stop(&mut self) -> Result<(), qcicada::QCicadaError> {
356            self.state
357                .lock()
358                .unwrap_or_else(|e| e.into_inner())
359                .stop_calls += 1;
360            Ok(())
361        }
362    }
363
364    fn test_config(mode: &str) -> QCicadaConfig {
365        QCicadaConfig {
366            port: None,
367            timeout_ms: 5000,
368            post_process: mode.into(),
369        }
370    }
371
372    fn make_test_source(
373        mode: &str,
374        devices: Vec<(
375            Arc<Mutex<FakeDeviceState>>,
376            VecDeque<Result<Vec<u8>, qcicada::QCicadaError>>,
377        )>,
378        opened_modes: Arc<Mutex<Vec<qcicada::PostProcess>>>,
379        open_count: Arc<AtomicUsize>,
380    ) -> QCicadaSource {
381        let scripted_devices = Arc::new(Mutex::new(VecDeque::from(devices)));
382        let opener = Arc::new(move |_config: &QCicadaConfig, mode: qcicada::PostProcess| {
383            open_count.fetch_add(1, Ordering::SeqCst);
384            opened_modes
385                .lock()
386                .unwrap_or_else(|e| e.into_inner())
387                .push(mode);
388            let (state, scripted_reads) = scripted_devices
389                .lock()
390                .unwrap_or_else(|e| e.into_inner())
391                .pop_front()?;
392            let mut device = FakeDevice {
393                state,
394                scripted_reads,
395            };
396            configure_device(&mut device, mode)?;
397            Some(Box::new(device) as DeviceHandle)
398        });
399        QCicadaSource::with_opener(test_config(mode), opener)
400    }
401
402    #[test]
403    fn info() {
404        let src = QCicadaSource::default();
405        assert_eq!(src.name(), "qcicada");
406        assert_eq!(src.info().category, SourceCategory::Quantum);
407        assert_eq!(src.info().platform, Platform::Any);
408        assert_eq!(src.info().entropy_rate_estimate, 8.0);
409        assert!(!src.info().composite);
410        assert!(!src.info().is_fast);
411        assert_eq!(src.info().requirements, &[Requirement::QCicada]);
412    }
413
414    #[test]
415    fn config_default() {
416        let config = QCicadaConfig {
417            port: None,
418            timeout_ms: 5000,
419            post_process: "raw".into(),
420        };
421        assert!(config.port.is_none());
422        assert_eq!(config.timeout_ms, 5000);
423        assert_eq!(config.post_process, "raw");
424    }
425
426    #[test]
427    fn config_explicit() {
428        let config = QCicadaConfig {
429            port: Some("/dev/ttyUSB0".into()),
430            timeout_ms: 3000,
431            post_process: "sha256".into(),
432        };
433        let src = QCicadaSource::with_opener(config, Arc::new(default_device_opener));
434        assert_eq!(src.config.port.as_deref(), Some("/dev/ttyUSB0"));
435        assert_eq!(src.config.timeout_ms, 3000);
436        assert_eq!(src.config.post_process, "sha256");
437    }
438
439    #[test]
440    fn post_process_mode_parsing() {
441        let src = |mode: &str| {
442            QCicadaSource::with_opener(test_config(mode), Arc::new(default_device_opener))
443        };
444        assert!(matches!(
445            src("sha256").post_process_mode(),
446            qcicada::PostProcess::Sha256
447        ));
448        assert!(matches!(
449            src("samples").post_process_mode(),
450            qcicada::PostProcess::RawSamples
451        ));
452        assert!(matches!(
453            src("raw").post_process_mode(),
454            qcicada::PostProcess::RawNoise
455        ));
456        assert!(matches!(
457            src("anything").post_process_mode(),
458            qcicada::PostProcess::RawNoise
459        ));
460    }
461
462    #[test]
463    fn set_config_mode() {
464        let src = QCicadaSource::default();
465        assert!(src.set_config("mode", "sha256").is_ok());
466        assert_eq!(src.config_options(), vec![("mode", "sha256".into())]);
467        assert!(src.set_config("mode", "samples").is_ok());
468        assert_eq!(src.config_options(), vec![("mode", "samples".into())]);
469        assert!(src.set_config("mode", "raw").is_ok());
470        assert_eq!(src.config_options(), vec![("mode", "raw".into())]);
471    }
472
473    #[test]
474    fn set_config_invalid() {
475        let src = QCicadaSource::default();
476        assert!(src.set_config("mode", "invalid").is_err());
477        assert!(src.set_config("unknown_key", "raw").is_err());
478    }
479
480    #[test]
481    fn source_is_send_sync() {
482        fn assert_send_sync<T: Send + Sync>() {}
483        assert_send_sync::<QCicadaSource>();
484    }
485
486    #[test]
487    fn collect_uses_continuous_reads_and_chunks_large_requests() {
488        let state = Arc::new(Mutex::new(FakeDeviceState::default()));
489        let opened_modes = Arc::new(Mutex::new(Vec::new()));
490        let open_count = Arc::new(AtomicUsize::new(0));
491        let src = make_test_source(
492            "raw",
493            vec![(
494                Arc::clone(&state),
495                VecDeque::from([Ok(vec![0xAA; 8192]), Ok(vec![0xBB; 808])]),
496            )],
497            Arc::clone(&opened_modes),
498            Arc::clone(&open_count),
499        );
500
501        let data = src.collect(9000);
502        let state = state.lock().unwrap_or_else(|e| e.into_inner());
503
504        assert_eq!(data.len(), 9000);
505        assert_eq!(open_count.load(Ordering::SeqCst), 1);
506        assert_eq!(
507            *opened_modes.lock().unwrap_or_else(|e| e.into_inner()),
508            vec![qcicada::PostProcess::RawNoise]
509        );
510        assert_eq!(
511            state.set_postprocess_calls,
512            vec![qcicada::PostProcess::RawNoise]
513        );
514        assert_eq!(state.start_continuous_fresh_calls, 1);
515        assert_eq!(state.read_requests, vec![8192, 808]);
516        assert_eq!(state.stop_calls, 0);
517    }
518
519    #[test]
520    fn collect_reconnects_and_restarts_continuous_after_read_error() {
521        let first_state = Arc::new(Mutex::new(FakeDeviceState::default()));
522        let second_state = Arc::new(Mutex::new(FakeDeviceState::default()));
523        let opened_modes = Arc::new(Mutex::new(Vec::new()));
524        let open_count = Arc::new(AtomicUsize::new(0));
525        let src = make_test_source(
526            "raw",
527            vec![
528                (
529                    Arc::clone(&first_state),
530                    VecDeque::from([Err(qcicada::QCicadaError::Protocol(
531                        "simulated read failure".into(),
532                    ))]),
533                ),
534                (
535                    Arc::clone(&second_state),
536                    VecDeque::from([Ok(vec![0x5A; 64])]),
537                ),
538            ],
539            Arc::clone(&opened_modes),
540            Arc::clone(&open_count),
541        );
542
543        let data = src.collect(64);
544        let first_state = first_state.lock().unwrap_or_else(|e| e.into_inner());
545        let second_state = second_state.lock().unwrap_or_else(|e| e.into_inner());
546
547        assert_eq!(data, vec![0x5A; 64]);
548        assert_eq!(open_count.load(Ordering::SeqCst), 2);
549        assert_eq!(
550            *opened_modes.lock().unwrap_or_else(|e| e.into_inner()),
551            vec![
552                qcicada::PostProcess::RawNoise,
553                qcicada::PostProcess::RawNoise
554            ]
555        );
556        assert_eq!(first_state.start_continuous_fresh_calls, 1);
557        assert_eq!(first_state.read_requests, vec![64]);
558        assert_eq!(first_state.stop_calls, 1);
559        assert_eq!(second_state.start_continuous_fresh_calls, 1);
560        assert_eq!(second_state.read_requests, vec![64]);
561    }
562
563    #[test]
564    fn set_config_stops_active_device_and_reopens_with_new_mode() {
565        let first_state = Arc::new(Mutex::new(FakeDeviceState::default()));
566        let second_state = Arc::new(Mutex::new(FakeDeviceState::default()));
567        let opened_modes = Arc::new(Mutex::new(Vec::new()));
568        let open_count = Arc::new(AtomicUsize::new(0));
569        let src = make_test_source(
570            "raw",
571            vec![
572                (
573                    Arc::clone(&first_state),
574                    VecDeque::from([Ok(vec![0x11; 4])]),
575                ),
576                (
577                    Arc::clone(&second_state),
578                    VecDeque::from([Ok(vec![0x22; 4])]),
579                ),
580            ],
581            Arc::clone(&opened_modes),
582            Arc::clone(&open_count),
583        );
584
585        assert_eq!(src.collect(4), vec![0x11; 4]);
586        assert!(src.set_config("mode", "sha256").is_ok());
587        assert!(
588            src.device
589                .lock()
590                .unwrap_or_else(|e| e.into_inner())
591                .is_none()
592        );
593        assert_eq!(src.collect(4), vec![0x22; 4]);
594
595        let first_state = first_state.lock().unwrap_or_else(|e| e.into_inner());
596        let second_state = second_state.lock().unwrap_or_else(|e| e.into_inner());
597        assert_eq!(first_state.stop_calls, 1);
598        assert_eq!(
599            *opened_modes.lock().unwrap_or_else(|e| e.into_inner()),
600            vec![qcicada::PostProcess::RawNoise, qcicada::PostProcess::Sha256]
601        );
602        assert_eq!(
603            second_state.set_postprocess_calls,
604            vec![qcicada::PostProcess::Sha256]
605        );
606        assert_eq!(open_count.load(Ordering::SeqCst), 2);
607    }
608
609    #[test]
610    fn drop_stops_active_device() {
611        let state = Arc::new(Mutex::new(FakeDeviceState::default()));
612        let opened_modes = Arc::new(Mutex::new(Vec::new()));
613        let open_count = Arc::new(AtomicUsize::new(0));
614        let src = make_test_source(
615            "raw",
616            vec![(Arc::clone(&state), VecDeque::from([Ok(vec![0x33; 8])]))],
617            opened_modes,
618            open_count,
619        );
620
621        assert_eq!(src.collect(8), vec![0x33; 8]);
622        drop(src);
623
624        let state = state.lock().unwrap_or_else(|e| e.into_inner());
625        assert_eq!(state.stop_calls, 1);
626    }
627
628    #[test]
629    #[ignore] // Requires QCicada hardware connected via USB
630    fn collects_quantum_bytes() {
631        let src = QCicadaSource::default();
632        if src.is_available() {
633            let data = src.collect(64);
634            assert!(!data.is_empty());
635            assert!(data.len() <= 64);
636        }
637    }
638}