ndn_cert/challenge/
pin.rs

1//! PIN/OTP challenge — client proves identity by submitting a pre-shared PIN.
2//!
3//! The PIN is known to both the CA (pre-provisioned at device manufacture time
4//! or via out-of-band admin workflow) and the device operator. It is never stored
5//! in plaintext on the CA — only the SHA-256 hash is retained.
6//!
7//! # YubiKey HOTP integration
8//!
9//! When paired with a YubiKey configured in HOTP mode (slot 2, long-press), this
10//! challenge enables secure headless bootstrapping:
11//!
12//! 1. Admin provisions the YubiKey HOTP seed via the dashboard (→ `ykpersonalize`)
13//! 2. YubiKey is plugged into the headless router
14//! 3. Router starts enrollment; enrollment client reads from `stdin`
15//! 4. Operator presses the YubiKey button → 44-char OTP emitted via USB HID
16//! 5. Enrollment client captures the code and submits it as `{ "code": "..." }`
17//! 6. CA verifies hash → certificate issued
18//!
19//! The `max_tries` limit protects against brute-force; set to 1 for HOTP
20//! where each press generates a unique non-replayable code.
21
22use std::{future::Future, pin::Pin};
23
24use crate::{
25    challenge::{ChallengeHandler, ChallengeOutcome, ChallengeState},
26    error::CertError,
27    protocol::CertRequest,
28};
29
30/// NDNCERT PIN/OTP challenge handler.
31///
32/// Single-round: the client submits `{ "code": "<pin>" }` and the CA checks
33/// the SHA-256 hash against the stored value.
34pub struct PinChallenge {
35    /// SHA-256 hash of the expected PIN/OTP.
36    pin_hash: [u8; 32],
37    /// Maximum number of incorrect attempts before the request is denied.
38    max_tries: u8,
39}
40
41impl PinChallenge {
42    /// Create a challenge from a plaintext PIN (hashed internally).
43    pub fn new(pin: &str) -> Self {
44        Self::new_with_max_tries(pin, 3)
45    }
46
47    /// Create a challenge with an explicit attempt limit.
48    pub fn new_with_max_tries(pin: &str, max_tries: u8) -> Self {
49        use ring::digest::{SHA256, digest};
50        let hash = digest(&SHA256, pin.as_bytes());
51        let mut pin_hash = [0u8; 32];
52        pin_hash.copy_from_slice(hash.as_ref());
53        Self {
54            pin_hash,
55            max_tries,
56        }
57    }
58}
59
60impl ChallengeHandler for PinChallenge {
61    fn challenge_type(&self) -> &'static str {
62        "pin"
63    }
64
65    fn begin<'a>(
66        &'a self,
67        _req: &'a CertRequest,
68    ) -> Pin<Box<dyn Future<Output = Result<ChallengeState, CertError>> + Send + 'a>> {
69        let max_tries = self.max_tries;
70        Box::pin(async move {
71            Ok(ChallengeState {
72                challenge_type: "pin".to_string(),
73                data: serde_json::json!({ "remaining_tries": max_tries }),
74            })
75        })
76    }
77
78    fn verify<'a>(
79        &'a self,
80        state: &'a ChallengeState,
81        parameters: &'a serde_json::Map<String, serde_json::Value>,
82    ) -> Pin<Box<dyn Future<Output = Result<ChallengeOutcome, CertError>> + Send + 'a>> {
83        use ring::digest::{SHA256, digest};
84
85        let code = parameters
86            .get("code")
87            .and_then(|v| v.as_str())
88            .map(str::to_string);
89        let remaining_tries = state
90            .data
91            .get("remaining_tries")
92            .and_then(|v| v.as_u64())
93            .unwrap_or(1) as u8;
94        let pin_hash = self.pin_hash;
95
96        Box::pin(async move {
97            let code = match code {
98                Some(c) => c,
99                None => {
100                    return Ok(ChallengeOutcome::Denied(
101                        "missing 'code' parameter".to_string(),
102                    ));
103                }
104            };
105
106            let submitted_hash = digest(&SHA256, code.as_bytes());
107            let matches = submitted_hash.as_ref() == pin_hash;
108
109            if matches {
110                Ok(ChallengeOutcome::Approved)
111            } else if remaining_tries <= 1 {
112                Ok(ChallengeOutcome::Denied(
113                    "PIN verification failed: no attempts remaining".to_string(),
114                ))
115            } else {
116                let new_tries = remaining_tries - 1;
117                Ok(ChallengeOutcome::Pending {
118                    status_message: format!("Incorrect PIN — {new_tries} attempt(s) remaining"),
119                    remaining_tries: new_tries,
120                    remaining_time_secs: 300,
121                    next_state: ChallengeState {
122                        challenge_type: "pin".to_string(),
123                        data: serde_json::json!({ "remaining_tries": new_tries }),
124                    },
125                })
126            }
127        })
128    }
129}