ndn_identity/
device.rs

1//! Fleet and device provisioning — zero-touch provisioning (ZTP) for NDN devices.
2
3use std::{path::PathBuf, sync::Arc, time::Duration};
4
5use ndn_packet::Name;
6use ndn_security::{KeyChain, SecurityManager};
7
8use crate::{
9    enroll::{ChallengeParams, NdncertClient},
10    error::IdentityError,
11    identity::NdnIdentity,
12    renewal::start_renewal,
13};
14
15/// Factory-installed credential used to bootstrap device enrollment.
16#[derive(Debug, Clone)]
17pub enum FactoryCredential {
18    /// A pre-provisioned one-time token (most common for fleet devices).
19    Token(String),
20    /// A `did:key` string embedded in firmware for possession-based enrollment.
21    DidKey(String),
22    /// An existing certificate name + signing key seed for renewal-style enrollment.
23    Existing {
24        cert_name: String,
25        key_seed: [u8; 32],
26    },
27}
28
29/// When to automatically renew a certificate.
30#[derive(Debug, Clone)]
31pub enum RenewalPolicy {
32    /// Renew when N% of the certificate lifetime remains. Default: 20.
33    WhenPercentRemaining(u8),
34    /// Renew on a fixed interval regardless of cert expiry.
35    Every(Duration),
36    /// Never auto-renew.
37    Manual,
38}
39
40impl Default for RenewalPolicy {
41    fn default() -> Self {
42        RenewalPolicy::WhenPercentRemaining(20)
43    }
44}
45
46/// Configuration for zero-touch device provisioning.
47pub struct DeviceConfig {
48    /// The NDN namespace this device should claim
49    /// (e.g. `/com/acme/fleet/VIN-123456`).
50    pub namespace: Name,
51    /// Persistent storage for keys and certificates. `None` = in-memory only.
52    pub storage: Option<PathBuf>,
53    /// Factory credential used for the first enrollment.
54    pub factory_credential: FactoryCredential,
55    /// CA prefix. `None` = derive from namespace (drop last component, append `/CA`).
56    pub ca_prefix: Option<Name>,
57    /// Auto-renewal policy.
58    pub renewal: RenewalPolicy,
59    /// Sub-namespaces to delegate after enrollment
60    /// (e.g. ECU names under a vehicle namespace).
61    pub delegate: Vec<Name>,
62}
63
64/// Run the zero-touch provisioning flow.
65pub async fn run_provisioning(config: DeviceConfig) -> Result<NdnIdentity, IdentityError> {
66    let ca_prefix = config
67        .ca_prefix
68        .clone()
69        .unwrap_or_else(|| derive_ca_prefix(&config.namespace));
70
71    // Set up security manager.
72    let manager = if let Some(ref path) = config.storage {
73        let (mgr, _) = SecurityManager::auto_init(&config.namespace, path)?;
74        mgr
75    } else {
76        SecurityManager::new()
77    };
78
79    // Generate enrollment key.
80    let key_name = config
81        .namespace
82        .clone()
83        .append("KEY")
84        .append_version(now_ms());
85    manager.generate_ed25519(key_name.clone())?;
86    let signer = manager.get_signer_sync(&key_name)?;
87    let pubkey_bytes = signer
88        .public_key()
89        .ok_or_else(|| IdentityError::Enrollment("signer has no public key".to_string()))?;
90
91    let manager = Arc::new(manager);
92
93    // Build challenge from factory credential.
94    let challenge = build_challenge(&config.factory_credential, &key_name);
95
96    // We need a Consumer to talk to the CA. In the ZTP scenario the device
97    // must have a face to the CA already configured. We expect a Consumer
98    // to be available via the socket path conventionally at /run/ndn/router.sock.
99    // If not available we return a clear error asking the caller to provide one.
100    //
101    // For embedded/mobile scenarios, the caller should use NdncertClient directly.
102    let socket = std::path::Path::new("/run/ndn/router.sock");
103    if !socket.exists() {
104        return Err(IdentityError::Enrollment(
105            "ZTP requires a running NDN router at /run/ndn/router.sock; \
106             use NdncertClient directly for custom connectivity"
107                .to_string(),
108        ));
109    }
110
111    let consumer = ndn_app::Consumer::connect(socket).await?;
112    let mut client = NdncertClient::new(consumer, ca_prefix);
113
114    let cert = client
115        .enroll(
116            key_name.clone(),
117            pubkey_bytes.to_vec(),
118            86400, // 24h default
119            challenge,
120        )
121        .await?;
122
123    manager.add_trust_anchor(cert);
124
125    // Start renewal if requested.
126    let renewal = match &config.renewal {
127        RenewalPolicy::Manual => None,
128        policy => Some(start_renewal(
129            manager.clone(),
130            key_name.clone(),
131            config.namespace.clone(),
132            &policy.clone(),
133            config.storage.clone(),
134        )),
135    };
136
137    let keychain = KeyChain::from_parts(manager, config.namespace.clone(), key_name);
138    Ok(NdnIdentity::from_keychain(keychain, renewal))
139}
140
141fn build_challenge(credential: &FactoryCredential, _key_name: &Name) -> ChallengeParams {
142    match credential {
143        FactoryCredential::Token(token) => ChallengeParams::Token {
144            token: token.clone(),
145        },
146        FactoryCredential::DidKey(did) => {
147            // For did:key credentials, we use a raw challenge carrying the DID key.
148            // In a full implementation, we'd sign the request with the DID key.
149            ChallengeParams::Raw({
150                let mut m = serde_json::Map::new();
151                m.insert("did_key".to_string(), did.clone().into());
152                m
153            })
154        }
155        FactoryCredential::Existing {
156            cert_name,
157            key_seed,
158        } => {
159            // Sign the cert name (used as nonce by possession challenge).
160            use ndn_security::{Ed25519Signer, Signer};
161            let signer = Ed25519Signer::from_seed(
162                key_seed,
163                cert_name
164                    .parse()
165                    .unwrap_or_else(|_| ndn_packet::Name::root()),
166            );
167            let sig = signer.sign_sync(cert_name.as_bytes()).unwrap_or_default();
168            ChallengeParams::Possession {
169                cert_name: cert_name.clone(),
170                signature: sig.to_vec(),
171            }
172        }
173    }
174}
175
176fn derive_ca_prefix(namespace: &Name) -> Name {
177    // Heuristic: drop the last component (device ID) and append /CA.
178    let comps = namespace.components();
179    if comps.len() > 1 {
180        Name::from_components(comps[..comps.len() - 1].iter().cloned()).append("CA")
181    } else {
182        namespace.clone().append("CA")
183    }
184}
185
186fn now_ms() -> u64 {
187    use std::time::{SystemTime, UNIX_EPOCH};
188    SystemTime::now()
189        .duration_since(UNIX_EPOCH)
190        .unwrap_or_default()
191        .as_millis() as u64
192}