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 workspace_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 IC mainnet, whose root trust anchor is compiled into Canic and cannot be enrolled"
99    )]
100    IcMainnetEnrollment { 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-mainnet 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.workspace_root, options.environment)?
176        == BuildNetwork::Ic
177    {
178        return Err(NetworkIdentityError::IcMainnetEnrollment {
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.workspace_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    workspace_root: &Path,
264    environment: &str,
265) -> Result<CanonicalNetworkId, NetworkIdentityError> {
266    validate_environment_name(environment)?;
267    let build_network = resolve_icp_build_network_from_root(workspace_root, environment)?;
268    let profile_path = environment_profile_path(workspace_root, environment);
269
270    if build_network == BuildNetwork::Ic {
271        let expected = CanonicalNetworkId::ic_mainnet();
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(workspace_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
306/// Read an explicitly enrolled local trust anchor for a browser handoff.
307///
308/// Mainnet uses the SDK's built-in anchor and never exports an alternate key.
309pub fn frontend_root_key(
310    workspace_root: &Path,
311    environment: &str,
312) -> Result<Option<Vec<u8>>, NetworkIdentityError> {
313    let network = resolve_canonical_network_id_from_root(workspace_root, environment)?;
314    if network == CanonicalNetworkId::ic_mainnet() {
315        return Ok(None);
316    }
317    let paths = NetworkPaths::new(workspace_root, environment, network);
318    let bytes = read_required_regular_file(&paths.root_key)?;
319    let observed = CanonicalNetworkId::from_der_root_trust_anchor(&bytes).map_err(|error| {
320        NetworkIdentityError::InvalidRootKeyDer {
321            reason: error.to_string(),
322        }
323    })?;
324    if observed != network {
325        return Err(NetworkIdentityError::ContradictoryAuthority {
326            reason: "enrolled root key changed during frontend export".to_string(),
327        });
328    }
329    Ok(Some(bytes))
330}
331
332fn validate_existing_authority(
333    paths: &NetworkPaths,
334    expected_digest: [u8; 32],
335    observed_network_id: CanonicalNetworkId,
336    root_key: Option<&[u8]>,
337    enrollment: Option<&NetworkEnrollmentRecord>,
338    profile: Option<&EnvironmentNetworkProfile>,
339) -> Result<(), NetworkIdentityError> {
340    if enrollment.is_some() && root_key.is_none() {
341        return Err(NetworkIdentityError::ContradictoryAuthority {
342            reason: format!(
343                "{} exists without {}",
344                paths.enrollment.display(),
345                paths.root_key.display()
346            ),
347        });
348    }
349    if profile.is_some() && (root_key.is_none() || enrollment.is_none()) {
350        return Err(NetworkIdentityError::ContradictoryAuthority {
351            reason: format!(
352                "{} is visible without a complete authority",
353                paths.profile.display()
354            ),
355        });
356    }
357    if let Some(enrollment) = enrollment {
358        validate_environment_name(&enrollment.source_profile)?;
359        validate_complete_authority(
360            paths.canonical_network_id,
361            expected_digest,
362            observed_network_id,
363            enrollment,
364            paths,
365        )?;
366    }
367    Ok(())
368}
369
370fn validate_complete_authority(
371    canonical_network_id: CanonicalNetworkId,
372    root_key_digest: [u8; 32],
373    observed_network_id: CanonicalNetworkId,
374    enrollment: &NetworkEnrollmentRecord,
375    paths: &NetworkPaths,
376) -> Result<(), NetworkIdentityError> {
377    if enrollment.root_key_digest != root_key_digest {
378        return Err(NetworkIdentityError::ContradictoryAuthority {
379            reason: format!(
380                "{} does not match the exact root trust anchor",
381                paths.enrollment.display()
382            ),
383        });
384    }
385    if observed_network_id != canonical_network_id {
386        return Err(NetworkIdentityError::ContradictoryAuthority {
387            reason: format!(
388                "{} derives network {observed_network_id}, not {canonical_network_id}",
389                paths.root_key.display()
390            ),
391        });
392    }
393    Ok(())
394}
395
396pub(crate) fn validate_environment_name(name: &str) -> Result<(), NetworkIdentityError> {
397    if !name.is_empty()
398        && name
399            .bytes()
400            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
401    {
402        Ok(())
403    } else {
404        Err(NetworkIdentityError::InvalidEnvironmentName {
405            name: name.to_string(),
406        })
407    }
408}
409
410fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
411    Sha256::digest(bytes).into()
412}
413
414fn parse_fingerprint(value: &str) -> Result<[u8; 32], NetworkIdentityError> {
415    if value.len() != 64
416        || !value
417            .bytes()
418            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
419    {
420        return Err(NetworkIdentityError::InvalidFingerprint);
421    }
422    let mut digest = [0; 32];
423    for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
424        digest[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
425    }
426    Ok(digest)
427}
428
429fn decode_nibble(byte: u8) -> u8 {
430    match byte {
431        b'0'..=b'9' => byte - b'0',
432        b'a'..=b'f' => byte - b'a' + 10,
433        _ => unreachable!("fingerprint was validated before decoding"),
434    }
435}
436
437fn encode_digest(digest: [u8; 32]) -> String {
438    digest
439        .iter()
440        .fold(String::with_capacity(64), |mut encoded, byte| {
441            write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
442            encoded
443        })
444}
445
446fn create_new(path: &Path, bytes: &[u8]) -> Result<(), NetworkIdentityError> {
447    match create_new_bytes_with_parents(path, bytes) {
448        Ok(()) => Ok(()),
449        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
450            let existing = read_required_regular_file(path)?;
451            if existing == bytes {
452                Ok(())
453            } else {
454                Err(NetworkIdentityError::TrustAnchorConflict {
455                    path: path.to_path_buf(),
456                })
457            }
458        }
459        Err(source) => Err(NetworkIdentityError::Io {
460            path: path.to_path_buf(),
461            source,
462        }),
463    }
464}
465
466fn create_new_enrollment(
467    paths: &NetworkPaths,
468    enrollment: &NetworkEnrollmentRecord,
469) -> Result<(), NetworkIdentityError> {
470    let path = &paths.enrollment;
471    let bytes = encode_json(enrollment)?;
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_json::<NetworkEnrollmentRecord>(path)?;
476            validate_environment_name(&existing.source_profile)?;
477            validate_complete_authority(
478                paths.canonical_network_id,
479                enrollment.root_key_digest,
480                paths.canonical_network_id,
481                &existing,
482                paths,
483            )
484        }
485        Err(source) => Err(NetworkIdentityError::Io {
486            path: path.clone(),
487            source,
488        }),
489    }
490}
491
492fn create_new_profile(paths: &NetworkPaths, environment: &str) -> Result<(), NetworkIdentityError> {
493    let path = &paths.profile;
494    let profile = EnvironmentNetworkProfile {
495        canonical_network_id: paths.canonical_network_id,
496    };
497    let bytes = encode_json(&profile)?;
498    match create_new_bytes_with_parents(path, &bytes) {
499        Ok(()) => Ok(()),
500        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
501            let existing = read_required_profile(path)?;
502            if existing == profile {
503                Ok(())
504            } else {
505                Err(NetworkIdentityError::ProfileConflict {
506                    environment: environment.to_string(),
507                    existing: existing.canonical_network_id,
508                    requested: profile.canonical_network_id,
509                })
510            }
511        }
512        Err(source) => Err(NetworkIdentityError::Io {
513            path: path.clone(),
514            source,
515        }),
516    }
517}
518
519fn encode_json<T: Serialize>(value: &T) -> Result<Vec<u8>, NetworkIdentityError> {
520    let mut bytes = serde_json::to_vec_pretty(value)?;
521    bytes.push(b'\n');
522    Ok(bytes)
523}
524
525fn read_required_json<T: for<'de> Deserialize<'de>>(
526    path: &Path,
527) -> Result<T, NetworkIdentityError> {
528    let bytes = read_required_regular_file(path)?;
529    serde_json::from_slice(&bytes).map_err(|source| NetworkIdentityError::Decode {
530        path: path.to_path_buf(),
531        source,
532    })
533}
534
535fn read_optional_json<T: for<'de> Deserialize<'de>>(
536    path: &Path,
537) -> Result<Option<T>, NetworkIdentityError> {
538    let Some(bytes) = read_optional_regular_file(path)? else {
539        return Ok(None);
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_profile(path: &Path) -> Result<EnvironmentNetworkProfile, NetworkIdentityError> {
550    read_optional_profile(path)?.ok_or_else(|| NetworkIdentityError::MissingProfile {
551        path: path.to_path_buf(),
552    })
553}
554
555fn read_optional_profile(
556    path: &Path,
557) -> Result<Option<EnvironmentNetworkProfile>, NetworkIdentityError> {
558    let bytes = match read_regular_file(path, FilePurpose::Profile) {
559        Ok(bytes) => bytes,
560        Err(NetworkIdentityError::Io { source, .. })
561            if source.kind() == io::ErrorKind::NotFound =>
562        {
563            return Ok(None);
564        }
565        Err(error) => return Err(error),
566    };
567    serde_json::from_slice(&bytes)
568        .map(Some)
569        .map_err(|source| NetworkIdentityError::Decode {
570            path: path.to_path_buf(),
571            source,
572        })
573}
574
575fn read_required_regular_file(path: &Path) -> Result<Vec<u8>, NetworkIdentityError> {
576    read_optional_regular_file(path)?.ok_or_else(|| NetworkIdentityError::MissingAuthority {
577        path: path.to_path_buf(),
578    })
579}
580
581fn read_optional_regular_file(path: &Path) -> Result<Option<Vec<u8>>, NetworkIdentityError> {
582    match read_regular_file(path, FilePurpose::Authority) {
583        Ok(bytes) => Ok(Some(bytes)),
584        Err(NetworkIdentityError::Io { source, .. })
585            if source.kind() == io::ErrorKind::NotFound =>
586        {
587            Ok(None)
588        }
589        Err(error) => Err(error),
590    }
591}
592
593#[derive(Clone, Copy)]
594enum FilePurpose {
595    EnrollmentInput,
596    Authority,
597    Profile,
598}
599
600fn read_regular_file(path: &Path, purpose: FilePurpose) -> Result<Vec<u8>, NetworkIdentityError> {
601    match read_optional_regular_bytes(path) {
602        Ok(Some(bytes)) => Ok(bytes),
603        Ok(None) => Err(NetworkIdentityError::Io {
604            path: path.to_path_buf(),
605            source: io::Error::from(io::ErrorKind::NotFound),
606        }),
607        Err(RegularFileReadError::NotRegular) => Err(non_regular_file_error(path, purpose)),
608        Err(RegularFileReadError::Io(source)) => Err(NetworkIdentityError::Io {
609            path: path.to_path_buf(),
610            source,
611        }),
612        #[cfg(not(unix))]
613        Err(RegularFileReadError::UnsupportedPlatform) => Err(
614            NetworkIdentityError::UnsupportedPlatform(std::env::consts::OS),
615        ),
616    }
617}
618
619fn non_regular_file_error(path: &Path, purpose: FilePurpose) -> NetworkIdentityError {
620    match purpose {
621        FilePurpose::EnrollmentInput => NetworkIdentityError::RootKeyNotRegular {
622            path: path.to_path_buf(),
623        },
624        FilePurpose::Authority => NetworkIdentityError::AuthorityNotRegular {
625            path: path.to_path_buf(),
626        },
627        FilePurpose::Profile => NetworkIdentityError::ProfileNotRegular {
628            path: path.to_path_buf(),
629        },
630    }
631}
632
633fn environment_profile_path(workspace_root: &Path, environment: &str) -> PathBuf {
634    workspace_root
635        .join(CANIC_STATE_DIRECTORY)
636        .join(ENVIRONMENT_PROFILES_DIRECTORY)
637        .join(environment)
638        .join(NETWORK_PROFILE_FILE)
639}
640
641struct NetworkPaths {
642    canonical_network_id: CanonicalNetworkId,
643    authority_directory: PathBuf,
644    root_key: PathBuf,
645    enrollment: PathBuf,
646    profile: PathBuf,
647}
648
649impl NetworkPaths {
650    fn new(
651        workspace_root: &Path,
652        environment: &str,
653        canonical_network_id: CanonicalNetworkId,
654    ) -> Self {
655        let authority_directory = workspace_root
656            .join(CANIC_STATE_DIRECTORY)
657            .join(NETWORKS_DIRECTORY)
658            .join(canonical_network_id.to_string());
659        Self {
660            canonical_network_id,
661            root_key: authority_directory.join(ROOT_KEY_RELATIVE_PATH),
662            enrollment: authority_directory.join(ENROLLMENT_FILE),
663            profile: environment_profile_path(workspace_root, environment),
664            authority_directory,
665        }
666    }
667}
668
669mod digest_hex {
670    use super::*;
671
672    pub(super) fn serialize<S>(digest: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
673    where
674        S: Serializer,
675    {
676        serializer.serialize_str(&encode_digest(*digest))
677    }
678
679    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
680    where
681        D: Deserializer<'de>,
682    {
683        let value = String::deserialize(deserializer)?;
684        parse_fingerprint(&value).map_err(de::Error::custom)
685    }
686}