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