ndn_cert/challenge/
email.rs

1//! Email challenge — CA sends a one-time code to an email address, client submits it.
2//!
3//! This is the primary challenge type described in the NDNCERT paper (ndn-0050-1).
4//! It is a two-round exchange:
5//!
6//! **Round 1** — First CHALLENGE request: client provides `{ "email": "user@example.com" }`.
7//!   CA generates a 6-digit OTP, sends it to the address, stores a hash + expiry, and
8//!   responds with `Processing` status.
9//!
10//! **Round 2** — Second CHALLENGE request: client provides `{ "email": "...", "code": "123456" }`.
11//!   CA checks the hash and remaining attempts.
12//!
13//! # Email delivery
14//!
15//! The `EmailSender` trait is deliberately transport-agnostic so callers can
16//! plug in any delivery mechanism (SMTP via `lettre`, HTTP webhook, mock for tests).
17
18use 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
33/// Abstraction over email delivery.
34pub trait EmailSender: Send + Sync {
35    /// Send `code` to `address`. Returns an error string on failure.
36    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
43/// NDNCERT email challenge handler.
44pub struct EmailChallenge {
45    sender: Arc<dyn EmailSender>,
46    /// How long the OTP is valid after being sent, in seconds.
47    code_ttl_secs: u32,
48    /// Maximum incorrect attempts before the request is denied.
49    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            // begin() is called before the first CHALLENGE parameters arrive.
85            // We return a state that signals "awaiting email address".
86            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                    // Round 1: send the OTP to the provided email address.
134                    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                    // Round 2: verify the submitted code.
161                    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
225/// Generate a 6-digit numeric OTP.
226fn 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
235/// SHA-256 hash of a string, returned as lowercase hex.
236fn 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}