1#[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#[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 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#[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#[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#[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
169pub 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
260pub 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}