ndn_cert/challenge/
email.rs1use std::{
19 future::Future,
20 pin::Pin,
21 sync::Arc,
22 time::{SystemTime, UNIX_EPOCH},
23};
24
25use ring::digest::{SHA256, digest};
26
27use crate::{
28 challenge::{ChallengeHandler, ChallengeOutcome, ChallengeState},
29 error::CertError,
30 protocol::CertRequest,
31};
32
33pub trait EmailSender: Send + Sync {
35 fn send<'a>(
37 &'a self,
38 address: &'a str,
39 code: &'a str,
40 ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
41}
42
43pub struct EmailChallenge {
45 sender: Arc<dyn EmailSender>,
46 code_ttl_secs: u32,
48 max_tries: u8,
50}
51
52impl EmailChallenge {
53 pub fn new(sender: Arc<dyn EmailSender>) -> Self {
54 Self {
55 sender,
56 code_ttl_secs: 300,
57 max_tries: 3,
58 }
59 }
60
61 pub fn with_ttl(mut self, ttl_secs: u32) -> Self {
62 self.code_ttl_secs = ttl_secs;
63 self
64 }
65
66 pub fn with_max_tries(mut self, max_tries: u8) -> Self {
67 self.max_tries = max_tries;
68 self
69 }
70}
71
72impl ChallengeHandler for EmailChallenge {
73 fn challenge_type(&self) -> &'static str {
74 "email"
75 }
76
77 fn begin<'a>(
78 &'a self,
79 _req: &'a CertRequest,
80 ) -> Pin<Box<dyn Future<Output = Result<ChallengeState, CertError>> + Send + 'a>> {
81 let max_tries = self.max_tries;
82 let code_ttl_secs = self.code_ttl_secs;
83 Box::pin(async move {
84 Ok(ChallengeState {
87 challenge_type: "email".to_string(),
88 data: serde_json::json!({
89 "phase": "awaiting_email",
90 "remaining_tries": max_tries,
91 "code_ttl_secs": code_ttl_secs,
92 }),
93 })
94 })
95 }
96
97 fn verify<'a>(
98 &'a self,
99 state: &'a ChallengeState,
100 parameters: &'a serde_json::Map<String, serde_json::Value>,
101 ) -> Pin<Box<dyn Future<Output = Result<ChallengeOutcome, CertError>> + Send + 'a>> {
102 let email = parameters
103 .get("email")
104 .and_then(|v| v.as_str())
105 .map(str::to_string);
106 let code = parameters
107 .get("code")
108 .and_then(|v| v.as_str())
109 .map(str::to_string);
110
111 let state = state.clone();
112 let sender = Arc::clone(&self.sender);
113 let code_ttl_secs = self.code_ttl_secs;
114
115 Box::pin(async move {
116 let email = email.ok_or_else(|| {
117 CertError::InvalidRequest("missing 'email' parameter".to_string())
118 })?;
119
120 let phase = state
121 .data
122 .get("phase")
123 .and_then(|v| v.as_str())
124 .unwrap_or("");
125 let remaining_tries = state
126 .data
127 .get("remaining_tries")
128 .and_then(|v| v.as_u64())
129 .unwrap_or(1) as u8;
130
131 match phase {
132 "awaiting_email" => {
133 let otp = generate_otp();
135 sender.send(&email, &otp).await.map_err(|e| {
136 CertError::InvalidRequest(format!("email send failed: {e}"))
137 })?;
138
139 let otp_hash = sha256_hex(&otp);
140 let expires_at = now_secs() + code_ttl_secs as u64;
141
142 Ok(ChallengeOutcome::Pending {
143 status_message: format!("Code sent to {email}"),
144 remaining_tries,
145 remaining_time_secs: code_ttl_secs,
146 next_state: ChallengeState {
147 challenge_type: "email".to_string(),
148 data: serde_json::json!({
149 "phase": "awaiting_code",
150 "email": email,
151 "otp_hash": otp_hash,
152 "expires_at": expires_at,
153 "remaining_tries": remaining_tries,
154 }),
155 },
156 })
157 }
158
159 "awaiting_code" => {
160 let expires_at = state
162 .data
163 .get("expires_at")
164 .and_then(|v| v.as_u64())
165 .unwrap_or(0);
166
167 if now_secs() > expires_at {
168 return Ok(ChallengeOutcome::Denied(
169 "email code expired; please restart enrollment".to_string(),
170 ));
171 }
172
173 let code = match code {
174 Some(c) => c,
175 None => {
176 return Ok(ChallengeOutcome::Denied(
177 "missing 'code' parameter".to_string(),
178 ));
179 }
180 };
181
182 let stored_hash = state
183 .data
184 .get("otp_hash")
185 .and_then(|v| v.as_str())
186 .unwrap_or("");
187
188 if sha256_hex(&code) == stored_hash {
189 Ok(ChallengeOutcome::Approved)
190 } else if remaining_tries <= 1 {
191 Ok(ChallengeOutcome::Denied(
192 "incorrect code: no attempts remaining".to_string(),
193 ))
194 } else {
195 let new_tries = remaining_tries - 1;
196 let remaining_time = expires_at.saturating_sub(now_secs()) as u32;
197 Ok(ChallengeOutcome::Pending {
198 status_message: format!(
199 "Incorrect code — {new_tries} attempt(s) remaining"
200 ),
201 remaining_tries: new_tries,
202 remaining_time_secs: remaining_time,
203 next_state: ChallengeState {
204 challenge_type: "email".to_string(),
205 data: serde_json::json!({
206 "phase": "awaiting_code",
207 "email": email,
208 "otp_hash": stored_hash,
209 "expires_at": expires_at,
210 "remaining_tries": new_tries,
211 }),
212 },
213 })
214 }
215 }
216
217 _ => Ok(ChallengeOutcome::Denied(
218 "unknown challenge phase".to_string(),
219 )),
220 }
221 })
222 }
223}
224
225fn generate_otp() -> String {
227 use ring::rand::{SecureRandom, SystemRandom};
228 let rng = SystemRandom::new();
229 let mut buf = [0u8; 4];
230 rng.fill(&mut buf).unwrap_or(());
231 let n = u32::from_be_bytes(buf) % 1_000_000;
232 format!("{n:06}")
233}
234
235fn sha256_hex(input: &str) -> String {
237 let h = digest(&SHA256, input.as_bytes());
238 h.as_ref().iter().map(|b| format!("{b:02x}")).collect()
239}
240
241fn now_secs() -> u64 {
242 SystemTime::now()
243 .duration_since(UNIX_EPOCH)
244 .unwrap_or_default()
245 .as_secs()
246}