Skip to main content

canic_host/network/
mod.rs

1//! Module: network
2//!
3//! Responsibility: enroll and resolve canonical IC network trust identities.
4//! Does not own: gateway selection, Fleet identity, or trust-anchor rotation.
5//! Boundary: verified trust bytes are authoritative; environment profiles are lookup pointers only.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    durable_io::{
12        RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes,
13    },
14    icp_config::{IcpConfigError, resolve_icp_build_network_from_root},
15};
16use canic_core::ids::{BuildNetwork, CanonicalNetworkId};
17use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
18use sha2::{Digest, Sha256};
19use std::{
20    fmt::Write as _,
21    io,
22    path::{Path, PathBuf},
23    time::{SystemTime, SystemTimeError, UNIX_EPOCH},
24};
25use thiserror::Error as ThisError;
26
27const CANIC_STATE_DIRECTORY: &str = ".canic";
28const NETWORKS_DIRECTORY: &str = "networks";
29const ENVIRONMENT_PROFILES_DIRECTORY: &str = "environment-profiles";
30const ROOT_KEY_RELATIVE_PATH: &str = "trust/root-key.der";
31const ENROLLMENT_FILE: &str = "enrollment.json";
32const NETWORK_PROFILE_FILE: &str = "network.json";
33
34///
35/// NetworkEnrollmentRecord
36///
37/// Authoritative record binding one canonical network to its enrolled trust anchor.
38///
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields)]
42pub struct NetworkEnrollmentRecord {
43    #[serde(with = "digest_hex")]
44    pub root_key_digest: [u8; 32],
45    /// Unix timestamp in seconds.
46    pub enrolled_at: u64,
47    pub source_profile: String,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52struct EnvironmentNetworkProfile {
53    canonical_network_id: CanonicalNetworkId,
54}
55
56///
57/// NetworkEnrollmentOptions
58///
59/// Inputs to one explicit trust-anchor enrollment.
60///
61
62#[derive(Clone, Copy, Debug)]
63pub struct NetworkEnrollmentOptions<'a> {
64    pub project_root: &'a Path,
65    pub environment: &'a str,
66    pub root_key: &'a Path,
67    pub fingerprint: &'a str,
68}
69
70///
71/// NetworkEnrollmentReport
72///
73/// Result of enrolling or confirming one network profile.
74///
75
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct NetworkEnrollmentReport {
78    pub environment: String,
79    pub canonical_network_id: CanonicalNetworkId,
80    pub root_key_fingerprint: String,
81    pub authority_directory: PathBuf,
82    pub profile_path: PathBuf,
83    pub created_profile: bool,
84}
85
86///
87/// NetworkIdentityError
88///
89/// Typed failure while enrolling or resolving a canonical network identity.
90///
91
92#[derive(Debug, ThisError)]
93pub enum NetworkIdentityError {
94    #[error("invalid ICP environment name {name:?}")]
95    InvalidEnvironmentName { name: String },
96
97    #[error(
98        "ICP environment {environment:?} resolves to the public IC, whose root trust anchor is compiled into Canic and cannot be enrolled"
99    )]
100    PublicIcEnrollment { environment: String },
101
102    #[error("root-key fingerprint must contain exactly 64 lowercase hexadecimal characters")]
103    InvalidFingerprint,
104
105    #[error(
106        "root-key fingerprint mismatch: expected {expected}, observed {observed}; no enrollment was written"
107    )]
108    FingerprintMismatch { expected: String, observed: String },
109
110    #[error("root key is not a regular non-symlink file: {}", path.display())]
111    RootKeyNotRegular { path: PathBuf },
112
113    #[error("root key is not a valid DER-encoded IC root public key: {reason}")]
114    InvalidRootKeyDer { reason: String },
115
116    #[error("required network profile is missing: {}", path.display())]
117    MissingProfile { path: PathBuf },
118
119    #[error("network profile is not a regular non-symlink file: {}", path.display())]
120    ProfileNotRegular { path: PathBuf },
121
122    #[error("required network authority file is missing: {}", path.display())]
123    MissingAuthority { path: PathBuf },
124
125    #[error("network authority file is not a regular non-symlink file: {}", path.display())]
126    AuthorityNotRegular { path: PathBuf },
127
128    #[error(
129        "environment profile {environment:?} is already bound to network {existing}, not {requested}"
130    )]
131    ProfileConflict {
132        environment: String,
133        existing: CanonicalNetworkId,
134        requested: CanonicalNetworkId,
135    },
136
137    #[error("network trust anchor conflicts with the authority at {}", path.display())]
138    TrustAnchorConflict { path: PathBuf },
139
140    #[error("network authority is incomplete or contradictory: {reason}")]
141    ContradictoryAuthority { reason: String },
142
143    #[error("could not decode network document {}: {source}", path.display())]
144    Decode {
145        path: PathBuf,
146        #[source]
147        source: serde_json::Error,
148    },
149
150    #[error("could not encode network document: {0}")]
151    Encode(#[from] serde_json::Error),
152
153    #[error("network filesystem operation failed for {}: {source}", path.display())]
154    Io {
155        path: PathBuf,
156        #[source]
157        source: io::Error,
158    },
159
160    #[error(transparent)]
161    IcpConfig(#[from] IcpConfigError),
162
163    #[error("system clock is before the Unix epoch: {0}")]
164    Clock(#[from] SystemTimeError),
165
166    #[error("secure network trust files are unsupported on platform {0}")]
167    UnsupportedPlatform(&'static str),
168}
169
170/// Enroll an exact non-public trust anchor and publish its environment profile.
171pub fn enroll_network(
172    options: NetworkEnrollmentOptions<'_>,
173) -> Result<NetworkEnrollmentReport, NetworkIdentityError> {
174    validate_environment_name(options.environment)?;
175    if resolve_icp_build_network_from_root(options.project_root, options.environment)?
176        == BuildNetwork::Ic
177    {
178        return Err(NetworkIdentityError::PublicIcEnrollment {
179            environment: options.environment.to_string(),
180        });
181    }
182
183    let expected_digest = parse_fingerprint(options.fingerprint)?;
184    let root_key = read_regular_file(options.root_key, FilePurpose::EnrollmentInput)?;
185    let canonical_network_id =
186        CanonicalNetworkId::from_der_root_trust_anchor(&root_key).map_err(|error| {
187            NetworkIdentityError::InvalidRootKeyDer {
188                reason: error.to_string(),
189            }
190        })?;
191    let observed_digest = sha256_digest(&root_key);
192    if observed_digest != expected_digest {
193        return Err(NetworkIdentityError::FingerprintMismatch {
194            expected: encode_digest(expected_digest),
195            observed: encode_digest(observed_digest),
196        });
197    }
198
199    let paths = NetworkPaths::new(
200        options.project_root,
201        options.environment,
202        canonical_network_id,
203    );
204    let existing_profile = read_optional_profile(&paths.profile)?;
205    if let Some(profile) = &existing_profile
206        && profile.canonical_network_id != canonical_network_id
207    {
208        return Err(NetworkIdentityError::ProfileConflict {
209            environment: options.environment.to_string(),
210            existing: profile.canonical_network_id,
211            requested: canonical_network_id,
212        });
213    }
214
215    let existing_root_key = read_optional_regular_file(&paths.root_key)?;
216    if existing_root_key
217        .as_deref()
218        .is_some_and(|existing| existing != root_key)
219    {
220        return Err(NetworkIdentityError::TrustAnchorConflict {
221            path: paths.root_key,
222        });
223    }
224    let existing_enrollment = read_optional_json::<NetworkEnrollmentRecord>(&paths.enrollment)?;
225    validate_existing_authority(
226        &paths,
227        observed_digest,
228        canonical_network_id,
229        existing_root_key.as_deref(),
230        existing_enrollment.as_ref(),
231        existing_profile.as_ref(),
232    )?;
233
234    if existing_root_key.is_none() {
235        create_new(&paths.root_key, &root_key)?;
236    }
237    if existing_enrollment.is_none() {
238        let enrollment = NetworkEnrollmentRecord {
239            root_key_digest: observed_digest,
240            enrolled_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
241            source_profile: options.environment.to_string(),
242        };
243        create_new_enrollment(&paths, &enrollment)?;
244    }
245
246    let created_profile = existing_profile.is_none();
247    if created_profile {
248        create_new_profile(&paths, options.environment)?;
249    }
250
251    Ok(NetworkEnrollmentReport {
252        environment: options.environment.to_string(),
253        canonical_network_id,
254        root_key_fingerprint: encode_digest(observed_digest),
255        authority_directory: paths.authority_directory,
256        profile_path: paths.profile,
257        created_profile,
258    })
259}
260
261/// Resolve an environment profile to its verified canonical network identity.
262pub fn resolve_canonical_network_id_from_root(
263    project_root: &Path,
264    environment: &str,
265) -> Result<CanonicalNetworkId, NetworkIdentityError> {
266    validate_environment_name(environment)?;
267    let build_network = resolve_icp_build_network_from_root(project_root, environment)?;
268    let profile_path = environment_profile_path(project_root, environment);
269
270    if build_network == BuildNetwork::Ic {
271        let expected = CanonicalNetworkId::public_ic();
272        if let Some(profile) = read_optional_profile(&profile_path)?
273            && profile.canonical_network_id != expected
274        {
275            return Err(NetworkIdentityError::ProfileConflict {
276                environment: environment.to_string(),
277                existing: profile.canonical_network_id,
278                requested: expected,
279            });
280        }
281        return Ok(expected);
282    }
283
284    let profile = read_required_profile(&profile_path)?;
285    let paths = NetworkPaths::new(project_root, environment, profile.canonical_network_id);
286    let root_key = read_required_regular_file(&paths.root_key)?;
287    let observed_network_id =
288        CanonicalNetworkId::from_der_root_trust_anchor(&root_key).map_err(|error| {
289            NetworkIdentityError::InvalidRootKeyDer {
290                reason: error.to_string(),
291            }
292        })?;
293    let observed_digest = sha256_digest(&root_key);
294    let enrollment = read_required_json::<NetworkEnrollmentRecord>(&paths.enrollment)?;
295    validate_environment_name(&enrollment.source_profile)?;
296    validate_complete_authority(
297        profile.canonical_network_id,
298        observed_digest,
299        observed_network_id,
300        &enrollment,
301        &paths,
302    )?;
303    Ok(profile.canonical_network_id)
304}
305
306fn validate_existing_authority(
307    paths: &NetworkPaths,
308    expected_digest: [u8; 32],
309    observed_network_id: CanonicalNetworkId,
310    root_key: Option<&[u8]>,
311    enrollment: Option<&NetworkEnrollmentRecord>,
312    profile: Option<&EnvironmentNetworkProfile>,
313) -> Result<(), NetworkIdentityError> {
314    if enrollment.is_some() && root_key.is_none() {
315        return Err(NetworkIdentityError::ContradictoryAuthority {
316            reason: format!(
317                "{} exists without {}",
318                paths.enrollment.display(),
319                paths.root_key.display()
320            ),
321        });
322    }
323    if profile.is_some() && (root_key.is_none() || enrollment.is_none()) {
324        return Err(NetworkIdentityError::ContradictoryAuthority {
325            reason: format!(
326                "{} is visible without a complete authority",
327                paths.profile.display()
328            ),
329        });
330    }
331    if let Some(enrollment) = enrollment {
332        validate_environment_name(&enrollment.source_profile)?;
333        validate_complete_authority(
334            paths.canonical_network_id,
335            expected_digest,
336            observed_network_id,
337            enrollment,
338            paths,
339        )?;
340    }
341    Ok(())
342}
343
344fn validate_complete_authority(
345    canonical_network_id: CanonicalNetworkId,
346    root_key_digest: [u8; 32],
347    observed_network_id: CanonicalNetworkId,
348    enrollment: &NetworkEnrollmentRecord,
349    paths: &NetworkPaths,
350) -> Result<(), NetworkIdentityError> {
351    if enrollment.root_key_digest != root_key_digest {
352        return Err(NetworkIdentityError::ContradictoryAuthority {
353            reason: format!(
354                "{} does not match the exact root trust anchor",
355                paths.enrollment.display()
356            ),
357        });
358    }
359    if observed_network_id != canonical_network_id {
360        return Err(NetworkIdentityError::ContradictoryAuthority {
361            reason: format!(
362                "{} derives network {observed_network_id}, not {canonical_network_id}",
363                paths.root_key.display()
364            ),
365        });
366    }
367    Ok(())
368}
369
370pub(crate) fn validate_environment_name(name: &str) -> Result<(), NetworkIdentityError> {
371    if !name.is_empty()
372        && name
373            .bytes()
374            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
375    {
376        Ok(())
377    } else {
378        Err(NetworkIdentityError::InvalidEnvironmentName {
379            name: name.to_string(),
380        })
381    }
382}
383
384fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
385    Sha256::digest(bytes).into()
386}
387
388fn parse_fingerprint(value: &str) -> Result<[u8; 32], NetworkIdentityError> {
389    if value.len() != 64
390        || !value
391            .bytes()
392            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
393    {
394        return Err(NetworkIdentityError::InvalidFingerprint);
395    }
396    let mut digest = [0; 32];
397    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
398        digest[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
399    }
400    Ok(digest)
401}
402
403fn decode_nibble(byte: u8) -> u8 {
404    match byte {
405        b'0'..=b'9' => byte - b'0',
406        b'a'..=b'f' => byte - b'a' + 10,
407        _ => unreachable!("fingerprint was validated before decoding"),
408    }
409}
410
411fn encode_digest(digest: [u8; 32]) -> String {
412    digest
413        .iter()
414        .fold(String::with_capacity(64), |mut encoded, byte| {
415            write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
416            encoded
417        })
418}
419
420fn create_new(path: &Path, bytes: &[u8]) -> Result<(), NetworkIdentityError> {
421    match create_new_bytes_with_parents(path, bytes) {
422        Ok(()) => Ok(()),
423        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
424            let existing = read_required_regular_file(path)?;
425            if existing == bytes {
426                Ok(())
427            } else {
428                Err(NetworkIdentityError::TrustAnchorConflict {
429                    path: path.to_path_buf(),
430                })
431            }
432        }
433        Err(source) => Err(NetworkIdentityError::Io {
434            path: path.to_path_buf(),
435            source,
436        }),
437    }
438}
439
440fn create_new_enrollment(
441    paths: &NetworkPaths,
442    enrollment: &NetworkEnrollmentRecord,
443) -> Result<(), NetworkIdentityError> {
444    let path = &paths.enrollment;
445    let bytes = encode_json(enrollment)?;
446    match create_new_bytes_with_parents(path, &bytes) {
447        Ok(()) => Ok(()),
448        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
449            let existing = read_required_json::<NetworkEnrollmentRecord>(path)?;
450            validate_environment_name(&existing.source_profile)?;
451            validate_complete_authority(
452                paths.canonical_network_id,
453                enrollment.root_key_digest,
454                paths.canonical_network_id,
455                &existing,
456                paths,
457            )
458        }
459        Err(source) => Err(NetworkIdentityError::Io {
460            path: path.clone(),
461            source,
462        }),
463    }
464}
465
466fn create_new_profile(paths: &NetworkPaths, environment: &str) -> Result<(), NetworkIdentityError> {
467    let path = &paths.profile;
468    let profile = EnvironmentNetworkProfile {
469        canonical_network_id: paths.canonical_network_id,
470    };
471    let bytes = encode_json(&profile)?;
472    match create_new_bytes_with_parents(path, &bytes) {
473        Ok(()) => Ok(()),
474        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
475            let existing = read_required_profile(path)?;
476            if existing == profile {
477                Ok(())
478            } else {
479                Err(NetworkIdentityError::ProfileConflict {
480                    environment: environment.to_string(),
481                    existing: existing.canonical_network_id,
482                    requested: profile.canonical_network_id,
483                })
484            }
485        }
486        Err(source) => Err(NetworkIdentityError::Io {
487            path: path.clone(),
488            source,
489        }),
490    }
491}
492
493fn encode_json<T: Serialize>(value: &T) -> Result<Vec<u8>, NetworkIdentityError> {
494    let mut bytes = serde_json::to_vec_pretty(value)?;
495    bytes.push(b'\n');
496    Ok(bytes)
497}
498
499fn read_required_json<T: for<'de> Deserialize<'de>>(
500    path: &Path,
501) -> Result<T, NetworkIdentityError> {
502    let bytes = read_required_regular_file(path)?;
503    serde_json::from_slice(&bytes).map_err(|source| NetworkIdentityError::Decode {
504        path: path.to_path_buf(),
505        source,
506    })
507}
508
509fn read_optional_json<T: for<'de> Deserialize<'de>>(
510    path: &Path,
511) -> Result<Option<T>, NetworkIdentityError> {
512    let Some(bytes) = read_optional_regular_file(path)? else {
513        return Ok(None);
514    };
515    serde_json::from_slice(&bytes)
516        .map(Some)
517        .map_err(|source| NetworkIdentityError::Decode {
518            path: path.to_path_buf(),
519            source,
520        })
521}
522
523fn read_required_profile(path: &Path) -> Result<EnvironmentNetworkProfile, NetworkIdentityError> {
524    read_optional_profile(path)?.ok_or_else(|| NetworkIdentityError::MissingProfile {
525        path: path.to_path_buf(),
526    })
527}
528
529fn read_optional_profile(
530    path: &Path,
531) -> Result<Option<EnvironmentNetworkProfile>, NetworkIdentityError> {
532    let bytes = match read_regular_file(path, FilePurpose::Profile) {
533        Ok(bytes) => bytes,
534        Err(NetworkIdentityError::Io { source, .. })
535            if source.kind() == io::ErrorKind::NotFound =>
536        {
537            return Ok(None);
538        }
539        Err(error) => return Err(error),
540    };
541    serde_json::from_slice(&bytes)
542        .map(Some)
543        .map_err(|source| NetworkIdentityError::Decode {
544            path: path.to_path_buf(),
545            source,
546        })
547}
548
549fn read_required_regular_file(path: &Path) -> Result<Vec<u8>, NetworkIdentityError> {
550    read_optional_regular_file(path)?.ok_or_else(|| NetworkIdentityError::MissingAuthority {
551        path: path.to_path_buf(),
552    })
553}
554
555fn read_optional_regular_file(path: &Path) -> Result<Option<Vec<u8>>, NetworkIdentityError> {
556    match read_regular_file(path, FilePurpose::Authority) {
557        Ok(bytes) => Ok(Some(bytes)),
558        Err(NetworkIdentityError::Io { source, .. })
559            if source.kind() == io::ErrorKind::NotFound =>
560        {
561            Ok(None)
562        }
563        Err(error) => Err(error),
564    }
565}
566
567#[derive(Clone, Copy)]
568enum FilePurpose {
569    EnrollmentInput,
570    Authority,
571    Profile,
572}
573
574fn read_regular_file(path: &Path, purpose: FilePurpose) -> Result<Vec<u8>, NetworkIdentityError> {
575    match read_optional_regular_bytes(path) {
576        Ok(Some(bytes)) => Ok(bytes),
577        Ok(None) => Err(NetworkIdentityError::Io {
578            path: path.to_path_buf(),
579            source: io::Error::from(io::ErrorKind::NotFound),
580        }),
581        Err(RegularFileReadError::NotRegular) => Err(non_regular_file_error(path, purpose)),
582        Err(RegularFileReadError::Io(source)) => Err(NetworkIdentityError::Io {
583            path: path.to_path_buf(),
584            source,
585        }),
586        #[cfg(not(unix))]
587        Err(RegularFileReadError::UnsupportedPlatform) => Err(
588            NetworkIdentityError::UnsupportedPlatform(std::env::consts::OS),
589        ),
590    }
591}
592
593fn non_regular_file_error(path: &Path, purpose: FilePurpose) -> NetworkIdentityError {
594    match purpose {
595        FilePurpose::EnrollmentInput => NetworkIdentityError::RootKeyNotRegular {
596            path: path.to_path_buf(),
597        },
598        FilePurpose::Authority => NetworkIdentityError::AuthorityNotRegular {
599            path: path.to_path_buf(),
600        },
601        FilePurpose::Profile => NetworkIdentityError::ProfileNotRegular {
602            path: path.to_path_buf(),
603        },
604    }
605}
606
607fn environment_profile_path(project_root: &Path, environment: &str) -> PathBuf {
608    project_root
609        .join(CANIC_STATE_DIRECTORY)
610        .join(ENVIRONMENT_PROFILES_DIRECTORY)
611        .join(environment)
612        .join(NETWORK_PROFILE_FILE)
613}
614
615struct NetworkPaths {
616    canonical_network_id: CanonicalNetworkId,
617    authority_directory: PathBuf,
618    root_key: PathBuf,
619    enrollment: PathBuf,
620    profile: PathBuf,
621}
622
623impl NetworkPaths {
624    fn new(
625        project_root: &Path,
626        environment: &str,
627        canonical_network_id: CanonicalNetworkId,
628    ) -> Self {
629        let authority_directory = project_root
630            .join(CANIC_STATE_DIRECTORY)
631            .join(NETWORKS_DIRECTORY)
632            .join(canonical_network_id.to_string());
633        Self {
634            canonical_network_id,
635            root_key: authority_directory.join(ROOT_KEY_RELATIVE_PATH),
636            enrollment: authority_directory.join(ENROLLMENT_FILE),
637            profile: environment_profile_path(project_root, environment),
638            authority_directory,
639        }
640    }
641}
642
643mod digest_hex {
644    use super::*;
645
646    pub fn serialize<S>(digest: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
647    where
648        S: Serializer,
649    {
650        serializer.serialize_str(&encode_digest(*digest))
651    }
652
653    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
654    where
655        D: Deserializer<'de>,
656    {
657        let value = String::deserialize(deserializer)?;
658        parse_fingerprint(&value).map_err(de::Error::custom)
659    }
660}