ndn_cert/
ca.rs

1//! CA-side stateless logic for NDNCERT.
2//!
3//! [`CaState`] processes incoming protocol messages and returns responses.
4//! It is deliberately stateless with respect to the network — all in-flight
5//! requests are held in a [`DashMap`]. The network wiring (Producer) lives
6//! in `ndn-identity`.
7
8use std::{
9    sync::Arc,
10    time::{Duration, SystemTime, UNIX_EPOCH},
11};
12
13use base64::Engine;
14use dashmap::{DashMap, DashSet};
15use ndn_security::{Certificate, SecurityManager};
16
17use crate::{
18    challenge::{ChallengeHandler, ChallengeOutcome, ChallengeState},
19    ecdh::{EcdhKeypair, SessionKey},
20    error::CertError,
21    policy::{NamespacePolicy, PolicyDecision},
22    protocol::CertRequest,
23    tlv::{
24        CaProfileTlv, ChallengeResponseTlv, NewRequestTlv, NewResponseTlv, ProbeResponseTlv,
25        REVOKE_STATUS_NOT_FOUND, REVOKE_STATUS_REVOKED, REVOKE_STATUS_UNAUTHORIZED,
26        RevokeRequestTlv, RevokeResponseTlv, STATUS_FAILURE, STATUS_PENDING, STATUS_SUCCESS,
27    },
28};
29
30/// Configuration for an NDNCERT CA.
31pub struct CaConfig {
32    /// NDN name prefix for this CA (e.g. `/com/acme/fleet/CA`).
33    pub prefix: ndn_packet::Name,
34    /// Human-readable description.
35    pub info: String,
36    /// Default certificate lifetime.
37    pub default_validity: Duration,
38    /// Maximum certificate lifetime the CA will issue.
39    pub max_validity: Duration,
40    /// Supported challenge handlers (first match wins on preference).
41    pub challenges: Vec<Box<dyn ChallengeHandler>>,
42    /// Namespace policy.
43    pub policy: Box<dyn NamespacePolicy>,
44}
45
46/// In-flight enrollment request stored between NEW and CHALLENGE.
47struct PendingRequest {
48    cert_request: CertRequest,
49    /// Challenge state — `None` until the first CHALLENGE request arrives.
50    /// `begin()` is deferred so the client's chosen challenge type is used.
51    challenge_state: Option<ChallengeState>,
52    /// Challenge type selected by the client (set on first CHALLENGE).
53    challenge_type: Option<String>,
54    created_at: u64,
55    /// AES-GCM-128 session key derived from ECDH + HKDF.
56    session_key: SessionKey,
57    /// 8-byte request identifier (raw bytes, used as HKDF info and AAD).
58    request_id_bytes: [u8; 8],
59}
60
61/// The stateless CA processor.
62///
63/// Holds in-flight request state and the signing identity.
64/// All methods take `&self` and are safe to call from concurrent tasks.
65pub struct CaState {
66    config: CaConfig,
67    manager: Arc<SecurityManager>,
68    pending: DashMap<String, PendingRequest>,
69    /// Certificate names that have been revoked.
70    revoked: DashSet<String>,
71}
72
73impl CaState {
74    pub fn new(config: CaConfig, manager: Arc<SecurityManager>) -> Self {
75        Self {
76            config,
77            manager,
78            pending: DashMap::new(),
79            revoked: DashSet::new(),
80        }
81    }
82
83    /// Remove pending requests older than `ttl_secs`.
84    ///
85    /// Called lazily from [`handle_new`] to amortize cleanup cost.
86    /// Per NDNCERT 0.3, the NEW→CHALLENGE window is 60 seconds.
87    pub fn cleanup_expired(&self, ttl_secs: u64) {
88        let cutoff = now_secs().saturating_sub(ttl_secs);
89        self.pending.retain(|_, v| v.created_at >= cutoff);
90    }
91
92    /// Check whether a certificate name has been revoked.
93    pub fn is_revoked(&self, cert_name: &str) -> bool {
94        self.revoked.contains(cert_name)
95    }
96
97    /// Handle a CA INFO request — return the CA's profile as TLV.
98    pub fn handle_info(&self) -> Vec<u8> {
99        let ca_certificate = self
100            .manager
101            .trust_anchor_names()
102            .first()
103            .and_then(|name| self.manager.trust_anchor(name))
104            .map(|cert| bytes::Bytes::from(serialize_cert(&cert)))
105            .unwrap_or_else(|| {
106                tracing::warn!(
107                    "CA has no trust anchor configured; INFO response has empty ca_certificate"
108                );
109                bytes::Bytes::new()
110            });
111
112        let profile = CaProfileTlv {
113            ca_prefix: self.config.prefix.to_string(),
114            ca_info: self.config.info.clone(),
115            ca_certificate,
116            max_validity_secs: self.config.max_validity.as_secs(),
117            challenges: self
118                .config
119                .challenges
120                .iter()
121                .map(|c| c.challenge_type().to_string())
122                .collect(),
123        };
124        profile.encode().to_vec()
125    }
126
127    /// Handle a PROBE request — check namespace policy without creating state.
128    ///
129    /// Route: `/<ca-prefix>/CA/PROBE`; requested name in ApplicationParameters.
130    /// Returns TLV-encoded [`ProbeResponseTlv`].
131    pub fn handle_probe(&self, requested_name: &str) -> Vec<u8> {
132        let result: Result<ndn_packet::Name, _> = requested_name.parse();
133        let resp = match result {
134            Err(_) => ProbeResponseTlv {
135                allowed: false,
136                reason: Some(format!("invalid NDN name: {requested_name}")),
137                max_suffix_length: None,
138            },
139            Ok(name) => match self
140                .config
141                .policy
142                .evaluate(&name, None, &self.config.prefix)
143            {
144                PolicyDecision::Allow => ProbeResponseTlv {
145                    allowed: true,
146                    reason: None,
147                    max_suffix_length: None,
148                },
149                PolicyDecision::Deny(reason) => ProbeResponseTlv {
150                    allowed: false,
151                    reason: Some(reason),
152                    max_suffix_length: None,
153                },
154            },
155        };
156        resp.encode().to_vec()
157    }
158
159    /// Handle a NEW request — validate, perform ECDH, store state, return challenges.
160    ///
161    /// Body: TLV-encoded [`NewRequestTlv`] (ECDH pub key + cert request bytes).
162    /// Returns: TLV-encoded [`NewResponseTlv`] (CA ECDH pub key + salt + request_id + challenges).
163    pub async fn handle_new(&self, body: &[u8]) -> Result<Vec<u8>, CertError> {
164        // Amortized cleanup: remove requests that missed the 60-second window.
165        self.cleanup_expired(60);
166
167        // Decode TLV request.
168        let new_req = NewRequestTlv::decode(bytes::Bytes::copy_from_slice(body))?;
169
170        // Decode cert request from binary blob embedded in the TLV.
171        let req = decode_cert_request_bytes(&new_req.cert_request)?;
172
173        // Parse and policy-check the requested identity name.
174        let name: ndn_packet::Name = req
175            .name
176            .parse()
177            .map_err(|_| CertError::Name(format!("invalid name: {}", req.name)))?;
178
179        match self
180            .config
181            .policy
182            .evaluate(&name, None, &self.config.prefix)
183        {
184            PolicyDecision::Allow => {}
185            PolicyDecision::Deny(reason) => return Err(CertError::PolicyDenied(reason)),
186        }
187
188        if self.config.challenges.is_empty() {
189            return Err(CertError::InvalidRequest(
190                "CA has no challenge handlers".to_string(),
191            ));
192        }
193
194        // ECDH key agreement: generate CA ephemeral keypair, derive session key.
195        let ca_kp = EcdhKeypair::generate();
196        let ca_pub_bytes = ca_kp.public_key_bytes();
197        let salt = EcdhKeypair::random_salt();
198        let request_id_bytes = generate_request_id_bytes();
199
200        let session_key = ca_kp.derive_session_key(&new_req.ecdh_pub, &salt, &request_id_bytes)?;
201
202        let request_id_hex = bytes_to_hex(&request_id_bytes);
203
204        // Store pending request with session key.
205        self.pending.insert(
206            request_id_hex,
207            PendingRequest {
208                cert_request: req,
209                challenge_state: None,
210                challenge_type: None,
211                created_at: now_secs(),
212                session_key,
213                request_id_bytes,
214            },
215        );
216
217        let resp = NewResponseTlv {
218            ecdh_pub: bytes::Bytes::from(ca_pub_bytes),
219            salt,
220            request_id: request_id_bytes,
221            challenges: self
222                .config
223                .challenges
224                .iter()
225                .map(|c| c.challenge_type().to_string())
226                .collect(),
227        };
228        Ok(resp.encode().to_vec())
229    }
230
231    /// Handle a CHALLENGE request — decrypt parameters, verify, issue or deny.
232    ///
233    /// Body: TLV-encoded [`ChallengeRequestTlv`] (encrypted challenge parameters).
234    /// Returns: TLV-encoded [`ChallengeResponseTlv`].
235    pub async fn handle_challenge(
236        &self,
237        request_id: &str,
238        body: &[u8],
239    ) -> Result<Vec<u8>, CertError> {
240        use crate::tlv::ChallengeRequestTlv;
241
242        let chal_tlv = ChallengeRequestTlv::decode(bytes::Bytes::copy_from_slice(body))?;
243
244        // Validate that the request_id in the TLV matches the Interest name component.
245        let req_id_from_tlv = bytes_to_hex(&chal_tlv.request_id);
246        if req_id_from_tlv != request_id {
247            return Err(CertError::InvalidRequest(
248                "request_id in TLV does not match Interest name".into(),
249            ));
250        }
251
252        // Read current state without holding a DashMap reference across await points.
253        let (cert_request, existing_state, existing_type, session_key, request_id_bytes) = {
254            let pending = self
255                .pending
256                .get(request_id)
257                .ok_or_else(|| CertError::RequestNotFound(request_id.to_string()))?;
258            (
259                pending.cert_request.clone(),
260                pending.challenge_state.clone(),
261                pending.challenge_type.clone(),
262                pending.session_key.clone(),
263                pending.request_id_bytes,
264            )
265        };
266
267        // Decrypt challenge parameters with the ECDH-derived session key.
268        let params_json = session_key.decrypt(
269            &chal_tlv.iv,
270            &chal_tlv.encrypted_payload,
271            &chal_tlv.auth_tag,
272            &request_id_bytes,
273        )?;
274        let parameters: serde_json::Map<String, serde_json::Value> =
275            serde_json::from_slice(&params_json)?;
276
277        let challenge_type = &chal_tlv.selected_challenge;
278
279        // Reject challenge-type switching mid-enrollment.
280        if let Some(ref locked_type) = existing_type
281            && locked_type != challenge_type
282        {
283            return Err(CertError::InvalidRequest(format!(
284                "challenge type locked to '{}' for this request",
285                locked_type
286            )));
287        }
288
289        // Find the handler matching the client's chosen challenge type.
290        let handler = self
291            .config
292            .challenges
293            .iter()
294            .find(|h| h.challenge_type() == challenge_type)
295            .ok_or_else(|| {
296                CertError::InvalidRequest(format!("unsupported challenge type: {challenge_type}",))
297            })?;
298
299        // On first CHALLENGE: call begin() to initialize state, lock in challenge type.
300        let state = match existing_state {
301            Some(s) => s,
302            None => {
303                let s = handler.begin(&cert_request).await?;
304                if let Some(mut entry) = self.pending.get_mut(request_id) {
305                    entry.challenge_state = Some(s.clone());
306                    entry.challenge_type = Some(challenge_type.clone());
307                }
308                s
309            }
310        };
311
312        let outcome = handler.verify(&state, &parameters).await?;
313
314        match outcome {
315            ChallengeOutcome::Denied(reason) => {
316                self.pending.remove(request_id);
317                Ok(ChallengeResponseTlv {
318                    status: STATUS_FAILURE,
319                    challenge_status: None,
320                    remaining_tries: None,
321                    remaining_time_secs: None,
322                    issued_cert_name: None,
323                    error_code: Some(7), // OutOfTries
324                    error_info: Some(reason),
325                    iv: None,
326                    encrypted_payload: None,
327                    auth_tag: None,
328                }
329                .encode()
330                .to_vec())
331            }
332
333            ChallengeOutcome::Pending {
334                status_message,
335                remaining_tries,
336                remaining_time_secs,
337                next_state,
338            } => {
339                if let Some(mut entry) = self.pending.get_mut(request_id) {
340                    entry.challenge_state = Some(next_state);
341                }
342                Ok(ChallengeResponseTlv {
343                    status: STATUS_PENDING,
344                    challenge_status: Some(status_message),
345                    remaining_tries: Some(remaining_tries),
346                    remaining_time_secs: Some(remaining_time_secs),
347                    issued_cert_name: None,
348                    error_code: None,
349                    error_info: None,
350                    iv: None,
351                    encrypted_payload: None,
352                    auth_tag: None,
353                }
354                .encode()
355                .to_vec())
356            }
357
358            ChallengeOutcome::Approved => {
359                let (_, pending) = self
360                    .pending
361                    .remove(request_id)
362                    .ok_or_else(|| CertError::RequestNotFound(request_id.to_string()))?;
363
364                let cert = self.issue_certificate(&pending.cert_request).await?;
365                let cert_name = cert.name.to_string();
366                let cert_bytes = serialize_cert(&cert);
367
368                // Embed the serialized cert in `encrypted_payload` (unencrypted on success —
369                // the session is complete and no further encryption is needed).
370                Ok(ChallengeResponseTlv {
371                    status: STATUS_SUCCESS,
372                    challenge_status: None,
373                    remaining_tries: None,
374                    remaining_time_secs: None,
375                    issued_cert_name: Some(cert_name),
376                    error_code: None,
377                    error_info: None,
378                    iv: None,
379                    encrypted_payload: Some(bytes::Bytes::from(cert_bytes)),
380                    auth_tag: None,
381                }
382                .encode()
383                .to_vec())
384            }
385        }
386    }
387
388    /// Handle a REVOKE request.
389    ///
390    /// Route: `/<ca-prefix>/CA/REVOKE`; body is TLV-encoded [`RevokeRequestTlv`].
391    /// Returns TLV-encoded [`RevokeResponseTlv`].
392    pub async fn handle_revoke(&self, body: &[u8]) -> Vec<u8> {
393        let status = self.do_revoke(body).await;
394        RevokeResponseTlv {
395            status,
396            reason: None,
397        }
398        .encode()
399        .to_vec()
400    }
401
402    async fn do_revoke(&self, body: &[u8]) -> u8 {
403        let req = match RevokeRequestTlv::decode(bytes::Bytes::copy_from_slice(body)) {
404            Ok(r) => r,
405            Err(_) => return REVOKE_STATUS_UNAUTHORIZED,
406        };
407
408        // Find the cert in CA trust anchors to get its public key.
409        let cert_name_parsed: ndn_packet::Name = match req.cert_name.parse() {
410            Ok(n) => n,
411            Err(_) => return REVOKE_STATUS_NOT_FOUND,
412        };
413
414        let anchor = self.manager.trust_anchor(&cert_name_parsed);
415        let public_key = match anchor {
416            Some(c) => c.public_key,
417            None => return REVOKE_STATUS_NOT_FOUND,
418        };
419
420        // Verify: requester signed cert_name with the cert's private key.
421        use ndn_security::{Ed25519Verifier, Verifier, VerifyOutcome};
422        let outcome = Ed25519Verifier
423            .verify(req.cert_name.as_bytes(), &req.signature, &public_key)
424            .await;
425
426        match outcome {
427            Ok(VerifyOutcome::Valid) => {
428                self.revoked.insert(req.cert_name);
429                REVOKE_STATUS_REVOKED
430            }
431            _ => REVOKE_STATUS_UNAUTHORIZED,
432        }
433    }
434
435    /// Issue a certificate for an approved request.
436    async fn issue_certificate(&self, req: &CertRequest) -> Result<Certificate, CertError> {
437        let subject_name: ndn_packet::Name = req
438            .name
439            .parse()
440            .map_err(|_| CertError::Name(req.name.clone()))?;
441
442        let public_key = base64::engine::general_purpose::URL_SAFE_NO_PAD
443            .decode(&req.public_key)
444            .map_err(|_| CertError::InvalidRequest("invalid public key base64".to_string()))?;
445
446        // Refuse to re-issue a revoked certificate.
447        if self.is_revoked(&req.name) {
448            return Err(CertError::PolicyDenied(format!(
449                "certificate {} has been revoked",
450                req.name
451            )));
452        }
453
454        // Find the CA signing key
455        let ca_key_names = self.manager.trust_anchor_names();
456        let ca_key_name = ca_key_names.first().ok_or_else(|| {
457            CertError::InvalidRequest("CA has no signing key configured".to_string())
458        })?;
459
460        // Determine validity from the client's request, capped at the configured maximum.
461        // Per NDNCERT 0.3: not_after <= min(now + max_validity, ca_cert.valid_until).
462        let max_validity_ms = self.config.max_validity.as_millis() as u64;
463        let requested_ms = req.not_after.saturating_sub(req.not_before);
464        let validity_ms = if requested_ms > 0 {
465            requested_ms.min(max_validity_ms)
466        } else {
467            self.config
468                .default_validity
469                .as_millis()
470                .min(self.config.max_validity.as_millis()) as u64
471        };
472
473        let cert = self
474            .manager
475            .certify(
476                &subject_name,
477                bytes::Bytes::from(public_key),
478                ca_key_name.as_ref(),
479                validity_ms,
480            )
481            .await
482            .map_err(CertError::Security)?;
483
484        Ok(cert)
485    }
486}
487
488/// Encode a certificate as a minimal byte blob for transport.
489///
490/// Format: [8 bytes: valid_from][8 bytes: valid_until][4 bytes: pubkey_len][pubkey]
491///         [4 bytes: name_len][name_utf8]
492fn serialize_cert(cert: &Certificate) -> Vec<u8> {
493    let name_bytes = cert.name.to_string().into_bytes();
494    let mut out = Vec::new();
495    out.extend_from_slice(&cert.valid_from.to_be_bytes());
496    out.extend_from_slice(&cert.valid_until.to_be_bytes());
497    out.extend_from_slice(&(cert.public_key.len() as u32).to_be_bytes());
498    out.extend_from_slice(&cert.public_key);
499    out.extend_from_slice(&(name_bytes.len() as u32).to_be_bytes());
500    out.extend_from_slice(&name_bytes);
501    out
502}
503
504/// Deserialize a certificate from the transport byte blob.
505pub fn deserialize_cert(data: &[u8]) -> Option<Certificate> {
506    if data.len() < 20 {
507        return None;
508    }
509    let valid_from = u64::from_be_bytes(data[0..8].try_into().ok()?);
510    let valid_until = u64::from_be_bytes(data[8..16].try_into().ok()?);
511    let pk_len = u32::from_be_bytes(data[16..20].try_into().ok()?) as usize;
512    if data.len() < 20 + pk_len + 4 {
513        return None;
514    }
515    let public_key = bytes::Bytes::copy_from_slice(&data[20..20 + pk_len]);
516    let name_len = u32::from_be_bytes(data[20 + pk_len..24 + pk_len].try_into().ok()?) as usize;
517    let name_bytes = data.get(24 + pk_len..24 + pk_len + name_len)?;
518    let name_str = std::str::from_utf8(name_bytes).ok()?;
519    let name: ndn_packet::Name = name_str.parse().ok()?;
520    Some(Certificate {
521        name: std::sync::Arc::new(name),
522        public_key,
523        valid_from,
524        valid_until,
525        issuer: None,
526        signed_region: None,
527        sig_value: None,
528    })
529}
530
531fn generate_request_id_bytes() -> [u8; 8] {
532    use ring::rand::{SecureRandom, SystemRandom};
533    let rng = SystemRandom::new();
534    let mut bytes = [0u8; 8];
535    rng.fill(&mut bytes).unwrap_or(());
536    bytes
537}
538
539fn bytes_to_hex(bytes: &[u8; 8]) -> String {
540    bytes.iter().map(|b| format!("{b:02x}")).collect()
541}
542
543/// Decode a binary-encoded cert request blob from [`NewRequestTlv::cert_request`].
544///
545/// Format: `[8 not_before][8 not_after][4 pubkey_len][pubkey][4 name_len][name_utf8]`
546/// Exposed as `pub(crate)` for unit-testing in `client.rs`.
547#[cfg(test)]
548pub(crate) fn decode_cert_request_bytes_pub(data: &[u8]) -> Result<CertRequest, CertError> {
549    decode_cert_request_bytes(data)
550}
551
552fn decode_cert_request_bytes(data: &[u8]) -> Result<CertRequest, CertError> {
553    if data.len() < 20 {
554        return Err(CertError::InvalidRequest("cert request too short".into()));
555    }
556    let not_before = u64::from_be_bytes(data[0..8].try_into().unwrap());
557    let not_after = u64::from_be_bytes(data[8..16].try_into().unwrap());
558    let pk_len = u32::from_be_bytes(data[16..20].try_into().unwrap()) as usize;
559    if data.len() < 20 + pk_len + 4 {
560        return Err(CertError::InvalidRequest(
561            "cert request truncated at pubkey".into(),
562        ));
563    }
564    let public_key =
565        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&data[20..20 + pk_len]);
566    let name_len = u32::from_be_bytes(data[20 + pk_len..24 + pk_len].try_into().unwrap()) as usize;
567    if data.len() < 24 + pk_len + name_len {
568        return Err(CertError::InvalidRequest(
569            "cert request truncated at name".into(),
570        ));
571    }
572    let name = std::str::from_utf8(&data[24 + pk_len..24 + pk_len + name_len])
573        .map_err(|_| CertError::InvalidRequest("invalid name UTF-8 in cert request".into()))?
574        .to_string();
575    Ok(CertRequest {
576        name,
577        public_key,
578        not_before,
579        not_after,
580    })
581}
582
583fn now_secs() -> u64 {
584    SystemTime::now()
585        .duration_since(UNIX_EPOCH)
586        .unwrap_or_default()
587        .as_secs()
588}
589
590#[allow(dead_code)]
591fn now_ms() -> u64 {
592    SystemTime::now()
593        .duration_since(UNIX_EPOCH)
594        .unwrap_or_default()
595        .as_millis() as u64
596}