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    MAX_DOCUMENT_READ_BYTES,
12    icp_config::{IcpConfigError, resolve_icp_build_network_from_root},
13};
14use canic_contracts::ids::{BuildNetwork, CanonicalNetworkId};
15use ic_host_artifacts::artifact::{ArtifactError, Sha256Digest};
16use ic_host_fs::{durable::create_new_bytes_with_parents, read::read_optional_file_no_follow};
17use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
18use std::{
19    io,
20    path::{Path, PathBuf},
21    time::{SystemTime, SystemTimeError, UNIX_EPOCH},
22};
23use thiserror::Error as ThisError;
24
25const CANIC_STATE_DIRECTORY: &str = ".canic";
26const NETWORKS_DIRECTORY: &str = "networks";
27const ENVIRONMENT_PROFILES_DIRECTORY: &str = "environment-profiles";
28const ROOT_KEY_RELATIVE_PATH: &str = "trust/root-key.der";
29const ENROLLMENT_FILE: &str = "enrollment.json";
30const NETWORK_PROFILE_FILE: &str = "network.json";
31
32///
33/// NetworkEnrollmentRecord
34///
35/// Authoritative record binding one canonical network to its enrolled trust anchor.
36///
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct NetworkEnrollmentRecord {
41    #[serde(with = "digest_hex")]
42    pub root_key_digest: [u8; 32],
43    /// Unix timestamp in seconds.
44    pub enrolled_at: u64,
45    pub source_profile: String,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(deny_unknown_fields)]
50struct EnvironmentNetworkProfile {
51    canonical_network_id: CanonicalNetworkId,
52}
53
54///
55/// NetworkEnrollmentOptions
56///
57/// Inputs to one explicit trust-anchor enrollment.
58///
59
60#[derive(Clone, Copy, Debug)]
61pub struct NetworkEnrollmentOptions<'a> {
62    pub workspace_root: &'a Path,
63    pub environment: &'a str,
64    pub root_key: &'a Path,
65    pub fingerprint: &'a str,
66}
67
68///
69/// NetworkEnrollmentReport
70///
71/// Result of enrolling or confirming one network profile.
72///
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct NetworkEnrollmentReport {
76    pub environment: String,
77    pub canonical_network_id: CanonicalNetworkId,
78    pub root_key_fingerprint: String,
79    pub authority_directory: PathBuf,
80    pub profile_path: PathBuf,
81    pub created_profile: bool,
82}
83
84///
85/// NetworkIdentityError
86///
87/// Typed failure while enrolling or resolving a canonical network identity.
88///
89
90#[derive(Debug, ThisError)]
91pub enum NetworkIdentityError {
92    #[error("invalid ICP environment name {name:?}")]
93    InvalidEnvironmentName { name: String },
94
95    #[error(
96        "ICP environment {environment:?} resolves to IC mainnet, whose root trust anchor is compiled into Canic and cannot be enrolled"
97    )]
98    IcMainnetEnrollment { environment: String },
99
100    #[error("root-key fingerprint must contain exactly 64 lowercase hexadecimal characters")]
101    InvalidFingerprint,
102
103    #[error(
104        "root-key fingerprint mismatch: expected {expected}, observed {observed}; no enrollment was written"
105    )]
106    FingerprintMismatch { expected: String, observed: String },
107
108    #[error("root key is not a regular non-symlink file: {}", path.display())]
109    RootKeyNotRegular { path: PathBuf },
110
111    #[error("root key is not a valid DER-encoded IC root public key: {reason}")]
112    InvalidRootKeyDer { reason: String },
113
114    #[error("required network profile is missing: {}", path.display())]
115    MissingProfile { path: PathBuf },
116
117    #[error("network profile is not a regular non-symlink file: {}", path.display())]
118    ProfileNotRegular { path: PathBuf },
119
120    #[error("required network authority file is missing: {}", path.display())]
121    MissingAuthority { path: PathBuf },
122
123    #[error("network authority file is not a regular non-symlink file: {}", path.display())]
124    AuthorityNotRegular { path: PathBuf },
125
126    #[error(
127        "environment profile {environment:?} is already bound to network {existing}, not {requested}"
128    )]
129    ProfileConflict {
130        environment: String,
131        existing: CanonicalNetworkId,
132        requested: CanonicalNetworkId,
133    },
134
135    #[error("network trust anchor conflicts with the authority at {}", path.display())]
136    TrustAnchorConflict { path: PathBuf },
137
138    #[error("network authority is incomplete or contradictory: {reason}")]
139    ContradictoryAuthority { reason: String },
140
141    #[error("could not decode network document {}: {source}", path.display())]
142    Decode {
143        path: PathBuf,
144        #[source]
145        source: serde_json::Error,
146    },
147
148    #[error("could not encode network document: {0}")]
149    Encode(#[from] serde_json::Error),
150
151    #[error("network filesystem operation failed for {}: {source}", path.display())]
152    Io {
153        path: PathBuf,
154        #[source]
155        source: io::Error,
156    },
157
158    #[error(transparent)]
159    IcpConfig(#[from] IcpConfigError),
160
161    #[error("system clock is before the Unix epoch: {0}")]
162    Clock(#[from] SystemTimeError),
163}
164
165/// Enroll an exact non-mainnet trust anchor and publish its environment profile.
166pub fn enroll_network(
167    options: NetworkEnrollmentOptions<'_>,
168) -> Result<NetworkEnrollmentReport, NetworkIdentityError> {
169    validate_environment_name(options.environment)?;
170    if resolve_icp_build_network_from_root(options.workspace_root, options.environment)?
171        == BuildNetwork::Ic
172    {
173        return Err(NetworkIdentityError::IcMainnetEnrollment {
174            environment: options.environment.to_string(),
175        });
176    }
177
178    let expected_digest = parse_fingerprint(options.fingerprint)?;
179    let root_key = read_regular_file(options.root_key, FilePurpose::EnrollmentInput)?;
180    let canonical_network_id =
181        CanonicalNetworkId::from_der_root_trust_anchor(&root_key).map_err(|error| {
182            NetworkIdentityError::InvalidRootKeyDer {
183                reason: error.to_string(),
184            }
185        })?;
186    let observed_digest = sha256_digest(&root_key);
187    if observed_digest != expected_digest {
188        return Err(NetworkIdentityError::FingerprintMismatch {
189            expected: encode_digest(expected_digest),
190            observed: encode_digest(observed_digest),
191        });
192    }
193
194    let paths = NetworkPaths::new(
195        options.workspace_root,
196        options.environment,
197        canonical_network_id,
198    );
199    let existing_profile = read_optional_profile(&paths.profile)?;
200    if let Some(profile) = &existing_profile
201        && profile.canonical_network_id != canonical_network_id
202    {
203        return Err(NetworkIdentityError::ProfileConflict {
204            environment: options.environment.to_string(),
205            existing: profile.canonical_network_id,
206            requested: canonical_network_id,
207        });
208    }
209
210    let existing_root_key = read_optional_regular_file(&paths.root_key)?;
211    if existing_root_key
212        .as_deref()
213        .is_some_and(|existing| existing != root_key)
214    {
215        return Err(NetworkIdentityError::TrustAnchorConflict {
216            path: paths.root_key,
217        });
218    }
219    let existing_enrollment = read_optional_json::<NetworkEnrollmentRecord>(&paths.enrollment)?;
220    validate_existing_authority(
221        &paths,
222        observed_digest,
223        canonical_network_id,
224        existing_root_key.as_deref(),
225        existing_enrollment.as_ref(),
226        existing_profile.as_ref(),
227    )?;
228
229    if existing_root_key.is_none() {
230        create_new(&paths.root_key, &root_key)?;
231    }
232    if existing_enrollment.is_none() {
233        let enrollment = NetworkEnrollmentRecord {
234            root_key_digest: observed_digest,
235            enrolled_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
236            source_profile: options.environment.to_string(),
237        };
238        create_new_enrollment(&paths, &enrollment)?;
239    }
240
241    let created_profile = existing_profile.is_none();
242    if created_profile {
243        create_new_profile(&paths, options.environment)?;
244    }
245
246    Ok(NetworkEnrollmentReport {
247        environment: options.environment.to_string(),
248        canonical_network_id,
249        root_key_fingerprint: encode_digest(observed_digest),
250        authority_directory: paths.authority_directory,
251        profile_path: paths.profile,
252        created_profile,
253    })
254}
255
256/// Resolve an environment profile to its verified canonical network identity.
257pub fn resolve_canonical_network_id_from_root(
258    workspace_root: &Path,
259    environment: &str,
260) -> Result<CanonicalNetworkId, NetworkIdentityError> {
261    validate_environment_name(environment)?;
262    let build_network = resolve_icp_build_network_from_root(workspace_root, environment)?;
263    let profile_path = environment_profile_path(workspace_root, environment);
264
265    if build_network == BuildNetwork::Ic {
266        let expected = CanonicalNetworkId::ic_mainnet();
267        if let Some(profile) = read_optional_profile(&profile_path)?
268            && profile.canonical_network_id != expected
269        {
270            return Err(NetworkIdentityError::ProfileConflict {
271                environment: environment.to_string(),
272                existing: profile.canonical_network_id,
273                requested: expected,
274            });
275        }
276        return Ok(expected);
277    }
278
279    let profile = read_required_profile(&profile_path)?;
280    let paths = NetworkPaths::new(workspace_root, environment, profile.canonical_network_id);
281    let root_key = read_required_regular_file(&paths.root_key)?;
282    let observed_network_id =
283        CanonicalNetworkId::from_der_root_trust_anchor(&root_key).map_err(|error| {
284            NetworkIdentityError::InvalidRootKeyDer {
285                reason: error.to_string(),
286            }
287        })?;
288    let observed_digest = sha256_digest(&root_key);
289    let enrollment = read_required_json::<NetworkEnrollmentRecord>(&paths.enrollment)?;
290    validate_environment_name(&enrollment.source_profile)?;
291    validate_complete_authority(
292        profile.canonical_network_id,
293        observed_digest,
294        observed_network_id,
295        &enrollment,
296        &paths,
297    )?;
298    Ok(profile.canonical_network_id)
299}
300
301/// Read an explicitly enrolled local trust anchor for a browser handoff.
302///
303/// Mainnet uses the SDK's built-in anchor and never exports an alternate key.
304pub fn frontend_root_key(
305    workspace_root: &Path,
306    environment: &str,
307) -> Result<Option<Vec<u8>>, NetworkIdentityError> {
308    let network = resolve_canonical_network_id_from_root(workspace_root, environment)?;
309    if network == CanonicalNetworkId::ic_mainnet() {
310        return Ok(None);
311    }
312    let paths = NetworkPaths::new(workspace_root, environment, network);
313    let bytes = read_required_regular_file(&paths.root_key)?;
314    let observed = CanonicalNetworkId::from_der_root_trust_anchor(&bytes).map_err(|error| {
315        NetworkIdentityError::InvalidRootKeyDer {
316            reason: error.to_string(),
317        }
318    })?;
319    if observed != network {
320        return Err(NetworkIdentityError::ContradictoryAuthority {
321            reason: "enrolled root key changed during frontend export".to_string(),
322        });
323    }
324    Ok(Some(bytes))
325}
326
327fn validate_existing_authority(
328    paths: &NetworkPaths,
329    expected_digest: [u8; 32],
330    observed_network_id: CanonicalNetworkId,
331    root_key: Option<&[u8]>,
332    enrollment: Option<&NetworkEnrollmentRecord>,
333    profile: Option<&EnvironmentNetworkProfile>,
334) -> Result<(), NetworkIdentityError> {
335    if enrollment.is_some() && root_key.is_none() {
336        return Err(NetworkIdentityError::ContradictoryAuthority {
337            reason: format!(
338                "{} exists without {}",
339                paths.enrollment.display(),
340                paths.root_key.display()
341            ),
342        });
343    }
344    if profile.is_some() && (root_key.is_none() || enrollment.is_none()) {
345        return Err(NetworkIdentityError::ContradictoryAuthority {
346            reason: format!(
347                "{} is visible without a complete authority",
348                paths.profile.display()
349            ),
350        });
351    }
352    if let Some(enrollment) = enrollment {
353        validate_environment_name(&enrollment.source_profile)?;
354        validate_complete_authority(
355            paths.canonical_network_id,
356            expected_digest,
357            observed_network_id,
358            enrollment,
359            paths,
360        )?;
361    }
362    Ok(())
363}
364
365fn validate_complete_authority(
366    canonical_network_id: CanonicalNetworkId,
367    root_key_digest: [u8; 32],
368    observed_network_id: CanonicalNetworkId,
369    enrollment: &NetworkEnrollmentRecord,
370    paths: &NetworkPaths,
371) -> Result<(), NetworkIdentityError> {
372    if enrollment.root_key_digest != root_key_digest {
373        return Err(NetworkIdentityError::ContradictoryAuthority {
374            reason: format!(
375                "{} does not match the exact root trust anchor",
376                paths.enrollment.display()
377            ),
378        });
379    }
380    if observed_network_id != canonical_network_id {
381        return Err(NetworkIdentityError::ContradictoryAuthority {
382            reason: format!(
383                "{} derives network {observed_network_id}, not {canonical_network_id}",
384                paths.root_key.display()
385            ),
386        });
387    }
388    Ok(())
389}
390
391pub(crate) fn validate_environment_name(name: &str) -> Result<(), NetworkIdentityError> {
392    if !name.is_empty()
393        && name
394            .bytes()
395            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
396    {
397        Ok(())
398    } else {
399        Err(NetworkIdentityError::InvalidEnvironmentName {
400            name: name.to_string(),
401        })
402    }
403}
404
405fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
406    *Sha256Digest::compute(bytes).as_bytes()
407}
408
409fn parse_fingerprint(value: &str) -> Result<[u8; 32], NetworkIdentityError> {
410    value
411        .parse::<Sha256Digest>()
412        .map(|digest| *digest.as_bytes())
413        .map_err(|_| NetworkIdentityError::InvalidFingerprint)
414}
415
416fn encode_digest(digest: [u8; 32]) -> String {
417    Sha256Digest::from_bytes(digest).to_string()
418}
419
420fn create_new(path: &Path, bytes: &[u8]) -> Result<(), NetworkIdentityError> {
421    match create_new_bytes_with_parents(path, bytes).map_err(crate::publication::ops::io_error) {
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).map_err(crate::publication::ops::io_error) {
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).map_err(crate::publication::ops::io_error) {
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_file_no_follow(path, MAX_DOCUMENT_READ_BYTES) {
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(ArtifactError::NotRegularFile) => Err(non_regular_file_error(path, purpose)),
582        Err(source) => Err(NetworkIdentityError::Io {
583            path: path.to_path_buf(),
584            source: source.into(),
585        }),
586    }
587}
588
589fn non_regular_file_error(path: &Path, purpose: FilePurpose) -> NetworkIdentityError {
590    match purpose {
591        FilePurpose::EnrollmentInput => NetworkIdentityError::RootKeyNotRegular {
592            path: path.to_path_buf(),
593        },
594        FilePurpose::Authority => NetworkIdentityError::AuthorityNotRegular {
595            path: path.to_path_buf(),
596        },
597        FilePurpose::Profile => NetworkIdentityError::ProfileNotRegular {
598            path: path.to_path_buf(),
599        },
600    }
601}
602
603fn environment_profile_path(workspace_root: &Path, environment: &str) -> PathBuf {
604    workspace_root
605        .join(CANIC_STATE_DIRECTORY)
606        .join(ENVIRONMENT_PROFILES_DIRECTORY)
607        .join(environment)
608        .join(NETWORK_PROFILE_FILE)
609}
610
611struct NetworkPaths {
612    canonical_network_id: CanonicalNetworkId,
613    authority_directory: PathBuf,
614    root_key: PathBuf,
615    enrollment: PathBuf,
616    profile: PathBuf,
617}
618
619impl NetworkPaths {
620    fn new(
621        workspace_root: &Path,
622        environment: &str,
623        canonical_network_id: CanonicalNetworkId,
624    ) -> Self {
625        let authority_directory = workspace_root
626            .join(CANIC_STATE_DIRECTORY)
627            .join(NETWORKS_DIRECTORY)
628            .join(canonical_network_id.to_string());
629        Self {
630            canonical_network_id,
631            root_key: authority_directory.join(ROOT_KEY_RELATIVE_PATH),
632            enrollment: authority_directory.join(ENROLLMENT_FILE),
633            profile: environment_profile_path(workspace_root, environment),
634            authority_directory,
635        }
636    }
637}
638
639mod digest_hex {
640    use super::*;
641
642    pub(super) fn serialize<S>(digest: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
643    where
644        S: Serializer,
645    {
646        serializer.serialize_str(&encode_digest(*digest))
647    }
648
649    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
650    where
651        D: Deserializer<'de>,
652    {
653        let value = String::deserialize(deserializer)?;
654        parse_fingerprint(&value).map_err(de::Error::custom)
655    }
656}