1#[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#[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 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#[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#[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#[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
170pub 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
261pub 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
306pub 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}