confium_signatif/
passport.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{SignatifError, SignatifResult};
7use crate::jcs;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Passport {
14 pub version: u32,
16 pub object_id: String,
18 pub key_fingerprint: String,
20 pub scope_summary: String,
22 pub valid_from: DateTime<Utc>,
24 pub valid_until: DateTime<Utc>,
26}
27
28impl Passport {
29 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 pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
42 now >= self.valid_from && now <= self.valid_until
43 }
44
45 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#[derive(Debug, Clone)]
61pub struct Challenge {
62 pub nonce: [u8; 32],
64 pub issued_at: DateTime<Utc>,
66 pub window: chrono::Duration,
68}
69
70impl Challenge {
71 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 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 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#[cfg(feature = "barcode")]
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum QrEcc {
171 Low,
173 Medium,
175 Quartile,
177 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 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 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 assert_eq!(p.qr_svg(QrEcc::High).unwrap(), svg);
236 assert!(p.qr_svg(QrEcc::Low).is_ok());
238 }
239}