Skip to main content

confium_signatif/
passport.rs

1//! Delivery formats: passports and challenge-response (SIGNATIF §16).
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{SignatifError, SignatifResult};
7use crate::jcs;
8
9/// A machine-readable passport: a compact, deterministic summary of a
10/// certificate or artifact, verifiable against the same anchor bundle
11/// and transparency log as the underlying object.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Passport {
14    /// Passport format version.
15    pub version: u32,
16    /// The certificate or artifact identifier.
17    pub object_id: String,
18    /// Key fingerprint of the identified object.
19    pub key_fingerprint: String,
20    /// Human- and machine-readable scope summary.
21    pub scope_summary: String,
22    /// Passport validity period start.
23    pub valid_from: DateTime<Utc>,
24    /// Passport validity period end.
25    pub valid_until: DateTime<Utc>,
26}
27
28impl Passport {
29    /// Deterministic distribution bytes (JCS) — the barcode payload
30    /// base for 2D delivery.
31    ///
32    /// # Errors
33    ///
34    /// Propagates canonicalization errors.
35    pub fn distribution_bytes(&self) -> SignatifResult<Vec<u8>> {
36        let v = serde_json::to_value(self).expect("passport serializes");
37        Ok(jcs::canonicalize(&v)?.into_bytes())
38    }
39
40    /// Whether the passport is valid at `now`.
41    pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
42        now >= self.valid_from && now <= self.valid_until
43    }
44
45    /// Parse a passport from its deterministic bytes.
46    ///
47    /// # Errors
48    ///
49    /// Returns an encoding error on malformed input.
50    pub fn from_distribution_bytes(bytes: &[u8]) -> SignatifResult<Self> {
51        serde_json::from_slice(bytes)
52            .map_err(|e| SignatifError::Encoding(format!("passport decode: {e}")))
53    }
54}
55
56/// A challenge issued to a device signer: a fresh 256-bit nonce and a
57/// validity window. Authenticity is established by the ability to
58/// produce a timely, nonce-bound response — a static copy of a prior
59/// artifact cannot satisfy the challenge.
60#[derive(Debug, Clone)]
61pub struct Challenge {
62    /// The nonce (>= 128 bits of entropy; we use 256).
63    pub nonce: [u8; 32],
64    /// When the challenge was issued.
65    pub issued_at: DateTime<Utc>,
66    /// Freshness window.
67    pub window: chrono::Duration,
68}
69
70impl Challenge {
71    /// Generate a challenge with an OS-random nonce.
72    ///
73    /// # Errors
74    ///
75    /// Returns a revocation-category error if the OS RNG fails.
76    pub fn generate(window: chrono::Duration) -> SignatifResult<Self> {
77        use rand_core::RngCore;
78        let mut nonce = [0u8; 32];
79        rand_core::OsRng
80            .try_fill_bytes(&mut nonce)
81            .map_err(|e| SignatifError::Revocation(format!("os rng: {e}")))?;
82        Ok(Self {
83            nonce,
84            issued_at: Utc::now(),
85            window,
86        })
87    }
88
89    /// The canonical payload the response artifact must carry: the
90    /// nonce bound into a deterministic JSON object.
91    pub fn expected_payload(&self) -> serde_json::Value {
92        serde_json::json!({
93            "challenge_nonce": hex::encode(self.nonce),
94            "challenged_at": self.issued_at.to_rfc3339(),
95        })
96    }
97
98    /// Verify a response artifact's payload: the nonce must match and
99    /// the response must be inside the freshness window.
100    ///
101    /// # Errors
102    ///
103    /// Returns an artifact-format error on nonce mismatch or expiry.
104    pub fn verify_response(
105        &self,
106        response_payload: &serde_json::Value,
107        now: DateTime<Utc>,
108    ) -> SignatifResult<()> {
109        let got = response_payload
110            .get("challenge_nonce")
111            .and_then(|v| v.as_str())
112            .ok_or_else(|| {
113                SignatifError::ArtifactFormat("response lacks challenge_nonce".into())
114            })?;
115        if got != hex::encode(self.nonce) {
116            return Err(SignatifError::ArtifactFormat(
117                "challenge nonce mismatch — replayed response".into(),
118            ));
119        }
120        if now > self.issued_at + self.window {
121            return Err(SignatifError::ArtifactFormat(
122                "response outside freshness window".into(),
123            ));
124        }
125        Ok(())
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn passport_round_trips_deterministically() {
135        let p = Passport {
136            version: 1,
137            object_id: "end-cert-7".into(),
138            key_fingerprint: "ab00".into(),
139            scope_summary: "domain:pharma/*".into(),
140            valid_from: Utc::now(),
141            valid_until: Utc::now() + chrono::Duration::days(365),
142        };
143        let bytes = p.distribution_bytes().unwrap();
144        let back = Passport::from_distribution_bytes(&bytes).unwrap();
145        assert_eq!(back.object_id, p.object_id);
146        assert_eq!(back.distribution_bytes().unwrap(), bytes);
147        assert!(back.is_valid_at(Utc::now()));
148    }
149
150    #[test]
151    fn challenge_response_binds_nonce_and_freshness() {
152        let c = Challenge::generate(chrono::Duration::seconds(30)).unwrap();
153        let payload = c.expected_payload();
154        assert!(c.verify_response(&payload, Utc::now()).is_ok());
155
156        let mut wrong = payload.clone();
157        wrong["challenge_nonce"] = serde_json::json!("00");
158        assert!(c.verify_response(&wrong, Utc::now()).is_err());
159
160        let late = Utc::now() + chrono::Duration::minutes(5);
161        assert!(c.verify_response(&payload, late).is_err());
162    }
163}
164
165/// QR error-correction levels for the barcode delivery (§16
166/// `barcode-encoding`): the level is chosen for the expected scanning
167/// environment.
168#[cfg(feature = "barcode")]
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum QrEcc {
171    /// Recover ~7% of codewords — clean environments, high density.
172    Low,
173    /// Recover ~15%.
174    Medium,
175    /// Recover ~25% — typical print-and-scan.
176    Quartile,
177    /// Recover ~30% — damaged or low-quality scans.
178    High,
179}
180
181#[cfg(feature = "barcode")]
182impl QrEcc {
183    fn to_qrcode(self) -> qrcode::EcLevel {
184        match self {
185            QrEcc::Low => qrcode::EcLevel::L,
186            QrEcc::Medium => qrcode::EcLevel::M,
187            QrEcc::Quartile => qrcode::EcLevel::Q,
188            QrEcc::High => qrcode::EcLevel::H,
189        }
190    }
191}
192
193#[cfg(feature = "barcode")]
194impl Passport {
195    /// The passport's QR barcode as an SVG document — a deterministic,
196    /// self-contained 2D delivery carrying the passport distribution
197    /// bytes, with error correction sufficient for the scanning
198    /// environment.
199    ///
200    /// # Errors
201    ///
202    /// Encoding errors if the payload exceeds the QR capacity or the
203    /// distribution bytes cannot be produced.
204    pub fn qr_svg(&self, ecc: QrEcc) -> SignatifResult<String> {
205        let bytes = self.distribution_bytes()?;
206        let code = qrcode::QrCode::with_error_correction_level(&bytes, ecc.to_qrcode())
207            .map_err(|e| SignatifError::Encoding(format!("qr encode: {e}")))?;
208        Ok(code.render::<char>().min_dimensions(200, 200).build())
209    }
210}
211
212#[cfg(all(test, feature = "barcode"))]
213mod qr_tests {
214    use super::Passport;
215    #[cfg(feature = "barcode")]
216    use super::QrEcc;
217    use chrono::Utc;
218
219    #[cfg(feature = "barcode")]
220    #[test]
221    fn passport_qr_encodes_with_error_correction() {
222        let p = Passport {
223            version: 1,
224            object_id: "cnml-cert-2026-00001".into(),
225            key_fingerprint: "ab00".into(),
226            scope_summary: "domain:metrology".into(),
227            valid_from: Utc::now(),
228            valid_until: Utc::now() + chrono::Duration::days(365),
229        };
230        // render::<char> yields a character grid, not SVG.
231        let svg = p.qr_svg(QrEcc::High).unwrap();
232        let lines: Vec<&str> = svg.lines().collect();
233        assert!(lines.len() >= 20, "qr grid too small: {}", lines.len());
234        // Deterministic: same passport, same bytes, same grid.
235        assert_eq!(p.qr_svg(QrEcc::High).unwrap(), svg);
236        // Higher ECC still encodes the same payload.
237        assert!(p.qr_svg(QrEcc::Low).is_ok());
238    }
239}