1#[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#[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 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#[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#[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#[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
165pub 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
256pub 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
301pub 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}