ndn_identity/
enroll.rs

1//! NDNCERT enrollment — interactive certificate issuance via the NDNCERT protocol.
2
3use base64::Engine;
4use ndn_cert::EnrollmentSession;
5use ndn_packet::{Name, encode::InterestBuilder};
6use ndn_security::SecurityManager;
7
8use crate::{error::IdentityError, identity::NdnIdentity};
9
10/// Parameters for a specific challenge type.
11#[derive(Debug, Clone)]
12pub enum ChallengeParams {
13    /// Token challenge: submit a pre-provisioned token.
14    Token { token: String },
15    /// Possession challenge: prove ownership of an existing certificate.
16    Possession {
17        cert_name: String,
18        /// Ed25519 signature over the request_id bytes.
19        signature: Vec<u8>,
20    },
21    /// Raw key-value parameters for custom or future challenge types.
22    Raw(serde_json::Map<String, serde_json::Value>),
23}
24
25impl ChallengeParams {
26    pub fn challenge_type(&self) -> &str {
27        match self {
28            ChallengeParams::Token { .. } => "token",
29            ChallengeParams::Possession { .. } => "possession",
30            ChallengeParams::Raw(_) => "raw",
31        }
32    }
33
34    pub fn to_map(&self) -> serde_json::Map<String, serde_json::Value> {
35        match self {
36            ChallengeParams::Token { token } => {
37                let mut m = serde_json::Map::new();
38                m.insert("token".to_string(), token.clone().into());
39                m
40            }
41            ChallengeParams::Possession {
42                cert_name,
43                signature,
44            } => {
45                let mut m = serde_json::Map::new();
46                m.insert("cert_name".to_string(), cert_name.clone().into());
47                m.insert(
48                    "signature".to_string(),
49                    base64::engine::general_purpose::URL_SAFE_NO_PAD
50                        .encode(signature)
51                        .into(),
52                );
53                m
54            }
55            ChallengeParams::Raw(map) => map.clone(),
56        }
57    }
58}
59
60/// Configuration for NDNCERT enrollment.
61pub struct EnrollConfig {
62    /// The NDN name to enroll (should end with `/KEY/v=<n>`).
63    pub name: Name,
64    /// The CA prefix (e.g. `/com/acme/fleet/CA`).
65    pub ca_prefix: Name,
66    /// Certificate validity in seconds.
67    pub validity_secs: u64,
68    /// The challenge response to use.
69    pub challenge: ChallengeParams,
70    /// Optional storage path for the resulting PIB.
71    pub storage: Option<std::path::PathBuf>,
72}
73
74/// Run the full NDNCERT enrollment exchange.
75///
76/// This uses an in-process loopback for now (real network fetch is wired
77/// in `NdncertCa::serve`). For external CA enrollment, use a connected Consumer.
78pub async fn run_enrollment(config: EnrollConfig) -> Result<NdnIdentity, IdentityError> {
79    let manager = SecurityManager::new();
80
81    // Generate the key.
82    let key_name = manager.generate_ed25519(config.name.clone())?;
83    let signer = manager.get_signer_sync(&key_name)?;
84    let pubkey = signer
85        .public_key()
86        .ok_or_else(|| IdentityError::Enrollment("signer has no public key".to_string()))?;
87
88    // Build the enrollment session.
89    let mut session =
90        EnrollmentSession::new(config.name.clone(), pubkey.to_vec(), config.validity_secs);
91
92    let new_body = session.new_request_body()?;
93
94    // In a real implementation, this would send an Interest to config.ca_prefix/CA/NEW
95    // and receive a Data response. For now this returns an error to signal that
96    // a connected CA is required.
97    // The NdncertClient (below) provides the connected version.
98    let _ = new_body;
99
100    Err(IdentityError::Enrollment(
101        "direct enrollment requires a connected CA; use NdncertClient for network enrollment"
102            .to_string(),
103    ))
104}
105
106/// A connected NDNCERT client that exchanges protocol messages over the NDN network.
107pub struct NdncertClient {
108    consumer: ndn_app::Consumer,
109    ca_prefix: Name,
110}
111
112impl NdncertClient {
113    pub fn new(consumer: ndn_app::Consumer, ca_prefix: Name) -> Self {
114        Self {
115            consumer,
116            ca_prefix,
117        }
118    }
119
120    /// Fetch the CA profile.
121    pub async fn fetch_ca_profile(&mut self) -> Result<ndn_cert::CaProfile, IdentityError> {
122        let info_name = self.ca_prefix.clone().append("CA").append("INFO");
123        let data = self.consumer.fetch(info_name).await?;
124        let content = data.content().ok_or_else(|| {
125            IdentityError::Enrollment("CA INFO response has no content".to_string())
126        })?;
127        let profile: ndn_cert::CaProfile = serde_json::from_slice(content)
128            .map_err(|e| IdentityError::Enrollment(e.to_string()))?;
129        Ok(profile)
130    }
131
132    /// Run the full enrollment exchange and return the issued certificate.
133    pub async fn enroll(
134        &mut self,
135        name: Name,
136        public_key: Vec<u8>,
137        validity_secs: u64,
138        challenge: ChallengeParams,
139    ) -> Result<ndn_security::Certificate, IdentityError> {
140        let mut session = EnrollmentSession::new(name.clone(), public_key, validity_secs);
141
142        // Step 1: NEW
143        let new_body = session.new_request_body()?;
144        let new_name = self
145            .ca_prefix
146            .clone()
147            .append("CA")
148            .append("NEW")
149            .append_version(now_ms());
150
151        let new_data = self
152            .consumer
153            .fetch_with(InterestBuilder::new(new_name).app_parameters(new_body))
154            .await?;
155        let new_content = new_data
156            .content()
157            .ok_or_else(|| IdentityError::Enrollment("NEW response has no content".to_string()))?;
158        session.handle_new_response(new_content)?;
159
160        let request_id = session
161            .request_id()
162            .ok_or_else(|| IdentityError::Enrollment("no request_id from CA".to_string()))?
163            .to_string();
164
165        // Step 2: CHALLENGE
166        let challenge_type = challenge.challenge_type().to_string();
167        let params = challenge.to_map();
168        let challenge_body = session.challenge_request_body(&challenge_type, params)?;
169        let challenge_name = self
170            .ca_prefix
171            .clone()
172            .append("CA")
173            .append("CHALLENGE")
174            .append(&request_id);
175
176        let challenge_data = self
177            .consumer
178            .fetch_with(InterestBuilder::new(challenge_name).app_parameters(challenge_body))
179            .await?;
180        let challenge_content = challenge_data.content().ok_or_else(|| {
181            IdentityError::Enrollment("CHALLENGE response has no content".to_string())
182        })?;
183        session.handle_challenge_response(challenge_content)?;
184
185        session
186            .into_certificate()
187            .ok_or_else(|| IdentityError::Enrollment("no certificate returned".to_string()))
188    }
189}
190
191fn now_ms() -> u64 {
192    use std::time::{SystemTime, UNIX_EPOCH};
193    SystemTime::now()
194        .duration_since(UNIX_EPOCH)
195        .unwrap_or_default()
196        .as_millis() as u64
197}